Splitwise (LLD)#
Problem statement (interviewer prompt)
Design the class model for Splitwise: users, groups, expenses with multiple splits (equal, exact, percent), and a balance ledger that tracks who owes whom. Add an option to simplify N×N debts into the minimum transfers.
classDiagram
class User {
+id, name, email
}
class Group {
+id, name
-members : Set~User~
+addMember(u)
+addExpense(e)
}
class Expense {
+id, description, amount, paidBy : User
-splits : List~Split~
}
class Split {
<<abstract>>
+user : User
+amount(total) double
}
class EqualSplit
class ExactSplit { +exact : double }
class PercentSplit { +percent : double }
class BalanceSheet {
-balances : Map~User, Map~User, double~~
+record(expense)
+balanceOf(u) Map
+simplify() List~Transfer~
}
Split <|-- EqualSplit
Split <|-- ExactSplit
Split <|-- PercentSplit
Expense *-- "many" Split
Group *-- "many" User
Group *-- "many" Expense
BalanceSheet ..> Expense
Strategy pattern for split types. BalanceSheet maintains the running ledger; simplify reduces N×N edges to a minimum-transfer set using a heap.
A canonical OO interview problem: small surface area, rich modelling decisions, clear extension points.
Domain model#
classDiagram
class User {
+id, name, email
}
class Group {
+id, name
-members : Set~User~
-expenses : List~Expense~
+addMember(u)
+addExpense(e)
}
class Expense {
+id, description
+amount : double
+paidBy : User
+createdAt : Instant
-splits : List~Split~
+validate() boolean
}
class Split {
<<abstract>>
+user : User
+amountFor(total) double
}
class EqualSplit {
+amountFor(total) total/N
}
class ExactSplit {
+exact : double
+amountFor(total) exact
}
class PercentSplit {
+percent : double
+amountFor(total) total*percent/100
}
class BalanceSheet {
-balances : Map~User, Map~User, double~~
+record(expense)
+balanceBetween(a, b) double
+balanceOf(u) Map~User, double~
+simplify() List~Transfer~
}
class Transfer {
+from, to : User
+amount : double
}
Split <|-- EqualSplit
Split <|-- ExactSplit
Split <|-- PercentSplit
Expense *-- "many" Split
Group *-- "many" User
Group *-- "many" Expense
BalanceSheet ..> Expense
BalanceSheet ..> Transfer
Recording an expense#
def record(self, e: Expense):
assert sum(s.amount_for(e.amount) for s in e.splits) == e.amount
for split in e.splits:
if split.user == e.paid_by:
continue
owed = split.amount_for(e.amount)
self.balances[split.user][e.paid_by] += owed
self.balances[e.paid_by][split.user] -= owed
Each user has a map of how much they owe (positive) or are owed (negative) per other user.
Split strategies#
| Type | When |
|---|---|
| EqualSplit | Default; total / N |
| ExactSplit | "Alice paid $50, owes $20 only" |
| PercentSplit | "Bob owns 40% of the rent" |
| ShareSplit | Weighted shares (1, 2, 1) |
Validate at construction: sums must equal the total within a cent tolerance.
Simplify debts#
flowchart TB
Net[Compute net balance per user] --> Heap[Max heap of creditors, min heap of debtors]
Heap --> Match[Pop biggest debtor and biggest creditor]
Match --> Transfer[Create transfer = min of two, reduce balances]
Transfer --> Repeat[Repeat until both heaps empty]
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class Net,Heap,Match,Transfer,Repeat service;
Greedy approximation - finds at most N-1 transfers (vs up to N(N-1)/2 raw). Exact minimum is NP-hard.
Concurrency#
- BalanceSheet should be transactional: record(expense) atomic across both users' maps.
- Persist via outbox if syncing to other services.
- For multi-user concurrent writes, lock per-group.
Audit log / history#
Every record/settle produces an immutable LedgerEntry; balances are the materialised view of summing entries. Enables time-travel ("what did we owe last month?").
Extensions#
- Multi-currency (each Expense has
Currency; ledger keeps separate per-currency balances). - Recurring expenses (cron expression).
- Comment threads on expenses.
- Receipt OCR.
- Payment integration (settle through Stripe, UPI).
Where Splitwise LLD fits#
flowchart TB
SW((Splitwise<br/>LLD))
BPL[Behavioral patterns<br/>Strategy for splits]
OOP[OOP pillars<br/>inheritance for splits]
SWH[Splitwise HLD<br/>system-design sibling]
DE[Domain events + aggregates<br/>LedgerEntry as event]
BPL --> SW
OOP --> SW
SW -. complement .- SWH
SW --> DE
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class SW service;
class BPL,OOP,SWH,DE datastore;
Glossary & fundamentals#
| Tag | Concept | What it is | Page |
|---|---|---|---|
LLD |
Behavioral patterns | Strategy for splits | behavioral-patterns |
LLD |
OOP pillars | inheritance for Split types | oop-pillars |
HLD |
Splitwise (HLD) | the system-design sibling | splitwise |
LLD |
Domain events and aggregates | LedgerEntry as event | domain-events-aggregates |
Quick reference#
Classes#
- User, Group
- Expense (paidBy, amount, splits)
- Split (Equal / Exact / Percent / Share)
- BalanceSheet (per-user owed-map)
- Transfer (from, to, amount)
Recording#
For each split: increment owed (debtor → creditor), decrement reverse.
Validation#
Sum of split amounts == expense total within cent tolerance.
Simplify (greedy)#
- Net balance per user
- Max-heap of creditors, min-heap of debtors
- Pop largest of each; settle min; reinsert remainder
- ≤ N-1 transfers (vs raw N(N-1)/2)
Concurrency#
Per-group lock around record/settle.
Extensions#
- Multi-currency
- Recurring expenses
- Payment integration (Stripe, UPI)
- Audit log via LedgerEntry events
- Receipt OCR
Refs#
- LeetCode #465 (similar simplify variant)
- Splitwise engineering blog
FAQ#
How do you model an expense in Splitwise?#
An Expense has a payer, total amount, and a list of Split objects (one per participant). Each Split records the share, so the balance ledger updates atomically.
Which design pattern fits the split types?#
Strategy: an abstract SplitStrategy with EqualSplit, ExactSplit, and PercentSplit implementations. The Expense calls strategy.compute(total, participants) to get the per-user share.
How is the balance ledger maintained?#
A two-level map balance[a][b] holds how much a owes b. Adding an Expense increments payer credits and participant debts symmetrically so totals always net to zero.
How does the simplify-debts algorithm work?#
Compute each user's net balance, then greedily match the largest creditor with the largest debtor until all balances are zero, producing the minimum transfer count.
How do you support group expenses?#
Expense optionally references a Group; reports filter by group membership. The same balance ledger is reused, with views that aggregate only intra-group transactions.
Related Topics#
- Splitwise (HLD): the system-design view
- Domain Events and Aggregates: LedgerEntry-as-event style
- Behavioral Patterns: the Strategy that makes split types pluggable