Knowledge Guide
HomeHands-On BuildsLLD Katas

Design Splitwise

Build a Splitwise (Expense Sharing)

"Design Splitwise" looks like a modeling exercise — User, Expense, Group — until the interviewer asks "okay, now how do you tell me who owes whom, and how do we settle up with the fewest payments?" That question is where candidates who only drew classes stall, and where candidates who understand the Strategy pattern and a bit of greedy-algorithm reasoning pull ahead. This kata makes you build the ledger yourself — split strategies, a net-balance sheet instead of a debt web, a minimum-cash-flow settlement, and a concurrency bug you'll reproduce and fix — in Java and Go, from an empty file.

The Trap

The obvious first instinct: every time someone owes someone else money, record it as a directed edge in a debt matrix. Three friends on a trip:

Store that literally as debt[from][to] += amount for each event and you get six populated matrix cells: debt[Bob][Alice]=30, debt[Carol][Alice]=30, debt[Alice][Bob]=20, debt[Carol][Bob]=20, debt[Alice][Carol]=10, debt[Bob][Carol]=10. Open the app and it tells Alice two contradictory-looking things at once: "Bob owes you $30" and "You owe Bob $20." Both are technically true entries, and both are about the same pair of people — the UI either has to remember to net them live on every render, or it shows stale-looking double debt. Now scale the trip to 8 people across a week: the matrix has up to 8×7=56 live cells, most of which are two people passing small amounts back and forth that mostly cancel out, and nobody stored the fact that they cancel. Settling up naively means walking all 56 cells and firing a payment for each nonzero one — way more transfers than the group actually needs, plus real risk of double-counting a debt that was already netted by a different expense.

Scope it like a senior

Before writing a line of ledger code, pin the contract down:

Answer: 4 pluggable split strategies, integer cents, a single net-balance sheet (not a matrix), a greedy min-cash-flow settlement, thread-safe expense addition, append-only expense log, single currency.

Reason to the design

Simplest thing that could work is the trap itself: a pairwise debt matrix, one entry per ordered pair who have ever transacted. Why it fails beyond the double-counting UX bug above: it's O(n²) storage that grows with every new pair, and — the part that actually matters for settling up — even a correctly-netted pairwise matrix isn't enough, because it only cancels debt between the same two people. It's blind to a cycle across three or more people. Walk the trip example fully netted per pair:

That's the "smart" pairwise version, and it still says: 3 payments (Bob→Alice $10, Carol→Alice $20, Carol→Bob $10). But look at Bob's overall position: he's owed $10 (from Carol) and he owes $10 (to Alice) — his two obligations cancel completely, and pairwise netting has no way to see that because it never looks across the triangle. The insight: track one net balance per person — how much the world owes them, or they owe the world, full stop — and the three-way cycle disappears for free:

One transaction settles the whole trip: Carol pays Alice $30. Bob never has to move money, even though the pairwise view showed him with two separate obligations. This is the whole trick real expense-sharing apps use: the historical trail of who-paid-what stays in an append-only expense log for the activity feed; the balance sheet is a completely separate, O(n)-space projection of net position. You never need the matrix to answer "who owes whom" — you only need it (or the log) to answer "show me the history."

Rounding — why not double: split $10.00 three ways as floats and 10.00 / 3 == 3.3333333333333335; round each to cents and you get $3.33 × 3 = $9.99 — a full cent of the actual $10.00 has vanished, owed by nobody, paid by nobody. Do that across thousands of expenses and the group's tracked total quietly diverges from the sum of real payments — a silent accounting bug that shows up as "the numbers don't add up" weeks later with no single culprit line. The fix: work in integer cents the whole way through, and make the last participant absorb whatever's left after flooring everyone else's share — so by construction, sum(shares) == totalCents, exactly, every time. 1000 cents / 3 → base 333 for the first two, last gets 1000 - 333*2 = 334. Same idea generalizes to percentages (use basis points, parts-per-10,000, so no float ever enters) and weighted shares (integer weights, floor the division, last absorbs the remainder).

Build it — milestones

Attempt-first: the contract is Ledger.addExpense(paidBy, totalCents, participants, SplitStrategy) mutates the net-balance sheet; Ledger.balanceOf(user) reads one person's net cents; Settlement.settle(balances) turns the sheet into a list of pay-this-to-that transactions. Try each milestone before reading the reference implementation below.

Reference implementation — Java

Four strategies behind one interface, a Ledger holding only net balances (no matrix anywhere), and a greedy Settlement. Note Ledger ships a second, deliberately unsynchronized method — addExpenseUnsafe — purely so the break-it section below can reproduce the race on the exact same class; never call it from real code.

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;

/** Split type is a strategy, not an if/else in Ledger. */
interface SplitStrategy {
    /** Returns participant -> owed cents. Must sum EXACTLY to totalCents. */
    Map<String, Long> computeShares(long totalCents, List<String> participants);
}

/** Equal split: base cents each, the LAST participant absorbs the remainder. */
final class EqualSplit implements SplitStrategy {
    public Map<String, Long> computeShares(long totalCents, List<String> participants) {
        int n = participants.size();
        if (n == 0) throw new IllegalArgumentException("no participants");
        long base = totalCents / n;
        Map<String, Long> shares = new HashMap<>();
        for (int i = 0; i < n - 1; i++) shares.put(participants.get(i), base);
        long last = totalCents - base * (n - 1);          // absorbs the remainder -- sum is exact by construction
        shares.put(participants.get(n - 1), last);
        return shares;
    }
}

/** Exact split: caller states cents per person; must reconcile to the total. */
final class ExactSplit implements SplitStrategy {
    private final Map<String, Long> amounts;
    ExactSplit(Map<String, Long> amounts) { this.amounts = amounts; }
    public Map<String, Long> computeShares(long totalCents, List<String> participants) {
        long sum = 0;
        for (String p : participants) sum += amounts.getOrDefault(p, 0L);
        if (sum != totalCents)
            throw new IllegalArgumentException("exact amounts sum to " + sum + " but total is " + totalCents);
        return new HashMap<>(amounts);
    }
}

/** Percentage split: basis points (parts per 10_000) so no floating point enters the split. */
final class PercentSplit implements SplitStrategy {
    private final Map<String, Long> basisPoints;           // e.g. 5000 = 50.00%
    PercentSplit(Map<String, Long> basisPoints) { this.basisPoints = basisPoints; }
    public Map<String, Long> computeShares(long totalCents, List<String> participants) {
        long bpSum = 0;
        for (String p : participants) bpSum += basisPoints.getOrDefault(p, 0L);
        if (bpSum != 10_000)
            throw new IllegalArgumentException("basis points sum to " + bpSum + " but must total 10000");
        Map<String, Long> shares = new HashMap<>();
        int n = participants.size();
        long running = 0;
        for (int i = 0; i < n - 1; i++) {
            String p = participants.get(i);
            long cents = totalCents * basisPoints.get(p) / 10_000;   // floor
            shares.put(p, cents);
            running += cents;
        }
        shares.put(participants.get(n - 1), totalCents - running);   // last absorbs the floor remainder
        return shares;
    }
}

/** Share split: integer weights (e.g. roommates splitting rent 2:1:1 by room size). */
final class ShareSplit implements SplitStrategy {
    private final Map<String, Long> shareWeights;
    ShareSplit(Map<String, Long> shareWeights) { this.shareWeights = shareWeights; }
    public Map<String, Long> computeShares(long totalCents, List<String> participants) {
        long totalShares = 0;
        for (String p : participants) totalShares += shareWeights.getOrDefault(p, 0L);
        if (totalShares <= 0) throw new IllegalArgumentException("total shares must be positive");
        Map<String, Long> shares = new HashMap<>();
        int n = participants.size();
        long running = 0;
        for (int i = 0; i < n - 1; i++) {
            String p = participants.get(i);
            long cents = totalCents * shareWeights.get(p) / totalShares;   // floor
            shares.put(p, cents);
            running += cents;
        }
        shares.put(participants.get(n - 1), totalCents - running);        // last absorbs the floor remainder
        return shares;
    }
}

/** A settled payment instruction produced by Settlement.settle(). */
final class Transaction {
    final String from, to;
    final long amountCents;
    Transaction(String from, String to, long amountCents) { this.from = from; this.to = to; this.amountCents = amountCents; }
    public String toString() { return from + " pays " + to + " " + amountCents + "c"; }
}

/** The net-balance sheet: ONE number per user, not a pairwise matrix. */
final class Ledger {
    // +ve => this user is owed money; -ve => this user owes money. Invariant: sum of all values == 0.
    private final Map<String, Long> balances = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    /** Thread-safe: the read-modify-write of every participant's balance is one atomic unit. */
    void addExpense(String paidBy, long totalCents, List<String> participants, SplitStrategy strategy) {
        Map<String, Long> shares = strategy.computeShares(totalCents, participants);
        long check = 0;
        for (long v : shares.values()) check += v;
        if (check != totalCents)
            throw new IllegalStateException("strategy bug: shares summed to " + check + " != " + totalCents);
        lock.lock();
        try {
            applyShares(paidBy, shares);
        } finally {
            lock.unlock();
        }
    }

