Design Projects: Classic Systems · Splitwise

Design Splitwise

Designing an expense-sharing app: money in cents, pluggable splits, a debt ledger and settle-up.

  • 8 min read
  • 16 practice questions
  • 4 graded problems
On this page
  1. Description
  2. The approach
  3. The pieces in code
  4. Settling up
  5. Where you'll see it
  6. Which tool for which job?
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

Description

Splitwise - tracking who owes whom in a group and settling up - looks like arithmetic and is really about money handling and invariants: amounts that must never lose or invent a cent, a ledger whose balances always sum to zero, split rules that keep growing, and a settle-up that uses few transfers. This skill builds that ledger in integer cents, makes split types pluggable, and settles debts greedily.

Splitwise-style apps look like arithmetic, and the first attempt treats them that way:

Java

Map<String, Double> balance = new HashMap<>();

void addExpense(String payer, double amount, List<String> people) {
    double share = amount / people.size();                  // 10.00 / 3 = 3.3333333333333335
    balance.merge(payer, amount, Double::sum);
    for (String p : people) balance.merge(p, -share, Double::sum);
}

Python

balance: dict[str, float] = {}

def add_expense(payer: str, amount: float, people: list[str]) -> None:
    share = amount / len(people)                      # 10.00 / 3 = 3.3333333333333335
    balance[payer] = balance.get(payer, 0.0) + amount
    for p in people:
        balance[p] = balance.get(p, 0.0) - share

Every line hides a bug that shows up in a test. double can't represent most decimal amounts exactly (0.1 + 0.2 is 0.30000000000000004), so balances end up as -0.0000000001 and "is everyone even?" is never quite true. Three people splitting 10.00 each owe a third of a cent more than can be paid, and someone has to absorb that cent. An unknown participant throws halfway through the loop, after the payer was already credited. And "settle up" usually means one payment per debt, when most of them could cancel out.

We'll go from simple to more technical:

  1. The model: expenses, splits and a pairwise ledger in cents.
  2. Splitting exactly, including the leftover cent.
  3. Settling up with at most n - 1 transfers.
  4. The bugs that lose or invent money, and the tradeoffs in what to store.

The approach

  1. Clarify. Which split types - equally, exact amounts, percentages, shares? What currency and precision; who gets the leftover cent? Can the payer be left out of the split? Should settling up minimize transfers? Can expenses be edited? (Multiple currencies and groups are common follow-ups; agree they're out of scope first.)
  2. Model. Money as a long count of cents (an int in Python, which never overflows). A SplitStrategy interface with one class per split type. A Transfer record for settle-up plans. An ExpenseManager that owns the users and the ledger.
  3. API. addUser(id), addExpense(paidBy, amountCents, split), recordPayment(from, to, amountCents), balanceOf(id), owes(from, to), settleUp().
  4. Core. A ledger of pairwise net debts, updated on both sides together, and a greedy settle-up over the net balances.

The pieces in code

Each split type answers one question - who owes how much of this amount? - so it's a strategy:

Java

public interface SplitStrategy {
    Map<String, Long> shares(long amountCents);   // always adds up to amountCents
}

public class EqualSplit implements SplitStrategy {
    private final List<String> participants;
    // constructor rejects an empty list or a duplicate

    public Map<String, Long> shares(long amountCents) {
        int n = participants.size();
        long each = amountCents / n, leftover = amountCents % n;
        Map<String, Long> shares = new LinkedHashMap<>();
        for (int i = 0; i < n; i++) shares.put(participants.get(i), each + (i < leftover ? 1 : 0));
        return shares;
    }
}

Python

from abc import ABC, abstractmethod


class SplitStrategy(ABC):
    @abstractmethod
    def shares(self, amount_cents: int) -> dict[str, int]: ...    # always adds up to amount_cents


class EqualSplit(SplitStrategy):
    def __init__(self, participants: list[str]):
        # rejects an empty list or a duplicate
        self._participants = participants

    def shares(self, amount_cents: int) -> dict[str, int]:
        each, leftover = divmod(amount_cents, len(self._participants))
        return {p: each + (1 if i < leftover else 0) for i, p in enumerate(self._participants)}

1000 cents among three is 334, 333 and 333: rounded down, and the leftover cent handed to the first participant by a rule that's written down and deterministic. PercentSplit does the same with a percentage per person (round down, hand out the leftover in a fixed order), and ExactSplit simply checks that the given shares add up.

The ledger keeps one number per pair, stored on both sides:

Java

/** net.get(a).get(b) > 0 means a owes b that much; always net[a][b] == -net[b][a]. */
private final Map<String, Map<String, Long>> net = new HashMap<>();

public void addExpense(String paidBy, long amountCents, SplitStrategy split) {
    if (amountCents <= 0) throw new IllegalArgumentException("The amount must be positive");
    requireUser(paidBy);
    Map<String, Long> shares = split.shares(amountCents);       // may throw: nothing has changed yet
    for (String p : shares.keySet()) requireUser(p);           // validate everything first...
    for (var e : shares.entrySet())                            // ...then apply everything
        if (!e.getKey().equals(paidBy)) move(e.getKey(), paidBy, e.getValue());
}

private void move(String a, String b, long amount) {          // a owes b `amount` more
    net.get(a).merge(b, amount, Long::sum);
    net.get(b).merge(a, -amount, Long::sum);
}

Python

def __init__(self):
    # net[a][b] > 0 means a owes b that much; always net[a][b] == -net[b][a]
    self._net: dict[str, dict[str, int]] = {}

def add_expense(self, paid_by: str, amount_cents: int, split: SplitStrategy) -> None:
    if amount_cents <= 0:
        raise ValueError("The amount must be positive")
    self._require_user(paid_by)
    shares = split.shares(amount_cents)           # may raise: nothing has changed yet
    for p in shares:
        self._require_user(p)                     # validate everything first...
    for p, share in shares.items():               # ...then apply everything
        if p != paid_by:
            self._move(p, paid_by, share)

def _move(self, a: str, b: str, amount: int) -> None:    # a owes b `amount` more
    self._net[a][b] = self._net[a].get(b, 0) + amount
    self._net[b][a] = self._net[b].get(a, 0) - amount
  • Both sides in one place. move is the only code that touches the ledger, so "what A owes B" and "what B owes A" can't disagree. If A owes B 500 and later B owes A 200, the pair simply nets to 300.
  • Derived numbers are derived. owes(a, b) is max(0, net[a][b]); balanceOf(a) is minus the sum of a's row. A payment is just a move in the other direction, and overpaying flips the debt.
  • All or nothing. Every check runs before the first move, so a rejected expense changes nothing.

Settling up

Only net balances matter for settling: if A owes B and B owes C the same amount, A can pay C and B does nothing. The greedy plan:

while someone's balance is negative:
    debtor   = the most negative balance   (ties: alphabetically first)
    creditor = the most positive balance   (ties: alphabetically first)
    amount   = min(-balance[debtor], balance[creditor])
    record a transfer (debtor, creditor, amount) and update both balances

Every transfer brings at least one of the two to exactly zero, and the last one brings both, so n people with non-zero balances need at most n - 1 transfers. The plan is computed on a copy - settleUp proposes payments; recordPayment is what changes the ledger when money actually moves.

Where you'll see it

Real expense apps are ledgers. Expenses and payments are immutable entries; editing an expense writes a reversing entry plus a new one, so every balance can be explained from history and audited. Payment requests carry an idempotency key, so a mobile client that retries after a timeout doesn't record the same payment twice. Multi-currency groups store each entry in its own currency and convert at a recorded rate when settling. And the rounding rule is part of the product spec, because users will ask where the extra cent went.

Which tool for which job?

You needUse
Money amountsinteger cents (long / int), or decimals with explicit rounding
Equal, exact, percentage and share splitsa split strategy per type
"What does A owe B?"a pairwise ledger, stored once per pair
"What is A's total?"the sum of A's pairs (or a running balance)
Settling upgreedy: largest debtor pays largest creditor
Edits and deletionsreversing entries, then new entries

Common mistakes

MistakeWhat happensDo this instead
double amountsshares like 3.3333333333333335; balances that never reach zerointeger cents
Dropping the leftover centshares no longer add up to the expensegive leftovers to participants by a fixed rule
Updating one side of a pairthe two pages disagreeupdate both sides together
Applying an expense halfway, then failing validationmoney appears or disappearsvalidate the whole expense first
Editing an expense in placehistory no longer explains balancesreverse it and record the new version

Check yourself

Try to answer before opening each one - recalling beats re-reading.

1. Three friends split a 10.00 dinner equally. What does each owe, and what property must the shares always have?

Show answer

In cents: 1000 / 3 is 333 with 1 left over, so one person owes 334 and the others 333 - by a fixed rule, e.g. the first participant listed. The shares must always add up to exactly the amount; otherwise balances stop summing to zero and settle-up can't finish.

2. A owes B 500 from one dinner; later B owes A 200 from a taxi. What do owes(A, B), owes(B, A) and A's balance return?

Show answer

owes(A, B) is 300, owes(B, A) is 0, and A's balance is -300. The pairwise ledger nets the two debts into one number per pair, stored as +300 on A's side and -300 on B's.

3. Why can greedy settle-up never need more than n - 1 transfers for n people with non-zero balances?

Show answer

Each transfer moves the smaller of the debtor's debt and the creditor's credit, so at least one of them ends at exactly zero and drops out. The balances always sum to zero, so when only two people are left they're equal and opposite, and the final transfer settles both. That's at most n - 1 transfers.

Key takeaways

  • Money is a long count of cents (or BigDecimal), never double; shares round down and the leftover cents follow a fixed, documented rule.
  • Each split type is a SplitStrategy returning shares that add up to the amount - a new split type is a new class.
  • Keep a pairwise net ledger updated on both sides by one method; balances and "who owes whom" are derived from it.
  • Validate the whole entry before applying any of it, so a rejected expense changes nothing.
  • Greedy settle-up (largest debtor pays largest creditor) needs at most n - 1 transfers; the true minimum is NP-hard, so greedy is what ships.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You needUse
Moneyinteger cents; decimals with an explicit rounding mode
Split typesa strategy per type
Leftover centsa fixed order (equal) or largest remainder (percent, shares)
Who owes whoma pairwise ledger, both sides updated together
Settling upgreedy (n - 1); exact subset search for small groups
Currenciesoriginal currency + rate per expense; balances per currency
Editsreversing entries

Snippets worth knowing by heart

Java

long[] splitEqually(long cents, int n) {
    long[] shares = new long[n];
    for (int i = 0; i < n; i++) shares[i] = cents / n + (i < cents % n ? 1 : 0);   // leftovers to the first
    return shares;
}
BigDecimal share = new BigDecimal("2.675").setScale(2, RoundingMode.HALF_UP);   // from a string, explicit mode

Python

def largest_remainder(total: int, weights: list[int]) -> list[int]:
    s = sum(weights)
    exact = [total * w for w in weights]
    shares = [e // s for e in exact]
    leftover = total - sum(shares)
    for i in sorted(range(len(weights)), key=lambda i: (-(exact[i] % s), i))[:leftover]:
        shares[i] += 1
    return shares

The traps

  • double money.
  • Lost or randomly assigned leftover cents.
  • Half-applied expenses.
  • One-sided debts.
  • A settle-up that applies payments.

Staff-level points, one line each

  • Python round(2.5) is 2 (half to even); round(2.675, 2) is 2.67 (binary floats).
  • Java Math.round(2.5) is 3, Math.round(-2.5) is -2; new BigDecimal(2.675) rounds to 2.67 - build from strings.
  • Largest remainder: 999 cents at 50/25/25 -> 499, 250, 250.
  • Currencies: keep originals and rates; never mix currencies in one balance.
  • Edits are reversing entries - an append-only ledger.
  • Concurrent expenses: one lock or transaction per expense, not per-entry atomics.
  • Greedy needed 4 transfers where 3 suffice (A -4, B -3, C +2, D +2, E +3); exact search over zero-sum subsets.

Say it in an interview

  • "All amounts are integer cents, and leftover cents go to participants in a fixed order."
  • "Each split type is a strategy, so percentages are one new class."
  • "Edits append a reversal and a new entry; nothing is rewritten."
  • "Greedy settles in at most n - 1 transfers; for small groups I can find the exact minimum."

Practice

This skill comes with 16 multiple-choice questions that tell you which misconception a wrong answer reveals and 4 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Design Splitwise free

Split the Bill, Then Settle Up

MediumProCoding problemJava · Python

A group shares expenses. Each expense is paid by one person and split equally among a list of participants (the payer may or may not be one of them). Amounts are in cents.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Build a Splitwise Expense Sharer

MediumProDesign projectJava · Python

Build the core of an expense-sharing app like Splitwise: a group of users record who paid for what and how each expense is split, the app tracks who owes whom, and it can propose a short list of payments that settles everyone up.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Debug: Splitwise - The Missing Cent

MediumProDebugging taskJava · Python

Finance's nightly reconciliation keeps finding groups where a payer is owed a cent or two less than they paid out. The bug report and the job's log are in the project's README.md.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Review: Splitwise - The Percent Split PR

MediumProCode reviewJava · Python

Review this pull request before it's merged. It touches two files - switch between them above the code. Click the margin next to each line with a problem, and say what kind of problem it is, how bad it is, and why. The number of planted problems is shown above the code.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Design Splitwise free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime