Design Splitwise, Low Level Design (LLD) Interview
An app for sharing costs. It records who paid and who owes. It splits in three ways without losing a paisa to rounding. It keeps one balance per person, and settles a group in few payments.
Where it shows up
A common machine-coding and LLD question in India. It comes up at product and fintech companies like Flipkart, PhonePe, Swiggy, Razorpay and CRED. It is asked from SDE1 to SDE3 level. It is one of the most searched LLD problems in India.
Why this is asked
It looks like a small app. But it tests the habits of a careful engineer. Money must never be stored as a decimal number. Three ways of splitting must plug in without changing the core. Shares must add up to the total exactly, even when ₹10 is split three ways. Balances must come from a record of expenses, not from editing numbers by hand. And there is a real algorithm at the end: settle a group with few payments. Interviewers can stop after the classes, or push into the algorithm and threads. So it fits a 60 to 90 minute round well.
Requirements
Functional
- Users can be in one or more groups.
- Any member can add an expense: who paid, how much, and who shares it.
- An expense can be split equally, by exact amounts, or by percentages.
- Show each person's balance in a group: how much they are owed, or owe.
- Settle up: list the payments that clear every balance in the group.
- Reject wrong expenses, like exact shares that do not add up to the total.
Constraints & non-functional
- Money is stored as whole paise in a long, never as a decimal number.
- The shares of an expense always add up to exactly the total, even after rounding.
- An expense never changes once saved. Balances come from expenses, never from direct edits.
- Adding the same expense twice, for example a retried request, changes no balance.
- New split types can be added without changing the expense service.
- This is in-memory LLD. Storage is hidden behind the service, and a lock keeps threads safe.
Core classes & entities
User
A person who can pay for and share expenses. Known by a fixed id.
attrs: id, name
Group
A set of users who share costs, like a trip or a flat. Everyone in an expense must be a member.
attrs: id, name, members: Set<User>
methods: add(user)
Expense
A fixed record of one payment. It holds who paid, the total and each person's share. It is created once and never changed.
attrs: id, paidBy: User, amount: long (paise), shares: Map<User, Long>
SplitStrategy
Turns an amount and a list of people into exact shares. There is one version per split type. So the service holds no split logic.
methods: split(amount, users, values): Map<User, Long>
EqualSplit
Splits equally. It gives the leftover paise to the first people, one each. So the shares add up exactly.
methods: split(amount, users, values)
ExactSplit
Uses the amounts given. It rejects them if they do not add up to the total.
methods: split(amount, users, values)
PercentSplit
Splits by percentages that must add up to 100. The last person takes the rounding, so the shares add up exactly.
methods: split(amount, users, percents)
BalanceSheet
Keeps one balance per person in a group. Each new expense updates it. A positive balance means others owe that person.
attrs: net: Map<User, Long>
methods: apply(expense), of(user), snapshot()
DebtSimplifier
Turns balances into a list of payments that settles everyone. Each step matches the biggest creditor with the biggest debtor.
methods: settle(net): List<Payment>
ExpenseService
The single way in. It checks an expense, picks the split, saves the expense once and updates the balances. It does all of this under one lock.
attrs: expenses: Map<id, Expense>, sheets: Map<groupId, BalanceSheet>
methods: addExpense(...), balances(group), settleUp(group)
Relationships
- Group → aggregation → User. A group has members. Users exist outside any one group.
- Expense → association → User. An expense points to the payer and to each person with a share.
- SplitStrategy → implements → EqualSplit. One strategy per split type.
- SplitStrategy → implements → ExactSplit. One strategy per split type.
- SplitStrategy → implements → PercentSplit. One strategy per split type.
- ExpenseService → composition → Expense. The service owns the saved expenses.
- ExpenseService → composition → BalanceSheet. One balance sheet per group, owned by the service.
- ExpenseService → association → SplitStrategy. A factory picks it from the split type.
- ExpenseService → association → DebtSimplifier. Used to work out the settle-up payments.
Design patterns used
Strategy in SplitStrategy with EqualSplit, ExactSplit and PercentSplit
Each way of splitting changes on its own. A new one is a new class, not an edit to the service.
Factory in SplitFactory.of(splitType)
The service asks for a split by type. It never names a real class.
Facade in ExpenseService
One class is the only way to change state. So checks, retries and the lock live in one place.
Fixed records plus worked-out state in Expense and BalanceSheet
Balances come from expenses that never change. So they can always be rebuilt and checked, like a ledger.
Enums
Key API / methods
Expense addExpense(String expenseId, Group g, String desc, User paidBy, long amountPaise, SplitType type, List<User> users, List<Long> values)Checks and saves an expense once, then updates the group's balances. Calling it again with the same expenseId returns the saved expense. Nothing changes.
Map<User, Long> balances(Group g)Each member's balance in paise. Positive means they are owed. Negative means they owe.
List<Payment> settleUp(Group g)The payments that clear every balance in the group. There is at most one fewer payment than the number of people who owe or are owed.
Code skeleton
import java.util.*;
// ---------- Enums ----------
enum SplitType { EQUAL, EXACT, PERCENT }
// ---------- Users and groups ----------
final class User {
final String id, name;
User(String id, String name) { this.id = id; this.name = name; }
public String toString() { return name; }
}
final class Group {
final String id, name;
final Set<User> members = new LinkedHashSet<>();
Group(String id, String name) { this.id = id; this.name = name; }
void add(User u) { members.add(u); }
}
// ---------- Splits: one strategy per way of dividing an amount ----------
// All money is in paise (long). Never double: 0.1 + 0.2 != 0.3.
interface SplitStrategy {
Map<User, Long> split(long amountPaise, List<User> users, List<Long> values);
}
final class EqualSplit implements SplitStrategy {
public Map<User, Long> split(long amount, List<User> users, List<Long> ignored) {
Map<User, Long> out = new LinkedHashMap<>();
long share = amount / users.size(), remainder = amount % users.size();
for (int i = 0; i < users.size(); i++) // the first `remainder` people pay 1 paisa more,
out.put(users.get(i), share + (i < remainder ? 1 : 0)); // so the shares always add up exactly
return out;
}
}
final class ExactSplit implements SplitStrategy {
public Map<User, Long> split(long amount, List<User> users, List<Long> values) {
long sum = values.stream().mapToLong(Long::longValue).sum();
if (sum != amount) throw new IllegalArgumentException("exact shares add up to " + sum + ", not " + amount);
Map<User, Long> out = new LinkedHashMap<>();
for (int i = 0; i < users.size(); i++) out.put(users.get(i), values.get(i));
return out;
}
}
final class PercentSplit implements SplitStrategy {
public Map<User, Long> split(long amount, List<User> users, List<Long> percents) {
if (percents.stream().mapToLong(Long::longValue).sum() != 100)
throw new IllegalArgumentException("percentages must add up to 100");
Map<User, Long> out = new LinkedHashMap<>();
long given = 0;
for (int i = 0; i < users.size(); i++) {
long share = (i == users.size() - 1) ? amount - given // last person takes the rounding
: amount * percents.get(i) / 100;
out.put(users.get(i), share); given += share;
}
return out;
}
}
final class SplitFactory {
static SplitStrategy of(SplitType t) {
return switch (t) { case EQUAL -> new EqualSplit(); case EXACT -> new ExactSplit(); case PERCENT -> new PercentSplit(); };
}
}
// ---------- Expense: an immutable record of who paid and who owes ----------
final class Expense {
final String id, description; final User paidBy; final long amount; final Map<User, Long> shares;
Expense(String id, String description, User paidBy, long amount, Map<User, Long> shares) {
this.id = id; this.description = description; this.paidBy = paidBy; this.amount = amount;
this.shares = Collections.unmodifiableMap(shares);
}
}
// ---------- Balance sheet: one net number per person ----------
final class BalanceSheet {
// net > 0: others owe this person. net < 0: this person owes others.
private final Map<User, Long> net = new LinkedHashMap<>();
void apply(Expense e) {
net.merge(e.paidBy, e.amount, Long::sum);
for (var s : e.shares.entrySet()) net.merge(s.getKey(), -s.getValue(), Long::sum);
}
long of(User u) { return net.getOrDefault(u, 0L); }
Map<User, Long> snapshot() { return new LinkedHashMap<>(net); }
}
// ---------- Settle up: turn net balances into as few payments as the greedy method gives ----------
record Payment(User from, User to, long amount) {
public String toString() { return from + " pays " + to + " " + rupees(amount); }
static String rupees(long p) { return String.format("Rs %d.%02d", p / 100, p % 100); }
}
final class DebtSimplifier {
static List<Payment> settle(Map<User, Long> net) {
// max-heaps of who is owed the most and who owes the most
PriorityQueue<Map.Entry<User, Long>> owed = new PriorityQueue<>((a, b) -> Long.compare(b.getValue(), a.getValue()));
PriorityQueue<Map.Entry<User, Long>> owes = new PriorityQueue<>((a, b) -> Long.compare(a.getValue(), b.getValue()));
for (var e : net.entrySet()) {
if (e.getValue() > 0) owed.add(new AbstractMap.SimpleEntry<>(e));
else if (e.getValue() < 0) owes.add(new AbstractMap.SimpleEntry<>(e));
}
List<Payment> out = new ArrayList<>();
while (!owed.isEmpty() && !owes.isEmpty()) {
var c = owed.poll(); var d = owes.poll();
long x = Math.min(c.getValue(), -d.getValue());
out.add(new Payment(d.getKey(), c.getKey(), x));
if (c.getValue() - x > 0) owed.add(new AbstractMap.SimpleEntry<>(c.getKey(), c.getValue() - x));
if (d.getValue() + x < 0) owes.add(new AbstractMap.SimpleEntry<>(d.getKey(), d.getValue() + x));
}
return out;
}
}
// ---------- The service: the only way in, so every expense is validated and applied once ----------
final class ExpenseService {
private final Map<String, Expense> expenses = new LinkedHashMap<>();
private final Map<String, BalanceSheet> sheets = new HashMap<>();
synchronized Expense addExpense(String expenseId, Group g, String desc, User paidBy, long amount,
SplitType type, List<User> users, List<Long> values) {
if (expenses.containsKey(expenseId)) return expenses.get(expenseId); // a retried request changes nothing
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
if (!g.members.contains(paidBy) || !g.members.containsAll(users))
throw new IllegalArgumentException("everyone in an expense must be in the group");
Expense e = new Expense(expenseId, desc, paidBy, amount, SplitFactory.of(type).split(amount, users, values));
expenses.put(expenseId, e);
sheets.computeIfAbsent(g.id, k -> new BalanceSheet()).apply(e);
return e;
}
synchronized Map<User, Long> balances(Group g) { return sheets.getOrDefault(g.id, new BalanceSheet()).snapshot(); }
synchronized List<Payment> settleUp(Group g) { return DebtSimplifier.settle(balances(g)); }
}
// ---------- Demo: every line of output below is checked, not just printed ----------
public class Splitwise {
public static void main(String[] args) {
User a = new User("u1", "Asha"), b = new User("u2", "Bala"), c = new User("u3", "Chitra"), d = new User("u4", "Dev");
Group trip = new Group("g1", "Goa trip");
for (User u : List.of(a, b, c, d)) trip.add(u);
ExpenseService svc = new ExpenseService();
svc.addExpense("e1", trip, "Hotel", a, 1_000_00, SplitType.EQUAL, List.of(a, b, c, d), null);
svc.addExpense("e2", trip, "Dinner", b, 1_000, SplitType.EQUAL, List.of(a, b, c), null); // Rs 10 / 3
svc.addExpense("e3", trip, "Cab", c, 600_00, SplitType.EXACT, List.of(a, d), List.of(200_00L, 400_00L));
svc.addExpense("e4", trip, "Tickets", d, 900_00, SplitType.PERCENT, List.of(a, b, c, d), List.of(10L, 20L, 30L, 40L));
svc.addExpense("e1", trip, "Hotel (retried)", a, 1_000_00, SplitType.EQUAL, List.of(a, b, c, d), null); // ignored
Map<User, Long> bal = svc.balances(trip);
bal.forEach((u, p) -> System.out.println(u + " net " + (p >= 0 ? "+" : "-") + Payment.rupees(Math.abs(p))));
long total = bal.values().stream().mapToLong(Long::longValue).sum();
check(total == 0, "net balances add up to zero");
List<Payment> pays = svc.settleUp(trip);
pays.forEach(System.out::println);
Map<User, Long> after = new HashMap<>(bal);
for (Payment p : pays) { after.merge(p.from(), p.amount(), Long::sum); after.merge(p.to(), -p.amount(), Long::sum); }
check(after.values().stream().allMatch(v -> v == 0), "after the payments everyone is settled");
check(pays.size() <= bal.size() - 1, "at most n-1 payments for n people");
try { svc.addExpense("e5", trip, "Bad", a, 100_00, SplitType.EXACT, List.of(a, b), List.of(10_00L, 20_00L)); check(false, "bad exact split rejected"); }
catch (IllegalArgumentException ex) { check(true, "bad exact split rejected: " + ex.getMessage()); }
}
static void check(boolean ok, String what) {
System.out.println((ok ? " ok " : " FAIL ") + what);
if (!ok) System.exit(1);
}
}
/* Output of this exact program (javac + java 21, 2026-09-25):
* Asha net +Rs 456.66
* Bala net -Rs 423.33
* Chitra net +Rs 76.67
* Dev net -Rs 110.00
* ok net balances add up to zero
* Bala pays Asha Rs 423.33
* Dev pays Chitra Rs 76.67
* Dev pays Asha Rs 33.33
* ok after the payments everyone is settled
* ok at most n-1 payments for n people
* ok bad exact split rejected: exact shares add up to 3000, not 10000
*/How it works

