Engineering Atlas › Low-Level Design

Code an expense-sharing app

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

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 ..> Transfer

Key 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 id

Simplify 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

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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 + delta inside one transaction with the expense insert, lock pairs in sorted order, and dedupe retries with an idempotency key.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →