Home › Low-Level Design › Design Splitwise
D

Design Splitwise

Split a dinner bill four ways, track who owes whom, and never let the numbers lie by a paisa. It looks friendly, which is exactly the trap. Under the smiley "split a bill" surface it quietly tests three hard things, and the candidates who name them in the first five minutes tend to pass.

Low-Level DesignMedium11 min read

If you remember five things

The whole answer in one glance. Everything below defends these five calls.

  1. BigDecimal, never double. Model money as exact decimal from the first minute. Every downstream choice (rounding, netting, validation) depends on this one discipline.
  2. Strategy pattern for splitting. Equal, exact, and percent live behind one tiny interface, so a fourth split type is a new class plus one map entry, not a rewrite.
  3. Expenses are the facts; balances are derived. The BalanceSheet is a cache you can rebuild by replaying every expense. That directly serves the read-heavy requirement.
  4. Rounding stays exact. Round each share down, then push the leftover paisa onto one participant so the parts always sum back to the total.
  5. Name the concurrency guarantee honestly. A coarse lock serializes writes on one node; say where it hurts and know the per-pair-lock or DB-transaction answer for scale.

This is a classic LLD interview problem. Walk through designing it like you would at a whiteboard, out loud, in roughly this order.

Why this problem is harder than it looks

It quietly tests three things at once: can you model money without floating-point rot, can you keep a ledger honest under concurrent writes, and can you pick the one abstraction that makes "split this however the group wants" a one-line extension instead of a rewrite. Candidates who treat it as a CRUD toy stall at the 20-minute mark.

1Clarify the requirements#

Pin down scope before drawing a box. Splitwise could be a weekend project or a billion-dollar ledger, so ask. The interviewer is watching whether you bound the problem instead of boiling the ocean.

Functional requirements.

  • A user can create a group and add other users to it.
  • Any member can add an expense: who paid, how much, and who shares it.
  • Splits can be equal, exact amounts, or percentages. I want all three, because the third one is where the design either holds or breaks.
  • The system tracks the net balance between every pair of users. "Alice owes Bob 300."
  • A user can settle up, fully or partially, which pays down a balance.
  • Show a user their total: what they owe and what they're owed.

Non-functional requirements.

  • Money must be exact. No floating-point drift. A 100 rupee expense split 3 ways must sum back to 100, not 99.99. Non-negotiable, and I say so first.
  • Reads dwarf writes. A user opens the app and stares at their balances; they add an expense once in a while. Reads should be cheap, even at the cost of some write-time work.
  • Concurrent adds must not corrupt the ledger. Two people at the same dinner tapping "add" at once is the normal case, not the rare one.

What I state upfront rather than wait to be asked.

  • Out of scope: no payment gateway, no auth, no notifications. Those are integration points, not the core model. If the interviewer wants them, I add seams, not full implementations.
  • One sharp question I always ask: "Do you want simplified debts?" If Alice owes Bob and Bob owes Carol, should the system collapse that so Alice pays Carol directly?
  • I build the honest pairwise ledger first and treat debt simplification as a follow-up, because conflating the two early is how people box themselves in.
From the interviewer's chair

I am scoring this opening before you draw anything. I want to hear "money must be exact" and "balances are read far more than written" come out of your mouth unprompted. Ask me the simplified-debts question and you have told me you have seen this problem in the wild, not just in a blog post.

2Identify the core entities#

Here's what earns a class and why. The "why" matters more than the list. Anyone can name nouns; the signal is justifying each one.

  • User. A person. Has an id and a name. Dumb on purpose, so it stays a clean key in maps and never grows balance logic it has no business owning.
  • Group. A named set of users. An expense usually belongs to a group, but Splitwise also supports non-group expenses between two people, so I keep the Group optional rather than mandatory.
  • Expense. One spend event: who paid, total amount, and the list of splits. This is the source of truth. Everything else is derived from expenses, which is the single most important sentence in this whole design.
  • Split. One person's share of one expense, a (user, amount) pair. The sum of all splits must equal the total. That invariant is the thing I validate everywhere.
  • SplitStrategy. The thing that turns "500, equal, 4 people" into four concrete Split objects. Equal, exact, and percentage are different strategies behind one interface. This is the heart of the design.
  • BalanceSheet. The running ledger of net amounts between pairs of users. Derived from expenses, cached for fast reads, which directly serves the read-heavy requirement.
  • ExpenseManager / SplitwiseService. The orchestrator. Creates expenses, applies the strategy, updates the ledger, runs settlements. It owns the wiring so nothing else has to know about everything else.

Money gets its own type too.

  • I use BigDecimal in Java and never double.
  • That decision alone is half the "handles money correctly" signal.
  • I make it in the first minute so the interviewer stops worrying about it.

3Class design#

The design splits into three jobs, and keeping them apart is what lets me reason about each in isolation.

  • Modeling: User, Group, Expense, Split.
  • Splitting: the SplitStrategy family.
  • Bookkeeping: BalanceSheet. The service wires them together.

The key idea: an Expense never computes its own splits.

  • It receives a SplitStrategy, hands it the total and the participants, and gets back a validated list of splits.
  • That keeps the splitting logic in one place and lets me add new split types without touching Expense.
  • If I ever catch myself writing a switch on split type inside Expense, I've already lost.

The ledger is a nested map.

  • BalanceSheet stores balances as payer -> (ower -> amount).
  • When Bob pays and Alice owes 300, I record balance[Alice][Bob] += 300.
  • A settlement is just the reverse entry; net balance between two people is a subtraction.
  • Simple structure, and it makes "show me everything about user X" a single map lookup.
classDiagram class User { -String id -String name } class Group { -String id -String name -List~User~ members +addMember(user) void } class Expense { -String id -BigDecimal amount -User paidBy -List~Split~ splits } class Split { -User user -BigDecimal amount } class SplitStrategy { <> +calculate(total, participants, meta) List~Split~ } class EqualSplitStrategy class ExactSplitStrategy class PercentSplitStrategy class BalanceSheet { -Map balances +applyExpense(expense) void +settle(from, to, amount) void +balanceFor(user) Map } class SplitwiseService { +addExpense(group, paidBy, amount, type, meta) Expense +settleUp(from, to, amount) void +showBalances(user) Map } Group o-- User Expense *-- Split Expense --> User SplitStrategy <|-- EqualSplitStrategy SplitStrategy <|-- ExactSplitStrategy SplitStrategy <|-- PercentSplitStrategy SplitwiseService --> SplitStrategy SplitwiseService --> BalanceSheet SplitwiseService --> Expense
The three jobs drawn apart. Notice the one arrow that earns the design: SplitwiseService points at the SplitStrategy interface, not at any concrete strategy, so a new split type slots in without the service noticing.

4Key implementation#

Start with the value types. Nothing clever, but notice two things: BigDecimal, and that both fields are final. Splits are immutable facts. Once an expense is recorded, its shares never mutate in place.

public class User {
    private final String id;
    private final String name;
    public User(String id, String name) { this.id = id; this.name = name; }
    public String getId() { return id; }
    public String getName() { return name; }
}

public class Split {
    private final User user;
    private final BigDecimal amount;
    public Split(User user, BigDecimal amount) {
        this.user = user;
        this.amount = amount;
    }
    public User getUser() { return user; }
    public BigDecimal getAmount() { return amount; }
}

Now the strategy family. Each one takes the total, the participants, and some optional metadata (exact amounts or percentages), and returns splits that provably sum to the total. The interface is deliberately tiny, which is what lets a fourth strategy drop in later for free.

public interface SplitStrategy {
    List<Split> calculate(BigDecimal total, List<User> participants, Map<User, BigDecimal> meta);
}

public class EqualSplitStrategy implements SplitStrategy {
    public List<Split> calculate(BigDecimal total, List<User> participants, Map<User, BigDecimal> meta) {
        int n = participants.size();
        BigDecimal share = total.divide(BigDecimal.valueOf(n), 2, RoundingMode.DOWN);
        List<Split> splits = new ArrayList<>();
        BigDecimal running = BigDecimal.ZERO;
        for (int i = 0; i < n; i++) {
            BigDecimal amt = share;
            running = running.add(share);
            splits.add(new Split(participants.get(i), amt));
        }
        // give the leftover cents to the first participant so sums stay exact
        BigDecimal remainder = total.subtract(running);
        if (remainder.compareTo(BigDecimal.ZERO) != 0) {
            Split first = splits.get(0);
            splits.set(0, new Split(first.getUser(), first.getAmount().add(remainder)));
        }
        return splits;
    }
}
Pause before you read on: you split 100 three ways. 100 / 3 is 33.33, and three of those sum to 99.99. Where did the last paisa go, and what do you do with it?
Reveal the answer
Rounding down each share leaked a paisa. You track a running sum, compute `remainder = total - running`, and push that leftover onto one participant so the splits sum back to exactly 100. Skip this and the ledger drifts a paisa per uneven split, which compounds into real discrepancies and support tickets. Naming the remainder trick unprompted is the money-code signal the interviewer is waiting for.