Start with money, because that is where most people lose marks. Store every amount as whole paise in a long. A decimal number cannot hold 0.1 exactly. Small errors add up across hundreds of expenses. Soon a balance is off by a paisa that nobody can explain.
Next, keep what happened apart from what it means. An Expense is a fixed record: who paid, how much, and each person's share. A BalanceSheet is worked out from those records. It keeps one balance per person and updates as each expense is applied. Paying adds the amount to the payer's balance. Each share takes away from that person's balance. So the balances in a group always add up to zero. The program checks that.
Splitting is the part that changes, so it is a Strategy. EqualSplit divides the amount and gives the leftover paise to the first people, one at a time. ExactSplit uses the given amounts and rejects them if they do not add up. PercentSplit gives the rounding to the last person. A factory picks the split from its type. So ExpenseService never holds a long list of split types that keeps growing.
ExpenseService is the only way in. It checks the amount and checks that everyone is in the group. It runs the split and saves the expense under its id. Then it applies the expense to the group's balances. All of this happens inside one locked method. The id makes the call safe to repeat. A retried request with the same id returns the saved expense and changes nothing.
Settle up is the algorithm. Put everyone who is owed money in one heap, largest first. Put everyone who owes in another. Match the largest creditor with the largest debtor. Make a payment of the smaller amount. Put back whoever still has a balance. Repeat. Each step settles at least one person fully. So a group of n people needs at most n minus 1 payments. In the demo, four people and four expenses settle in three payments. The program checks that everyone ends at zero.
If the interviewer pushes further, say two things. First, finding the true smallest number of payments is much harder in general. It is NP-hard, so the greedy method is the practical answer. Second, a real service would store expenses in a database. The expense id would be a unique key there.
Edge cases & gotchas
- ₹10 split three ways: 1,000 paise does not divide by 3. The equal split gives 334, 333 and 333. The shares still add up to 1,000.
- Exact amounts that do not add up to the total are rejected before anything is saved.
- Percentages that do not add up to 100 are rejected. The last person takes the rounding, so shares add up exactly.
- A retried request with the same expense id is ignored. So a bad network cannot charge someone twice.
- The payer is also in the split. Their share comes off what they paid. Paying ₹1,000 for four people leaves them owed ₹750.
- A person who is not in the group cannot be added to an expense.
- Everyone is already settled: settle up returns no payments.
- Two people add expenses at once. The service lock applies one expense fully before the next. No update is lost.