    /**
     * UNSAFE TWIN of addExpense -- same logic, NO lock. Exists ONLY so the break-it test
     * below can demonstrate the lost-update race on the shared balances map. Never call
     * this from real code.
     */
    void addExpenseUnsafe(String paidBy, long totalCents, List<String> participants, SplitStrategy strategy) {
        Map<String, Long> shares = strategy.computeShares(totalCents, participants);
        applyShares(paidBy, shares);
    }

    private void applyShares(String paidBy, Map<String, Long> shares) {
        for (Map.Entry<String, Long> e : shares.entrySet()) {
            String participant = e.getKey();
            long owed = e.getValue();
            if (participant.equals(paidBy)) continue;   // payer doesn't owe their own share
            // classic read-modify-write: get() then put() -- NOT atomic across the whole map
            // when two threads do this concurrently without the lock above.
            long participantBal = balances.getOrDefault(participant, 0L);
            balances.put(participant, participantBal - owed);   // they now owe `owed` more
            long payerBal = balances.getOrDefault(paidBy, 0L);
            balances.put(paidBy, payerBal + owed);               // payer is owed `owed` more
        }
    }

    long balanceOf(String user) {
        lock.lock();
        try { return balances.getOrDefault(user, 0L); } finally { lock.unlock(); }
    }

    Map<String, Long> snapshot() {
        lock.lock();
        try { return new HashMap<>(balances); } finally { lock.unlock(); }
    }
}

/** Greedy min-cash-flow: max debtor pays max creditor, repeat. At most n-1 transactions. */
final class Settlement {
    static List<Transaction> settle(Map<String, Long> balancesIn) {
        Map<String, Long> bal = new HashMap<>(balancesIn);
        List<Transaction> txns = new ArrayList<>();
        while (true) {
            String maxCreditor = null, maxDebtor = null;
            long maxCredit = 0, maxDebit = 0;               // maxDebit is the most-negative balance
            for (Map.Entry<String, Long> e : bal.entrySet()) {
                if (e.getValue() > maxCredit) { maxCredit = e.getValue(); maxCreditor = e.getKey(); }
                if (e.getValue() < maxDebit) { maxDebit = e.getValue(); maxDebtor = e.getKey(); }
            }
            if (maxCreditor == null || maxDebtor == null || maxCredit == 0) break;   // everyone is settled
            long amount = Math.min(maxCredit, -maxDebit);
            txns.add(new Transaction(maxDebtor, maxCreditor, amount));
            bal.merge(maxDebtor, amount, Long::sum);
            bal.merge(maxCreditor, -amount, Long::sum);
        }
        return txns;
    }
}

Reference implementation — Go

Same shape: a SplitStrategy interface, four implementations, a mutex-guarded Ledger, and the identical greedy Settle. Save as splitwise.go, package splitwise.

// Package splitwise is the reference implementation for the "Build a
// Splitwise" LLD kata: a net-balance ledger with pluggable split
// strategies and a greedy min-cash-flow settlement.
package splitwise

import (
    "errors"
    "fmt"
    "sync"
)

// SplitStrategy computes participant -> owed cents for an expense.
// Implementations MUST make the returned shares sum EXACTLY to totalCents.
type SplitStrategy interface {
    ComputeShares(totalCents int64, participants []string) (map[string]int64, error)
}

// EqualSplit divides evenly; the LAST participant absorbs the remainder,
// so the shares always sum exactly to totalCents regardless of rounding.
type EqualSplit struct{}

func (EqualSplit) ComputeShares(totalCents int64, participants []string) (map[string]int64, error) {
    n := int64(len(participants))
    if n == 0 {
        return nil, errors.New("no participants")
    }
    base := totalCents / n
    shares := make(map[string]int64, n)
    for i := int64(0); i < n-1; i++ {
        shares[participants[i]] = base
    }
    shares[participants[n-1]] = totalCents - base*(n-1) // absorbs the remainder
    return shares, nil
}

// ExactSplit: caller states cents per person; must reconcile to the total.
type ExactSplit struct{ Amounts map[string]int64 }

func (s ExactSplit) ComputeShares(totalCents int64, participants []string) (map[string]int64, error) {
    var sum int64
    for _, p := range participants {
        sum += s.Amounts[p]
    }
    if sum != totalCents {
        return nil, fmt.Errorf("exact amounts sum to %d but total is %d", sum, totalCents)
    }
    out := make(map[string]int64, len(s.Amounts))
    for k, v := range s.Amounts {
        out[k] = v
    }
    return out, nil
}

// PercentSplit: basis points (parts per 10_000) -- keeps floating point out
// of the split entirely.
type PercentSplit struct{ BasisPoints map[string]int64 }

