Design Splitwise
Design an expense-sharing LLD: record a bill, keep group balances honest, settle cash, and simplify who should pay whom.
Page content
Alice pays ₹1200 for dinner. Bob and Carol each owe her ₹400. Later Bob hands Alice ₹400 cash.
If you only store “who paid the bill” and never update balances, the app still shows Bob owing his share. Carol still looks like she owes the full ₹400 too — which is true for Carol, but the ledger cannot tell a live debt from a debt that already settled. The dinner is frozen. Cash never happened.
The invariant: after every expense or settlement, the sum of net balances in a group is 0, and nobody is shown a debt that already settled.
This is an LLD interview: objects, a ledger, a simplify algorithm, and one critical section around a group. Not a payments network. Same habit as Design a Parking Lot — two writers, one lock — applied to money instead of stalls.
The main design question is:
How do we record an expense, keep pairwise (or net) balances correct, and simplify who should pay whom without a pile of tiny IOUs?
We will start with a list of bills and watch settle become impossible. Nets, pairwise edges, leftover paise, and greedy min-cash-flow appear when that list is no longer enough. If they later say “at Splitwise scale,” shard by group_id. Each group is this design.
1. Clarify the problem
“Design Splitwise” can mean a roommate spreadsheet or a company with ten thousand groups. The questions that change the objects:
- Groups, or only 1:1 debts between two people?
- Equal split only, or exact amounts and percents too?
- Do we show “you owe Alice,” or only a net number?
- Is simplify a button, or always-on?
- Can people settle in cash outside the app?
- One currency or many?
- What happens if someone leaves with an open debt?
If the interviewer gives no extra constraints, I would state:
Scope Groups of 2–20 people; 1:1 is a group of two
Splits EQUAL, EXACT, PERCENT
Currency One per group (INR). Amounts stored as integer paise
Balances Net per member, plus pairwise edges for the UI
Simplify Opt-in. Computes suggested payments; does not move money
Settle Recorded. Reduces balances. Expenses stay
Leave group Rejected while net != 0
Not in v1 Multi-currency FX, comments, receipt photos, recurring bills
Comments and photos sit on the expense row. They do not change the ledger. FX is a product. Say so and move on.
2. Functional requirements
The system must:
- Create a group and add members.
- Add an expense: who paid, how it splits, who was involved.
- Show each member a net balance, and “you owe X / X owes you.”
- Record a settlement when cash (or UPI) changes hands.
- Suggest a simplified set of payments that zeros the group.
- Void a wrong expense and reverse its balance effect.
- Keep every expense for audit after settle or void.
Not in v1: bank rails, currency conversion, simplify across groups, “Alice paid 40% of the bill and Bob paid 60%” as a second payer model. One payer per expense is enough. Multiple payers is the same math with extra credit lines.
3. Non-functional requirements
This is not a 50k QPS problem. Interviewers want the graph and the leftover paise, not a cluster.
| Requirement | Target |
|---|---|
| Balance write | Atomic with the expense or settlement |
| Invariant | sum(nets) == 0 after every committed write |
| Rounding | sum(splits) == amount always, in paise |
| History | Expenses and settlements kept; never deleted |
| Concurrency | Two addExpense calls in one group do not lose an update |
| Clock | Server time on the row; never the client |
Durability is optional until they ask to persist. The ledger outlives the dinner.
Edge cases
- The same dinner is submitted twice (retry, or two people tapping Save).
- Alice leaves the group while she is still owed ₹400.
- ₹1000 split three ways: 0.01 leftover.
- Alice and Bob add two expenses in the same group at once.
- Exact or percent shares that do not add up.
- Void after a settlement has already used those balances.
- A settlement larger than the outstanding pairwise debt.
4. Scale
Say the numbers so nobody spends the interview on QPS.
People / group 2 – 20 (a trip, a flat, a dinner)
Expenses / group hundreds, not millions
Groups / user dozens
Writes a few per minute per group, bursty after a trip
A full scan of 200 expenses works in a toy and is wrong once you need settle, simplify, and a concurrent add. Keep expenses for audit, not as the only way to know who owes whom.
If they push “Splitwise scale,” do not invent Kafka. Route by group_id. Group A and Group B do not share a balance row.
Splitwise
├── Group g_trip ← this design
├── Group g_flat ← this design
└── Group g_dinner ← this design
5. Entities / what we store
Six facts. Balances are maintained, not guessed.
User id, name
Group id, name, currency, members
Expense id, group_id, payer_id, amount_paise, split_type,
note, status OPEN | VOID, client_request_id
Split expense_id, user_id, share_paise
Settlement id, group_id, from_id, to_id, amount_paise, created_at
Balance group_id, user_id, net_paise
optional: (group_id, from_id, to_id, amount_paise) pairwise
An expense plus its splits is the source of truth for “what happened at dinner.” The balance row is occupancy you update in the same critical section. If the UI and the balance row disagree, the balance row wins — then you rebuild it from expenses and settlements if you must.
E1 dinner Alice paid 1200, split 400/400/400
Alice net +800 Bob -400 Carol -400
S1 cash Bob paid Alice 400
Alice net +400 Bob 0 Carol -400
E1 VOID reverse the dinner (S1 still stands)
Alice net -400 Bob +400 Carol 0
Void undoes the bill, not the cash. Alice now owes Bob ₹400 because Bob already paid her for a dinner that no longer exists. That is why you do not delete rows. The audit trail is how you explain a weird net.
Do not treat a person’s name as the key. User ids are. Amounts are integer paise. ₹1200 is 120000 paise. Never use a float.
6. APIs or class methods
In LLD you may show a service, not HTTP. Either is fine.
addExpense(group, payer, amount, members, split) -> Expense
settle(group, from, to, amount) -> Settlement
balances(group) -> [user, net, pairwise]
simplify(group) -> [from, to, amount]
voidExpense(expense_id) -> Expense
Add expense
Request:
POST /v1/groups/g_dinner/expenses
{ "payer_id": "u_alice", "amount": { "paise": 120000, "currency": "INR" }, "split_type": "EQUAL", "member_ids": ["u_alice", "u_bob", "u_carol"], "note": "Dinner", "client_request_id": "phone-9f3a" }
Response:
HTTP 201
{ "expense_id": "e_dinner", "splits": [{ "user_id": "u_alice", "paise": 40000 }, { "user_id": "u_bob", "paise": 40000 }, { "user_id": "u_carol", "paise": 40000 }], "nets": { "u_alice": 80000, "u_bob": -40000, "u_carol": -40000 } }
A retry with the same client_request_id returns the same body. It does not add dinner twice. Exact splits that miss the amount, or percents that miss 100, return 400 SPLIT_MISMATCH.
Settle
Request:
POST /v1/groups/g_dinner/settlements
{ "from_id": "u_bob", "to_id": "u_alice", "amount": { "paise": 40000 } }
Response:
HTTP 201
{ "settlement_id": "s_1", "from_id": "u_bob", "to_id": "u_alice", "amount": { "paise": 40000 }, "nets": { "u_alice": 40000, "u_bob": 0, "u_carol": -40000 } }
Overpay is a product choice: allow it (Alice now owes Bob) or 400 OVERPAY. Say which. Do not silently cap.
Balances and simplify
Request:
GET /v1/groups/g_dinner/balances
Response:
HTTP 200
{ "currency": "INR", "nets": { "u_alice": 40000, "u_bob": 0, "u_carol": -40000 }, "pairwise": [{ "from": "u_carol", "to": "u_alice", "paise": 40000 }] }
Request:
POST /v1/groups/g_dinner/simplify
Response:
HTTP 200
{ "suggested": [{ "from": "u_carol", "to": "u_alice", "paise": 40000 }] }
simplify is a read. It does not write settlements. Mixing the two is how you invent money.
7. Start with a list of expenses — show why settle is hard
The first design everyone writes:
function whoOwesWhom(group):
nets = { u: 0 for u in group.members }
for expense in group.expenses:
nets[expense.payer] += expense.amount
for split in expense.splits:
nets[split.user] -= split.share
return nets
Dinner is easy to draw.
Scan E1
Alice +1200
Alice -400 Bob -400 Carol -400
=> Alice +800 Bob -400 Carol -400
Then Bob pays Alice ₹400 cash. There is no expense. If you do not have a second list, the scan still says Bob owes ₹400. The invariant “nobody is shown a settled debt” is gone.
You can bolt settlements onto the scan:
for settlement in group.settlements:
nets[settlement.from] += settlement.amount // less in debt
nets[settlement.to] -= settlement.amount // less owed
That is correct and O(expenses + settlements) on every screen open. It also means two threads can add E2 and E3, both scan, both write a cached net, and one dinner vanishes from the totals.
The bug is not the loop. The bug is treating the log as the only live state, then caching it without a claim.
scan expenses only wrong after cash
scan expenses + settlements correct, slow, racy if cached
maintain balances on write correct, O(members) per write
We will use the last one. The scan becomes a rebuild tool for tests and corruption, not the read path.
8. Net balance per user — one number
A net is one integer per member: how much the group owes them (positive) or they owe the group (negative). After dinner:
paid share net
Alice 1200 - 400 = +800
Bob 0 - 400 = -400
Carol 0 - 400 = -400
------------------------
column sum 1200 1200 0
Read the ledger left to right. Alice put ₹1200 in. She ate ₹400 of it. The group owes her ₹800. Bob and Carol each ate ₹400 and put in nothing.
+800
Alice
/ \
400/ \400
v v
Bob Carol
-400 -400
The arrows are a story for the UI. The stored fact is the three nets. After Bob’s cash: Alice +400, Bob 0, Carol −400. Sum is still 0. That is the whole model.
A net does not say who should pay Alice. With two debtors it did not matter. With four people it will.
9. Pairwise balances versus net
Pairwise means one number for an ordered pair: “Bob owes Alice ₹400.” A group of n people has up to n(n-1)/2 directed debts if you store one direction, or a sparse map of the pairs that are actually non-zero.
Net (3 cells) Pairwise (the UI)
Alice +800 Bob --400--> Alice
Bob -400 Carol --400--> Alice
Carol -400
When pairwise matters:
- The home screen says “you owe Alice ₹400,” not “your net is −400.”
- Two people shared three bills; you want one number between them, not three IOUs.
- A cycle makes pairwise look busy while nets are already zero.
Cycle that nets to nothing
Alice --100--> Bob --100--> Carol --100--> Alice
Alice net 0
Bob net 0
Carol net 0
sum 0
If you only show pairwise, everyone thinks they owe ₹100. If you only show nets, everyone looks settled — which they are, as a group. Simplify is how you get from a messy pairwise graph to a small set of payments that match the nets.
Suggested payments need only nets. Pairwise is for “you owe Alice” before simplify, and for refusing a settlement that does not follow an existing pair. I keep both and update them in the same write. If they drift, rebuild from the log. Net-only is less state and a colder home screen.
10. Add expense: one critical section, leftover paise
Adding dinner is not “insert a row.” It is insert plus balance updates, or it is a lie.
synchronized (groupLock) / BEGIN … FOR UPDATE balances
reject if client_request_id seen
shares = split(amount, members, type)
assert sum(shares) == amount
insert Expense OPEN + Split rows
net[payer] += (amount - payer_share)
for each other member:
net[member] -= share
pairwise[member -> payer] += share
commit
Dinner: Alice +800, Bob −400, Carol −400. Sum 0.
Rounding: last person eats the leftover paise
₹1000.00 is 100000 paise. Three people. 100000 / 3 = 33333 remainder 1.
Alice 33333
Bob 33333
Carol 33334 ← last by sorted user_id
sum 100000
Last is a stable user_id sort, not luck. Nearest-each-share can yield 33333 × 3 = 99999 and invent a missing paisa. Fix that with the same leftover rule.
EQUAL: q, r = divmod(amount, n)
first n-1 people get q; last gets q+r
EXACT: caller sends shares; reject if sum != amount
PERCENT: raw = amount * pct / 100 (integer)
leftover paise go to last member
reject if percents != 100
The rule you say out loud: sum(splits) == amount, always. The leftover paisa is a product decision, not a precision bug. I would not split leftover across everyone; that is more code for one paisa.
Floats are banned. 1200.0 / 3 in IEEE is how you fail the rounding test.
11. Settle: record cash, do not delete dinner
Bob pays Alice ₹400. That is a Settlement, not an edit of E1.
synchronized (groupLock)
insert Settlement(Bob -> Alice, 400)
net[Bob] += 400 // less in debt
net[Alice] -= 400 // less owed
pairwise[Bob -> Alice] -= 400
commit
E1 stays OPEN
splits stay 400/400/400
Before S1 After S1
Alice +800 Alice +400
Bob -400 Bob 0
Carol -400 Carol -400
Bob --400--> Alice (edge gone)
Carol --400--> Alice Carol --400--> Alice
Delete E1 and you lose the audit: why does Carol still owe ₹400? You also cannot void the dinner later without inventing history. Settle is “cash moved,” not simplify. The dinner remains the reason Carol still owes Alice.
12. Simplify debts: greedy min-cash-flow
A trip with four people collects a pile of IOUs. Nobody wants to pay six times.
Min-cash-flow: find payments that zero every net, using as few transfers as you reasonably can.
Minimizing the exact number of transfers is NP-hard (it hides subset-sum: can this creditor be paid by a subset of debtors that adds up exactly?). The interview answer is greedy: largest debtor pays largest creditor, then repeat. It is correct. It is at most n-1 payments. It is not always minimum. Say that.
function simplify(nets):
debtors = max-heap of people with net < 0 // magnitude
creditors = max-heap of people with net > 0
payments = []
while debtors and creditors:
d = pop max debtor
c = pop max creditor
x = min(-d.net, c.net)
payments.append(d pays c the amount x)
d.net += x
c.net -= x
if d.net < 0: push d
if c.net > 0: push c
return payments
Worked example — four people, six IOUs
Alice, Bob, Carol, Dev after a weekend:
Pairwise before (six edges)
Alice --100--> Bob --250--> Carol
| | |
| 200 | 120 | 180
v v v
Carol Dev <---------+
|
| 80
v
Alice
Nets from those edges:
Alice -100 -200 +80 = -220
Bob +100 -250 -120 = -270
Carol +200 +250 -180 = +270
Dev +120 +180 -80 = +220
sum = 0
Greedy:
1. max debtor Bob 270, max creditor Carol 270
Bob pays Carol 270
Bob 0, Carol 0
left: Alice -220, Dev +220
2. Alice pays Dev 220
all zero
After (two edges)
Bob --270--> Carol
Alice --220--> Dev
Six IOUs became two payments. Same nets. Dev never split a cab with Alice; the algorithm still told her to pay him. That is the product cost of simplify. I would keep it opt-in. People who want the social story use pairwise. People who want to leave the restaurant use the button.
A net of zero drops out immediately (no heap entry). That is how the ₹100 cycle in section 9 becomes “suggested: nothing.”
13. State machines if they help
Do not invent a status for every mood. Two are enough.
Expense
OPEN ──── voidExpense ────► VOID
Void applies the inverse deltas of the original splits, in the same group lock. Illegal: edit shares in place. A correction is VOID plus a new OPEN expense. That keeps the audit and the rounding rule on one path.
Group membership
ACTIVE ──── leave (only if net == 0) ────► LEFT
Alice still owed ₹400? 409 OPEN_DEBT. Someone settles her out, or the group records a settlement that zeros her, then she may leave. Transferring her net onto Bob is a settlement, not a hidden mutation.
Settlements are append-only. Undo cash with a reversing row, not a status flip. The pair that must stay together:
BEGIN / synchronized
expense → OPEN
nets += dinner deltas
pairwise += dinner edges
END
BEGIN / synchronized
expense OPEN → VOID
nets -= dinner deltas
pairwise -= dinner edges
END
If you persist the expense and forget the nets, the next screen is wrong until a rebuild. If you update nets and roll back the insert, you invented money. Same critical-section lecture as the parking-lot ticket and stall.
14. Class sketch
Ideas, not a framework dump. Requests for one group share one GroupLedger.
Money paise: int, currency: INR
User id, name
Group id, name, currency, memberIds
Expense id, groupId, payerId, amount, splits, type, status, requestId
Settlement id, groupId, fromId, toId, amount
BalanceBook net[user], pair[from][to]
SplitPolicy equal / exact / percent // last eats leftover
BalanceBook applyExpense / applySettlement / reverseExpense / sumNets
DebtSimplifier suggest(nets) -> [from, to, amount]
ExpenseRepo insert / findByRequestId / markVoid
SettlementRepo insert
BalanceRepo lockGroup / saveBook
GroupLedger addExpense / settle / voidExpense / balances / simplify
GroupLedger.addExpense in one breath:
addExpense(payer, amount, members, type, requestId):
if seen(requestId): return existing
shares = SplitPolicy.for(type, amount, members)
expense = repo.insert(OPEN, payer, shares, requestId)
book.applyExpense(payer, shares)
repo.saveBook(book)
return expense
Handlers do not touch net[user] themselves. Tests fake the repository for split math; they use two threads against a real GroupLedger for the race.
15. Concurrency: two expenses, one group
Alice adds groceries. Bob adds cab fare. Same group, same second. Both read Alice = +800.
Alice Ledger Bob
| | |
|-- addExpense groceries ->| |
| |<-- addExpense cab ---------|
| nets: A +800 B -400 C -400 |
| lock; apply grocery; unlock |
| | lock; sees new nets |
| | apply cab; unlock |
|<-- 201 e_grocery --------|-------- 201 e_cab -------->|
| sum(nets) == 0, both rows exist |
Without the lock, both apply deltas to +800 and the second write drops the first dinner’s effect. Sum is no longer 0. That is the offer-level bug.
group mutex simple; two groups never wait on each other
row lock / FOR UPDATE same idea in SQL, one transaction
optimistic version UPDATE … WHERE version = n; retry on 0 rows
per-user locks only wrong; a dinner touches every member
I would start with one mutex per group_id, or SELECT … FOR UPDATE on every balance row in that group, in a stable id order so two transactions do not deadlock. I would not open a distributed lock “because money.” One group is one writer set.
Two expenses in different groups do not share a lock. That is the shard story from section 4.
Idempotency is a unique (group_id, client_request_id). The second thread with the same id returns the first expense. It must not apply deltas twice. Check the id inside the same critical section as the write.
16. Tests that matter
Skip getters. Test the invariant.
- Dinner sum. After E1, nets are +800 / −400 / −400.
sum == 0. Pairwise Bob→Alice and Carol→Alice are 400 each. - Cash. After S1, Bob is 0, Alice +400, Carol −400. E1 is still OPEN. Scan of expenses-only would fail this test — that is the point.
- Rounding. ₹1000 / 3. Shares 33333, 33333, 33334 (or your stated leftover rule). Sum equals 100000. No floats in the fixture.
- Simplify. The four-person graph in section 12. Suggested payments: Bob→Carol 270, Alice→Dev 220. All suggested nets match. Edge count ≤ 3.
- Cycle. ₹100 triangle.
simplify()returns empty. Nets stay 0. - Duplicate request. Two
addExpensewith the sameclient_request_id. One row. Deltas applied once. - Last-write race. Two threads, two different expenses, one group. Both persist.
sum(nets) == 0. Both ids exist. - Void. Void E1 after S1. Nets become the settlement only (Alice −400, Bob +400, Carol 0) — cash still happened.
- Leave. Alice net ≠ 0 →
OPEN_DEBT. After Carol settles, Alice may leave. - Bad exact split. Shares 300 + 300 + 300 on a 1000 amount →
SPLIT_MISMATCH, nets unchanged.
Test (3) and (7) are the offer if they only have time for two. Rounding is where juniors use double. The race is where they forget the lock.
17. Extensions
Stay on objects. Do not add a message bus.
Multi-currency. One currency per group is the LLD. A second currency is either a second group or an FX rate snapshotted onto the expense — and then you have invoice money, not this article. Do not convert at read time.
Simplify across groups. Usually no. The trip and the flat are different social contracts. Alice should not pay Dev for a cab because Carol owes her for rent. If they insist, you are netting users globally; say the trust change out loud.
Multiple payers. Two expenses, or two credit lines on one bill. I would use two expenses until the UI demands one receipt. Recurring rent is a job that calls addExpense with a new request id. Photos and comments are columns. Zero balance code.
18. Final object diagram
One group. Many phones. One ledger.
Alice's phone Bob's phone
| |
| addExpense / settle / balances |
v v
+---------------------------------------------+
| GroupLedger |
| addExpense / settle / void / simplify |
| lock: group_id |
+----------+----------+-------------+---------+
| | |
+---------v--+ +----v-----+ +----v--------+
| ExpenseRepo| | SettleRepo| | BalanceRepo |
| OPEN / VOID| | append | | net + pair |
| request id | | never edit| | lock group |
+-----+------+ +-----+-----+ +------+-----+
| | |
v v v
Expense+Split Settlement BalanceBook
e_dinner OPEN s_1 Bob→Alice A +400
shares 400x3 B 0
C -400
|
v
DebtSimplifier.suggest(nets)
|
v
Carol --400--> Alice (not written until settle)
addExpense idempotent id? -> split+leftover -> insert OPEN -> apply book
settle insert row -> apply book; do not touch expenses
void OPEN→VOID -> reverse book
simplify read nets -> greedy heaps -> suggested list
Kafka, Redis, and a region pair are not in this picture. If those words appear, you left LLD.
Source of truth Expense+Split and Settlement rows, plus BalanceBook
written in one critical section
Derived simplify suggestions, “you owe Alice” copy
Ephemeral the simplify response, phone UI
Audit OPEN and VOID expenses, every settlement
19. Interview-ready summary
How to walk through in 10–15 minutes
0–2 min. Dinner ₹1200, Bob’s ₹400 cash. Invariant: sum of nets is 0; settled debts disappear. LLD, not a payments company.
2–4 min. Groups, equal/exact/percent, one currency, opt-in simplify.
4–6 min. Entities: Expense, Split, Settlement, Balance. Amounts in paise.
6–9 min. Naive scan, why cash is invisible, maintain nets on write. Pairwise vs net, the ₹100 cycle.
9–12 min. Leftover paise, settle does not delete, greedy min-cash-flow on the four-person graph. NP-hard vs greedy.
12–15 min. Group mutex, VOID, leave-with-debt, the tests. Scale is group_id.
Key decisions to remember
- This is objects and one group ledger, not a distributed payments product.
- Expense (or settlement) and balance updates commit together.
- Store integer paise.
sum(splits) == amount. Last member eats leftover. - Net is the invariant. Pairwise is the UI. Simplify reads nets; it does not write cash.
- Minimizing transfer count exactly is NP-hard. Greedy max-debtor → max-creditor is the answer.
- Simplify may pair people who never shared a bill. Keep it opt-in.
- Settlements append. Expenses are OPEN or VOID. Nothing is deleted.
- Duplicate dinner is
(group_id, client_request_id), checked inside the lock. - Leave is refused while
net != 0. - “Splitwise scale” is shard by
group_id, not Kafka.
Likely interviewer follow-up questions
- Equal vs exact vs percent — where does the leftover paisa go?
- Why not recompute balances by scanning every expense?
- Pairwise or net — which do you store?
- Prove simplify on a four-person messy graph.
- Is min-cash-flow optimal? Why is greedy allowed?
- Two people tap Save on the same bill.
- Can Alice leave while she is owed money?
- Do we delete the dinner after Bob pays?
- What if they ask for multi-currency or global simplify?
- What if they ask for “Splitwise scale”?
Senior-level points that differentiate the answer
- Name the frozen-ledger bug before drawing classes. Classes without an invariant are a vocabulary list.
- Tie the group lock to the parking-lot assign: one critical section, two writers, one object.
- Separate “cash moved” (settlement) from “suggested payments” (simplify).
- Call out leftover paise and ban floats without being prompted.
- Admit greedy is not exact-min, and that simplify can invent a pair with no shared expense.
- Treat VOID as inverse deltas, not
DELETE. Scale isgroup_id, not a log.
A 1–2 minute verbal answer
Splitwise is a group ledger. An expense records who paid and how the shares split, in integer paise, with the last person eating leftover so the shares sum to the amount. I update each member’s net — and the pairwise edges for the UI — in the same critical section as the insert, so the sum of nets stays zero and a retry cannot apply dinner twice. A settlement is a second row when cash moves; it reduces balances and never deletes the bill. Simplify is greedy min-cash-flow: largest debtor pays largest creditor until everyone is zero. Exact minimum transfers is NP-hard; greedy is correct and at most n−1 payments. I would test leftover paise, the dinner-plus-cash nets, and two threads adding expenses in one group. One currency per group. If they want company scale, shard by group id — each group is this design.
For HLD framing around this, see the System Design Interview Complete Guide and the questions hub.
