Why this matters: Splitwise has one deep design decision hiding under its CRUD surface: what is the source of truth? Get that right and every other piece — splits, balances, settlement, even the awkward money math — falls into a clean layer. Get it wrong and the design works in the demo and cannot explain itself a week later.
Start from the operations
addExpense(payer, participants, total, split) -> record it, update who-owes-whom getBalance(user) -> where do I stand overall? getPairBalance(a, b) -> between two people? recordSettlement(from, to, amount) -> A paid B back settleUp(group) -> a short list of transfers that clears the group
Two of these write (addExpense, recordSettlement) and three read. Keeping that split in view is most of the design.
The record and the derived: expenses are truth, balances are arithmetic
The tempting shortcut is to store balances directly: a map of who-owes-whom, updated on every expense, and nothing else. It answers getBalance in O(1) and demos beautifully. It is also the single most damaging decision available in this problem, because a balance without its history cannot explain itself. When a user asks "why do I owe Rahul ₹340?" — and every real user asks — a balances-only design has no answer. There is no audit trail, no way to find a mis-entered expense, no way to rebuild after a bug.
So the core decision: the Expense is the immutable record of truth, and the ledger of balances is derived from it. Every expense is appended, never mutated. Balances are maintained incrementally for fast reads, but they are arithmetic over the record — if they were deleted, you could recompute them by replaying every expense. This is separation of concerns applied to data: the record answers "what happened," the ledger answers "where does that leave us," and only one of them is sacred.
The split hierarchy: validation lives with the rule
Three ways to divide an expense — equal, exact amounts, percentages — and each has its own definition of a valid input: exact amounts must sum to the total; percentages must sum to 100; equal just needs participants. Model this as a small hierarchy where each split kind computes its shares and validates its own input:
Split (abstract) shares(totalMinor, participants) -> user -> amount EqualSplit divides evenly, assigns leftover deterministically ExactSplit validates sum(amounts) == total PercentageSplit validates sum == 100%, computes then rounds
Putting validation inside each split kind is the single-responsibility principle doing real work: the rule and its validity condition are one concept, so they live in one class. It also buys open/closed behavior for free — a future split kind (shares-by-weight, itemized) arrives as a new class carrying its own validation, and no existing code learns about it. The alternative — a central validate(expense) with a switch over kinds — is the design where a new kind compiles fine and silently skips validation.
One hard rule rides on top: an invalid split rejects the entire expense, atomically. No shares applied, no partial ledger updates. An expense either happened or it didn't.
Money: integers, and one rounding site
Amounts arrive as decimal strings, and the representation is yours. The correct answer is integer minor units — paise, not rupees: "100.50" becomes 10050 at the boundary, and every calculation after that is integer arithmetic.
The case against floating point is not "floats are bad" — it's that binary floats cannot represent most decimal amounts exactly (0.10 has no finite binary form), so error accumulates silently across thousands of additions. A ledger that drifts by a paisa is not approximately correct; it is wrong, visibly, in an app whose entire job is being right about small numbers.
Integers force the leftover question into the open, which is a feature. ₹100.00 split three ways is 10000 ÷ 3 = 3333 with 1 left over. The invariant — shares must sum to exactly the total — is non-negotiable; the assignment of the leftover is policy, and the policy must be deterministic (lesson 04 works the options). Rounding happens exactly once, at split time, inside the split kind. No other code rounds, ever.
The ledger: pairwise balances, one write path
Balances are pairwise — between two users — with a stated sign convention so there's exactly one cell per pair:
key (min(a,b), max(a,b)) -- canonical ordering
value net amount, signed -- positive: higher id owes lower
addExpense for each non-payer participant p:
ledger.apply(creditor=payer, debtor=p, share(p))
And here is the move that keeps the system honest: a settlement is not a special case — it flows through the same write path as an expense. "A paid B ₹200" is one more ledger-affecting entry in the record, applied by the same apply() with the roles reversed. One write path means one place where balance math can be wrong, one place to make atomic, one shape of entry to replay when deriving from scratch. Designs that bolt settlement on as its own code path end up with two subtly different implementations of "money moved between two people."
Settle-up: a read-side computation
settleUp(group) — the short list of transfers that clears everyone — is the piece most likely to be over-engineered into the core. Resist. Computing the minimal transfer plan is a view over net balances: fold the group's ledger cells into per-user net positions, then repeatedly match the largest debtor against the largest creditor until everyone nets to zero. Greedy, O(n log n) with two heaps, and it settles n users in at most n−1 transfers.
Two properties matter more than the algorithm. First, it's derived: it reads balances and writes nothing — a suggested plan only becomes real when someone actually records a settlement, which then flows through the ordinary write path. Second, it's honest: the truly minimal number of transfers is an NP-hard problem, and the greedy is a good heuristic, not an optimum — say so, and you'll sound like someone who checks claims.
Invariants, stated out loud
- shares of any expense sum to exactly its total, in minor units - ledger == fold(expenses + settlements); balances are always derivable - expenses and settlements share ONE write path into the ledger - money is never represented as floating point, anywhere - an invalid split rejects its expense atomically — no partial state
Key takeaway
Splitwise's core design is a layering decision executed four ways: immutable expenses as the record with balances derived from them, split kinds that own their shares and their validation, money as integer minor units rounded exactly once under a sums-exactly invariant, and settlements flowing through the same single write path as expenses — with settle-up living strictly on the read side as a greedy, honestly-non-optimal plan over net balances.