System Design Road

Medium LLD interview problem

Design Splitwise

Splitwise is a favourite medium-level LLD question because it hides real traps under friendly arithmetic. Splitting a ₹100 cab three ways doesn’t divide evenly. Four kinds of split need four kinds of input. Balances must never drift, even by a paisa. And “simplify debts” sounds like a one-liner until you find out the obvious algorithm is sometimes wrong. This walkthrough builds the whole thing from nothing, with every number on the page coming from running the code.

27 min read4 diagrams15 sections

This gives away every stage. For a clean attempt, practise it first, then come back.

The short version

  • Money is whole paise in a long, never a double. Every split must add up exactly to the bill, so leftover paise are handed out by a fixed rule.
  • Equal, exact, percentage and share splits each need different input. Make each one a small record that carries its own data and knows how to split, under one sealed interface.
  • Keep a running balance for every pair of people, updated on each expense, and always change both sides together. Net balances per person come from those.
  • Simplifying debts: match the person who owes most with the person owed most. It needs at most n − 1 payments, and is optimal almost always, but not always. The exact answer is a search that works for groups up to about twenty.
  • Phones retry. Give every expense an id from the client and ignore one you’ve already seen, inside the same lock that updates balances.

How this walkthrough is built

Same eleven steps as the other problems in this section. What’s new in this one: modelling money so it can never be wrong by a paisa, a sealed type for things that differ in their data, and an algorithm question hiding inside a design question.

StepWhat you doWhat comes out of it
1. Understand the problemAsk, number the requirementsWhat it must do, and how exact
2. Break it into entitiesNouns, verbs, what changesUsers, groups, expenses, splits, a ledger
3. Choose a directionTop-down to design, bottom-up to codeThe order to work in
4. Design each classKnows, does, doesn’t doA card per class
5. Connect the classesRelationshipsWho owns whom
6. Draw the diagramThree versionsThe full picture
7. Name the patternsWhere they earned a placeSealed strategy, Observer
8. Walk the flowsAdd an expense, settle up, simplifyGaps found before coding
9. Write the codeBottom-upCode that compiles and runs
10. Make it safeMany phones, retriesOne lock per group, ids for idempotency
11. Extend itExact simplification, edits, currenciesNew classes, no edits

Step 1Understand the problem

Picture the real thing first

Four friends go to Goa. Aarav books the hotel. Bhavna pays for a dinner where everyone ate different amounts. Chetan pays for scuba diving that only three of them did, and Divya pays for a cab. By the end, nobody knows who owes whom. The app’s job: record each expense, keep a running tally, and at the end say “Divya, pay Aarav ₹4,966.66” in as few payments as possible.

Hints: group, expense, paid by, split, everyone ate different amounts, only three of them, who owes whom, as few payments as possible.

Ask before you design

Group the questions: people and groups, kinds of split, money, settling up, and how it’s used.

Interviewer

Interviewer: Design Splitwise.

You

You: Are expenses always inside a group, or can two friends share one directly?

Interviewer

Interviewer: Let’s keep it to groups. Two friends can be a group of two.

You

You: How can an expense be split?

Interviewer

Interviewer: Equally, by exact amounts, by percentages, or by shares, like “Chetan had two drinks, everyone else one”.

You

You: Can more than one person pay for one expense?

Interviewer

Interviewer: Assume one payer for now.

You

You: What currency, and how exact?

Interviewer

Interviewer: Rupees, exact to the paisa. People get upset over one paisa.

You

You: What happens when an amount doesn’t divide evenly?

Interviewer

Interviewer: You decide, but the shares must add up to the bill exactly.

You

You: How do people settle up?

Interviewer

Interviewer: They record a payment: “Bhavna paid Aarav ₹2,300”. And we want a “simplify debts” button that suggests the fewest payments to settle the group.

You

You: Can expenses be edited or deleted?

Interviewer

Interviewer: Yes, but treat that as a follow-up.

You

You: Will people add expenses at the same time from different phones?

Interviewer

Interviewer: All the time. And phones on bad networks retry.

You

You: Notifications when someone adds an expense?

Interviewer

Interviewer: Yes, but just the hook for them.

QuestionWhat the answer decided
Groups only?Group is the unit that holds members, expenses and balances
Four kinds of splitFour ways to split, each needing different input
One payerExpense has one paidBy (more is a follow-up)
Exact to the paisaMoney is a whole number of paise; never double
Doesn’t divide evenlyA fixed, fair rule for leftover paise, and shares that always add up
Settle up, simplifyPayments update balances; simplification is an algorithm question
EditsExpenses must be kept, not just their effect on balances
Same time, retriesOne lock per group, and a way to recognise a retried request
NotificationsA listener on groups

Functional requirements

  1. FR1. A group has members. Any member can add an expense: who paid, how much, a description, and how it’s split.
  2. FR2. Splits can be equal, exact amounts, percentages, or shares (weights).
  3. FR3. The system keeps, for every pair of members, how much one owes the other.
  4. FR4. Each member can see their net balance: how much they’re owed overall, or owe.
  5. FR5. A member can record a payment to another (settling up).
  6. FR6. “Simplify debts” suggests a small set of payments that settles everyone.
  7. FR7. Members are told when an expense is added.

Non-functional requirements

  1. NFR1: Money is exact. Shares add up to the bill to the paisa, and the group’s balances always add up to zero.
  2. NFR2: Correct under concurrency. Expenses added at the same time from many phones never lose an update.
  3. NFR3: Retries are safe. A request sent twice records the expense once.
  4. NFR4: Easy to extend. New split types and new simplification methods are added, not edited in.

NFR1’s second half is worth saying out loud: every rupee someone is owed, someone else owes. If the balances in a group ever add up to anything other than zero, money has been created or destroyed. That one check catches almost every bug in this problem, and we’ll use it in step 10.

Assumptions we’ll write down

AssumptionWhy it’s reasonable
Amounts are stored as whole paise in a longExact, fast, and a long holds more paise than exist
Percentages are in hundredths of a percent (12.5% = 1250)Exact, and enough precision for any real bill
Leftover paise go to the people with the biggest remainders, earlier names first on tiesFair, and the same every time
The payer can be part of the split (their own share costs nothing)That’s how real bills work
Users and groups already exist; sign-up is someone else’s problemKeeps the hour on the interesting parts
Everything in memory for nowStorage is a follow-up

Step 2Break it into entities

Pull out the nouns

User, member, friend, group, expense, bill, payer, amount, description, split, share, percentage, balance, debt, payment, settlement, notification, currency, paisa.

Three questions that decide if a noun is a class

NounOwn data?Own rules?Changes alone?Becomes
User, member, friendid, name——User record
Groupmembers, expenses, balancesonly members take part; retries are ignored—Group class
Expense, billpayer, amount, description, who owes what——Expense record
Splitthe input for one way of splittingshares add up exactlyyes: four kinds now, more laterSplit sealed interface with four records
Balance, debtwho owes whom, how muchboth sides always change together—Ledger class
Payment, settlementfrom, to, amount——a call on Group, and Transfer for suggestions
Simplify—how to settle in few paymentsyesDebtSimplifier interface
Amount, paisa, currency—formatting—long paise, plus a small Money helper
Notification——yesGroupListener interface

The trap: one split method with flags

The first design most people write puts everything in one method:

Java
enum SplitType { EQUAL, EXACT, PERCENT, SHARES }

void addExpense(User paidBy, double amount, SplitType type,
                List users, List values) {
    if (type == SplitType.EQUAL) { ... ignore values ... }
    else if (type == SplitType.EXACT) { ... values are rupees ... }
    else if (type == SplitType.PERCENT) { ... values are percentages ... }
    else { ... values are weights ... }
}

Three problems hide in it.

First, double. 0.1 + 0.2 is 0.30000000000000004 in Java, because most decimal fractions can’t be stored exactly in binary. Add a few thousand expenses and the balances drift by paise. The interviewer said people get upset over one paisa. Money is a whole number of paise, in a long.

Second, values means something different for each type. For an equal split it’s ignored; for exact splits it’s rupees; for percentages it’s percent; for shares it’s weights. Nothing stops a caller passing percentages with EXACT. The type of the input should say what it is.

Third, the if chain. A new kind of split means editing this method, and every other method with the same chain.

The fix: each kind of split is its own small record that holds exactly the data it needs and knows how to split:

Java
sealed interface Split permits EqualSplit, ExactSplit, PercentSplit, ShareSplit {
    Map amounts(long total);
}

record EqualSplit(List users) implements Split { ... }
record ExactSplit(Map exact) implements Split { ... }
record PercentSplit(Map basisPoints) implements Split { ... }
record ShareSplit(Map shares) implements Split { ... }

Now an equal split can’t be given percentages by mistake: it doesn’t have anywhere to put them. And the group never asks what kind of split it has; it just calls amounts(total).

sealed means only these four types can implement Split. That’s a deliberate choice, discussed in step 7.

The second trap: one amount, three people

Split ₹100 equally between three people. ₹33.33 each makes ₹99.99. One paisa has vanished. Round up to ₹33.34 each and ₹100.02 has been charged for a ₹100 bill.

There’s no way around it: someone pays one paisa more. The design question is who, and the answer must be the same every time. Our rule: work out each person’s exact share as a fraction, give everyone the whole-paise part, then hand the leftover paise one at a time to the people with the biggest leftover fractions (earlier in the list wins a tie). For ₹100 three ways, everyone’s fraction is the same, so the first person gets the extra paisa: ₹33.34, ₹33.33, ₹33.33.

The same rule works for percentages and shares, so all three use one helper. This method of sharing out the remainder is old and well known: it’s called the largest remainder method, and it’s how some countries share out parliament seats.

Pull out the verbs

Add an expense, split it, check the shares, update balances, record a payment, see balances, simplify, notify, recognise a retry.

Use caseWho starts itVerbs inside it
UC1: Add an expensea member’s phonerecognise a retry, check members, split, record, update balances, notify
UC2: Settle upa membercheck members, update the pair’s balance
UC3: See balancesa memberpairwise and net
UC4: Simplifya memberturn net balances into a few payments

Give each job to the class that knows the most

JobInformation neededOwner
How much does each person owe for this bill?the total, plus this split’s own dataeach Split record
Share leftover paise fairlyweights and remaindersShares helper
Who owes whom, per pairevery expense and paymentLedger
Net balance per personthe pairwise balancesLedger
Is this person in the group? Is this a retry?members, past expense idsGroup
Suggest paymentsnet balances, plus a methodDebtSimplifier

CRC cards

ClassResponsibilitiesCollaborators
Split (four records)turn a total into exact per-person amountsShares, User
Ledgerkeep pairwise balances; always change both sides; work out netUser
Groupmembers; add expenses once; settle; tell listenersSplit, Ledger, Expense, GroupListener
DebtSimplifiersuggest payments for net balancesTransfer

Find what will change

Will changeHintBecomes
Kinds of splitfour now; “by item” or “by nights stayed” laterSplit
How debts are simplified“fewest payments” can be done several waysDebtSimplifier
Who hears about expensesnotifications, an activity feed, emailsGroupListener

Step 3Top-down or bottom-up?

Top-down: a phone calls addExpense(id, description, paidBy, amount, split). That needs a split that can produce amounts (Split.amounts), a ledger that can record a debt (Ledger.record), and a way to recognise a repeated id. “Simplify” needs Ledger.net and a DebtSimplifier.

Bottom-up: first the money rule (Shares.byWeight, tested on awkward amounts until it never loses a paisa), then the four splits, then the ledger, then the group.

Design from the phone inward; code from the paisa outward.

Step 4Design each class

Group: the shared space

Job: hold members, record expenses and payments exactly once, and keep balances right.

KnowsDoesDoesn’t do
its name and membersaddExpense(id, description, paidBy, amount, split)work out shares (splits do)
expenses, by idsettle(from, to, amount)keep the balance maths (the ledger does)
a ledgerowes(a, b), net()decide how to simplify (a simplifier does)
listenerssimplify(simplifier)send notifications (listeners do)

The expense id comes from the phone, not the server. That’s the key to safe retries: if the same id arrives twice, the second is the same request again, and the group returns the expense it already recorded. Step 10 explains why the server can’t make this id.

Why keep expenses at all, when the ledger already has their effect? Because people edit and delete expenses (a follow-up), and you can’t undo an expense you no longer have. The expenses are the record of what happened; the ledger is a running summary of them. If the summary is ever in doubt, it can be rebuilt from the record.

Split and its four records: how a bill is shared

Job: given a total, return what each person owes, adding up exactly to the total.

RecordHoldsRule
EqualSplitthe people sharingeveryone gets weight 1
ExactSpliteach person’s amount in paisethe amounts must add up to the total
PercentSpliteach person’s percentage, in hundredths of a percentmust add up to 100%
ShareSpliteach person’s weightproportional to weight

Three of the four use the same helper, Shares.byWeight, which applies the largest remainder rule. Only ExactSplit doesn’t, because there’s nothing to divide: it just checks the sum.

Ledger: who owes whom

Job: keep, for every pair, how much one owes the other, and never let the two sides disagree.

KnowsDoesDoesn’t do
owes[a][b]: how much a owes brecord(debtor, creditor, amount)know about expenses or splits
owes(a, b)
net(): per person, owed minus owing

Every change writes both sides: if Bhavna owes Aarav ₹2,300 more, then owes[Bhavna][Aarav] goes up by 2,300 and owes[Aarav][Bhavna] goes down by 2,300. That pair of writes is what keeps the group’s total at zero. It only ever happens inside record, so no other code can get it half right.

Debts between two people also cancel. On the Goa trip, Aarav owes Chetan ₹3,000 for scuba and Chetan owes Aarav ₹3,000 for the hotel, so their balance is simply zero. Storing one signed number per pair does that automatically.

DebtSimplifier: few payments to settle everyone

Job: given each person’s net balance, suggest payments that bring everyone to zero.

An interface with one method, settle(net) → list of transfers. GreedySimplifier is the standard answer; OptimalSimplifier (step 11) finds the true minimum for small groups. Step 8 explains both.

Expense, Transfer, User, Money: the vocabulary

Expense is a record: id, description, payer, amount, and the exact amount each person owes. Transfer is a suggested payment. User is an id and a name. Money.format turns paise into “₹2,633.33” for display; everywhere else, money is just a long.

GroupListener: who hears about it

One method, expenseAdded(group, expense). Notifications, an activity feed, and emails are all listeners.

Step 5How the classes relate

RelationshipIn this design
Compositiona group is made of its ledger and its expenses; they mean nothing outside it
Aggregationa group has members, who exist in other groups too
Associationan expense refers to its payer
RealizationEqualSplit is a Split; GreedySimplifier is a DebtSimplifier
Dependencya group uses a split for a moment while adding an expense, then keeps only the amounts

That last row is a small, deliberate choice. The expense stores the resulting amounts, not the split object. If the rules for percentage splits ever change, old expenses don’t change with them.

Step 6The class diagram, built up

Version 1: groups, expenses, balances

Group- members: Set- expenses: Map+ addExpense(id, desc, paidBy, amount, split): Expense+ settle(from, to, amount): voidLedger- owes: Map>+ record(debtor, creditor, amount): void+ net(): MapExpense- id: String- paidBy: User- amount: long- owed: Map0..*

Version 2: the four splits

«interface»Split+ amounts(total): MapEqualSplit- users: ListExactSplit- exact: MapPercentSplit- basisPoints: MapShareSplit- shares: Map

Three of the four (equal, percent, shares) hand their work to the Shares helper, which version 3 shows. ExactSplit doesn’t need it: there’s nothing to divide, only a sum to check. It’s a small detail, but interviewers notice when your diagram and your code agree.

Version 3: the whole picture

Group- members: Set- expenses: Map- ledger: Ledger+ addExpense(id, desc, paidBy, amount, split): Expense+ settle(from, to, amount): void+ simplify(s: DebtSimplifier): List«interface»GroupListener+ expenseAdded(g, e): void«interface»Split+ amounts(total): MapEqualSplitPercentSplitShares+ byWeight(total, weights): MapLedger- owes: Map>+ record(debtor, creditor, amount): void+ net(): Map«interface»DebtSimplifier+ settle(net): ListGreedySimplifiernotifies

ExactSplit, ShareSplit, Expense, Transfer and User are left off to keep it readable.

Check it against the requirements

RequirementWhere it lives
FR1: members add expensesGroup.addExpense, member checks
FR2: four kinds of splitEqualSplit, ExactSplit, PercentSplit, ShareSplit
FR3: pairwise balancesLedger.owes
FR4: net balanceLedger.net
FR5: settle upGroup.settle → Ledger.record
FR6: simplifyDebtSimplifier
FR7: notificationsGroupListener
NFR1: exact moneylong paise; Shares.byWeight; both sides written in Ledger.record
NFR2: concurrencyone lock per group (step 10)
NFR3: retriesclient-made expense ids, checked inside the lock
NFR4: extendSplit, DebtSimplifier, GroupListener

Step 7The patterns, and why each one is here

PatternWhereThe problem it solved
Strategy, as sealed recordsSplitfour ways to split, each with its own input
StrategyDebtSimplifierseveral ways to simplify, with different costs
ObserverGroupListenernotifications without the group knowing about them
Value objectsExpense, Transfer, User, the splitsdata passed around never changes

Sealed or open?

Split is sealed: only our four records can implement it. Why close it?

Because a split has a promise to keep: the amounts add up to the total. With an open interface, anyone could add a split that breaks that promise, and the ledger would quietly drift. Sealing it means every split in the system is one we wrote and tested. Adding a fifth kind is still easy (a new record, plus one word in the permits list), but it’s a deliberate change by us.

DebtSimplifier, on the other hand, is open. A bad simplifier only produces bad suggestions; it can’t corrupt balances, because the suggestions only take effect when people record real payments. Different risk, different choice. Saying this in the interview shows you didn’t just pick a keyword.

Why not a Singleton

“One expense service” sounds natural, but the state lives in groups, and there are millions of them. Each group is its own object with its own lock. Nothing global is needed.

Patterns we deliberately didn’t use

  • Factory for splits. The records’ constructors already say exactly what each needs.
  • Command for expenses. Expenses are already kept as records; editing them is a follow-up, and the record is enough to undo one.
  • Decorator. Nothing wraps anything here.

Step 8Walk the flows

Adding an expense

PhoneGroupSplitLedgerListener1 addExpense("e4", "Cab", Divya, ₹1000, equal 3 ways)2 lock; seen "e4" before? no3 payer and everyone in the split are members?4 amounts(100000)5 Aarav 33334, Bhavna 33333, Divya 333336 store expense e47 record(Aarav owes Divya 33334)8 record(Bhavna owes Divya 33333)9 record(Divya owes Divya: skipped)10 unlock11 expenseAdded(e4)12 expense e4
  1. The lock is taken before checking the id. If two copies of the same request arrive together, only one can be inside at a time, so the second always sees the first’s expense.
  2. Every check happens before anything is stored.
  3. The split works out amounts that add up to exactly ₹1,000.00.
  4. The payer’s own share is recorded as nothing: you can’t owe yourself.
  5. Listeners are told after the lock is released. A slow notification service should never stop other expenses being added to the group.

Settling up

Bhavna pays Aarav ₹2,300. That’s recorded as a debt in the other direction: “Aarav owes Bhavna ₹2,300”, which cancels her existing ₹2,300 debt to him. One method, Ledger.record, handles expenses and payments alike, so there’s only one place where balances change.

Simplifying debts

After the four Goa expenses, five pairs of people owe each other something:

Text
Bhavna owes Aarav ₹2300
Divya owes Aarav ₹2666.66
Divya owes Chetan ₹3000
Chetan owes Bhavna ₹900
Bhavna owes Divya ₹33.33

Settling those one by one takes five payments. But what matters to each person is only their net: how much they’re owed overall, minus how much they owe.

Text
net: Aarav ₹4966.66, Bhavna -₹1433.33, Chetan ₹2100, Divya -₹5633.33
net adds up to 0

Aarav and Chetan are owed money; Bhavna and Divya owe it. The net adds up to exactly zero, as NFR1 promises.

Goa trip: settling up
5
AaravBhavnaChetanDivya

Five payments, and money going round in circles: Chetan pays Bhavna while Bhavna pays Divya while Divya pays Chetan. Notice nothing moves between Aarav and Chetan: the hotel and the scuba cancelled exactly.

Settle

Bad solutionSettle every pair separately

Five payments here, and in a big group, up to one for every pair of people: 45 for ten friends.

Good solutionGreedy matching

Take the person owed the most and the person who owes the most. The debtor pays the creditor as much as possible, the smaller of the two amounts. One of them is now settled. Repeat.

Text
Divya pays Aarav ₹4966.66
Bhavna pays Chetan ₹1433.33
Divya pays Chetan ₹666.67

Three payments. Each payment settles at least one person, so for n people with non-zero balances it never needs more than n − 1 payments.

Great solutionGreedy, and know when it’s wrong

Greedy is not always the minimum. Here’s a five-person group (in rupees: Aarav −300, Bhavna +300, Chetan −200, Divya +400, Esha −200) where it isn’t, from a real run:

Greedy (4 payments)Optimal (3 payments)
Aarav pays Divya ₹300Chetan pays Divya ₹200
Chetan pays Bhavna ₹200Esha pays Divya ₹200
Esha pays Bhavna ₹100Aarav pays Bhavna ₹300
Esha pays Divya ₹100

Greedy grabbed the biggest numbers first (Divya +400 and Aarav −300) and missed that Aarav and Bhavna exactly cancel each other. The true minimum comes from splitting the group into as many subgroups that add up to zero on their own as possible. Each subgroup of k people needs k − 1 payments, so more subgroups means fewer payments. Finding the best split into subgroups is a hard search problem (no fast method is known for large groups), which is why real apps use greedy.

How often does it matter? We built 2,000 random groups at each size, each with twice as many random expenses as people, and compared:

PeopleGroups where greedy used more paymentsAverage payments, greedyAverage payments, optimalWorst case
40.0%2.972.97—
60.3%4.994.98one extra
80.5%7.006.99one extra
100.3%9.009.00one extra

So the honest answer is: greedy, because it’s simple, fast and almost always optimal; and I know it can be off by a payment or so, and how I’d get the exact answer for a small group if the product needed it. That’s the “Great” answer: the algorithm plus a clear sense of its limits.

What walking the flows taught us

Found while walkingChange
Two copies of one request could both pass an “already seen?” checkCheck the id inside the same lock that updates balances
A slow notification could hold up the whole groupTell listeners after unlocking
The payer appears in their own splitLedger.record ignores debts to yourself
Payments and expenses both change balancesBoth go through Ledger.record, so there’s one place to get right

When things go wrong

SituationWhat happens
Exact amounts don’t add up to the billrefused: “exact amounts add up to ₹900, not ₹1000”
Percentages don’t add up to 100refused
Someone outside the group in the splitrefused: “Esha is not in Goa trip”
Zero or negative amountrefused
The same request sent twicethe first expense is returned again; nothing changes
The same id reused for a different expenserefused: that’s a client bug, not a retry

Step 9Write the code, bottom-up

Each class only uses those before it. Complete Java; every output on this page comes from running it.

9.1 Users and money

Java
record User(String id, String name) {
    @Override public String toString() { return name; }
}

final class Money {
    private Money() { }
    static String format(long paise) {
        String sign = paise < 0 ? "-" : "";
        long p = Math.abs(paise);
        String rupees = sign + "₹" + (p / 100);
        return p % 100 == 0 ? rupees : rupees + String.format(".%02d", p % 100);
    }
}

9.2 Sharing out paise fairly

Java
final class Shares {
    private Shares() { }

    // Splits total in proportion to the weights, in whole paise, adding up exactly.
    static Map byWeight(long total, Map weights) {
        long sum = 0;
        for (long w : weights.values()) {
            if (w < 0) throw new IllegalArgumentException("negative weight");
            sum += w;
        }
        if (sum == 0) throw new IllegalArgumentException("nobody to split between");
        Map out = new LinkedHashMap<>();
        List order = new ArrayList<>(weights.keySet());
        Map rest = new HashMap<>();
        long given = 0;
        for (User u : order) {
            long exact = total * weights.get(u);                  // (1)
            out.put(u, exact / sum);
            rest.put(u, exact % sum);
            given += exact / sum;
        }
        order.sort((a, b) -> Long.compare(rest.get(b), rest.get(a)));   // (2)
        for (int i = 0; i < total - given; i++) out.merge(order.get(i), 1L, Long::sum);
        return out;
    }
}
  1. Each person’s exact share is total × weight ÷ sum. Integer division gives the whole-paise part; the remainder says how much of a paisa they’re “missing”.
  2. Biggest remainders first. Java’s list sort keeps equal items in their original order, so ties go to whoever was listed first. The loop then hands out exactly the missing paise, one each.

From real runs:

Text
₹100 three ways: {Aarav=3334, Bhavna=3333, Chetan=3333}
₹10 by 33.33/33.33/33.34%: {Aarav=333, Bhavna=333, Chetan=334}

9.3 The four splits

Java
sealed interface Split permits EqualSplit, ExactSplit, PercentSplit, ShareSplit {
    Map amounts(long total); 1
}

record EqualSplit(List users) implements Split {
    public Map amounts(long total) {
        Map w = new LinkedHashMap<>();
        for (User u : users) w.put(u, 1L);
        return Shares.byWeight(total, w);
    }
}

record ExactSplit(Map exact) implements Split {
    public Map amounts(long total) {
        long sum = exact.values().stream().mapToLong(Long::longValue).sum();
        if (sum != total)
            throw new IllegalArgumentException("exact amounts add up to "
                + Money.format(sum) + ", not " + Money.format(total));
        return new LinkedHashMap<>(exact);
    }
}

record PercentSplit(Map basisPoints) implements Split { 2
    public Map amounts(long total) {
        long sum = basisPoints.values().stream().mapToLong(Long::longValue).sum();
        if (sum != 10_000)
            throw new IllegalArgumentException("percentages must add to 100");
        return Shares.byWeight(total, basisPoints);
    }
}

record ShareSplit(Map shares) implements Split {
    public Map amounts(long total) {
        return Shares.byWeight(total, shares);
    }
}
  1. 1always adds up to total
  2. 212.5% = 1250
  1. The promise every split keeps, written on the interface.
  2. Hundredths of a percent, so 33.33% is 3333. Exact, with no decimals.

Callers should pass a LinkedHashMap (or List) so the order, and therefore who gets a spare paisa, is the same every time.

9.4 The ledger

Java
final class Ledger {
    private final Map> owes = new HashMap<>(); 1

    void record(User debtor, User creditor, long amount) {
        if (debtor.equals(creditor) || amount == 0) return;
        add(debtor, creditor, amount);
        add(creditor, debtor, -amount);                                // (2)
    }

    private void add(User a, User b, long amount) {
        Map row = owes.computeIfAbsent(a, x -> new HashMap<>());
        long v = row.getOrDefault(b, 0L) + amount;
        if (v == 0) row.remove(b); else row.put(b, v);
    }

    long owes(User a, User b) {
        return owes.getOrDefault(a, Map.of()).getOrDefault(b, 0L);
    }

    Map net() {                                           // (3)
        Map net = new HashMap<>();
        owes.forEach((a, row) -> row.forEach((b, v) -> net.merge(a, -v, Long::sum)));
        net.values().removeIf(v -> v == 0);
        return net;
    }
}
  1. 1a owes b
  1. owes[a][b] is how much a owes b. Negative means b owes a.
  2. Both sides, in the same method, every time. This is what keeps the group’s total at zero.
  3. Positive net means the person is owed money overall.

9.5 Expenses, transfers and simplifying

Java
record Expense(String id, String description, User paidBy, long amount,
               Map owed) { }

record Transfer(User from, User to, long amount) {
    @Override public String toString() {
        return from + " pays " + to + " " + Money.format(amount);
    }
}

interface DebtSimplifier {
    List settle(Map net);
}

final class GreedySimplifier implements DebtSimplifier {
    public List settle(Map net) {
        Comparator> big = (x, y) -> {
            int c = Long.compare(Math.abs(y.getValue()), Math.abs(x.getValue()));
            return c != 0 ? c : x.getKey().id().compareTo(y.getKey().id());   // (1)
        };
        PriorityQueue> owed = new PriorityQueue<>(big);
        PriorityQueue> owing = new PriorityQueue<>(big);
        net.forEach((u, v) -> {
            if (v > 0) owed.add(Map.entry(u, v));
            else if (v < 0) owing.add(Map.entry(u, v));
        });
        List out = new ArrayList<>();
        while (!owed.isEmpty() && !owing.isEmpty()) {
            var c = owed.poll();
            var d = owing.poll();
            long amount = Math.min(c.getValue(), -d.getValue());       // (2)
            out.add(new Transfer(d.getKey(), c.getKey(), amount));
            if (c.getValue() > amount)
                owed.add(Map.entry(c.getKey(), c.getValue() - amount));
            if (-d.getValue() > amount)
                owing.add(Map.entry(d.getKey(), d.getValue() + amount));
        }
        return out;
    }
}
  1. Ties are broken by user id, so the same balances always give the same suggestions. Without this, two phones could show different advice for the same group.
  2. Pay the smaller of the two amounts, so at least one person is fully settled each time.

Two priority queues keep the biggest creditor and biggest debtor at the front, so each step costs O(log n).

9.6 The group

Java
interface GroupListener {
    void expenseAdded(Group group, Expense expense);
}

final class Group {
    private final String name;
    private final Set members;
    private final Map expenses = new LinkedHashMap<>();
    private final Ledger ledger = new Ledger();
    private final List listeners = new CopyOnWriteArrayList<>();

    Group(String name, Collection members) {
        this.name = name;
        this.members = Set.copyOf(members);
    }

    void subscribe(GroupListener l) { listeners.add(l); }

    Expense addExpense(String id, String description, User paidBy, long amount,
                       Split split) {
        Expense e;
        synchronized (this) {
            Expense seen = expenses.get(id);
            if (seen != null) {                                        // (1)
                if (seen.amount() != amount || !seen.paidBy().equals(paidBy))
                    throw new IllegalStateException("expense id reused: " + id);
                return seen;
            }
            if (amount <= 0)
                throw new IllegalArgumentException("amount must be positive");
            check(paidBy);
            Map owed = split.amounts(amount);
            owed.keySet().forEach(this::check);
            e = new Expense(id, description, paidBy, amount,
                            Collections.unmodifiableMap(owed));             // (3)
            expenses.put(id, e);
            owed.forEach((u, share) -> ledger.record(u, paidBy, share));
        }
        for (GroupListener l : listeners) l.expenseAdded(this, e);    // (2)
        return e;
    }

    synchronized void settle(User from, User to, long amount) {
        check(from);
        check(to);
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        ledger.record(to, from, amount);                               // (4)
    }

    synchronized long owes(User a, User b) { return ledger.owes(a, b); }
    synchronized Map net() { return ledger.net(); }
    synchronized int expenseCount() { return expenses.size(); }
    String name() { return name; }

    List simplify(DebtSimplifier s) { return s.settle(net()); }

    private void check(User u) {
        if (!members.contains(u))
            throw new IllegalArgumentException(u + " is not in " + name);
    }
}
  1. A retry gets the expense it already created. If the same id comes back with a different payer or amount, that’s not a retry; it’s a bug in the client, so refuse it loudly.
  2. Listeners are called outside the synchronized block.
  3. The split returns a fresh, ordered map; wrapping it read-only keeps the expense unchangeable and keeps the order people were listed in.
  4. “From paid to” means to now owes from that much, which cancels what from owed.

9.7 Putting it together

The Goa trip:

Java
public class Demo {
    public static void main(String[] args) {
        User aarav = new User("u1", "Aarav"), bhavna = new User("u2", "Bhavna"),
             chetan = new User("u3", "Chetan"), divya = new User("u4", "Divya");
        List all = List.of(aarav, bhavna, chetan, divya);
        Group goa = new Group("Goa trip", all);

        goa.addExpense("e1", "Hotel", aarav, 12_000_00, new EqualSplit(all));
        Map dinner = new LinkedHashMap<>();
        dinner.put(aarav, 700_00L);
        dinner.put(bhavna, 600_00L);
        dinner.put(chetan, 900_00L);
        dinner.put(divya, 300_00L);
        goa.addExpense("e2", "Dinner", bhavna, 2_500_00, new ExactSplit(dinner));
        Map scuba = new LinkedHashMap<>();
        scuba.put(chetan, 40_00L);                                     // 40.00%
        scuba.put(divya, 30_00L);
        scuba.put(aarav, 30_00L);
        goa.addExpense("e3", "Scuba", chetan, 10_000_00, new PercentSplit(scuba));
        EqualSplit three = new EqualSplit(List.of(aarav, bhavna, divya));
        Expense cab = goa.addExpense("e4", "Cab", divya, 1_000_00, three);
        Expense again = goa.addExpense("e4", "Cab", divya, 1_000_00, three);

        System.out.println("cab: " + cab.owed());
        System.out.println("retry is the same expense: " + (again == cab)
            + ", expenses: " + goa.expenseCount());
        for (User x : all)
            for (User y : all) {
                long v = goa.owes(x, y);
                if (v > 0) System.out.println(x + " owes " + y + " " + Money.format(v));
            }
        System.out.println("greedy:");
        goa.simplify(new GreedySimplifier()).forEach(t -> System.out.println("  " + t));
        goa.settle(bhavna, aarav, 2_300_00);
        System.out.println("Bhavna owes Aarav after paying: "
            + Money.format(goa.owes(bhavna, aarav)));
    }
}
Text
cab: {Aarav=33334, Bhavna=33333, Divya=33333}
retry is the same expense: true, expenses: 4
Bhavna owes Aarav ₹2300
Bhavna owes Divya ₹33.33
Chetan owes Bhavna ₹900
Divya owes Aarav ₹2666.66
Divya owes Chetan ₹3000
greedy:
  Divya pays Aarav ₹4966.66
  Bhavna pays Chetan ₹1433.33
  Divya pays Chetan ₹666.67
Bhavna owes Aarav after paying: ₹0

Notice what isn’t there: nothing between Aarav and Chetan. The hotel and the scuba diving cancelled out exactly.

Step 10Make it safe

Interviewer

Interviewer: Four friends add expenses from four phones at the same time, on a bad network. What can go wrong?

You

You: Two different things. Two different requests can race on the balances. And the same request can arrive twice because a phone retried.

Shared stateChanged byDangerProtection
the ledgerevery expense and paymentlost updates; balances not adding to zerothe group’s lock
expenses by idevery expenseduplicates from retriesid check inside the same lock
the listener listsubscribe, addcrash while loopingCopyOnWriteArrayList

Why a retry is a different problem

A phone sends “add ₹1,000 cab”. The server records it, but the reply is lost in a tunnel. The phone can’t tell “never arrived” from “arrived, reply lost”, so it sends the request again. Without protection, the cab is now in the group twice and Aarav owes for it twice.

Locks don’t fix this: the two copies might arrive a minute apart. What fixes it is recognising the second copy. So the phone makes a unique id for the expense when the user taps “save”, and sends the same id on every retry. The server only has to remember which ids it has seen.

Why can’t the server make the id? Because the server only makes an id when the request reaches it, and each copy of the request would get a new one. Only the phone knows that two requests are the same tap.

This is called making the operation idempotent: doing it twice has the same effect as doing it once. The same idea protects payments in lesson 7.3.

Why the check must be inside the lock

If the check ran first and the lock came later:

TimeCopy 1 of “e4”Copy 2 of “e4”
1seen “e4”? no
2seen “e4”? no
3lock; record e4; unlock
4lock; record e4 again; unlock

Both copies pass the check, and the cab is recorded twice. The check and the write must be one step, so they share one lock.

Prove it

We sent 10,000 expenses from 8 threads, every one of them twice (shuffled, so copies of the same request often ran at the same moment), to two versions of the group: one with the lock, and one without:

RunNo lock: expensesNo lock: balances add up toLocked: expensesLocked: same balances as one thread?
110,008−₹5,603.8010,000yes
210,009₹4,145.8710,000yes
310,003₹3,188.3010,000yes

Without the lock, a few retries slipped through as duplicates, and the group’s balances stopped adding up to zero: thousands of rupees created or destroyed by lost updates, a different amount each run. With the lock, exactly 10,000 expenses, and balances identical to adding them one at a time on a single thread.

That “adds up to zero” check is worth putting in real code too: run it after tests, and alert if a live group ever fails it.

Why one lock per group

Expenses in different groups never touch the same balances, so they don’t need to wait for each other. One lock per group means a million groups can be updated in parallel, while expenses inside one group, which is a handful of friends, take turns. That’s never a bottleneck: a group doesn’t get thousands of expenses a second.

Step 11Follow-ups, extended without editing

The exact minimum number of payments

Interviewer

Interviewer: Can you always find the fewest payments?

For small groups, yes. The idea from step 8: split the people into as many subgroups as possible whose balances add up to zero, then settle each subgroup with greedy (k − 1 payments for k people). To find the most subgroups, look at every subset of people, using a bitmask, and remember the best answer for each:

Java
final class OptimalSimplifier implements DebtSimplifier {
    private final DebtSimplifier inside = new GreedySimplifier();

