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:
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:
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 points — 60% == 6000 — so the percentage kind never touches floats either, and validates sum == 10000.
The ledger: one cell per pair
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
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.