func (s PercentSplit) ComputeShares(totalCents int64, participants []string) (map[string]int64, error) {
    var bpSum int64
    for _, p := range participants {
        bpSum += s.BasisPoints[p]
    }
    if bpSum != 10_000 {
        return nil, fmt.Errorf("basis points sum to %d but must total 10000", bpSum)
    }
    n := len(participants)
    shares := make(map[string]int64, n)
    var running int64
    for i := 0; i < n-1; i++ {
        p := participants[i]
        cents := totalCents * s.BasisPoints[p] / 10_000 // floor
        shares[p] = cents
        running += cents
    }
    shares[participants[n-1]] = totalCents - running // absorbs the floor remainder
    return shares, nil
}

// ShareSplit: integer weights (e.g. 2:1:1 rent by room size).
type ShareSplit struct{ Weights map[string]int64 }

func (s ShareSplit) ComputeShares(totalCents int64, participants []string) (map[string]int64, error) {
    var totalShares int64
    for _, p := range participants {
        totalShares += s.Weights[p]
    }
    if totalShares <= 0 {
        return nil, errors.New("total shares must be positive")
    }
    n := len(participants)
    shares := make(map[string]int64, n)
    var running int64
    for i := 0; i < n-1; i++ {
        p := participants[i]
        cents := totalCents * s.Weights[p] / totalShares // floor
        shares[p] = cents
        running += cents
    }
    shares[participants[n-1]] = totalCents - running // absorbs the floor remainder
    return shares, nil
}

// Transaction is a settle-up payment instruction.
type Transaction struct {
    From, To    string
    AmountCents int64
}

// Ledger is the net-balance sheet: ONE number per user, not a pairwise matrix.
type Ledger struct {
    mu       sync.Mutex
    balances map[string]int64 // +ve = owed to them, -ve = they owe. sum() == 0 always.
}

func NewLedger() *Ledger { return &Ledger{balances: make(map[string]int64)} }

// AddExpense is thread-safe: the whole read-modify-write is one critical section.
func (l *Ledger) AddExpense(paidBy string, totalCents int64, participants []string, strategy SplitStrategy) error {
    shares, err := strategy.ComputeShares(totalCents, participants)
    if err != nil {
        return err
    }
    var check int64
    for _, v := range shares {
        check += v
    }
    if check != totalCents {
        return fmt.Errorf("strategy bug: shares summed to %d != %d", check, totalCents)
    }
    l.mu.Lock()
    defer l.mu.Unlock()
    l.applyShares(paidBy, shares)
    return nil
}

// AddExpenseUnsafe is the UNSAFE twin of AddExpense -- identical logic, NO
// lock. It exists ONLY so the break-it demo can reproduce the race on the
// shared balances map. Never call this from real code.
func (l *Ledger) AddExpenseUnsafe(paidBy string, totalCents int64, participants []string, strategy SplitStrategy) error {
    shares, err := strategy.ComputeShares(totalCents, participants)
    if err != nil {
        return err
    }
    l.applyShares(paidBy, shares)
    return nil
}

func (l *Ledger) applyShares(paidBy string, shares map[string]int64) {
    for participant, owed := range shares {
        if participant == paidBy {
            continue
        }
        l.balances[participant] -= owed // read-modify-write on a plain map -- the race
        l.balances[paidBy] += owed
    }
}

func (l *Ledger) BalanceOf(user string) int64 {
    l.mu.Lock()
    defer l.mu.Unlock()
    return l.balances[user]
}

func (l *Ledger) Snapshot() map[string]int64 {
    l.mu.Lock()
    defer l.mu.Unlock()
    out := make(map[string]int64, len(l.balances))
    for k, v := range l.balances {
        out[k] = v
    }
    return out
}

// Settle runs the greedy min-cash-flow: max debtor pays max creditor,
// repeat until every balance is zero. At most n-1 transactions.
func Settle(balancesIn map[string]int64) []Transaction {
    bal := make(map[string]int64, len(balancesIn))
    for k, v := range balancesIn {
        bal[k] = v
    }
    var txns []Transaction
    for {
        var maxCreditor, maxDebtor string
        var maxCredit, maxDebit int64
        for user, v := range bal {
            if v > maxCredit {
                maxCredit, maxCreditor = v, user
            }
            if v < maxDebit {
                maxDebit, maxDebtor = v, user
            }
        }
        if maxCreditor == "" || maxDebtor == "" || maxCredit == 0 {
            break // everyone is settled
        }
        amount := maxCredit
        if -maxDebit < amount {
            amount = -maxDebit
        }
        txns = append(txns, Transaction{From: maxDebtor, To: maxCreditor, AmountCents: amount})
        bal[maxDebtor] += amount
        bal[maxCreditor] -= amount
    }
    return txns
}

Break it

The reference impl above ships two paths on the exact same Ledger: addExpense (locked) and addExpenseUnsafe (identical logic, no lock — kept only for this demonstration). Hammer both with 16 threads × 500 expenses each and compare against the ledger's core invariant: the sum of every balance in the sheet must always be exactly 0 — money owed to someone always exactly equals money someone else owes. That's not a nice-to-have, it's the conservation law the whole net-balance idea depends on.

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class SplitwiseTests {

    static int pass = 0, fail = 0;

    static void check(String name, boolean cond) {
        if (cond) { pass++; System.out.println("PASS: " + name); }
        else      { fail++; System.out.println("FAIL: " + name); }
    }

    public static void main(String[] args) throws InterruptedException {

        // --- M1: equal split + net balance ---
        {
            Ledger ledger = new Ledger();
            ledger.addExpense("alice", 1000, Arrays.asList("alice", "bob", "carol"), new EqualSplit());
            // 1000 / 3 -> base=333, last (carol) absorbs remainder -> 333,333,334
            check("equal split: alice net +667", ledger.balanceOf("alice") == 667);
            check("equal split: bob owes 333", ledger.balanceOf("bob") == -333);
            check("equal split: carol owes 334 (absorbs remainder)", ledger.balanceOf("carol") == -334);
            long sum = ledger.balanceOf("alice") + ledger.balanceOf("bob") + ledger.balanceOf("carol");
            check("conservation: net balances sum to 0", sum == 0);
        }

        // --- M2: exact split rejects a bad sum ---
        {
            Ledger ledger = new Ledger();
            Map<String, Long> exact = new HashMap<>();
            exact.put("alice", 200L); exact.put("bob", 300L); exact.put("carol", 400L); // sums to 900, not 1000
            boolean threw = false;
            try {
                ledger.addExpense("alice", 1000, Arrays.asList("alice", "bob", "carol"), new ExactSplit(exact));
            } catch (IllegalArgumentException e) { threw = true; }
            check("exact split: rejects mismatched total", threw);
        }

        // --- M2: percentage split, rounding-remainder fix ---
        {
            Ledger ledger = new Ledger();
            Map<String, Long> bp = new HashMap<>();                 // 33.33% / 33.33% / 33.34%
            bp.put("alice", 3333L); bp.put("bob", 3333L); bp.put("carol", 3334L);
            ledger.addExpense("alice", 1000, Arrays.asList("alice", "bob", "carol"), new PercentSplit(bp));
            long sum = ledger.balanceOf("alice") + ledger.balanceOf("bob") + ledger.balanceOf("carol");
            check("percent split: still conserves to 0 despite rounding", sum == 0);
            check("percent split: carol (33.34%) owes 334", ledger.balanceOf("carol") == -334);
        }

        // --- M2: share split (weighted, e.g. 2:1:1 rent) ---
        {
            Ledger ledger = new Ledger();
            Map<String, Long> shares = new HashMap<>();
            shares.put("alice", 2L); shares.put("bob", 1L); shares.put("carol", 1L);
            ledger.addExpense("alice", 1000, Arrays.asList("alice", "bob", "carol"), new ShareSplit(shares));
            // alice's own share (500) doesn't count as owed-to-self; bob/carol owe 250 each
            check("share split: bob owes 250 (1/4 share)", ledger.balanceOf("bob") == -250);
            check("share split: carol owes 250 (1/4 share)", ledger.balanceOf("carol") == -250);
        }

        // --- M3: who-owes-whom + min-cash-flow settlement (the trip example) ---
        {
            Ledger ledger = new Ledger();
            ledger.addExpense("alice", 9000, Arrays.asList("alice", "bob", "carol"), new EqualSplit()); // dinner
            ledger.addExpense("bob", 6000, Arrays.asList("alice", "bob", "carol"), new EqualSplit());   // cab
            ledger.addExpense("carol", 3000, Arrays.asList("alice", "bob", "carol"), new EqualSplit()); // snacks
            Map<String, Long> snap = ledger.snapshot();
            long total = 0;
            for (long v : snap.values()) total += v;
            check("3-expense trip: balances still conserve to 0", total == 0);
            check("trip: alice net is +3000 (owed $30)", snap.get("alice") == 3000L);
            check("trip: bob nets to exactly 0 (his debts cancel across the cycle)", snap.getOrDefault("bob", 0L) == 0L);
            check("trip: carol net is -3000 (owes $30)", snap.get("carol") == -3000L);
            List<Transaction> txns = Settlement.settle(snap);
            check("settlement collapses the 3-person cycle to exactly 1 transaction", txns.size() == 1);
            // replay the settlement and confirm it zeroes every balance
            Map<String, Long> replay = new HashMap<>(snap);
            for (Transaction t : txns) {
                replay.merge(t.from, t.amountCents, Long::sum);
                replay.merge(t.to, -t.amountCents, Long::sum);
            }
            boolean allZero = true;
            for (long v : replay.values()) if (v != 0) allZero = false;
            check("settlement: replaying the transaction(s) zeroes every balance", allZero);
        }

        // --- M4: concurrency -- the break-it demonstration ---
        {
            final int threads = 16, itersPerThread = 500;
            List<String> people = Arrays.asList("alice", "bob", "carol", "dave");

            // UNSAFE path: hammer addExpenseUnsafe from many threads with no lock.
            Ledger unsafeLedger = new Ledger();
            Thread[] pool = new Thread[threads];
            for (int t = 0; t < threads; t++) {
                final String payer = people.get(t % people.size());
                pool[t] = new Thread(() -> {
                    for (int i = 0; i < itersPerThread; i++) {
                        unsafeLedger.addExpenseUnsafe(payer, 400, people, new EqualSplit());
                    }
                });
            }
            for (Thread th : pool) th.start();
            for (Thread th : pool) th.join();
            long unsafeSum = 0;
            for (long v : unsafeLedger.snapshot().values()) unsafeSum += v;
            // This is the LESSON, not a normal assertion: the unsynchronized read-modify-write
            // race drops updates, so the conservation invariant (sum == 0) breaks.
            System.out.println("UNSAFE ledger conservation sum (expected 0 if race-free): " + unsafeSum);
            check("break-it: UNSAFE path VIOLATES conservation (proves the race is real)", unsafeSum != 0);

            // SAFE path: identical hammering through the locked addExpense.
            Ledger safeLedger = new Ledger();
            Thread[] pool2 = new Thread[threads];
            for (int t = 0; t < threads; t++) {
                final String payer = people.get(t % people.size());
                pool2[t] = new Thread(() -> {
                    for (int i = 0; i < itersPerThread; i++) {
                        safeLedger.addExpense(payer, 400, people, new EqualSplit());
                    }
                });
            }
            for (Thread th : pool2) th.start();
            for (Thread th : pool2) th.join();
            long safeSum = 0;
            for (long v : safeLedger.snapshot().values()) safeSum += v;
            check("fix: SAFE (locked) path holds conservation under identical load", safeSum == 0);
        }

        System.out.println();
        System.out.println(pass + " passed, " + fail + " failed");
        if (fail > 0) System.exit(1);
    }
}

Measured in this session (javac Splitwise.java SplitwiseTests.java && java SplitwiseTests): all 14/14 assertions pass, and the unsafe run genuinely violates conservation every time it was executed — three separate runs measured drifts of 157,900, 248,500, and 444,400 phantom cents ($1,579–$4,444) that nobody paid and nobody is owed, purely from lost updates on the shared HashMap. The locked path holds the invariant every time.

For Go, the two paths are split into separate binaries deliberately, because Go's map implementation reacts to a genuine concurrent write very differently than Java's: it detects the unsynchronized access and crashes the whole process rather than silently corrupting data. Keep this file as splitwise_test.go next to splitwise.go above — it exercises ONLY the safe AddExpense path and is expected to pass clean under go test -race:

package splitwise

import (
    "sync"
    "testing"
)

func TestEqualSplitAndNetBalance(t *testing.T) {
    l := NewLedger()
    if err := l.AddExpense("alice", 1000, []string{"alice", "bob", "carol"}, EqualSplit{}); err != nil {
        t.Fatal(err)
    }
    if got := l.BalanceOf("alice"); got != 667 {
        t.Errorf("alice net = %d, want 667", got)
    }
    if got := l.BalanceOf("bob"); got != -333 {
        t.Errorf("bob net = %d, want -333", got)
    }
    if got := l.BalanceOf("carol"); got != -334 {
        t.Errorf("carol net = %d, want -334 (absorbs remainder)", got)
    }
    sum := l.BalanceOf("alice") + l.BalanceOf("bob") + l.BalanceOf("carol")
    if sum != 0 {
        t.Errorf("conservation violated: sum = %d, want 0", sum)
    }
}

func TestExactSplitRejectsBadTotal(t *testing.T) {
    l := NewLedger()
    err := l.AddExpense("alice", 1000, []string{"alice", "bob", "carol"},
        ExactSplit{Amounts: map[string]int64{"alice": 200, "bob": 300, "carol": 400}}) // sums to 900
    if err == nil {
        t.Error("expected an error for mismatched exact-split total, got nil")
    }
}

func TestPercentSplitRoundingFix(t *testing.T) {
    l := NewLedger()
    bp := map[string]int64{"alice": 3333, "bob": 3333, "carol": 3334}
    if err := l.AddExpense("alice", 1000, []string{"alice", "bob", "carol"}, PercentSplit{BasisPoints: bp}); err != nil {
        t.Fatal(err)
    }
    sum := l.BalanceOf("alice") + l.BalanceOf("bob") + l.BalanceOf("carol")
    if sum != 0 {
        t.Errorf("conservation violated despite rounding: sum = %d, want 0", sum)
    }
    if got := l.BalanceOf("carol"); got != -334 {
        t.Errorf("carol (33.34%%) owes %d, want -334", got)
    }
}

func TestShareSplitWeighted(t *testing.T) {
    l := NewLedger()
    w := map[string]int64{"alice": 2, "bob": 1, "carol": 1}
    if err := l.AddExpense("alice", 1000, []string{"alice", "bob", "carol"}, ShareSplit{Weights: w}); err != nil {
        t.Fatal(err)
    }
    if got := l.BalanceOf("bob"); got != -250 {
        t.Errorf("bob owes %d, want -250 (1/4 share)", got)
    }
    if got := l.BalanceOf("carol"); got != -250 {
        t.Errorf("carol owes %d, want -250 (1/4 share)", got)
    }
}

func TestSettlementMinCashFlow(t *testing.T) {
    l := NewLedger()
    must := func(err error) {
        if err != nil {
            t.Fatal(err)
        }
    }
    must(l.AddExpense("alice", 9000, []string{"alice", "bob", "carol"}, EqualSplit{})) // dinner
    must(l.AddExpense("bob", 6000, []string{"alice", "bob", "carol"}, EqualSplit{}))   // cab
    must(l.AddExpense("carol", 3000, []string{"alice", "bob", "carol"}, EqualSplit{})) // snacks

    snap := l.Snapshot()
    var total int64
    for _, v := range snap {
        total += v
    }
    if total != 0 {
        t.Fatalf("3-expense trip: balances don't conserve, sum = %d", total)
    }

    txns := Settle(snap)
    if len(txns) != 1 {
        t.Errorf("settlement produced %d transactions, want exactly 1 (the 3-cycle collapses)", len(txns))
    }

    replay := make(map[string]int64, len(snap))
    for k, v := range snap {
        replay[k] = v
    }
    for _, tx := range txns {
        replay[tx.From] += tx.AmountCents
        replay[tx.To] -= tx.AmountCents
    }
    for user, bal := range replay {
        if bal != 0 {
            t.Errorf("after replaying settlement, %s still has balance %d, want 0", user, bal)
        }
    }
}

// TestConcurrentAddExpenseIsSafe hammers the LOCKED AddExpense path from many
// goroutines and asserts the conservation invariant holds. Run with
// `go test -race`: it must report zero races here (contrast with the
// break-it demo below, which uses AddExpenseUnsafe and is a SEPARATE program).
func TestConcurrentAddExpenseIsSafe(t *testing.T) {
    const goroutines = 16
    const itersEach = 500
    people := []string{"alice", "bob", "carol", "dave"}
    l := NewLedger()

    var wg sync.WaitGroup
    for g := 0; g < goroutines; g++ {
        payer := people[g%len(people)]
        wg.Add(1)
        go func(payer string) {
            defer wg.Done()
            for i := 0; i < itersEach; i++ {
                if err := l.AddExpense(payer, 400, people, EqualSplit{}); err != nil {
                    t.Error(err)
                }
            }
        }(payer)
    }
    wg.Wait()

    var sum int64
    for _, v := range l.Snapshot() {
        sum += v
    }
    if sum != 0 {
        t.Errorf("locked ledger under concurrent load: conservation violated, sum = %d, want 0", sum)
    }
}

And this is the actual break-it program — put it at cmd/breakit/main.go in a module that imports the splitwise package above:

// Command breakit hammers Ledger.AddExpenseUnsafe (no lock) from many
// goroutines. Expect a crash: Go's map implementation detects concurrent
// writers and calls fatal() -- "fatal error: concurrent map writes" --
// which is NOT recoverable. Run it with the race detector to see the
// underlying data race reported first:
//
//  go run ./cmd/breakit          # crashes: fatal error: concurrent map writes
//  go run -race ./cmd/breakit    # prints "WARNING: DATA RACE" then crashes
package main

import (
    "fmt"
    "sync"

    "splitwise"
)

