Free preview

Why this matters: Splitwise implementations fail in the details the design already decided: an accidental double, a second rounding site, a settlement path that doesn't quite match the expense path. The walkthrough below is ordered the way you'd write it live, and each block exists to make one design claim checkable in code.

Money at the boundary

Parse once, then integers forever:

java
class Money { static long toMinor(String decimal) { // "100.50" -> 10050 // parse as string arithmetic, NOT via double var parts = decimal.split("\\."); long rupees = Long.parseLong(parts[0]); long paise = parts.length > 1 ? Long.parseLong(padTo2(parts[1])) : 0; return rupees * 100 + paise; } }

Narrate the trap as you write it: parsing "100.50" through a double on the way to an integer reintroduces the exact error the integer was meant to prevent. String to integer, directly.

The split hierarchy

Each kind computes shares and validates itself; the base class enforces the one invariant they all share:

java
abstract class Split { // returns participant -> share in minor units; throws InvalidSplit on bad input abstract Map<UserId, Long> shares(long totalMinor, List<UserId> participants); static void assertSumsExactly(Map<UserId, Long> shares, long totalMinor) { if (shares.values().stream().mapToLong(Long::longValue).sum() != totalMinor) throw new IllegalStateException("shares must sum to total"); // a bug, not bad input } } class EqualSplit extends Split { Map<UserId, Long> shares(long totalMinor, List<UserId> participants) { long base = totalMinor / participants.size(); long leftover = totalMinor % participants.size(); Map<UserId, Long> out = new LinkedHashMap<>(); List<UserId> ordered = participants.stream().sorted().toList(); // deterministic order for (int i = 0; i < ordered.size(); i++) out.put(ordered.get(i), base + (i < leftover ? 1 : 0)); // first `leftover` pay 1 extra assertSumsExactly(out, totalMinor); return out; } } class ExactSplit extends Split { final Map<UserId, Long> amounts; Map<UserId, Long> shares(long totalMinor, List<UserId> participants) { long sum = amounts.values().stream().mapToLong(Long::longValue).sum(); if (sum != totalMinor) throw new InvalidSplit("amounts sum to " + sum + ", not " + totalMinor); return amounts; } }

Two lines to say out loud. First, EqualSplit sorts participants before assigning the leftover — same input, same shares, every time; determinism is what makes rounding testable. Second, percentages (not shown) should be carried as basis points60% == 6000 — so the percentage kind never touches floats either, and validates sum == 10000.

The ledger: one cell per pair

java
record PairKey(UserId low, UserId high) { static PairKey of(UserId a, UserId b) { return a.compareTo(b) < 0 ? new PairKey(a, b) : new PairKey(b, a); } } class Ledger { Map<PairKey, Long> net = new HashMap<>(); // positive: high owes low void apply(UserId creditor, UserId debtor, long amountMinor) { PairKey k = PairKey.of(creditor, debtor); long delta = creditor.equals(k.low()) ? +amountMinor : -amountMinor; net.merge(k, delta, Long::sum); } }

The canonical key means "A owes B" and "B owes A" are one signed number, not two cells that can disagree. State the convention as you write it — interviewers listen for whether you have one.

The write path — both writes

java
class SplitwiseCore { final List<Entry> record = new ArrayList<>(); // expenses AND settlements, append-only final Ledger ledger = new Ledger(); void addExpense(UserId payer, List<UserId> participants, long totalMinor, Split split) { Map<UserId, Long> shares = split.shares(totalMinor, participants); // validates or throws for (var e : shares.entrySet()) if (!e.getKey().equals(payer)) ledger.apply(payer, e.getKey(), e.getValue()); // each non-payer owes their share record.add(Entry.expense(payer, participants, totalMinor, shares)); } void recordSettlement(UserId from, UserId to, long amountMinor) { ledger.apply(from, to, amountMinor); // "from" paid, so "to" now owes "from" that much less record.add(Entry.settlement(from, to, amountMinor)); } }

The claim from lesson 02, now visible: both writes append to the same record and mutate the ledger through the same apply(). The validation happens before any ledger mutation — a thrown InvalidSplit leaves both structures untouched, which is the atomicity promise kept by ordering rather than by machinery.

Settle-up, described

netPositions = fold group's ledger cells into user -> net amount
creditors    = max-heap of users with net > 0
debtors      = max-heap of users with net < 0 (by magnitude)
repeat: pop largest of each, transfer min(|debit|, credit),
        push back whichever has remainder
-> at most n-1 suggested transfers

In a live round, this description plus the heap costs is a complete answer — code it only if time is generous. The suggested transfers are a view; nothing changes until someone records a settlement, which re-enters through recordSettlement like any other fact.

What you'd say about complexity

addExpense is O(participants) ledger merges after an O(participants) share computation. getPairBalance is one hash lookup; a user's overall balance is O(their counterparties). The greedy settle-up is O(n log n) for n group members — and it's a heuristic: fewest-transfers-optimal is NP-hard, so present the greedy as good, not perfect.

Key takeaway

The implementation makes the design checkable: money parsed straight to integer minor units, split kinds that validate themselves with a deterministic leftover rule, a canonically-keyed signed ledger, and expenses and settlements entering through the same append-then-apply write path — with settle-up as a described read-side greedy whose non-optimality you volunteer before anyone asks.

Enjoying the preview?

Create a free account to unlock the rest of this course, the in-browser judge, and live AI mock interviews.

Sign up free to continue