Why the remainder handling is the part interviewers watch for.

  • 100 split 3 ways is 33.33 each, which sums to 99.99.
  • I round down with RoundingMode.DOWN, then push the extra 0.01 onto one person.
  • The ledger balances to the paisa.
  • Do total / n with double and hand back three 33.333..., and you've just invented money out of thin air. The interviewer will notice.

Exact and percentage strategies validate instead of distribute. Exact trusts the caller's numbers but refuses them if they don't add up. Percentage computes the amounts and then applies the same remainder trick.

public class ExactSplitStrategy implements SplitStrategy {
    public List<Split> calculate(BigDecimal total, List<User> participants, Map<User, BigDecimal> meta) {
        BigDecimal sum = meta.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        if (sum.compareTo(total) != 0) {
            throw new IllegalArgumentException("Exact splits must sum to the total");
        }
        List<Split> splits = new ArrayList<>();
        for (User u : participants) splits.add(new Split(u, meta.get(u)));
        return splits;
    }
}

public class PercentSplitStrategy implements SplitStrategy {
    public List<Split> calculate(BigDecimal total, List<User> participants, Map<User, BigDecimal> meta) {
        BigDecimal pctSum = meta.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        if (pctSum.compareTo(BigDecimal.valueOf(100)) != 0) {
            throw new IllegalArgumentException("Percentages must add up to 100");
        }
        List<Split> splits = new ArrayList<>();
        BigDecimal running = BigDecimal.ZERO;
        for (User u : participants) {
            BigDecimal amt = total.multiply(meta.get(u))
                                  .divide(BigDecimal.valueOf(100), 2, RoundingMode.DOWN);
            running = running.add(amt);
            splits.add(new Split(u, amt));
        }
        BigDecimal remainder = total.subtract(running);
        if (remainder.compareTo(BigDecimal.ZERO) != 0) {
            Split first = splits.get(0);
            splits.set(0, new Split(first.getUser(), first.getAmount().add(remainder)));
        }
        return splits;
    }
}

The ledger. This is where the actual "who owes whom" lives, and where the concurrency story plays out.

public class BalanceSheet {
    // balances.get(a).get(b) = how much a owes b
    private final Map<User, Map<User, BigDecimal>> balances = new ConcurrentHashMap<>();

    public synchronized void applyExpense(Expense expense) {
        User payer = expense.getPaidBy();
        for (Split split : expense.getSplits()) {
            User ower = split.getUser();
            if (ower.equals(payer)) continue;       // you don't owe yourself
            adjust(ower, payer, split.getAmount());  // ower owes payer this much
        }
    }

    public synchronized void settle(User from, User to, BigDecimal amount) {
        adjust(from, to, amount.negate());  // paying down reduces what 'from' owes 'to'
    }

    private void adjust(User debtor, User creditor, BigDecimal delta) {
        // net against any opposite balance so we store one clean number per pair
        BigDecimal reverse = get(creditor, debtor);
        if (reverse.compareTo(BigDecimal.ZERO) > 0) {
            BigDecimal net = reverse.subtract(delta);
            // simplified netting; collapse to a single direction
            put(creditor, debtor, BigDecimal.ZERO);
            if (net.compareTo(BigDecimal.ZERO) >= 0) put(creditor, debtor, net);
            else put(debtor, creditor, net.negate());
            return;
        }
        put(debtor, creditor, get(debtor, creditor).add(delta));
    }

    private BigDecimal get(User a, User b) {
        return balances.getOrDefault(a, Map.of()).getOrDefault(b, BigDecimal.ZERO);
    }

    private void put(User a, User b, BigDecimal v) {
        balances.computeIfAbsent(a, k -> new ConcurrentHashMap<>()).put(b, v);
    }

    public Map<User, BigDecimal> balanceFor(User user) {
        return balances.getOrDefault(user, Map.of());
    }
}

And the orchestrator that ties strategy to ledger. Notice the strategy lookup is a Map, not an if/else ladder, so adding a type never touches this method.

public class SplitwiseService {
    private final BalanceSheet sheet = new BalanceSheet();
    private final Map<SplitType, SplitStrategy> strategies = Map.of(
        SplitType.EQUAL, new EqualSplitStrategy(),
        SplitType.EXACT, new ExactSplitStrategy(),
        SplitType.PERCENT, new PercentSplitStrategy()
    );

    public Expense addExpense(User paidBy, BigDecimal amount, List<User> participants,
                              SplitType type, Map<User, BigDecimal> meta) {
        SplitStrategy strategy = strategies.get(type);
        List<Split> splits = strategy.calculate(amount, participants, meta);
        Expense expense = new Expense(UUID.randomUUID().toString(), amount, paidBy, splits);
        sheet.applyExpense(expense);
        return expense;
    }

    public void settleUp(User from, User to, BigDecimal amount) {
        sheet.settle(from, to, amount);
    }

    public Map<User, BigDecimal> showBalances(User user) {
        return sheet.balanceFor(user);
    }
}

A quick look at the runtime flow when someone adds an expense. The service orchestrates, the strategy computes, the sheet records. Three clean handoffs.

sequenceDiagram participant C as Client participant S as SplitwiseService participant ST as SplitStrategy participant B as BalanceSheet C->>S: addExpense(payer, amount, participants, type, meta) S->>ST: calculate(amount, participants, meta) ST-->>S: List of Split S->>B: applyExpense(expense) B->>B: adjust each ower -> payer B-->>S: ok S-->>C: Expense
The add-expense flow. The service never computes a share and the sheet never knows what a percentage is: each box does exactly one job, which is what keeps the split logic swappable.

5Design decisions and patterns used#

Each call here is one I can point at and defend, not decoration.

  • Strategy pattern for splitting. The obvious and correct call, and I reach for it unprompted. "Equal vs exact vs percent" is textbook interchangeable behavior behind one interface. Adding a "split by shares" type later is a new class and one map entry, zero edits to existing code. That's the open-closed principle paying off in a way you can actually point at.
  • Factory-ish lookup via the SplitType -> SplitStrategy map in the service. I could wrap it in a real factory, but a map reads cleaner here and does the same job: pick the right strategy without if/else chains. I say that trade-off out loud so it reads as a choice, not an oversight.
  • Single source of truth. Expenses are the facts. The BalanceSheet is a derived cache. If I ever doubt the ledger, I can recompute it by replaying every expense from scratch. This signals event-sourcing thinking without over-engineering, and it directly answers the read-heavy requirement: the cache absorbs reads, the expense log guarantees I can always rebuild it.
  • BigDecimal over double. Not a pattern, a discipline. I mention it early and the interviewer relaxes. Everything downstream (the rounding, the netting, the validation) depends on exact arithmetic, so this is the foundation, not a detail.
  • No Observer, on purpose. Notifications ("you were added to an expense") are a real feature, but bolting Observer on now is premature. I note it as the natural seam if asked, and I point at exactly where it plugs in: the BalanceSheet becomes the subject.

Expenses are the truth; the ledger is just a fast answer you can always rebuild.

6Handling edge cases and concurrency#

The ones I actually call out at the board, in order of how often they trip people up.

  • Rounding. The leftover-to-first-person trick keeps every expense summing exactly. In production you'd rotate who eats the extra paisa so it's fair over time, because always sticking it on participant zero means one unlucky person subsidizes every group they're in.
  • Payer is also a participant. If Alice pays 400 split equally among Alice, Bob, Carol, Alice should not owe herself. The if (ower.equals(payer)) continue line handles it. Alice's own share just reduces what the others owe her, which is the correct real-world behavior.
  • Self-cancelling balances. If Alice owes Bob 300 and then Bob pays for Alice's 300 lunch, the net should be zero, not two opposing 300s. The adjust method nets opposite-direction balances into one clean number per pair, so I never store both A owes B 300 and B owes A 300 at once.
  • Over-settlement. Someone tries to settle 500 on a 300 debt. I reject or cap it. Silent over-settlement that flips a debt backwards (now Bob owes Alice 200 out of nowhere) is a bug waiting to confuse users and generate support tickets.
  • Zero or negative amounts. Validate at the service boundary. An expense of 0 or -50 gets rejected before it touches the ledger. Garbage never reaches the BalanceSheet, so the ledger's invariants hold by construction.

Concurrency is the part a weak candidate hand-waves.

  • Two members of the same group add expenses at the same instant, both touching the Alice-Bob balance.
  • Naive read-modify-write loses an update: thread A reads 300 and writes 400, thread B also reads 300 and writes 450, and 50 rupees vanishes.
  • I guarded applyExpense and settle with synchronized on the BalanceSheet, which serializes writes so the read-modify-write is atomic. That's fine for a single-node design.
Red flag that fails you here

Claiming ConcurrentHashMap alone makes the ledger thread-safe. It does not. The map structure is safe, but the arithmetic (read the stored amount, add, write it back) is still a race. If you cannot draw the line between "the container is concurrent" and "my read-modify-write needs a lock," I have learned you have not actually debugged a lost-update bug.

Where the coarse lock hurts, and the honest answer.

  • synchronized on the whole sheet serializes every write across every user pair, even unrelated ones, so it won't scale write throughput.
  • At scale I'd shard locks per user-pair, or push the balance into a database row and update it inside a transaction with optimistic locking (a version column).
  • Then two concurrent updates to the same pair either both apply in order or one retries, never clobbering each other.
  • I make the container-vs-arithmetic distinction explicit, because conflating the two is a common mistake.

7Extensions and follow-ups#

Where interviewers usually push, and the answer I give for each.

  • Debt simplification. The classic follow-up. Given a tangle of pairwise debts, minimize the number of transactions to settle everyone. Alice owes Bob 100, Bob owes Carol 100, collapse to Alice pays Carol 100. This is a graph problem: net each person to a single balance, then greedily match the biggest creditor with the biggest debtor using two heaps. I keep this as a separate DebtSimplifier so the honest ledger stays intact and simplification is an optional view, never a mutation of the underlying facts.
  • Non-group expenses. Two friends split a cab with no group. The model already supports it: an expense doesn't strictly need a group, just a payer and participants. I designed it that way on purpose in section 2.
  • New split types. Split by shares (2 shares to Alice, 1 each to others), or split with an itemized bill. Both are just new SplitStrategy classes, which is the whole point of the Strategy seam.
  • Notifications. Now I'd add Observer. BalanceSheet becomes a subject, and an EmailNotifier or PushNotifier subscribes to balance changes. This is why I left it out earlier instead of never considering it.
  • Persistence. Move expenses to an append-only table and balances to a row-per-pair table updated transactionally. The in-memory version maps cleanly onto that: the expense log is the write-ahead truth, the balance rows are the materialized cache.
  • Multi-currency. Add a Currency to the amount and a conversion step. This gets messy fast with exchange rates (which rate, as of when, who absorbs the spread), and I flag it as a design conversation, not a one-liner.
Follow-ups the interviewer will ask (and your answers)
"Two people add an expense on the same pair at the same instant. What stops a lost update?"
On one node, `applyExpense` and `settle` are `synchronized` on the BalanceSheet, so the read-modify-write is atomic and serialized. `ConcurrentHashMap` only makes the map structure safe, not the arithmetic, so the lock is doing the real work. At scale I shard locks per user-pair or move the balance to a DB row updated under a transaction with optimistic locking, so concurrent writes to the same pair either order cleanly or one retries.
"100 split three ways is 33.33 each, which is 99.99. Where's the missing paisa?"
Rounding each share down with `RoundingMode.DOWN` leaks it. I track a running sum, compute `total - running`, and push that remainder onto the first participant so the splits sum back to exactly 100. In production I'd rotate who absorbs the leftover so one person doesn't subsidize every group.
"Add a new split type, say split-by-shares. How much changes?"
A new class implementing SplitStrategy and one entry in the `SplitType -> SplitStrategy` map. Zero edits to Expense, BalanceSheet, or the service method. That is the Strategy seam paying off: interchangeable algorithms behind one interface, open for extension, closed for modification.
"Can you simplify the debts so fewer transfers settle everyone?"
Yes, as a separate `DebtSimplifier`, never a mutation of the ledger. Net each person to a single balance, then greedily match the biggest creditor with the biggest debtor using two heaps. I keep the honest pairwise ledger as the source of truth and expose simplification as an optional view, so I never destroy the real facts to make the UI tidier.
"The in-memory balances are gone after a restart. Now what?"
Nothing is lost, because expenses are the source of truth and the BalanceSheet is a derived cache. I persist expenses to an append-only table, and rebuild balances by replaying them, or keep a row-per-pair materialized cache updated transactionally. That is the event-sourcing payoff: the ledger is always reconstructable from the log.

What you can now explain in the room

  • Why money is `BigDecimal` from the first minute, and how the remainder trick keeps every split summing exactly.
  • Why the Strategy pattern makes a new split type a one-class, one-map-entry change.
  • Why expenses are the source of truth and the BalanceSheet is a rebuildable cache.
  • How the nested-map ledger nets opposite debts into one clean number per pair.
  • Where a coarse `synchronized` lock hurts, and the per-pair-lock or DB-transaction fix for scale.
  • Why debt simplification stays a separate view and never mutates the honest ledger.

If you liked this, try Design a Parking Lot for the Strategy-and-Factory combo, or Design an Elevator System for a heavier dose of State and concurrency.

Get a prep plan built around your Design Splitwise interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design Splitwise prep plan is on the way.