Code an expense-sharing app
Open in the interactive app: practice, quiz, mock interview →In one minute
- Money is an integer count of the smallest currency unit (cents), never a float. Every split must add back up to the exact total, so the design needs a deterministic rule for who gets the leftover cent.
- How an expense is divided is a family of interchangeable algorithms (equal, exact, percent, shares). That is the Strategy pattern: the ledger asks a strategy for a list of shares and never branches on split type.
- Store balances per pair of people as one signed number. "Ben owes Ana 800" and "Ana owes Ben -800" are the same fact, so keep one canonical key per unordered pair and a sign.
- Debt simplification works on each person's net balance, not on the pairs. A greedy match of the biggest creditor with the biggest debtor settles a group of n people in at most n - 1 transfers. It is not always the true minimum (that problem is NP-hard), but it is fast, deterministic and good in practice.
- The shape underneath is "trust but verify" on a ledger: invariants such as "shares sum to the total" and "all net balances sum to zero" are cheap to check on every write and catch almost every bug.
Requirements
Functional
- Users form groups and add expenses: one payer, an amount, a set of participants and a split type.
- Split types: equal, exact amounts and percentages. The design must make adding a new one (shares, adjustments) a local change.
- Show what one user owes another, and each user's net balance in a group.
- Record a settlement (a cash or bank transfer) as a payment that reduces a balance.
- "Simplify debts": suggest a small set of transfers that settles the group.
Non-functional
- Exactness: no rounding drift. The sum of shares equals the total to the cent, and the same input always produces the same shares.
- Auditability: balances are derived from an append-only list of expenses and payments, so they can be recomputed and explained.
- Concurrency: two members adding expenses at the same moment must not lose an update.
Out of scope
- Multi-currency conversion, receipts and OCR, notifications, and real payment rails. We mention where they would plug in.
Entities and relationships
User is an id and a display name. Group holds members and owns a Ledger. An Expense records who paid, the total in cents, the participants and the SplitStrategy used, plus the resulting list of shares. A Payment (settlement) is a transfer from one user to another; it can be modelled as an expense paid by the debtor with the whole amount assigned to the creditor, which keeps one code path.
The Ledger is the heart of the design. It applies each expense to a map of pair balances, keyed by the two user ids in sorted order, with a positive number meaning "first owes second". From those pairs it derives each user's net balance: positive if the group owes them money, negative if they owe the group. DebtSimplifier reads the net balances and returns a list of Transfer values (from, to, amount).
Relationships: a Group has many Users and many Expenses; an Expense has one payer, many participants and exactly one strategy; the Ledger is derived from the Expenses. Keeping the Expense list as the source of truth and the Ledger as a projection is what makes "edit an expense" and "explain this balance" tractable later.
Class diagram
classDiagram
class SplitStrategy {
<<interface>>
+split(total, participants) list~int~
}
class EqualSplit
class ExactSplit {
-amounts list~int~
}
class PercentSplit {
-basisPoints list~int~
}
class Expense {
+id
+payer
+totalCents int
+participants
+shares list~int~
}
class Ledger {
-pairBalances map
+addExpense(payer, total, participants, strategy)
+owes(debtor, creditor) int
+net() map
+simplify() list~Transfer~
}
class Group {
+members
+expenses
}
class Transfer {
+from
+to
+amount int
}
SplitStrategy <|.. EqualSplit
SplitStrategy <|.. ExactSplit
SplitStrategy <|.. PercentSplit
Group "1" o-- "*" Expense
Group "1" *-- "1" Ledger
Expense --> SplitStrategy
Ledger ..> TransferKey flows
The main flow is adding an expense. The service validates membership, asks the strategy for shares, persists the expense, and only then updates the pair balances.
sequenceDiagram
participant U as User
participant S as ExpenseService
participant St as SplitStrategy
participant L as Ledger
participant R as ExpenseRepo
U->>S: addExpense(payer, 3000, [ana, ben, cal], Equal)
S->>St: split(3000, [ana, ben, cal])
St-->>S: [1000, 1000, 1000]
S->>S: check sum(shares) == 3000
S->>R: append expense with shares
S->>L: apply(payer=ana, shares)
L->>L: pair(ana, ben) += 1000 owed to ana
L->>L: pair(ana, cal) += 1000 owed to ana
L-->>S: ok
S-->>U: expense idSimplify debts is a read-only flow on top: compute net balances from the pairs, then repeatedly match the largest creditor with the largest debtor, emit a transfer for the smaller of the two amounts, and drop whoever reaches zero. Each step zeroes at least one person, so a group of n people with non-zero balances needs at most n - 1 steps.
Design patterns used and why
Strategy for splits. Each split type is a small object with one method, split(total, participants) -> shares. The ledger never asks "which kind of split is this?" because it only consumes shares. A new "split by shares" (2 shares for the adult, 1 for each child) is a new class plus a test, with no change to the ledger. Strategies also own their validation: exact amounts must sum to the total, percentages must sum to 100%.
Value object for money. Amounts are plain integers in cents in the practice code, but in a production codebase you would wrap them in a Money type that carries the currency and refuses to add USD to EUR. Fowler's Money pattern describes exactly this, including the "allocate" operation that splits an amount by ratios without losing a cent.
Repository and projection. Expenses are appended; balances are a projection that can be rebuilt from them. This is event sourcing in miniature, and it is why edits and deletes are safe: an edit is a reversal of the old expense plus a new one.
Facade in the form of an ExpenseService that coordinates validation, persistence and ledger updates, so the API layer calls one method per user action.
What we deliberately avoid: an inheritance tree of EqualExpense, PercentExpense and so on. It couples the data (an expense) to the algorithm (how it was divided) and makes "change the split type of an existing expense" an awkward type change.
Concurrency and edge cases
Rounding and the leftover cent
Splitting 1000 cents three ways gives 333.33. Something has to receive the extra cent, and the rule must be deterministic or two servers will disagree. For equal splits, give one extra cent to the first total mod n participants in the order the user listed them. For percentages, use the largest remainder method: take the floor of each exact share, then hand the leftover cents to the participants with the largest discarded fractions, breaking ties by list order. Store percentages as integer basis points (1% = 100 bp), so 33.33% is 3333 and no float ever appears.
Validation
Reject an empty participant list, duplicate participants, a non-positive total, exact amounts that do not sum to the total, and percentages that do not sum to 10000 bp. The payer does not have to be a participant (Ana can pay for Ben's ticket), and the payer's own share creates no debt.
Concurrent writes
Two members add expenses to the same group at the same time. Each touches several pair balances. If the ledger is updated with read-modify-write without coordination, one update is lost. In memory, a mutex per group serialises writes; that is fine because a group has a handful of members and writes are rare. In a database, store pair balances as rows and apply deltas with UPDATE ... SET amount = amount + $delta, in one transaction with the expense insert, touching pairs in sorted key order so two transactions cannot deadlock. A retry of the same request must not double-count, so the client sends an idempotency key that is unique per expense.
Simplification is a suggestion
Simplification changes who pays whom, so it can propose that Cal pays Ana even though they never shared an expense. Show it as advice, and record each actual transfer as a payment. Never rewrite history to match the simplified graph.
Down the stack
Layer 1The contract
For every expense, sum(shares) == total exactly, and the same inputs always give the same shares. At any moment, the sum of all net balances in a group is zero. owes(a, b) == -owes(b, a). Simplify returns transfers that, if all paid, bring every net balance to zero, with at most n - 1 transfers for n non-zero members.
Layer 2The mechanism
Equal split is one divmod. Percent split is integer multiply, floor divide by 10000, then a sort of indices by (-remainder, index) to place the leftover cents. Pair balances are a hash map from a sorted (a, b) tuple to a signed integer, so each expense costs O(participants) map updates. Net balances are one pass over the pairs. The greedy simplifier picks max creditor and max debtor each round; with two heaps it runs in O(n log n), and with a linear scan it is O(n squared), which is fine for groups of a few dozen.
Layer 3The machine
An IEEE 754 double has a 53-bit significand, so 0.1 has no exact binary form and 0.1 + 0.2 is 0.30000000000000004. Sum a few thousand such values and the result drifts by whole cents. A 64-bit integer counts cents up to about 92 quadrillion dollars, and integer add is exact and one CPU cycle. In JavaScript every number is a double, but integers are exact up to 2^53 (about 90 trillion dollars in cents), which is why the TypeScript version can safely use plain numbers for cents. In Postgres the matching column type is bigint (8 bytes), not real or double precision; numeric is exact too but slower because it is stored as variable-length base-10000 digits.
Why it's built this way
Users compare totals to the cent, so any drift reads as a bug or a theft. Floats cannot represent decimal cents exactly, so use integers. Integers cannot split 1000 three ways evenly, so a remainder rule is needed. Different servers and clients must agree on that remainder, so the rule is deterministic and based only on the input order. Balances must be explainable months later, so they are a projection of an append-only expense log rather than mutable truth.
Extensibility
- New split type (shares, "equal plus adjustments", itemised receipt): add a strategy class. The ledger, simplifier and storage stay the same because they only see share lists.
- Multi-currency: store each expense in its own currency, keep balances per currency, and convert only at settlement time with a recorded rate. Mixing currencies inside one integer is the classic mistake.
- Recurring expenses (rent): a scheduler that creates ordinary expenses on a calendar, using an injected clock so it is testable.
- Edit and delete: append a reversal of the original shares and a new expense. Balances stay correct because the projection only ever adds deltas.
- Better simplification: if users want the true minimum number of transfers for small groups, search over subsets that sum to zero; each zero-sum subset of size k needs only k - 1 transfers. It is exponential, so cap it to groups under about 15 people and fall back to greedy.
Leadership lens
- The first code review question is "where can a float sneak in?" Check the API schema, the JSON parser on each client and the analytics export. A single
amount: 12.5in a request DTO undoes the whole design. - Decide the rounding rule once, write it in the API docs, and test it in every client that previews shares. A mobile app that shows 334/333/333 while the server stores 333/333/334 creates support tickets.
- Treat the "sum of nets is zero" invariant as a production alarm, not just a unit test. A nightly job that recomputes every group from its expense log and diffs against stored balances is cheap and finds bugs before users do.
- Simplification is a product decision as much as an algorithm. Some users dislike paying a stranger in the group; make it opt-in per group.
- The same shape appears in a company's internal cost allocation: a shared cloud bill split across teams by percentage needs the same integer cents, the same remainder rule and the same zero-sum check.
Check yourself
Why does the ledger key pair balances by a sorted pair instead of storing "A owes B" and "B owes A" separately?
Show answer
One signed value per unordered pair makes
owes(a, b) == -owes(b, a)true by construction and nets out mutual debts automatically. Two separate entries can drift apart and must be reconciled on every read.A 1000-cent bill is split 33.33%, 33.33%, 33.34%. What does largest remainder produce, and why not just round each share?
Show answer
The floors are 333, 333 and 333 with remainders 0.3, 0.3 and 0.4 cents, so the single leftover cent goes to the third person: 333, 333, 334. Rounding each share independently can produce totals of 999 or 1001.
Why does greedy simplification never need more than n - 1 transfers?
Show answer
Each transfer is the smaller of the top creditor's and top debtor's balance, so at least one of them reaches zero and leaves. With n people holding non-zero balances, after n - 1 steps the last person must also be at zero because the balances sum to zero.
Two expenses are added to the same group concurrently from two phones. What goes wrong without coordination, and what is the database fix?
Show answer
Read-modify-write on the same pair balance can lose one update. Apply deltas with
amount = amount + deltainside one transaction with the expense insert, lock pairs in sorted order, and dedupe retries with an idempotency key.
Watch
- L15: Splitwise LLD (CodeNCode): a full lecture that designs the classes for an expense-sharing app and codes the solution.
- Splitwise Machine Coding Interview: Complete Design + Code (Sanket Singh): a machine-coding style walkthrough, useful for pacing a timed round.
- Banking Ledger: System Design Interview (interviewing.io): the same balance-as-projection and correctness concerns at a larger scale.
Free resources
- Money (Martin Fowler, Patterns of Enterprise Application Architecture): the Money value object and the allocate operation that splits amounts without losing cents.
- Debts made simple (Splitwise blog): the product's own explanation of what simplify debts does for users.
- Largest remainder method (Wikipedia): the apportionment rule behind deterministic percentage splits.
- Floating-point arithmetic: issues and limitations (Python docs): a short, concrete explanation of why 0.1 cannot be stored exactly.
- Strategy pattern (Refactoring.Guru): structure, trade-offs and examples of the pattern used for split types.
- Currencies and minor units (Stripe docs): how a real payments API represents amounts as integers in the smallest currency unit, including zero-decimal currencies.