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:
- Day 1 — Alice pays $90 for dinner, split equally three ways ($30 each). Bob and Carol now each "owe Alice $30."
- Day 2 — Bob pays $60 for a cab, split equally ($20 each). Alice and Carol now each "owe Bob $20."
- Day 3 — Carol pays $30 for snacks, split equally ($10 each). Alice and Bob now each "owe Carol $10."
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:
- Which split types? Equal (divide evenly), exact (caller states cents per person), percentage (caller states a %), and shares (weighted, e.g. splitting rent 2:1:1 by room size). This kata builds all four, as a Strategy so
Ledgernever hardcodes a formula. - What currency/rounding rule? Single currency, no FX (multi-currency is a named extension, not core). Money is integer cents, never a float — the reason why is derived below. Every split's shares must sum EXACTLY to the expense total; if a split type can't guarantee that by construction (percentages, shares), the last participant absorbs whatever's left after flooring the rest.
- Do we minimize the number of settlement payments? Yes — but "minimize" has a trap of its own: the true minimum is NP-hard (derived below), so we want a greedy that's provably close and cheap, not an exact solver.
- Groups, editing, multi-currency? Out of scope for this kata — a flat set of users and an append-only expense log is enough to teach the actual mechanism; group-tagging is just a filter on top, editing/deleting expenses is a real feature but doesn't change the balance-sheet insight, and multi-currency needs an FX-rate service that's a whole separate design. Name these as extensions, don't build them by default.
- Concurrency? Yes — two expenses can be added by different users at the same instant, and the ledger's balance mutation must not lose an update. This is M4 and it's where the real bug lives.
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:
- Alice↔Bob: Bob owes Alice $30 (dinner) minus Alice owes Bob $20 (cab) → net Bob owes Alice $10.
- Alice↔Carol: Carol owes Alice $30 (dinner) minus Alice owes Carol $10 (snacks) → net Carol owes Alice $20.
- Bob↔Carol: Carol owes Bob $20 (cab) minus Bob owes Carol $10 (snacks) → net Carol owes Bob $10.
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:
- Alice: owed $60 (dinner) − owes $20 (cab) − owes $10 (snacks) = net +$30.
- Bob: owes $30 (dinner) + owed $40 (cab) − owes $10 (snacks) = net $0 — he drops out entirely.
- Carol: owes $30 (dinner) − owes $20 (cab) + owed $20 (snacks) = net −$30.
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.
- M1 — Equal split + net balance. One
SplitStrategy(equal), one map ofuser → net cents.addExpenseupdates every non-payer's balance down and the payer's balance up. Prove it on the dinner/cab/snacks trip. - M2 — Exact / percentage / share strategies + the rounding-remainder fix. Add the other three strategies behind the same interface; validate that a strategy's output actually reconciles to the total before committing it (reject silently-wrong splits loudly, at the source).
- M3 — Who-owes-whom + min-cash-flow settlement. Given the net-balance sheet, produce actual payment instructions with the greedy: repeatedly take the biggest creditor and the biggest debtor, settle
min(credit, debt)between them, repeat until everyone is at zero. Prove it collapses the trip to 1 transaction, not 3. - M4 — Thread-safe. Two
addExpensecalls from different threads/goroutines must never lose a balance update. Guard the read-modify-write with a lock; prove it with a concurrent test that fails without the lock and passes with it.
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
| Decision | Option A | Option B | When A wins | When B wins |
|---|---|---|---|---|
| Balance bookkeeping | Pairwise 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 algorithm | Greedy min-cash-flow (max debtor pays max creditor, repeat) | Exact minimum transactions | Any 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 optimal | Almost 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 representation | double | Integer 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 dispatch | Strategy pattern (SplitStrategy interface) | A switch/if-else in addExpense | You're certain only 1-2 split types will ever exist -- the extra interface is premature abstraction for a kata that never grows | Adding 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 granularity | Single lock over the whole ledger | Per-user striped locks | Simplicity wins until profiling says otherwise -- one critical section is trivially correct and fast enough for most real load | Very 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
- "How do you minimize the number of settlement payments?" — Track one net balance per person, not pairwise debts, then greedily match the biggest creditor with the biggest debtor and settle
min(credit, debt)between them; repeat until every balance is zero. Walk the trip example: Bob individually shows up owing $10 to Alice and being owed $10 by Carol in a pairwise view — a 3-payment plan — but his two obligations cancel completely once you look at his net position ($0), collapsing the whole trip to a single payment: Carol pays Alice $30. The greedy terminates in at mostn-1steps because each round zeroes out at least one person's balance entirely. - "Why is exact minimization NP-hard, and why is greedy fine?" — The true minimum number of transactions to zero out a set of signed balances is equivalent to partitioning the non-zero balances into the maximum number of subsets that each individually sum to zero (each such subset only needs
size-1internal transactions). Finding that optimal partition is a subset-sum-flavored problem — NP-hard in general, meaning worst-case exponential search over subsets. The greedy max-debtor/max-creditor approach isn't always optimal (there exist balance sets where a smarter subset grouping saves one more transaction), but it's bounded atn-1transactions, runs in polynomial time, and for real friend-group sizes the gap between greedy and true-optimal is usually zero or one transaction — not worth an exponential search to close. - "How do you handle rounding?" — Never use floating point for money. Work in integer cents; for any split that can't guarantee an exact sum by simple division (percentages, weighted shares), compute every participant's share by flooring except the last, and let the last participant absorb whatever floor-remainder is left — by construction the shares always sum exactly to the total.
Ledger.addExpenseadditionally re-validates that a strategy's returned shares reconcile to the total before committing anything, so a buggy strategy fails loudly at the source instead of silently drifting the ledger. - "What about concurrent expenses?" — Two
addExpensecalls touching overlapping participants must not interleave their read-modify-write on the balance map. The reference impl guards the whole "compute shares, then update every participant's balance" sequence with one lock; the break-it test proves it two ways — Java's unsynchronizedHashMapsilently drops updates (measured drift up to $4,444 across a few hundred thousand cents of concurrent load in this session), and Go's map detects the concurrent write and crashes the process outright. Different failure signature, same root cause, same fix. - "What breaks at 100×?" — Two different bottlenecks emerge. First,
Settle()'s linear scan for the max creditor/debtor each round isO(n)per step andO(n²)overall — fine for tens of people, a real cost at thousands; swap to two max-heaps (one of creditors, one of debtors) forO(n log n). Second, a single mutex around the entire balance map becomes a real contention point once unrelated user pairs are adding expenses concurrently at high volume — escalate to sharded/striped locks (with the fixed lock-ordering rule above) or, once the ledger needs to be durable and span services, to a database transaction (a per-row optimistic lock or a serializable transaction) — the exact same "shared mutable state needs a synchronization boundary" lesson, just moved from a single process to a distributed one.
You can now defend
- You can explain why a pairwise debt matrix is the wrong data structure for settling up — it's
O(n²), and even fully netted per-pair it's blind to multi-person cycles that a single net-balance-per-user sheet collapses for free. - You can derive the greedy min-cash-flow settlement from first principles, prove its
n-1-transaction bound, and explain precisely why the true minimum is NP-hard and why the greedy is the correct engineering trade-off anyway. - You know why money must never be a
double— you've seen the cent drift concretely — and you can implement the "last participant absorbs the remainder" fix for equal, percentage, and share splits so a total always reconciles exactly. - You've reproduced a real concurrency bug on a shared balance sheet in both Java and Go, seen the two different failure signatures (silent drift vs. hard crash) the same missing lock produces, fixed it, and can name the next escalation (striped locks, then a distributed transaction) when a single mutex stops being enough.
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.
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.
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.
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.
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.