Skip to content

Low Level DesignWorked problems

Design Splitwise

Design an expense-sharing app like Splitwise step by step for a low level design interview. Grow from one equal split to equal, exact and percent splits that never lose a cent, balances kept as a running ledger, groups with settle-up and deletes, simplifying who owes whom, and expenses added from many phones at once, with code in Java, Python, C#, Go and TypeScript.

Mid-level28 min core · 45 min full depth

“Design Splitwise: friends add shared expenses, the app tracks who owes whom, and they settle up.” Usually followed by “and can you reduce the number of payments?”

Splitwise looks like bookkeeping, and most of it is. But the design questions are small and sharp. How do you split 100.00 three ways without a cent going missing? Where do the rules for equal, exact and percent splits live, so a fourth kind doesn’t touch the rest? How do balances stay right when an expense is deleted or a payment is made? And how do you turn a tangle of debts into a few payments?

Interviewers use this question to see whether you handle money with care, and whether the split logic can take new kinds without an if for each one.

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. Money is whole cents, and every split adds up exactly. 100.00 three ways is 33.34, 33.33 and 33.33. The leftover cents go to someone by a fixed rule, and none disappear.
  2. Each kind of split is its own class. Equal, exact amounts and percentages each turn a total into shares behind one interface. A new kind is a new class.
  3. Balances are a running total. Each expense updates them as it arrives. Deleting an expense applies it again with the signs flipped, so nothing is ever rebuilt from history.
  4. Simplifying debts only needs each person’s net. Match the person who owes the most with the person owed the most, again and again. A group of n people settles in at most n − 1 payments.
  5. One lock per group. Expenses added from several phones at once are all counted.

We’ll build this in four stages, then deal with three phones adding expenses at once.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design Splitwise.

What they are testing

Whether you pin down the kinds of split, and how money is stored, within two minutes. Candidates who start with user profiles, friend requests and notifications rarely reach the balance logic, which is the question.

Here are the questions that matter:

  • Which kinds of split? Equal, exact amounts, and percentages. Shares by weight (“2 adults, 1 child”) would be one more kind.
  • One currency? Yes, with amounts in cents. Several currencies come later as an extension.
  • Who pays? One person pays each expense.
  • Groups? Yes: a trip, a flat. Balances are kept per group. Expenses between two friends are a group of two.
  • Simplify debts? Yes, as a suggested list of payments.

These answers give us one promise that matters more than the others: every split adds up to exactly what was paid, to the cent, and so do the balances. Three smaller promises come with it. An invalid split (exact amounts that don’t add up, percentages that aren’t 100) is refused, and nothing is recorded. Deleting an expense puts every balance back to what it would have been without it. And expenses added at the same moment from several phones are all counted.

We leave out moving real money, receipts and photos, notifications, and currency conversion.

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
Edit and delete expenses? Delete, and edit as delete then add A history of changes is an extension
Does the app move money? No: settling up records a payment made elsewhere Real payments add a payment provider and a “pending” state
How big are groups? A few to a few dozen people Huge groups change nothing; the busy spot is one active group

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).

Interviewerminute 3

Start simple: Ana pays 30.00 for lunch for Ana, Ben and Cy. How do you record it, and how do you answer “how much does Ben owe Ana?”

What they are testing

Whether the payer’s own share is handled (Ana doesn’t owe herself), and whether “owes” has a direction and can be negative. They also notice what type you use for money.

Start with the smallest thing that works. An Expense records who paid, the total in cents, and who shared it. Splitwise keeps the users and the list of expenses.

To answer “how much does Ben owe Ana?”, it goes through every expense. Each one Ana paid that Ben shared adds Ben’s share. Each one Ben paid that Ana shared takes away Ana’s share. A negative answer means Ana owes Ben.

Stage 1: expenses and a balance by replay

Splitwise

-users: Set

-expenses: List<Expense>

+addUser(user)

+addExpense(paidBy, total, people) : Expense

+owes(debtor, creditor) : long

Expense

+id

+paidBy

+total: cents

+people

Splitwise

-users: Set

-expenses: List<Expense>

+addUser(user)

+addExpense(paidBy, total, people) : Expense

+owes(debtor, creditor) : long

Expense

+id

+paidBy

+total: cents

+people

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Splitwise.javasplitwise.pySplitwise.cssplitwise.gosplitwise.ts
// Money is a whole number of cents. A double can't hold 0.10 exactly, and a ledger that drifts
// by a fraction of a cent per expense is wrong by real money after a year of dinners.
record Expense(String id, String paidBy, long total, List<String> people) {}
class Splitwise {
private final Set<String> users = new HashSet<>();
private final List<Expense> expenses = new ArrayList<>();
private final Supplier<String> ids;
Splitwise(Supplier<String> ids) { this.ids = ids; }
void addUser(String user) { users.add(user); }
// flaw 1: only equal splits. Rent is split by room size, a dinner by what each person ate.
// flaw 2: total / n drops cents. 100.00 three ways records 33.33 for each of them, and the
// third cent the payer spent is owed by nobody.
Expense addExpense(String paidBy, long total, List<String> people) {
if (!users.contains(paidBy) || !users.containsAll(people)) return null;
Expense e = new Expense(ids.get(), paidBy, total, people);
expenses.add(e);
return e;
}
// How much the debtor owes the creditor; negative when it's the other way round.
// flaw 3: every question replays every expense the app has ever recorded.
long owes(String debtor, String creditor) {
long sum = 0;
for (Expense e : expenses) {
long share = e.total() / e.people().size();
if (e.paidBy().equals(creditor) && e.people().contains(debtor)) sum += share;
if (e.paidBy().equals(debtor) && e.people().contains(creditor)) sum -= share;
}
return sum;
}
}

It works, but it has three problems, and we leave them in on purpose. Only equal splits exist. Whole-number division drops cents: 100.00 three ways records 33.33 each, and the third cent Ana paid is owed by nobody. And every balance question goes through every expense ever recorded.

Stage 2: Splits that add up, and a running ledger

Section titled “Stage 2: Splits that add up, and a running ledger”
Interviewerminute 7

Rent is split by room size: Ana pays 45%, Ben 35%, Cy 20%. And dinner was 62.40, where Ben had the steak. Add both, and make 100.00 three ways come out right.

What they are testing

Whether each kind of split becomes its own class behind one interface, or an if on a type field inside addExpense. They also want to hear where the leftover cent goes, and that an exact split that doesn’t add up is refused.

Each kind of split becomes a Split: an object that carries its own inputs, and turns a total into a share per person. EqualSplit holds the people, ExactSplit the amounts, PercentSplit the percentages. The expense code asks the split for shares and doesn’t know which kind it has. So a fourth kind (by weight, by nights stayed) is one new class. This is the Strategy pattern.

Every split must return shares that add up to the total exactly. Equal and percent splits share one helper, allocate. It works in three steps:

  1. Work out each person’s share, and round it down to a whole cent.
  2. Add up the rounded shares and see how many cents are left over.
  3. Give the leftover cents to the first people listed, one each.

So 100.00 three ways becomes 33.33 each, which is 99.99, with 1 cent left over. The first person gets it: 33.34, 33.33 and 33.33. Any rule works, as long as it always gives the same answer and nothing is lost.

An exact split checks that its amounts add up to what was paid. A percent split checks that its percentages add up to 100. If not, the expense is refused before anything is recorded.

Balances move into a Balances ledger, which is updated whenever an expense is added. So a question is one lookup, not a trip through history. It’s two-sided: when Ben owes Ana 10.00, owed[ben][ana] is 10.00 and owed[ana][ben] is −10.00. Recording a debt updates both. A person’s net (are they up or down overall?) is the sum of their row, with the sign flipped. Positive means they get money back.

Stage 2: split strategies and a ledger

asks for shares

returns

Splitwise

+addExpense(desc, paidBy, total, Split) : ExpenseResult

+owes(debtor, creditor) : long

«interface»

Split

+shares(total) : Map

EqualSplit

-people

ExactSplit

-amounts

PercentSplit

-percents

Balances

-owed: Map<user,Map>

+record(debtor, creditor, amount)

+owes(debtor, creditor) : long

+net(user) : long

Expense

+id

+paidBy

+total

+shares: Map<user,cents>

«enum»

Result

OK

UNKNOWN_USER

INVALID_SPLIT

asks for shares

returns

Splitwise

+addExpense(desc, paidBy, total, Split) : ExpenseResult

+owes(debtor, creditor) : long

«interface»

Split

+shares(total) : Map

EqualSplit

-people

ExactSplit

-amounts

PercentSplit

-percents

Balances

-owed: Map<user,Map>

+record(debtor, creditor, amount)

+owes(debtor, creditor) : long

+net(user) : long

Expense

+id

+paidBy

+total

+shares: Map<user,cents>

«enum»

Result

OK

UNKNOWN_USER

INVALID_SPLIT

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Balances.javabalances.pyBalances.cspercent_split.gobalances.ts
// How an expense is divided. Each kind of split carries its own inputs (who shares equally, the
// exact amounts, the percentages) and turns a total into a share per person that adds up to
// the total exactly. An impossible split returns null.
interface Split {
Map<String, Long> shares(long total);
}
// Everyone listed pays the same. A name listed twice is a mistake, so the split is refused.
class EqualSplit implements Split {
private final List<String> people;
EqualSplit(List<String> people) { this.people = people; }
public Map<String, Long> shares(long total) {
if (people.isEmpty() || new HashSet<>(people).size() != people.size()) return null;
return Shares.allocate(total, people, Collections.nCopies(people.size(), 1L));
}
}
class ExactSplit implements Split {
private final Map<String, Long> amounts; // insertion order kept by the caller's LinkedHashMap
ExactSplit(Map<String, Long> amounts) { this.amounts = amounts; }
// The amounts are the shares, as long as they add up to what was paid.
public Map<String, Long> shares(long total) {
long sum = amounts.values().stream().mapToLong(Long::longValue).sum();
boolean negative = amounts.values().stream().anyMatch(a -> a < 0);
return sum == total && !negative ? new LinkedHashMap<>(amounts) : null;
}
}
// Each person pays a percentage. The percentages must add up to exactly 100.
class PercentSplit implements Split {
private final Map<String, Integer> percents;
PercentSplit(Map<String, Integer> percents) { this.percents = percents; }
public Map<String, Long> shares(long total) {
int sum = percents.values().stream().mapToInt(Integer::intValue).sum();
if (sum != 100 || percents.values().stream().anyMatch(p -> p < 0)) return null;
List<Long> weights = percents.values().stream().map(Integer::longValue).toList();
return Shares.allocate(total, new ArrayList<>(percents.keySet()), weights);
}
}
// Used by the equal and percent splits: it turns weights into cents that add up exactly.
class Shares {
// Divides the total by weight, rounding each share down, then hands the cents left over to
// the first people listed, one each. Any rule works if it's deterministic and the shares add
// up to the total: 100.00 three ways is 33.34, 33.33 and 33.33, and no cent disappears.
static Map<String, Long> allocate(long total, List<String> people, List<Long> weights) {
long weightSum = weights.stream().mapToLong(Long::longValue).sum();
Map<String, Long> out = new LinkedHashMap<>();
long given = 0;
for (int i = 0; i < people.size(); i++) {
long share = total * weights.get(i) / weightSum;
out.put(people.get(i), share);
given += share;
}
for (int i = 0; given < total; i++, given++) out.merge(people.get(i), 1L, Long::sum);
return out;
}
}
// Who owes whom, kept up to date as expenses arrive, so a question is one lookup. The map is
// two-sided: when Ben owes Ana 10, owed[ben][ana] is 10 and owed[ana][ben] is -10.
class Balances {
private final Map<String, Map<String, Long>> owed = new HashMap<>();
void record(String debtor, String creditor, long amount) {
if (debtor.equals(creditor) || amount == 0) return;
add(debtor, creditor, amount);
add(creditor, debtor, -amount);
}
long owes(String debtor, String creditor) {
return owed.getOrDefault(debtor, Map.of()).getOrDefault(creditor, 0L);
}
// What everyone else owes this user in total: positive gets money back, negative pays.
long net(String user) {
return -owed.getOrDefault(user, Map.of()).values().stream().mapToLong(Long::longValue).sum();
}
private void add(String a, String b, long amount) {
owed.computeIfAbsent(a, k -> new HashMap<>()).merge(b, amount, (x, y) -> x + y == 0 ? null : x + y);
}
}
// One bill, already split. It keeps the shares, not the split rule, so deleting it later undoes
// exactly what it did.
record Expense(String id, String description, String paidBy, long total, Map<String, Long> shares) {}
enum Result { OK, UNKNOWN_USER, INVALID_SPLIT }
// The result, plus the new expense when it worked.
record ExpenseResult(Result result, Expense expense) {}
class Splitwise {
private final Set<String> users = new HashSet<>();
private final Map<String, Expense> expenses = new HashMap<>();
private final Balances balances = new Balances();
private final Supplier<String> ids;
Splitwise(Supplier<String> ids) { this.ids = ids; }
void addUser(String user) { users.add(user); }
// Each person's share is owed to whoever paid. The payer's own share is money they spent on
// themselves, so it creates no debt.
ExpenseResult addExpense(String description, String paidBy, long total, Split split) {
Map<String, Long> shares = split.shares(total);
if (total <= 0 || shares == null) return new ExpenseResult(Result.INVALID_SPLIT, null);
if (!users.contains(paidBy) || !users.containsAll(shares.keySet())) return new ExpenseResult(Result.UNKNOWN_USER, null);
Expense e = new Expense(ids.get(), description, paidBy, total, shares);
expenses.put(e.id(), e);
shares.forEach((person, share) -> balances.record(person, paidBy, share));
return new ExpenseResult(Result.OK, e);
}
long owes(String debtor, String creditor) { return balances.owes(debtor, creditor); }
}

What does a class per split cost? The phone has to build the right split object, so the app needs a way to name the kind and its inputs. In a web API that’s a type field plus the inputs, mapped to a class in one place.

Go deeperAna pays 100.00 for three, step by step1 min

Ana adds an expense of 10,000 cents, split equally between Ana, Ben and Cy. The group asks the EqualSplit for shares and gets 3,334, 3,333 and 3,333, which add up to 10,000. Ana’s own share is skipped: she doesn’t owe herself. The ledger records that Ben owes Ana 3,333 and Cy owes Ana 3,333, updating both sides of each pair.

Sequence: Ana pays 100.00 for three
BalancesEqualSplitSplitwiseAna's phoneBalancesEqualSplitSplitwiseAna's phoneana's own share creates no debtaddExpense(dinner, ana, 10000, equal(ana, ben, cy))shares(10000)ana 3334, ben 3333, cy 3333everyone is a user, shares add uprecord(ben owes ana 3333)record(cy owes ana 3333)OKBalancesEqualSplitSplitwiseAna's phoneBalancesEqualSplitSplitwiseAna's phoneana's own share creates no debtaddExpense(dinner, ana, 10000, equal(ana, ben, cy))shares(10000)ana 3334, ben 3333, cy 3333everyone is a user, shares add uprecord(ben owes ana 3333)record(cy owes ana 3333)OK
Scroll or pinch to zoom, drag to pan
  • Color key
  • Entity
  • Implementation
  • Facade

Stage 3: Groups, deleting, and settling up

Section titled “Stage 3: Groups, deleting, and settling up”
Interviewerminute 13

The flat and the ski trip should have separate balances. Ben entered a taxi twice, so he wants to delete one. And Ben pays Ana back 15.00. Handle all three.

What they are testing

Whether deleting is the exact reverse of adding, and whether a payment is recorded in the same ledger as expenses. Rebuilding all balances from the remaining expenses works, but they listen for why it isn’t needed.

A Group owns its members, expenses, payments and its own Balances. So the flat’s bills never mix with the trip’s. Adding an expense now also checks that everyone in it is a member. Splitwise becomes the front door that knows the users and creates groups.

Deleting an expense applies it again with every share made negative. Ben’s 33.33 debt to Ana goes back to 0, and every other balance is untouched. Because the ledger is a running total, undoing one entry is as cheap as adding it, and nothing is rebuilt from history. Deleting twice finds no expense the second time, so it can’t be undone twice.

Settling up records a Payment: Ben paid Ana 15.00. In the ledger, that’s Ana owing Ben 15.00, which cancels 15.00 of what Ben owed her. Any positive amount is accepted. Friends pay in round numbers, and paying a little too much simply flips who owes whom, which is what really happened.

Stage 3: groups, payments, and deletes

Splitwise

-users

-groups: Map<name,Group>

+createGroup(name, members) : Group

Group

-members

-expenses: Map<id,Expense>

-payments: List<Payment>

+addExpense(desc, paidBy, total, Split) : ExpenseResult

+deleteExpense(id) : Result

+settle(from, to, amount) : Result

+owes(debtor, creditor) : long

+net(member) : long

Balances

+record(debtor, creditor, amount)

«value»

Payment

+from

+to

+amount

Expense

«interface»

Split

Splitwise

-users

-groups: Map<name,Group>

+createGroup(name, members) : Group

Group

-members

-expenses: Map<id,Expense>

-payments: List<Payment>

+addExpense(desc, paidBy, total, Split) : ExpenseResult

+deleteExpense(id) : Result

+settle(from, to, amount) : Result

+owes(debtor, creditor) : long

+net(member) : long

Balances

+record(debtor, creditor, amount)

«value»

Payment

+from

+to

+amount

Expense

«interface»

Split

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Group.javagroup.pyGroup.csgroup.gogroup.ts
// What a request gets back, so the app can say what went wrong.
enum Result { OK, NOT_A_MEMBER, INVALID_SPLIT, INVALID_AMOUNT, NO_SUCH_EXPENSE }
// Money moving between two people outside of any expense: Ben pays Ana back.
record Payment(String from, String to, long amount) {}
// A trip, a flat, a couple. Expenses and payments belong to one group, and so do the balances,
// so the flat's electricity bill never mixes with the ski trip.
class Group {
private final String name;
private final Set<String> members;
private final Map<String, Expense> expenses = new LinkedHashMap<>();
private final List<Payment> payments = new ArrayList<>();
private final Balances balances = new Balances();
private final Supplier<String> ids;
Group(String name, Set<String> members, Supplier<String> ids) {
this.name = name;
this.members = members;
this.ids = ids;
}
// Check everything first, then change the balances. A bad expense changes nothing.
ExpenseResult addExpense(String description, String paidBy, long total, Split split) {
Map<String, Long> shares = split.shares(total);
if (total <= 0 || shares == null) return new ExpenseResult(Result.INVALID_SPLIT, null);
if (!members.contains(paidBy) || !members.containsAll(shares.keySet())) return new ExpenseResult(Result.NOT_A_MEMBER, null);
Expense e = new Expense(ids.get(), description, paidBy, total, shares);
expenses.put(e.id(), e);
apply(e, 1);
return new ExpenseResult(Result.OK, e);
}
// A mistake is undone by applying the expense again with the sign flipped, so balances stay
// a running sum and never need rebuilding from history. Deleting twice finds nothing.
Result deleteExpense(String expenseId) {
Expense e = expenses.remove(expenseId);
if (e == null) return Result.NO_SUCH_EXPENSE;
apply(e, -1);
return Result.OK;
}
// Paying someone back is recorded as money owed the other way. Any positive amount is
// accepted: friends settle in round numbers, and paying a little too much just flips who
// owes whom.
Result settle(String from, String to, long amount) {
if (!members.contains(from) || !members.contains(to)) return Result.NOT_A_MEMBER;
if (amount <= 0 || from.equals(to)) return Result.INVALID_AMOUNT;
payments.add(new Payment(from, to, amount));
balances.record(to, from, amount);
return Result.OK;
}
long owes(String debtor, String creditor) { return balances.owes(debtor, creditor); }
long net(String member) { return balances.net(member); }
Set<String> members() { return members; }
// Each person's share is money they owe the payer. sign is 1 to add the expense, -1 to undo it.
private void apply(Expense e, int sign) {
e.shares().forEach((person, share) -> balances.record(person, e.paidBy(), sign * share));
}
}
// The front door: who has an account, and which groups exist.
class Splitwise {
private final Set<String> users = new HashSet<>();
private final Map<String, Group> groups = new HashMap<>();
private final Supplier<String> ids;
Splitwise(Supplier<String> ids) { this.ids = ids; }
void addUser(String user) { users.add(user); }
Group createGroup(String name, Set<String> members) {
if (!users.containsAll(members)) return null;
Group g = new Group(name, new HashSet<>(members), ids);
groups.put(name, g);
return g;
}
}
Go deeperWhy not store only each person's net?1 min

Because people ask “what do I owe Ben?”, not just “am I up or down?”. A net per person can’t answer that. The two-sided ledger answers both: a pair is one lookup, and a net is the sum of a row. Stage 4 needs only the nets. The app’s screens need the pairs.

Go deeperHow does editing an expense work?1 min

As a delete of the old expense and an add of the corrected one, together in one step under the group’s lock. The ledger sees two entries that add up to the change. Keeping the old version in a history list gives the “Ben changed the taxi from 40.00 to 20.00” line in the activity feed for free.

Interviewerminute 19

After a week away, Ana owes Ben, Ben owes Cy, Cy owes Dee, and a few more. Twelve payments to sort it out. Can you do it in fewer?

What they are testing

Whether you see that only each person’s net matters, and know a simple rule that needs at most n − 1 payments. Strong candidates also say that the true minimum is a very hard problem, and that the simple rule is what apps use.

Who owed whom along the way doesn’t matter for settling up. What matters is each person’s net: how much they’re up or down overall. And the nets of a group always add up to zero, because every cent someone owes, someone else is owed.

A small example: Ana owes Ben 10.00, and Ben owes Cy 10.00. Ben’s net is zero: he’s just passing money along. So Ana can pay Cy directly, and that’s one payment instead of two.

The rule is simple:

  1. Take the person who owes the most, and the person who is owed the most.
  2. One pays the other the smaller of their two amounts. Now at least one of them is settled.
  3. Repeat with whoever is left.

Every payment settles at least one person, so a group of n people needs at most n − 1 payments, however tangled the debts were. Ties are broken by name, so the suggestion is the same every time the screen opens. A rule like this, which always takes the biggest option in front of it, is called greedy.

Let’s run it on a small group. After the trip, the nets are: Ana −50, Ben −30, Cy +20, Dee +60. They add up to zero, as they always do.

  • Ana owes the most (50), and Dee is owed the most (60). Ana pays Dee 50. Ana is settled; Dee is still owed 10.
  • Now Ben owes the most (30), and Cy is owed the most (20). Ben pays Cy 20. Cy is settled; Ben still owes 10.
  • Ben owes 10, and Dee is owed 10. Ben pays Dee 10. Everyone is settled.

Three payments for four people, which is n − 1, however many expenses the trip had.

Stage 4: the same debts, before and after simplifying

Before

After

5.00

5.00

10.00

10.00

5.00

Ana

Cy

Dee

Ana

Ben

Cy

Dee

Before

After

5.00

5.00

10.00

10.00

5.00

Ana

Cy

Dee

Ana

Ben

Cy

Dee

Scroll or pinch to zoom, drag to pan

The suggestions are a view, not a change. Nothing is recorded until someone pays, and a payment is recorded with settle, like any other. When Ana pays Cy, the ledger shows a debt between two people who never shared an expense. That’s fine: every net moves toward zero, and once all the suggested payments are made, every net is zero.

Stage 4: a simplifier over the group's nets

reads nets

suggests

DebtSimplifier

+simplify(Group) : List<Transfer>

Group

+members()

+net(member) : long

«value»

Transfer

+from

+to

+amount

reads nets

suggests

DebtSimplifier

+simplify(Group) : List<Transfer>

Group

+members()

+net(member) : long

«value»

Transfer

+from

+to

+amount

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
DebtSimplifier.javatransfer.pyDebtSimplifier.cstransfer.gotransfer.ts
// One payment the simplifier suggests: `from` pays `to` this amount.
record Transfer(String from, String to, long amount) {}
// After a weekend away, the pairwise debts can form a tangle: Ana owes Ben, Ben owes Cy, Cy owes
// Dee. Only each person's net matters: how much they are up or down overall. Matching the
// biggest debtor with the biggest creditor, again and again, settles everyone. Each transfer
// clears at least one person, so a group of n needs at most n - 1 transfers.
class DebtSimplifier {
static List<Transfer> simplify(Group g) {
// Biggest amount first; ties broken by name so the answer is the same every time.
Comparator<Map.Entry<String, Long>> biggest = Map.Entry.<String, Long>comparingByValue().reversed()
.thenComparing(Map.Entry.comparingByKey());
PriorityQueue<Map.Entry<String, Long>> debtors = new PriorityQueue<>(biggest);
PriorityQueue<Map.Entry<String, Long>> creditors = new PriorityQueue<>(biggest);
for (String m : g.members()) {
long net = g.net(m);
if (net < 0) debtors.add(Map.entry(m, -net));
if (net > 0) creditors.add(Map.entry(m, net));
}
List<Transfer> out = new ArrayList<>();
while (!debtors.isEmpty()) {
var d = debtors.poll();
var c = creditors.poll();
long amount = Math.min(d.getValue(), c.getValue());
out.add(new Transfer(d.getKey(), c.getKey(), amount));
// Whoever isn't fully settled goes back in with what's left.
if (d.getValue() > amount) debtors.add(Map.entry(d.getKey(), d.getValue() - amount));
if (c.getValue() > amount) creditors.add(Map.entry(c.getKey(), c.getValue() - amount));
}
return out;
}
}

What does greedy cost? It isn’t always the smallest number of payments. Here’s a case: Ana and Ben owe 3.00 each, and Cy owes 4.00. Dee is owed 6.00, and Eve 4.00. Greedy pairs Cy with Dee first and ends up needing four payments. But Cy paying Eve, and Ana and Ben each paying Dee, takes three. Finding the true minimum means splitting people into small groups whose nets add up to zero, and that’s a famously hard problem (computer scientists call it NP-hard). Real apps use greedy.

Go deeperFinding the true minimum, step by step2 min

Greedy needs at most n − 1 payments. When can we do better? Look at the counter-example again. Cy owes 4.00 and Eve is owed 4.00. Those two people form a small group of their own whose nets add up to zero: one payment settles them both. Ana, Ben and Dee form another: 3 + 3 = 6. Two payments settle those three.

That’s the whole idea. A group whose nets add up to zero can always be settled in “its size minus one” payments. So if the people can be split into k such groups, the total is n − k payments. More groups means fewer payments. Greedy found only one big group (n − 1 = 4 payments). The best split found two (5 − 2 = 3 payments).

To find the best split, a program would try every way of dividing the people into groups that each add up to zero, and keep the one with the most groups. Here’s why that’s slow: with 20 people, there are over a million subsets to check, and the number doubles with each person added. No one knows a fast way to do it for every case, which is what “NP-hard” means in practice.

For a group of a dozen friends, it can still be done in well under a second, by checking small subsets first and remembering which sets add up to zero. But it usually saves only one or two payments, and the suggestions can look strange. So most apps stay with greedy, and some offer the exact search only for small groups.

This is enough for a mid-level answer. Money is in cents, each split is a strategy that checks its inputs, the ledger is a running total, and debts are simplified with nets. The rest of the page is what makes it a senior answer.

Here is everything together. Each class is here because a requirement needed it.

Splitwise: full class diagram

returns

Splitwise

-users

-groups

+addUser(user)

+createGroup(name, members) : Group

SharedGroup

-lock

+addExpense(...)

+deleteExpense(id)

+settle(from, to, amount)

+simplify() : List

Group

-members

-expenses

-payments

+addExpense(desc, paidBy, total, Split) : ExpenseResult

+deleteExpense(id) : Result

+settle(from, to, amount) : Result

+owes(debtor, creditor) : long

+net(member) : long

Balances

-owed: Map<user,Map>

+record(debtor, creditor, amount)

+owes(debtor, creditor) : long

+net(user) : long

Expense

+id

+description

+paidBy

+total

+shares

«value»

Payment

«value»

Transfer

«interface»

Split

+shares(total) : Map

EqualSplit

ExactSplit

PercentSplit

DebtSimplifier

+simplify(Group) : List

«enum»

Result

returns

Splitwise

-users

-groups

+addUser(user)

+createGroup(name, members) : Group

SharedGroup

-lock

+addExpense(...)

+deleteExpense(id)

+settle(from, to, amount)

+simplify() : List

Group

-members

-expenses

-payments

+addExpense(desc, paidBy, total, Split) : ExpenseResult

+deleteExpense(id) : Result

+settle(from, to, amount) : Result

+owes(debtor, creditor) : long

+net(member) : long

Balances

-owed: Map<user,Map>

+record(debtor, creditor, amount)

+owes(debtor, creditor) : long

+net(user) : long

Expense

+id

+description

+paidBy

+total

+shares

«value»

Payment

«value»

Transfer

«interface»

Split

+shares(total) : Map

EqualSplit

ExactSplit

PercentSplit

DebtSimplifier

+simplify(Group) : List

«enum»

Result

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
splitwise· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.concurrent.locks.*;
import java.util.function.*;
// One bill, already split. It keeps the shares, not the split rule, so deleting it later undoes
// exactly what it did.
record Expense(String id, String description, String paidBy, long total, Map<String, Long> shares) {}
// The front door: who has an account, and which groups exist.
class Splitwise {
private final Set<String> users = new HashSet<>();
private final Map<String, Group> groups = new HashMap<>();
private final Supplier<String> ids;
Splitwise(Supplier<String> ids) { this.ids = ids; }
void addUser(String user) { users.add(user); }
Group createGroup(String name, Set<String> members) {
if (!users.containsAll(members)) return null;
Group g = new Group(name, new HashSet<>(members), ids);
groups.put(name, g);
return g;
}
}
// How an expense is divided. Each kind of split carries its own inputs (who shares equally, the
// exact amounts, the percentages) and turns a total into a share per person that adds up to
// the total exactly. An impossible split returns null.
interface Split {
Map<String, Long> shares(long total);
}
// Everyone listed pays the same. A name listed twice is a mistake, so the split is refused.
class EqualSplit implements Split {
private final List<String> people;
EqualSplit(List<String> people) { this.people = people; }
public Map<String, Long> shares(long total) {
if (people.isEmpty() || new HashSet<>(people).size() != people.size()) return null;
return Shares.allocate(total, people, Collections.nCopies(people.size(), 1L));
}
}
class ExactSplit implements Split {
private final Map<String, Long> amounts; // insertion order kept by the caller's LinkedHashMap
ExactSplit(Map<String, Long> amounts) { this.amounts = amounts; }
// The amounts are the shares, as long as they add up to what was paid.
public Map<String, Long> shares(long total) {
long sum = amounts.values().stream().mapToLong(Long::longValue).sum();
boolean negative = amounts.values().stream().anyMatch(a -> a < 0);
return sum == total && !negative ? new LinkedHashMap<>(amounts) : null;
}
}
// Each person pays a percentage. The percentages must add up to exactly 100.
class PercentSplit implements Split {
private final Map<String, Integer> percents;
PercentSplit(Map<String, Integer> percents) { this.percents = percents; }
public Map<String, Long> shares(long total) {
int sum = percents.values().stream().mapToInt(Integer::intValue).sum();
if (sum != 100 || percents.values().stream().anyMatch(p -> p < 0)) return null;
List<Long> weights = percents.values().stream().map(Integer::longValue).toList();
return Shares.allocate(total, new ArrayList<>(percents.keySet()), weights);
}
}
// Used by the equal and percent splits: it turns weights into cents that add up exactly.
class Shares {
// Divides the total by weight, rounding each share down, then hands the cents left over to
// the first people listed, one each. Any rule works if it's deterministic and the shares add
// up to the total: 100.00 three ways is 33.34, 33.33 and 33.33, and no cent disappears.
static Map<String, Long> allocate(long total, List<String> people, List<Long> weights) {
long weightSum = weights.stream().mapToLong(Long::longValue).sum();
Map<String, Long> out = new LinkedHashMap<>();
long given = 0;
for (int i = 0; i < people.size(); i++) {
long share = total * weights.get(i) / weightSum;
out.put(people.get(i), share);
given += share;
}
for (int i = 0; given < total; i++, given++) out.merge(people.get(i), 1L, Long::sum);
return out;
}
}
// Who owes whom, kept up to date as expenses arrive, so a question is one lookup. The map is
// two-sided: when Ben owes Ana 10, owed[ben][ana] is 10 and owed[ana][ben] is -10.
class Balances {
private final Map<String, Map<String, Long>> owed = new HashMap<>();
void record(String debtor, String creditor, long amount) {
if (debtor.equals(creditor) || amount == 0) return;
add(debtor, creditor, amount);
add(creditor, debtor, -amount);
}
long owes(String debtor, String creditor) {
return owed.getOrDefault(debtor, Map.of()).getOrDefault(creditor, 0L);
}
// What everyone else owes this user in total: positive gets money back, negative pays.
long net(String user) {
return -owed.getOrDefault(user, Map.of()).values().stream().mapToLong(Long::longValue).sum();
}
private void add(String a, String b, long amount) {
owed.computeIfAbsent(a, k -> new HashMap<>()).merge(b, amount, (x, y) -> x + y == 0 ? null : x + y);
}
}
// What a request gets back, so the app can say what went wrong.
enum Result { OK, NOT_A_MEMBER, INVALID_SPLIT, INVALID_AMOUNT, NO_SUCH_EXPENSE }
// The result, plus the new expense when it worked.
record ExpenseResult(Result result, Expense expense) {}
// Money moving between two people outside of any expense: Ben pays Ana back.
record Payment(String from, String to, long amount) {}
// A trip, a flat, a couple. Expenses and payments belong to one group, and so do the balances,
// so the flat's electricity bill never mixes with the ski trip.
class Group {
private final String name;
private final Set<String> members;
private final Map<String, Expense> expenses = new LinkedHashMap<>();
private final List<Payment> payments = new ArrayList<>();
private final Balances balances = new Balances();
private final Supplier<String> ids;
Group(String name, Set<String> members, Supplier<String> ids) {
this.name = name;
this.members = members;
this.ids = ids;
}
// Check everything first, then change the balances. A bad expense changes nothing.
ExpenseResult addExpense(String description, String paidBy, long total, Split split) {
Map<String, Long> shares = split.shares(total);
if (total <= 0 || shares == null) return new ExpenseResult(Result.INVALID_SPLIT, null);
if (!members.contains(paidBy) || !members.containsAll(shares.keySet())) return new ExpenseResult(Result.NOT_A_MEMBER, null);
Expense e = new Expense(ids.get(), description, paidBy, total, shares);
expenses.put(e.id(), e);
apply(e, 1);
return new ExpenseResult(Result.OK, e);
}
// A mistake is undone by applying the expense again with the sign flipped, so balances stay
// a running sum and never need rebuilding from history. Deleting twice finds nothing.
Result deleteExpense(String expenseId) {
Expense e = expenses.remove(expenseId);
if (e == null) return Result.NO_SUCH_EXPENSE;
apply(e, -1);
return Result.OK;
}
// Paying someone back is recorded as money owed the other way. Any positive amount is
// accepted: friends settle in round numbers, and paying a little too much just flips who
// owes whom.
Result settle(String from, String to, long amount) {
if (!members.contains(from) || !members.contains(to)) return Result.NOT_A_MEMBER;
if (amount <= 0 || from.equals(to)) return Result.INVALID_AMOUNT;
payments.add(new Payment(from, to, amount));
balances.record(to, from, amount);
return Result.OK;
}
long owes(String debtor, String creditor) { return balances.owes(debtor, creditor); }
long net(String member) { return balances.net(member); }
Set<String> members() { return members; }
// Each person's share is money they owe the payer. sign is 1 to add the expense, -1 to undo it.
private void apply(Expense e, int sign) {
e.shares().forEach((person, share) -> balances.record(person, e.paidBy(), sign * share));
}
}
// One payment the simplifier suggests: `from` pays `to` this amount.
record Transfer(String from, String to, long amount) {}
// After a weekend away, the pairwise debts can form a tangle: Ana owes Ben, Ben owes Cy, Cy owes
// Dee. Only each person's net matters: how much they are up or down overall. Matching the
// biggest debtor with the biggest creditor, again and again, settles everyone. Each transfer
// clears at least one person, so a group of n needs at most n - 1 transfers.
class DebtSimplifier {
static List<Transfer> simplify(Group g) {
// Biggest amount first; ties broken by name so the answer is the same every time.
Comparator<Map.Entry<String, Long>> biggest = Map.Entry.<String, Long>comparingByValue().reversed()
.thenComparing(Map.Entry.comparingByKey());
PriorityQueue<Map.Entry<String, Long>> debtors = new PriorityQueue<>(biggest);
PriorityQueue<Map.Entry<String, Long>> creditors = new PriorityQueue<>(biggest);
for (String m : g.members()) {
long net = g.net(m);
if (net < 0) debtors.add(Map.entry(m, -net));
if (net > 0) creditors.add(Map.entry(m, net));
}
List<Transfer> out = new ArrayList<>();
while (!debtors.isEmpty()) {
var d = debtors.poll();
var c = creditors.poll();
long amount = Math.min(d.getValue(), c.getValue());
out.add(new Transfer(d.getKey(), c.getKey(), amount));
// Whoever isn't fully settled goes back in with what's left.
if (d.getValue() > amount) debtors.add(Map.entry(d.getKey(), d.getValue() - amount));
if (c.getValue() > amount) creditors.add(Map.entry(c.getKey(), c.getValue() - amount));
}
return out;
}
}
// Three friends add dinner, the taxi and the bar tab from their phones at the same moment. Each
// expense reads a balance and writes it back, and two that interleave lose one update: the
// group's balances no longer add up to its expenses, and nobody can say which is right. Every
// operation on a group must run alone. A group sees a handful of writes a minute, so one lock
// per group is plenty, and different groups never wait on each other.
class SharedGroup {
private final Group group;
private final ReentrantLock lock = new ReentrantLock();
SharedGroup(Group group) { this.group = group; }
ExpenseResult addExpense(String description, String paidBy, long total, Split split) {
return locked(() -> group.addExpense(description, paidBy, total, split));
}
Result deleteExpense(String id) { return locked(() -> group.deleteExpense(id)); }
Result settle(String from, String to, long amount) { return locked(() -> group.settle(from, to, amount)); }
long owes(String debtor, String creditor) { return locked(() -> group.owes(debtor, creditor)); }
List<Transfer> simplify() { return locked(() -> DebtSimplifier.simplify(group)); }
// Runs one operation with the lock held. finally releases it even if the operation throws.
private <T> T locked(Supplier<T> op) {
lock.lock();
try {
return op.get();
} finally {
lock.unlock();
}
}
}
Read onlyJava 21Every file builds in CI16 files248 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI13 files256 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI15 files219 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI15 files350 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI13 files268 lines

Three ideas hold it together:

  • Every amount adds up. Shares add up to the expense, and the ledger’s entries for an expense add up to zero. So a group’s nets always add up to zero.
  • The ledger only ever adds. Expenses, deletes and payments are all entries in the same running total. So any balance is correct without going back through history.
  • Rules are strategies; views are worked out. Splits plug in (Strategy). Simplified payments are worked out from nets when asked for, and never stored. And the app only calls Splitwise and Group, simple front doors (a facade).

So far, one phone adds expenses at a time. But at the end of a trip, everyone adds their last receipts at once.

Interviewerminute 24

At the end of the trip, three people add the last expenses from their phones at the same moment. What can go wrong?

What they are testing

Whether you see the lost update on the ledger, choose a sensible thing to lock, and, for a database answer, think about a phone that retries after a lost reply and adds dinner twice.

Each expense reads a balance, adds a share, and writes it back. Two expenses that run at the same moment both read Ben’s debt to Ana as 20.00. Both add their share. Then one write overwrites the other. This is called a lost update. Ben’s balance is now wrong, the group’s nets no longer add up to zero, and the app can’t tell which number is right. With several shares per expense, someone reading balances can also see an expense that’s only half applied.

The fix, and the one we choose, is one lock per group. Every operation on a group runs alone. A group sees a handful of changes a minute, so nobody waits in practice. And no expense spans two groups, so different groups never wait for each other. SharedGroup adds the lock around Group, so the group’s own code stays simple.

Sequence: two expenses at once, with a lock per group
Cy's phoneSharedGroup (ski trip)Ana's phoneCy's phoneSharedGroup (ski trip)Ana's phonethe taxi holds the group lock, drinks waitdrinks runs on the updated balancesaddExpense(taxi)addExpense(drinks)OK, every share recordedOKCy's phoneSharedGroup (ski trip)Ana's phoneCy's phoneSharedGroup (ski trip)Ana's phonethe taxi holds the group lock, drinks waitdrinks runs on the updated balancesaddExpense(taxi)addExpense(drinks)OK, every share recordedOK
Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
Option How Correct? Cost
1. One lock per group (our choice in memory) Every SharedGroup method holds the lock Yes One server owns each group
2. Store entries, add up when reading Insert expense and share rows in a transaction; balances are SUM queries Yes Reads do more work; see the TypeScript tab
3. Balance rows updated in place UPDATE balances SET amount = amount + $1 per pair, in one transaction Yes Two expenses touching the same pairs in opposite order can block each other forever
4. A version number on the group Write only if the group’s version is unchanged, else retry Yes Retries during a busy end-of-trip rush
5. Read the balance, add, write it back Two separate statements No: lost updates None, which is why it ships by mistake
SharedGroup.javashared_group.pySharedGroup.csshared_group.godb.ts
// Three friends add dinner, the taxi and the bar tab from their phones at the same moment. Each
// expense reads a balance and writes it back, and two that interleave lose one update: the
// group's balances no longer add up to its expenses, and nobody can say which is right. Every
// operation on a group must run alone. A group sees a handful of writes a minute, so one lock
// per group is plenty, and different groups never wait on each other.
class SharedGroup {
private final Group group;
private final ReentrantLock lock = new ReentrantLock();
SharedGroup(Group group) { this.group = group; }
ExpenseResult addExpense(String description, String paidBy, long total, Split split) {
return locked(() -> group.addExpense(description, paidBy, total, split));
}
Result deleteExpense(String id) { return locked(() -> group.deleteExpense(id)); }
Result settle(String from, String to, long amount) { return locked(() -> group.settle(from, to, amount)); }
long owes(String debtor, String creditor) { return locked(() -> group.owes(debtor, creditor)); }
List<Transfer> simplify() { return locked(() -> DebtSimplifier.simplify(group)); }
// Runs one operation with the lock held. finally releases it even if the operation throws.
private <T> T locked(Supplier<T> op) {
lock.lock();
try {
return op.get();
} finally {
lock.unlock();
}
}
}

Why is this correct? Every change to a group holds the group’s lock from its first read to its last write. So no two expenses can read the same old balance, and nobody can read an expense that’s only half applied.

What does it cost? In-memory locks only work on one server, so each group must live on one server, or move to a database.

Go deeperThe database version, step by step1 min

In TypeScript, the in-memory Group needs no lock: addExpense updates every balance with no await in between. So the TypeScript tab shows the database version instead.

It stores no balances at all, only expense rows and share rows. A balance is a sum over the share rows, worked out when someone asks. So there’s no total for two writers to overwrite: each expense just adds new rows.

It also handles the retry. The phone sends a request ID it chose once for this expense. The expense table has a rule that this column must be unique. So a second attempt with the same ID is refused by the database, instead of becoming a second dinner. The phone treats that refusal as “already saved”.

Every test checks exact cents. The tests that matter:

  • 100.00 split three ways gives 33.34, 33.33 and 33.33, and the balances match.
  • Exact amounts that don’t add up, and percentages that aren’t 100, are refused.
  • 99.99 split 50/30/20 gives 50.00, 30.00 and 19.99, with no cent lost.
  • Debts in both directions net out to one number.
  • Someone outside the group can’t be part of an expense.
  • Settling up lowers the debt; a zero payment is refused.
  • Deleting an expense restores the balances, and deleting twice is refused.
  • A chain of debts simplifies to one payment, and a tangled group’s suggested payments leave every net at zero, in at most n − 1 payments.
  • 160 expenses added from 16 threads are all counted.
Go deeperThe tests in five languages2 min

The simplification test checks a property, not a fixed answer. It applies whatever payments the simplifier suggests, then checks that everyone is at zero. So a better algorithm later passes the same test.

Main.javaexample.pyProgram.csmain.goexample.ts
AtomicInteger nextId = new AtomicInteger();
Group trip;
@BeforeEach
void setUp() {
Splitwise app = new Splitwise(() -> "e" + nextId.incrementAndGet());
for (String u : List.of("ana", "ben", "cy", "dee", "eve")) app.addUser(u);
trip = app.createGroup("ski trip", Set.of("ana", "ben", "cy", "dee"));
}
// An ordered map, so remainder cents go to the people in the order they were listed.
static <V> Map<String, V> ordered(Object... kv) {
Map<String, V> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) m.put((String) kv[i], (V) kv[i + 1]);
return m;
}
Expense add(String paidBy, long total, Split split) {
ExpenseResult r = trip.addExpense("x", paidBy, total, split);
assertEquals(Result.OK, r.result());
return r.expense();
}
@Test
void anEqualSplitLosesNoCents() {
Expense e = add("ana", 10000, new EqualSplit(List.of("ana", "ben", "cy")));
assertEquals(List.of(3334L, 3333L, 3333L), List.copyOf(e.shares().values()));
assertEquals(3333, trip.owes("ben", "ana"));
assertEquals(-3333, trip.owes("ana", "cy")); // negative: it's the other way round
}
@Test
void exactAmountsMustAddUpToWhatWasPaid() {
ExpenseResult r = trip.addExpense("x", "ana", 5000, new ExactSplit(ordered("ben", 2000L, "cy", 2000L)));
assertEquals(Result.INVALID_SPLIT, r.result());
add("ana", 5000, new ExactSplit(ordered("ben", 2000L, "cy", 3000L)));
assertEquals(3000, trip.owes("cy", "ana"));
}
@Test
void percentagesRoundWithoutLosingTheTotal() {
Expense e = add("ana", 9999, new PercentSplit(ordered("ana", 50, "ben", 30, "cy", 20)));
assertEquals(List.of(5000L, 3000L, 1999L), List.copyOf(e.shares().values()));
ExpenseResult bad = trip.addExpense("x", "ana", 100, new PercentSplit(ordered("ana", 50, "ben", 40)));
assertEquals(Result.INVALID_SPLIT, bad.result());
}
@Test
void debtsInBothDirectionsNetOut() {
add("ana", 3000, new EqualSplit(List.of("ana", "ben")));
add("ben", 1000, new EqualSplit(List.of("ana", "ben")));
assertEquals(1000, trip.owes("ben", "ana"));
}
@Test
void someoneOutsideTheGroupCantBeAdded() {
ExpenseResult r = trip.addExpense("x", "ana", 1000, new EqualSplit(List.of("ana", "eve")));
assertEquals(Result.NOT_A_MEMBER, r.result());
}
@Test
void settlingUpRecordsAPayment() {
add("ana", 4000, new EqualSplit(List.of("ana", "ben")));
assertEquals(Result.OK, trip.settle("ben", "ana", 1500));
assertEquals(500, trip.owes("ben", "ana"));
assertEquals(Result.INVALID_AMOUNT, trip.settle("ben", "ana", 0));
}
@Test
void deletingAnExpenseUndoesItOnce() {
Expense e = add("ana", 4000, new EqualSplit(List.of("ana", "ben")));
add("ben", 1000, new ExactSplit(ordered("ana", 1000L)));
assertEquals(Result.OK, trip.deleteExpense(e.id()));
assertEquals(1000, trip.owes("ana", "ben"));
assertEquals(Result.NO_SUCH_EXPENSE, trip.deleteExpense(e.id()));
}
@Test
void aChainOfDebtsBecomesOneTransfer() {
add("ben", 1000, new ExactSplit(ordered("ana", 1000L))); // ana owes ben
add("cy", 1000, new ExactSplit(ordered("ben", 1000L))); // ben owes cy
assertEquals(List.of(new Transfer("ana", "cy", 1000)), DebtSimplifier.simplify(trip));
}
@Test
void simplifiedTransfersSettleEveryone() {
add("ana", 12000, new EqualSplit(List.of("ana", "ben", "cy", "dee")));
add("ben", 8000, new ExactSplit(ordered("cy", 5000L, "dee", 3000L)));
add("cy", 2000, new EqualSplit(List.of("ana", "dee")));
List<Transfer> plan = DebtSimplifier.simplify(trip);
assertTrue(plan.size() <= 3);
for (Transfer t : plan) trip.settle(t.from(), t.to(), t.amount());
for (String m : trip.members()) assertEquals(0, trip.net(m));
}
@Test
void expensesAddedAtOnceAreAllCounted() throws Exception {
SharedGroup shared = new SharedGroup(trip);
ExecutorService pool = Executors.newFixedThreadPool(16);
CountDownLatch start = new CountDownLatch(1);
List<Future<?>> done = new ArrayList<>();
for (int i = 0; i < 160; i++) {
done.add(pool.submit(() -> {
start.await();
return shared.addExpense("coffee", "ana", 100, new EqualSplit(List.of("ana", "ben")));
}));
}
start.countDown();
for (Future<?> f : done) f.get();
pool.shutdown();
assertEquals(8000, shared.owes("ben", "ana"));
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
100.00 three ways 33.34, 33.33, 33.33 allocate
Exact amounts don’t add up INVALID_SPLIT, nothing recorded ExactSplit.shares
Percentages aren’t 100, or negative INVALID_SPLIT PercentSplit.shares
Same person listed twice in an equal split INVALID_SPLIT EqualSplit.shares
Zero or negative total INVALID_SPLIT Group.addExpense
Someone outside the group NOT_A_MEMBER Group.addExpense
Payer not among those who shared Allowed: everyone else owes the payer their share Group.addExpense
Delete twice NO_SUCH_EXPENSE, reversed once Group.deleteExpense
Pay back more than owed Allowed: the debt flips direction Group.settle
Pay yourself, or pay zero INVALID_AMOUNT Group.settle
Everyone already settled simplify returns no payments DebtSimplifier
Phone retries an add Recorded once, by request ID the database version
Decision We chose We gave up
Money Whole cents, leftovers given by a fixed rule Fractions of a cent, which nobody can pay anyway
Kinds of split A strategy per kind, checking its own inputs One switch that’s quicker to write
Balances A two-sided ledger per pair, updated as a running total A replay that needs no extra data
Deleting Apply the expense again, made negative Nothing; it’s exact
Settling up A payment in the same ledger The app doesn’t move money
Fewer payments Greedy on nets, at most n − 1 The true minimum, which is NP-hard
Several phones Lock per group; entries and a request ID in the database Each group’s ledger lives in one place
Go deeperGenuinely different designs, and when to pick them2 min

An event log as the source of truth. Store every expense, delete and payment as an event that never changes, and work out balances by going through them, saving the result. It gives a perfect history and makes edits easy to audit. It’s the database version taken further, and what larger apps do.

Nets only. Store only each person’s net per group. It’s smaller and makes simplifying trivial, but it can’t answer “what do I owe Ben?”. Apps that always show simplified debts can use it. Apps that show who owes whom, like Splitwise, can’t.

The exact minimum number of payments. For small groups, search for the largest number of sub-groups whose nets add up to zero (each settles in its size minus one payments). The search time grows very fast, so it only fits groups of about a dozen, and it usually saves just one or two payments over greedy.

  • 0 to 3. Kinds of split, cents, groups, and whether the app moves money.
  • 3 to 5. The promises. Name “every split adds up” as the hard one.
  • 5 to 23. One equal split, split strategies and the ledger, groups with delete and settle, then simplifying. Code allocate, the ledger’s record, and simplify.
  • 23 to 25. Walk through the full diagram: everything adds up, the ledger only adds.
  • 25 to 32. Three phones at once: a lock per group, and the database version with a request ID.
  • 32 to 45. Currencies or several payers, then your own questions.

If time is short, skip deleting and spend it on the split strategies and simplifying. Those are the two parts interviewers come back to.

Interviewerminute 29

Half the trip was in euros and half in pounds. What changes?

What they are testing

Whether you keep balances per currency instead of converting when an expense is added, and see that the conversion question moves to settle-up time.

Most extensions fit into one place:

Extension What changes Touches the ledger?
Several currencies A balance per currency; simplify per currency Yes, one more key
Split by shares or weights A WeightedSplit using the same allocate No
Several payers on one expense The payer side becomes a split too; each payer is owed their part No
Repeating expenses (rent) A schedule that adds the expense each month No
Activity feed Keep every add, delete and payment as an event No
Reminders to settle A job that reads nets and reminds whoever owes the most No
Go deeperAdding several currencies, step by step2 min

The tempting answer is to convert everything into one currency when the expense is added. But at what rate? The rate that day is one the group never agreed on, and next week’s rate will be different. Balances would slowly drift away from what people actually paid.

So instead, money keeps its currency until someone actually pays:

  1. Each expense keeps its currency. “Dinner, 84.00 EUR”. The split works in that currency’s smallest unit (cents), exactly as before.
  2. The ledger adds one more key. Instead of “what does Ben owe Ana?”, it records “what does Ben owe Ana, in euros?” and “in pounds?”. Each currency has its own running total, and each one still adds up to zero across the group.
  3. Simplify per currency. The greedy rule runs once for euros and once for pounds. Nothing about it changes.
  4. Convert only when paying, if the group wants. At settle-up, the app can show “Ben owes Ana 20 EUR and 15 GBP”, or offer to convert it all into one currency at today’s rate. That’s one choice, made once, at the moment money moves, and the payment is recorded in the currency it was made in.

Notice what didn’t change: the split strategies, allocate, deleting, and the lock. The ledger grew one more key.

Go deeperAdding several payers, step by step2 min

Dinner was 90.00, and Ana paid 60.00 while Ben paid 30.00 because the card machine split the bill. Today, an expense has exactly one payer. Here’s how to allow several without touching the split strategies.

  1. The payer side becomes a list. An expense records who paid how much: Ana 6,000 cents, Ben 3,000 cents. These must add up to the total, checked like an exact split.
  2. Each person’s position is “paid minus owes”. The split still says what each person should pay: say 3,000 each for Ana, Ben and Cy. Then Ana is up 3,000 (paid 6,000, owes 3,000), Ben is at zero (paid 3,000, owes 3,000), and Cy is down 3,000.
  3. Turn the positions into ledger entries. Cy owes 3,000, and Ana is owed 3,000, so the ledger records Cy owing Ana 3,000. When several people are up and several are down, the same greedy matching from stage 4 turns positions into a few pairs.
  4. Deleting still works. The ledger entries for an expense still add up to zero, so applying them again with the signs flipped still undoes the expense exactly.

Notice that the split strategies didn’t change at all. They still answer “what should each person pay?”. The new part answers a different question, “what did each person pay?”, and the difference between the two is what goes in the ledger.

Go deeperFour more follow-up questions2 min
Interviewer asksWho should get the extra cent?

Any rule that always gives the same answer works, as long as the shares add up. Giving it to the first person listed is simple and easy to test. Giving it to the payer means nobody else ever pays more than an exact third, which some apps prefer. What’s wrong is a rule that depends on the order of a hash map, because the same expense could then split differently on different servers.

Interviewer asksHow would you show a user's total across all groups?

Add up their net in each group they belong to. It’s a few lookups per group, and can be cached per user and updated when any of their groups changes. Simplifying across groups is a different question. Most apps simplify within a group only, so payments stay with the trip they belong to.

Interviewer asksWhy not work out balances from all expenses on every read?

It’s correct and simple, which is why stage 1 did it. But the cost grows with history: a flat that has shared bills for five years would go through thousands of expenses to show one number. A running total makes reads quick, and a delete is a reversal, so nothing ever needs a replay.

Interviewer asksCan the simplifier suggest a payment between people who never met?

Yes, and that’s the point of it: Ana pays Cy because Ben was only passing money along. Some users dislike it, so apps make simplifying a group setting. With it off, the app shows the two-sided ledger instead. Both views come from the same data.