Design Splitwise (LLD)
Split bills four ways in integer cents with a fixed leftover rule, net the debts per pair, simplify them greedily, and keep settle-up race-free.
- 1001 = 334+334+333equal split of 1001 cents among three
- n - 1most payments the greedy simplify needs
- 1 lockper group, around check and subtract
Show what to say
“I'd keep money in integer cents and make each split type a strategy that turns an amount into one share per person, with the leftover cents handed out in a fixed order. Shares become debts to the payer in a ledger that nets each pair. Simplify matches the biggest debtor to the biggest creditor, and settle-up checks and subtracts under one group lock.”
Trade-offs
- Strategy per split vs one function: open to new split types, at the cost of an interface for a few lines.
- Greedy simplify vs the minimum: fast and repeatable, but the true fewest payments is NP-hard.
- Pair debts vs net balances only: pairs show who owes whom, at the cost of two numbers to keep in step.
Bring it up before they ask
Two people tapping settle-up on the same debt must not both pay, so the check and the subtract happen in one locked step.
Turn each bill into one share per person with a swappable split strategy, record each share as a debt to the payer in a ledger that nets opposite debts, and keep all money in integer cents with a fixed rule for the leftover cent. Simplifying debts is a greedy match of the biggest debtor to the biggest creditor.
Functional
- Groups of members add expenses paid by one member
- Split a bill equally, by exact amounts, by percent, or by shares
- Show who owes whom, and each member's net balance
- Settle up a debt, partly or fully
- Simplify debts into fewer payments
- Notify members of new expenses and settlements
Non-functional
- Shares always add up to the bill, to the cent
- The same input always gives the same split
- A rejected expense changes nothing
- Two settle-ups cannot both pay the same debt
- New split types need no change to the group or the ledger
Out of scope
- Currencies and exchange rates (a follow-up)
- Accounts, login and payments
- Recurring expenses
A low-level design (LLD) question where the classes are easy and the money is not. The test is whether you handle the stray cent and the races.
New to this? Read Swap behaviour first.
Scope and interface
Read more · 156 words
- Which split types: equal only, or exact, percent and shares too?
- Is money in one currency? Who rounds, and how?
- May the payer be outside the participants?
- Is a debt tracked per pair, or only as a net balance per person?
- Do we only show balances, or also suggest fewer payments?
The public interface:
| Call | Returns | Notes |
|---|---|---|
addExpense(payer, amount, type, users, data) | Shares, or a reason | Validated before anything is written |
settleUp(from, to, cents) | ok, or a reason | Never more than is owed |
owes(from, to) | Cents | Netted per pair |
balances() | Net cents per member | Sums to 0 |
simplify() | Transfers [from, to, cents] | At most n - 1 |
on(listener) | Hears expense and settled |
What it remembers:
membersand the list ofexpenses.owed: for each ordered pair, cents one member owes the other. At most one direction per pair is non-zero.- Nothing else: balances and the simplified plan are computed from
owedwhen asked.
Build the design in stages
Step through: the group and its expenses, then the ledger, then the split strategy, then new splits, then observers. Green marks what the latest stage added.
A Group owns its members, its expenses and a Ledger. When an expense arrives, the Group picks a SplitStrategy by type (equal, exact, percent or shares) to turn one amount into one share per person, then records each share in the Ledger as a debt to the payer. Listeners observe expenses and settlements.
- ◆ composition (owns)
- ◇ aggregation
- △ inheritance / implements (dashed)
- → association · ⇢ dependency
Splitting the bill
Each split type has one job: turn an amount and a list of people into one share each, or say the data is invalid.
Read more · 105 words
That is a Strategy. The group looks the type up by name, so it has no if (type === ...) ladder.
The cent problem is the real test. Split 1001 cents three ways: each floor is 333, leaving 2. Hand the leftover out one cent each to the first people, in the order given. Shares are now 334, 334, 333 and always add to 1001.
Play the add-expense diagram to see one expense flow through the strategy and the ledger.
When this is over-engineering. If the interviewer only asked for equal splits, a plain function is enough. Add the interface when the second split type shows up.
const shares = weights.map(w => Math.floor((amount * w) / total));
for (let i = 0, left = amount - sum(shares); left > 0; i++, left--) shares[i]++;- Client
- Service
- Worker
- Database
- Pub/Sub
Text description
- Ana → Group: addExpense(ana, 1001, equal, [ana, ben, cam])
- Group → SplitStrategy: split(1001, users, [])
- SplitStrategy replies to Group: [334, 334, 333]
- Group → Ledger: add(ben, ana, 334)
- Group → Ledger: add(cam, ana, 333)
- Group sends (async) to Listener: expense recorded
- Group replies to Ana: ok, shares
Simplifying debts
First net everyone: add up what they are owed and subtract what they owe. Nets always sum to 0.
Read more · 95 words
Then repeat: take the biggest debtor and the biggest creditor, move the smaller of the two amounts, and drop whoever reaches 0. Break ties by name so the answer repeats.
Each transfer clears at least one person, so n people need at most n - 1 transfers. The true minimum is NP-hard in general, so say so and keep the greedy answer.
Example: Ana owes Ben 10 and Ben owes Cam 10. The pairs show two debts, but the nets show Ana owing 10 and Cam owed 10, so one payment Ana to Cam settles both.
Threads and races
Put one lock on each group. Every method that reads or changes the ledger takes it.
Read more · 59 words
The dangerous one is settleUp. If it reads the debt and subtracts in two steps, two taps both see 100 and both pay. Do the check and the subtract in one locked step.
Run listeners after the lock is released, so a slow notification cannot block the group.
Open the settle-up branches to see the second racer lose cleanly.
- Client
- Service
- Database
Text description
- Ben → Group: settleUp(ben, ana, 100)
- Group → Ledger: settle(ben, ana, 100)
- Ledger replies to Group: true, now 0
- Group replies to Ben: ok
Step-by-step answer
In one line each:
Read more · 196 words
Clarify
Split types, rounding, currency, simplify.
Pick the money type
Integer cents, never floats.
Model expense and ledger
One payer, many shares, netted pairs.
Split as a strategy
Validate first, fixed leftover rule.
Break it
Stray cent, bad data, double settle.
In full:
Entities
Group,Expense,Ledger,SplitStrategy. A debt is a ledger entry, not an object.Money is integer cents
0.1 + 0.2is0.30000000000000004. A dinner costs1001, not10.01.Validate before you write
Check members, amount and split data first. Then add every share to the ledger. A bad expense never half-applies.
Net each pair
Adding a debt also cancels any debt the other way, so each pair holds one number.
const net = this.owes(debtor, creditor) + cents - this.owes(creditor, debtor); this.owed.set(`${debtor}>${creditor}`, Math.max(0, net)); this.owed.set(`${creditor}>${debtor}`, Math.max(0, -net));Balances and simplify come from the ledger
Compute both when asked. Nothing is stored twice, so nothing can disagree.
Tests
Random bills must always sum to the amount. Add the stray cent, a repeated pair, an overpay, a thread race on settle-up, and a messy trip that simplifies to zero.
Implementation
The same design in JavaScript, Java or Python.
Read more · 68 words
Java and Python add a real lock per group and run listeners after releasing it.
spreadis the only place that rounds. Every strategy except exact goes through it.- The ledger is not thread-safe by itself. The group's lock is what makes it safe.
Then code it yourself. Try the Expense Splitter exercise: four split types, a fixed leftover rule, settle-up limits and a simplify with exact tie-breaks.
// Strategy: each split type turns (amount in cents, participants) into one share per participant, in order,
// or null when the input makes no sense. Money is integer cents, so 10.00 is 1000 and nothing is a float.
const sum = xs => xs.reduce((a, b) => a + b, 0);
const whole = xs => xs.every(Number.isInteger);
// Proportional split. Floors lose under one cent per person, so the leftover is fewer cents than people: hand them
// out one each to the first participants in the order given. The rule is fixed so the same input always gives the same bill.
function spread(amount, weights) {
const total = sum(weights);
const shares = weights.map(w => Math.floor((amount * w) / total));
for (let i = 0, left = amount - sum(shares); left > 0; i++, left--) shares[i]++;
return shares;
}
export const SPLITS = {
equal: (amount, users) => spread(amount, users.map(() => 1)),
shares: (amount, users, data) => (data.length === users.length && whole(data) && data.every(w => w > 0) ? spread(amount, data) : null),
percent: (amount, users, data) =>
data.length === users.length && whole(data) && data.every(p => p > 0) && sum(data) === 100 ? spread(amount, data) : null,
exact: (amount, users, data) => (data.length === users.length && whole(data) && data.every(c => c >= 0) && sum(data) === amount ? data : null),
};Failure modes and trade-offs
Read more · 185 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Floats for money | Cents drift and books do not balance | Integer cents everywhere |
| Leftover cent dropped | Shares sum to less than the bill | Hand the cents out in a fixed order |
| Overpaid settle-up | A debt flips direction by accident | Reject more than is owed |
| Read-then-write settle | Two payments for one debt | Check and subtract under the lock |
| Percent not summing to 100 | Part of the bill is nobody's | Reject the expense |
| Plan from stale balances | Wrong payments suggested | Compute simplify from the live ledger |
Trade-offs.
| Choice | Pros | Cons |
|---|---|---|
| Per-pair debts vs net balances only | Shows who owes whom | Two numbers to keep in step |
| Strategy per split vs one function | Open to new splits | An interface for a few lines |
| Greedy simplify vs the minimum | Fast, repeatable | Not always the fewest payments |
| One lock per group | Simple and correct | Busy groups serialize |
Follow-up questions
How would you support several currencies?
Keep one ledger per currency and convert only when the user asks. Never mix them in one number.
How would you add a tax or tip on top?
Wrap a strategy in a decorator that adds the extra amount first and splits the total. The group does not change.
How would you let people undo an expense?
Store each expense with its shares, then reverse them in the ledger. Do it as a new entry, so the history stays honest.
How do you scale this to millions of groups?
A group is the unit of locking and of . by group id, and the locks never cross machines.
How would you test the simplify step?
Check that nets reach zero after applying the plan, that it has at most n - 1 transfers, and that ties give a fixed order.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.