    public List settle(Map net) {
        List people = new ArrayList<>(net.keySet());
        int n = people.size();
        if (n > 20)
            throw new IllegalArgumentException("too many people for an exact answer");
        int full = (1 << n) - 1;
        long[] sum = new long[full + 1];
        int[] groups = new int[full + 1];                     // (1)
        for (int mask = 1; mask <= full; mask++) {
            int low = Integer.numberOfTrailingZeros(mask);
            sum[mask] = sum[mask & (mask - 1)] + net.get(people.get(low));
            int best = 0;
            for (int i = 0; i < n; i++)
                if ((mask & (1 << i)) != 0)
                    best = Math.max(best, groups[mask ^ (1 << i)]);
            groups[mask] = best + (sum[mask] == 0 ? 1 : 0);
        }
        List order = new ArrayList<>();             // (2)
        for (int mask = full; mask != 0; ) {
            int need = groups[mask] - (sum[mask] == 0 ? 1 : 0);
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) != 0 && groups[mask ^ (1 << i)] == need) {
                    order.add(i);
                    mask ^= 1 << i;
                    break;
                }
            }
        }
        Collections.reverse(order);
        List out = new ArrayList<>();
        Map group = new LinkedHashMap<>();
        long running = 0;
        for (int i : order) {
            User u = people.get(i);
            group.put(u, net.get(u));
            running += net.get(u);
            if (running == 0) {                                  // (3)
                out.addAll(inside.settle(group));
                group.clear();
            }
        }
        return out;
    }
}
  1. groups[mask] is the most zero-sum subgroups the people in mask can be split into. Build it from smaller masks: take the best answer with one person removed, and add one if this whole set adds up to zero.
  2. Walk back from “everyone” to find an order of people in which the zero-sum subgroups appear one after another.
  3. Each time the running total returns to zero, a subgroup is complete; settle it on its own.

It’s a new DebtSimplifier; nothing else changes. The cost grows as 2ⁿ × n, so it’s for small groups only. From real runs: 12 people took under a millisecond, 16 people 2 ms, 20 people 54 ms, and each extra person roughly doubles it. Fine for a friends’ trip; not for a 200-person office group, which is why the class refuses more than 20 and the app falls back to greedy.

Editing and deleting an expense

Because the group keeps every expense, editing is “undo the old, apply the new”, inside the lock: for each person in the old expense, record the reverse of their share; then add the new version. Deleting is just the first half. A cleaner version stores every change as an event (added, edited, deleted) and treats the ledger as a summary that can always be rebuilt from the events. That also gives you the activity feed for free.

Several people paid

“Aarav paid ₹8,000 and Bhavna ₹4,000 for the hotel.” Make paidBy a map of payer to amount, which must add up to the total. Then the ledger records each person’s share as owed to the payers in proportion to what they paid, using the same Shares.byWeight helper. One expense becomes a few more ledger records; nothing else changes.

More than one currency

Money then needs a currency next to the amount: a small Money(long minorUnits, Currency currency) record instead of a bare long. Keep separate balances per currency (owing someone ₹500 and $10 is not one number), and only convert when someone chooses to settle in a different currency, at that day’s rate. Converting every expense the moment it’s added makes old balances change whenever rates move, which confuses everyone.

Recurring expenses

Rent every month: a scheduled job that calls addExpense with an id made from the rule and the month (rent-2026-11). If the job runs twice, the id is the same, and the group ignores the second. The idempotency we built for flaky phones protects the scheduler for free.

Mistakes people make on this one

  • *Using double for money,* and balances that drift by paise.
  • Splitting ₹100 three ways into ₹33.33 each, and losing a paisa every time.
  • *One addExpense with a type flag and a list of numbers* that mean different things for each type.
  • Updating one side of a balance and forgetting the other, so the group stops adding up to zero.
  • Storing only balances, so expenses can’t be edited or deleted.
  • Claiming greedy simplification is always optimal.
  • Ignoring retries, or checking for a duplicate outside the lock.
  • Calling listeners while holding the group’s lock.

How this answer gets scored

MomentWhat earns creditWhat costs you
The first five minutesAsking about split types, exactness, settling, concurrency and retriesStarting with a User class and a database schema
MoneyWhole paise in a long; a fair, fixed rule for leftover paisedouble, and shares that don’t add up
SplitsOne record per split type, carrying its own inputA type flag and a list of numbers
BalancesPairwise, both sides in one place; net derived; expenses keptUpdating one side; no record of expenses
SimplifyingGreedy with its n − 1 bound, and knowing it isn’t always optimal“Greedy is optimal”, or no idea how to settle
ConcurrencyOne lock per groupNo locking, or a global lock
RetriesClient-made ids, checked inside the lockServer-made ids; checking outside the lock
Follow-upsExact search for small groups; edits as undo + redo; currencies kept apartRewriting the group for each new idea

The whole design on one page

  1. Money: whole paise in a long. Shares.byWeight splits any total by weights and hands out leftover paise by largest remainder, so shares always add up.
  2. Splits: a sealed Split with four records (equal, exact, percent, shares), each holding its own input and returning exact amounts.
  3. Balances: a Ledger of pairwise amounts, both sides always written together; net per person derived from it. Payments go through the same method.
  4. Group: members, expenses by client-made id, a ledger, listeners. addExpense checks the id, splits, stores and records under one lock, then notifies.
  5. Simplify: greedy matching of biggest creditor and debtor (at most n − 1 payments, almost always optimal); an exact subset search for groups up to about twenty.
  6. Safety: one lock per group for races; idempotent ids for retries; “balances add up to zero” as the check that catches everything else.