func main() {
    const goroutines = 16
    const itersEach = 500
    people := []string{"alice", "bob", "carol", "dave"}
    l := splitwise.NewLedger()

    var wg sync.WaitGroup
    for g := 0; g < goroutines; g++ {
        payer := people[g%len(people)]
        wg.Add(1)
        go func(payer string) {
            defer wg.Done()
            for i := 0; i < itersEach; i++ {
                _ = l.AddExpenseUnsafe(payer, 400, people, splitwise.EqualSplit{})
            }
        }(payer)
    }
    wg.Wait()

    // Reached only if you got lucky and the runtime didn't catch the race
    // this run -- still wrong, just not caught THIS time. That's the danger.
    var sum int64
    for _, v := range l.Snapshot() {
        sum += v
    }
    fmt.Printf("no crash this run, but conservation sum = %d (want 0) -- still racy\n", sum)
}

Measured in this session: go build ./... and go vet ./... are clean; go test -race ./... passes 6/6 (0 races) using only the locked path. go run ./cmd/breakit crashed with fatal error: concurrent map writes every time it was run; go run -race ./cmd/breakit printed WARNING: DATA RACE pinpointing the exact unsynchronized read/write in applyShares before the crash. The same missing lock produces two different failure signatures across languages — Java's HashMap silently drops updates (wrong answer, no error), Go's map detects the concurrent write and kills the process outright (no answer at all, loudly). Neither is "safer" than the other; both mean the same thing: this code needed the lock and didn't have one.

Optimise — trade-offs

DecisionOption AOption BWhen A winsWhen B wins
Balance bookkeepingPairwise debt matrix (per-pair ledger, O(n²) storage)Net balance per user (one number per person, O(n))You need a full pairwise settlement history independent of the balance sheet — e.g. an audit trail of "which specific expense created this debt"Answering "who owes whom" or "settle up" -- the vast majority of the product surface. Also the only option that automatically collapses multi-person cycles (see the trip example: pairwise-net still needs 3 payments, global-net needs 1)
Settlement algorithmGreedy min-cash-flow (max debtor pays max creditor, repeat)Exact minimum transactionsAny real group size (2-200 people) -- runs in O(n² log n) worst case, guarantees ≤ n-1 transactions, and in practice matches or comes within 1-2 of optimalAlmost never in production: exact minimization is equivalent to finding a partition of the non-zero balances into the maximum number of zero-sum subsets -- NP-hard (subset-sum flavored), meaning exponential search in the worst case. Only worth it for small, fixed n (bitmask DP up to ~20 people) where transaction count directly costs real money (e.g. wire fees)
Money representationdoubleInteger cents (or BigDecimal)Never, for money you intend to reconcile exactly -- binary floating point can't represent most decimal fractions, and per-split rounding compounds into a silent, unattributable drift ($9.99 vs $10.00 shown above)Always. Integer cents: exact, fast, native arithmetic -- the standard (Stripe, real Splitwise). BigDecimal: also exact, worth the allocation/perf cost only for arbitrary precision or per-currency minor-unit variability (multi-currency)
Split-type dispatchStrategy pattern (SplitStrategy interface)A switch/if-else in addExpenseYou're certain only 1-2 split types will ever exist -- the extra interface is premature abstraction for a kata that never growsAdding a new split type (say, "adjustment" splits) is a new class with zero changes to Ledger, independently unit-testable, and each strategy owns its own validation -- this is what "open for extension, closed for modification" buys you concretely
Lock granularitySingle lock over the whole ledgerPer-user striped locksSimplicity wins until profiling says otherwise -- one critical section is trivially correct and fast enough for most real loadVery high concurrent addExpense throughput across unrelated user pairs -- but now every expense must acquire ALL participants' locks in a fixed global order (e.g. sorted by user ID) to avoid a lock-ordering deadlock. Real complexity; don't pay it until contention is measured, not guessed

Defend under drilling

You can now defend


Re-authored/Deepened for this guide. Related theory: Strategy Pattern — UML, Code & When to Use. Reference implementations (Java + Go) compiled and tested in-session: javac clean, 14/14 assertions pass (including the deliberately-adversarial break-it assertion, which measured real conservation-invariant drift of $1,579-$4,444 across repeated runs); go build + go vet + go test -race clean, 6/6 tests pass on the locked path; the separate unsafe break-it program crashed with fatal error: concurrent map writes on every run, with go run -race additionally reporting the exact data race before the crash.

🤖 Don't fully get this? Learn it with Claude

Stuck on Design Splitwise? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Design Splitwise** (Hands-On Builds) and want to truly understand it. Explain Design Splitwise from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Design Splitwise** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Design Splitwise** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Design Splitwise** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes