The idea
What it is
“Design Splitwise.” Or, if the interviewer is being kind: “four friends go to dinner, one person pays, split the bill.” It sounds like the easiest problem in the set.
It is not, and the reason is unusual. Every other machine-coding problem is graded on structure — did you find the right classes, is the seam in the right place. This one is graded on money. There is a property that has to hold after every operation you perform, and if it ever stops holding, no amount of clean design saves you.
The whole lesson in one line
Across everyone in the group, the balances must always sum to exactly zero. Somebody is owed precisely what somebody else owes. Write it as an assertion — assert sum(balances.values()) == 0 — put it at the end of addExpense(), and let it fail loudly. Every design decision below exists to keep that line true.
Here is why that is harder than it looks. Split ₹100 three ways. Each person owes 33.33. Three times 33.33 is 99.99. One paisa has vanished, the sum is no longer zero, and you did nothing wrong — you just used the obvious type for money.
long of paise, never a double. Then read the green box again — the leftover is given to someone, not rounded away.What is actually being graded
- Is money an integer? Paise in a
long, orBigDecimalwith an explicit scale. Adoubleanywhere near a balance is an automatic mark against you, and this is the problem where they look for it. - Does every split validate that it sums to the total? And does that check live inside the strategy, so it cannot be forgotten by a caller?
- Are balances a derived net map, or a growing list of IOUs? “How much do I owe Priya?” should be one lookup, not a fold over every expense ever made.
- Can you add a split type without touching
addExpense()? That is the Open/Closed (OCP) question, and this problem hands you a textbook place to answer it. - Does it run, and can you simplify the debts? Six IOUs between four people collapsing to three payments is the follow-up they always ask. Have it working, and know that the greedy version is not provably minimal.
Mechanics
How it works
Step 1 · Clarify — 4 minutes
- How are bills split? — the question that opens the whole design. Equally, by exact amounts, by percentage, by shares. Name all four; that list is what becomes your strategy interface.
- Can one person pay for people who are not splitting it? — yes. The payer and the participants are two different lists. Getting this wrong collapses a lot of real cases.
- Do expenses always belong to a group? — no. A one-off coffee between two friends has no group. Say so, or you will end up creating a two-person group for every lunch.
- One currency or many? — assume one, and offer multi-currency as a follow-up. It is a real answer, not a dodge, and it keeps the first 40 minutes clean.
- Do I need to minimise the number of payments? — ask it, because they want you to. If they say yes, that is your second act.
- Login, notifications, receipt photos, the mobile app? — out of scope, in one sentence.
Step 2 · Money is a long of paise
This costs you one sentence in the interview and it is the single highest-value sentence in the round. “I will store every amount as an integer number of paise, so 1000 rupees is 100000. Doubles cannot represent 0.1 exactly and money that drifts is a bug you find six months later in an audit.”
Three ways beginners break the zero-sum invariant
1. Splits that do not add up. An EXACT split of 300 + 300 + 300 on a ₹1000 bill. The payer is credited 1000, the participants are debited 900, and ₹100 appears from nowhere. 2. Percentages that do not sum to 100. Same bug wearing a different unit. 3. The rounding leak. ₹100 three ways at 33.33 each is 99.99 — nobody typed anything wrong, and a paisa still vanished. The first two are caught by validation; the third is only caught by not using floating point at all.
/**
* base = total / n, remainder = total % n, then hand the leftover paise out
* ONE AT A TIME to the first "remainder" people. Deterministic, so it is testable.
* 10000 paise among 3 -> 3334, 3333, 3333 (sums to exactly 10000)
*/
static long[] spread(long totalPaise, long[] weights) {
long weightSum = 0;
for (long w : weights) weightSum += w;
long[] out = new long[weights.length];
long assigned = 0;
for (int i = 0; i < weights.length; i++) {
out[i] = totalPaise * weights[i] / weightSum; // integer division, always rounds DOWN
assigned += out[i];
}
for (int i = 0; assigned < totalPaise; i++, assigned++) out[i]++; // give the leftover away
return out; // sum(out) == totalPaise, exactly
}Why the leftover goes to the first people, not a random one
Because a test has to be able to assert the answer. “Someone gets the extra paisa” is not a specification. Real apps do exactly this and then rotate who is first across expenses so the same person is not always paying the extra 0.01 — worth one sentence if you have it, but the determinism is the part that scores.
Step 3 · Four split types, one interface
SplitStrategy.split(totalPaise, participants) → Map<User, paise>. Four implementations. The design point is not that there is an interface — everyone writes an interface. It is where the validation lives.
addExpense() instead and it works — until the day someone calls split() from a new place and forgets it. This is Strategy doing its actual job, and it is why addExpense() never changes when a fifth split type arrives (Open/Closed (OCP)).The sentence that wins this section
“Each strategy validates its own output before returning, so an invalid split cannot reach the balance sheet at all.” Say it while you are writing the interface, not afterwards.
Step 4 · Balances are a net map, not a list of debts
The naive model stores rows: “Arjun owes Ravi ₹250”, “Arjun owes Priya ₹300”, and appends forever. After ten expenses you have thirty rows, and answering “how much do I owe Priya?” means folding the entire history every time. Worse, nobody can look at it and tell.
Keep a net instead. Per user, Map<otherUser, netAmount>, with one rule enforced on every write: balance[a][b] == -balance[b][a]. Adding an expense becomes: for each participant who is not the payer, move their split amount across that one edge. owes(me, priya) is then an O(1) lookup.
Balances are computed state, not the source of truth
Keep the Expense list forever — it is the audit trail, and it is what lets you rebuild the balance sheet from scratch after a bug, edit an old expense, or show “why do I owe this?”. The balance map is a cache of a fold over that list, kept up to date incrementally. Say that sentence and you have answered three follow-ups before they are asked.
== 0, not < 0.01 — with integer paise you get to write the strict version, and a strict assertion is one that actually catches things.The class diagram
Step 5 · Simplify debts — the follow-up they always ask
“Four friends, a weekend, five expenses. Nobody wants to make six payments. Can you reduce it?” This is where the round is won, and it takes about fifteen lines.
The algorithm is short. Compute each person's net position, which sums to zero by the invariant. Then repeatedly match the biggest creditor with the biggest debtor, transfer min(|debt|, credit), and put whatever is left back in the pool.
Simplification changes who pays whom — and that is a product decision
Look at the figure again. Before simplifying, Arjun owed Priya ₹100. Afterwards, Priya pays Arjun ₹216.67 — the direction flipped, and Priya is now sending money to someone she was owed by. The totals are perfectly right and the routing is unrecognisable. Real Splitwise makes simplification opt-in per group for exactly this reason: people trust a ledger they can trace back to a dinner. Mention it. It costs one sentence and it shows you thought past the algorithm.
/** Greedy: biggest creditor meets biggest debtor. At most n-1 transfers. */
List<Settlement> simplify(List<User> members) {
PriorityQueue<long[]> creditors = new PriorityQueue<>((a, b) -> Long.compare(b[1], a[1]));
PriorityQueue<long[]> debtors = new PriorityQueue<>((a, b) -> Long.compare(b[1], a[1]));
for (int i = 0; i < members.size(); i++) {
long net = netOf(members.get(i)); // sums to 0 across everyone
if (net > 0) creditors.add(new long[]{i, net});
else if (net < 0) debtors.add(new long[]{i, -net});
}
List<Settlement> out = new ArrayList<>();
while (!creditors.isEmpty() && !debtors.isEmpty()) {
long[] credit = creditors.poll(), debt = debtors.poll();
long amount = Math.min(credit[1], debt[1]); // one of them hits zero — always
out.add(new Settlement(members.get((int) debt[0]), members.get((int) credit[0]), amount));
if (credit[1] > amount) creditors.add(new long[]{credit[0], credit[1] - amount});
if (debt[1] > amount) debtors.add(new long[]{debt[0], debt[1] - amount});
}
return out; // size <= members.size() - 1
}Settle up, groups, and the things they ask next
- Settle up is an operation, not a reset.
settleUp(from, to, paise)moves the balance the other way and records aSettlementrow — who, whom, how much, when. Silently zeroing an edge destroys the only evidence that a payment happened, and it is the first thing an angry user asks about. - Group vs non-group. A
Groupis a named set of users plus its expenses. A one-off coffee between two friends belongs to no group — the expense just carries a payer and participants. Do not force a two-person group into existence for every lunch; say this unprompted and it reads as experience. - Multi-currency. Store the currency with the amount, and never mix currencies on one balance edge — keep one edge per currency pair, or normalise to a base currency. Convert only at display time, using the rate as of the expense date, not today's. A ₹ balance that changes when the dollar moves is a bug report.
- Concurrency. Two people add an expense to the same group at the same instant; both read
bal[a][b], both write it, one write is lost. That is the same check-then-act shape as Coffee Machine. Take one lock per group aroundaddExpense— the contended region is a few map writes, so a coarse lock costs nothing. More in Locks, Mutex, Semaphore. - Editing or deleting a settled expense. Do not mutate — reverse it. Apply the opposite balance movement and keep the original row plus a reversal row. An event log rather than an in-place edit means history is still explainable, and the invariant holds at every step.
- The activity feed and reminders. The group publishes
ExpenseAddedandSettlementRecorded; feeds, notifications and analytics subscribe. Observer — and it keeps all of that out ofaddExpense(), which stays about money. - Recurring expenses. A schedule that creates a normal
Expenseon a timer. No new balance logic at all, which is the answer they want to hear. - How would you test it? A property test: generate any sequence of random expenses, settlements and simplifications, and assert after every single one that the nets sum to zero and that
bal[a][b] == -bal[b][a]. That test finds every bug on this page.
Money as a value type on day one if there is any chance of a second currency. Everything above it is free because the seams were put in the right places (Single Responsibility (SRP)).The 60 minutes
simplify(), stop polishing the strategies and write it — a rough simplification beats a beautiful PercentSplit.How this round is lost
doublefor money. The single fastest way to lose this specific problem. It is often the first thing typed and the last thing noticed.- Splits that are never validated. 300 + 300 + 300 on a ₹1000 bill goes straight into the sheet, ₹100 appears from nowhere, and the invariant is dead with no error anywhere.
- A growing list of pairwise IOUs. Thirty rows after ten expenses, folded on every read, and no human can look at it and say what they owe.
- Simplification that does not terminate. Usually because a settled person is put back in the pool, or because a stale float never quite reaches zero. Integers and “remove whoever hit zero” make it provably finite.
- A new split type that requires editing
addExpense. The interface was decoration; the seam was never real. - No zero-sum check anywhere. The invariant existed only in your head, so nothing in the code could ever tell you it broke.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A live ledger for four friends. Press ➕ Add expense and watch two things at once: edges appear in the balance graph, and the total: ₹0.00 chip flashes green — the invariant, re-checked on every action. Press 🔢 Exact and then switch a participant off: the ➕ button turns red and refuses, because 250+250+250 is not 1000. Press 🪙 Odd split for ₹100 among three and read the paise. Then 🎲 Messy weekend for five real expenses, and ⚡ Simplify debts to watch 6 payments → 3 payments.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Add one expense and watch the chip
Leave the defaults — payer Ravi, ₹1000, all four participants, ⚖️ Equal — and press ➕ Add expense. Three edges appear pointing at Ravi, everyone's net moves, and the total: ₹0.00 chip flashes green. That chip is the assertion from the lesson, running on every action. It should never move off ₹0.00, no matter what you press.
Break the sum on purpose
Press 🔢 Exact — the per-person boxes appear, pre-filled with ₹250 each, and the validity line reads sum: ₹1000.00 ✓. Now click Meera in the participants row to drop her. The boxes still say 250, 250, 250. The line turns red — splits must sum to ₹1000.00 — got ₹750.00 — and ➕ Add expense goes red and refuses. Nothing reached the balance sheet. That refusal is the design point: the strategy caught it, not the caller.
Find the missing paisa
Press 🪙 Odd split: ₹100 equally among Ravi, Priya and Arjun. Read the three boxes — ₹33.34 / ₹33.33 / ₹33.33 — and the readout sum: ₹100.00 ✓. With a double those would be 33.33 each and the sum would be ₹99.99. The leftover paisa was not rounded away; it was given to the first person, deterministically, so a test can assert it.
Make a real mess
Press 🎲 Messy weekend — five expenses, four different payers, equal, share and exact splits. Now count the arrows in the balance graph: six open edges between four people. Read the stat row: expenses 5 · open edges 6 · payments needed 3. Six IOUs, and only three payments are actually required.
Simplify, then look at who pays whom
Press ⚡ Simplify debts. Three transfers land one at a time and the counter reads 6 payments → 3 payments. Now check the second explain line: before you pressed it, Arjun owed Priya ₹100; afterwards Priya pays Arjun ₹216.67. The nets are identical and the direction flipped. That is why real Splitwise makes this opt-in.
Settle one edge, then build it from memory
Press 💸 Settle up — the largest edge is paid, zeroed, and recorded as a payment row, not silently deleted. Then close this and write it blank-file, in this order: money as long paise → SplitStrategy with split() that validates its own sum → BalanceSheet.move() writing both bal[a][b] and bal[b][a] → Group.addExpense() ending in assert Σ net == 0 → simplify() with two heaps → a main() that runs the five expenses and prints three transfers. If your assertion ever fires, you have found a real bug.
In practice
When to use it — and what trips people up
The shape you just learned
Strip the friends and the dinner away and this is a system with a conservation law. Some quantity moves between parties, and the total across all parties must never change. Once you see it that way, the same three moves apply everywhere: integers for the quantity, one operation that moves it across exactly one edge, and an assertion on the total after every move.
- Double-entry bookkeeping — the 500-year-old version of this exact idea. Every debit has a matching credit, and the ledger balances or something is wrong.
- Inventory transfers between warehouses — stock leaves one and arrives at another; the global count is the invariant. A partial move is a lost pallet.
- Wallet and payment ledgers — money out of one account, into another, and never a
double. This is where the paise rule stops being an interview trick and becomes a compliance requirement. - Token or credit allocation — a quota moved between teams. Same shape, smaller stakes.
- Any graph reduced to net flow — the simplification step is just netting a flow network before routing it, which shows up in clearing houses and settlement systems.
The 20-second version to say out loud
“Money is an integer number of paise. Balances are a net map with bal[a][b] == -bal[b][a] written on every move, so the whole sheet always sums to zero — and I assert that after every operation. Splits go behind one interface, and each strategy validates that its own output sums to the total, so an invalid split can never reach the sheet. Simplification is greedy — biggest creditor against biggest debtor — which is at most n−1 transfers but is not provably minimal.”
Where this design stops working
- When there are millions of members in a group. The net map is a dense pairwise structure; at that size you store per-user net totals against the group instead of edges, and reconstruct pair balances on demand.
- When the balance must survive a crash. The in-memory map plus an in-process lock becomes a database transaction — the expense row and the balance updates must commit together, or you get exactly the half-applied edge the antisymmetry was protecting you from.
- When there are real payments involved.
settleUphere is an assertion that money moved. With a real gateway it becomes a two-phase thing: record an intent, wait for the callback, then move the balance — and now you need idempotency on the callback. - When currencies float. One balance edge per currency is fine until someone settles a ₹ debt in $. At that point the exchange itself is an expense, and somebody has to own the difference.
If you only remember one thing
Write the invariant as code, not as a comment. assert sum(nets) == 0 at the end of every mutating method costs one line and turns every bug on this page — bad splits, a lost paisa, a half-applied edge, a broken simplification — into a stack trace on the very operation that caused it.
What it gives you
- Integer paise make the zero-sum check an exact equality, so the invariant can be asserted rather than approximated — and no expense can ever leak a fraction.
- Validation inside each SplitStrategy means an invalid split is impossible to record, no matter which caller invokes it or how many callers appear later.
- A net balance map answers “what do I owe you?” in O(1) and stays readable to a human, instead of growing one IOU row per participant per expense.
- Writing bal[a][b] and bal[b][a] together makes a half-applied edge unrepresentable, which is what keeps the sheet self-balancing under any sequence of operations.
- Greedy simplification is about fifteen lines, provably terminates in at most n−1 transfers, and is the follow-up interviewers reach for most often.
Common mistakes
- The net map is pairwise, so memory grows with the square of group size — fine for a dinner, wrong for a ten-thousand-member group.
- Greedy simplification is not guaranteed minimal; the true minimum-transaction problem is NP-hard, so you are shipping a bound rather than an optimum.
- Simplification rewrites who pays whom, which is arithmetically correct and socially confusing — real products have to make it opt-in.
- Because balances are derived, any bug in a mutation silently diverges them from the expense log until something recomputes from scratch; the assertion catches sums, not attribution.
- One lock per group serialises concurrent expense entry for that group, and a very active group would need the balance updates pushed into the database rather than held in memory.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
/* ------------------------------------------------------------------ money */
/** Every amount in this file is an integer number of PAISE. Never a double. */
final class Money {
static String fmt(long paise) {
long abs = Math.abs(paise);
return (paise < 0 ? "-" : "") + "Rs." + (abs / 100) + "." + String.format("%02d", abs % 100);
}
}
record User(String id, String name) {
@Override public String toString() { return name; }
}
record Split(User user, long paise) {}
/* -------------------------------------------------------------- strategies */
interface SplitStrategy {
/** Returns one row per participant. MUST sum to totalPaise. */
List<Split> split(long totalPaise, List<User> participants);
/** The gate. Lives here so no caller can forget it. */
static List<Split> validated(long totalPaise, List<Split> splits) {
long sum = 0;
for (Split s : splits) sum += s.paise();
if (sum != totalPaise)
throw new IllegalArgumentException(
"splits must sum to " + Money.fmt(totalPaise) + " - got " + Money.fmt(sum));
return List.copyOf(splits);
}
/**
* base = total * weight / weightSum (rounds DOWN), then hand the leftover
* paise out one at a time to the first people. Sum is exact, and it is
* deterministic, so a test can assert every number.
*/
static long[] spread(long totalPaise, long[] weights) {
long weightSum = 0;
for (long w : weights) {
if (w < 0) throw new IllegalArgumentException("negative weight");
weightSum += w;
}
if (weightSum == 0) throw new IllegalArgumentException("weights must not all be zero");
long[] out = new long[weights.length];
long assigned = 0;
for (int i = 0; i < weights.length; i++) {
out[i] = totalPaise * weights[i] / weightSum;
assigned += out[i];
}
for (int i = 0; assigned < totalPaise; i = (i + 1) % out.length, assigned++) out[i]++;
return out;
}
static List<Split> zip(List<User> users, long[] amounts) {
List<Split> out = new ArrayList<>(users.size());
for (int i = 0; i < users.size(); i++) out.add(new Split(users.get(i), amounts[i]));
return out;
}
}
class EqualSplit implements SplitStrategy {
public List<Split> split(long totalPaise, List<User> participants) {
long[] ones = new long[participants.size()];
Arrays.fill(ones, 1L);
return SplitStrategy.validated(totalPaise,
SplitStrategy.zip(participants, SplitStrategy.spread(totalPaise, ones)));
}
}
class ExactSplit implements SplitStrategy {
private final long[] amounts;
ExactSplit(long... amounts) { this.amounts = amounts.clone(); }
public List<Split> split(long totalPaise, List<User> participants) {
// No spreading here: the user typed these. validated() is the only defence.
return SplitStrategy.validated(totalPaise, SplitStrategy.zip(participants, amounts));
}
}
class PercentSplit implements SplitStrategy {
private final long[] percents;
PercentSplit(long... percents) { this.percents = percents.clone(); }
public List<Split> split(long totalPaise, List<User> participants) {
long sum = 0;
for (long p : percents) sum += p;
if (sum != 100) throw new IllegalArgumentException("percentages must sum to 100 - got " + sum);
return SplitStrategy.validated(totalPaise,
SplitStrategy.zip(participants, SplitStrategy.spread(totalPaise, percents)));
}
}
class ShareSplit implements SplitStrategy {
private final long[] shares; // 2 for the couple, 1 each for the singles
ShareSplit(long... shares) { this.shares = shares.clone(); }
public List<Split> split(long totalPaise, List<User> participants) {
return SplitStrategy.validated(totalPaise,
SplitStrategy.zip(participants, SplitStrategy.spread(totalPaise, shares)));
}
}
/* ------------------------------------------------------------ balancesheet */
record Settlement(User from, User to, long paise) {
@Override public String toString() { return from + " -> " + to + " " + Money.fmt(paise); }
}
/** Derived state: a NET map, not a log of IOUs. bal[a][b] > 0 means a owes b. */
class BalanceSheet {
private final Map<String, Map<String, Long>> net = new LinkedHashMap<>();
/** The antisymmetry is written on every single move. Both lines, or neither. */
void move(User from, User to, long paise) {
if (from.equals(to) || paise == 0) return;
bump(from.id(), to.id(), paise);
bump(to.id(), from.id(), -paise);
}
private void bump(String a, String b, long delta) {
Map<String, Long> row = net.computeIfAbsent(a, k -> new LinkedHashMap<>());
long v = row.getOrDefault(b, 0L) + delta;
if (v == 0) row.remove(b); else row.put(b, v);
}
long owes(User a, User b) { // O(1) — that is the point
return net.getOrDefault(a.id(), Map.of()).getOrDefault(b.id(), 0L);
}
/** positive = is owed, negative = owes. */
long netOf(User u) {
long owed = 0;
for (long v : net.getOrDefault(u.id(), Map.of()).values()) owed -= v;
return owed;
}
long totalOfAllNets(List<User> members) {
long s = 0;
for (User u : members) s += netOf(u);
return s; // must be exactly 0
}
List<Settlement> openEdges(List<User> members) {
List<Settlement> out = new ArrayList<>();
for (User a : members)
for (User b : members)
if (owes(a, b) > 0) out.add(new Settlement(a, b, owes(a, b)));
return out;
}
/** Greedy: biggest creditor meets biggest debtor. At most n-1 transfers. */
List<Settlement> simplify(List<User> members) {
Comparator<long[]> byAmountDesc = (x, y) -> Long.compare(y[1], x[1]);
PriorityQueue<long[]> creditors = new PriorityQueue<>(byAmountDesc);
PriorityQueue<long[]> debtors = new PriorityQueue<>(byAmountDesc);
for (int i = 0; i < members.size(); i++) {
long n = netOf(members.get(i));
if (n > 0) creditors.add(new long[]{i, n});
else if (n < 0) debtors.add(new long[]{i, -n});
}
List<Settlement> out = new ArrayList<>();
while (!creditors.isEmpty() && !debtors.isEmpty()) {
long[] credit = creditors.poll(), debt = debtors.poll();
long amount = Math.min(credit[1], debt[1]); // one side always hits zero
out.add(new Settlement(members.get((int) debt[0]), members.get((int) credit[0]), amount));
if (credit[1] > amount) creditors.add(new long[]{credit[0], credit[1] - amount});
if (debt[1] > amount) debtors.add(new long[]{debt[0], debt[1] - amount});
}
return out;
}
void replaceWith(List<User> members, List<Settlement> transfers) {
net.clear();
for (Settlement t : transfers) move(t.from(), t.to(), t.paise());
}
}
/* -------------------------------------------------------------------- group */
record Expense(String description, User payer, long totalPaise, List<Split> splits) {}
class Group {
final String name;
final List<User> members;
final List<Expense> expenses = new ArrayList<>(); // the audit trail — kept forever
final List<Settlement> payments = new ArrayList<>();
final BalanceSheet balances = new BalanceSheet();
private final Object lock = new Object(); // one lock per group
Group(String name, List<User> members) { this.name = name; this.members = List.copyOf(members); }
Expense addExpense(String description, User payer, long totalPaise,
List<User> participants, SplitStrategy strategy) {
synchronized (lock) {
if (totalPaise <= 0) throw new IllegalArgumentException("amount must be positive");
List<Split> splits = strategy.split(totalPaise, participants); // validates itself
for (Split s : splits)
if (!s.user().equals(payer)) balances.move(s.user(), payer, s.paise());
Expense e = new Expense(description, payer, totalPaise, splits);
expenses.add(e);
assertZeroSum();
return e;
}
}
/** A payment is a balance movement AND a recorded row. Never a silent zeroing. */
void settleUp(User from, User to, long paise) {
synchronized (lock) {
balances.move(to, from, paise); // cancels what "from" owed "to"
payments.add(new Settlement(from, to, paise));
assertZeroSum();
}
}
List<Settlement> simplifyDebts() {
synchronized (lock) {
List<Settlement> transfers = balances.simplify(members);
balances.replaceWith(members, transfers);
assertZeroSum();
return transfers;
}
}
private void assertZeroSum() {
long total = balances.totalOfAllNets(members);
if (total != 0) throw new IllegalStateException("BALANCES DO NOT SUM TO ZERO: " + total);
}
}
/* --------------------------------------------------------------------- demo */
public class Main {
public static void main(String[] args) {
User ravi = new User("u1", "Ravi"), priya = new User("u2", "Priya");
User arjun = new User("u3", "Arjun"), meera = new User("u4", "Meera");
List<User> all = List.of(ravi, priya, arjun, meera);
Group g = new Group("Weekend", all);
g.addExpense("Dinner", ravi, 100_000, all, new EqualSplit());
g.addExpense("Cab", priya, 90_000, List.of(priya, arjun, meera), new EqualSplit());
g.addExpense("Movie", arjun, 120_000, all, new ShareSplit(1, 1, 2, 2));
g.addExpense("Groceries", meera, 80_000, List.of(priya, meera), new ExactSplit(50_000, 30_000));
g.addExpense("Coffee", ravi, 10_000, List.of(ravi, priya, arjun), new EqualSplit());
System.out.println("open edges:");
for (Settlement s : g.balances.openEdges(all)) System.out.println(" " + s);
System.out.println("nets:");
for (User u : all) System.out.println(" " + u + " " + Money.fmt(g.balances.netOf(u)));
System.out.println(" TOTAL " + g.balances.totalOfAllNets(all) + " <- must be exactly 0");
List<Settlement> transfers = g.simplifyDebts();
System.out.println("simplified: " + g.balances.openEdges(all).size()
+ " payments (was 6, bound is n-1 = " + (all.size() - 1) + ")");
for (Settlement s : transfers) System.out.println(" " + s);
g.settleUp(meera, ravi, 45_000);
System.out.println("after Meera pays Ravi: net(Meera) = " + Money.fmt(g.balances.netOf(meera))
+ ", payments recorded = " + g.payments.size());
System.out.println("TOTAL still " + g.balances.totalOfAllNets(all));
}
}
/* ---------------------------------------------------------------- output ---
open edges:
Priya -> Ravi Rs.283.33
Arjun -> Ravi Rs.83.33
Arjun -> Priya Rs.100.00
Priya -> Meera Rs.200.00
Meera -> Ravi Rs.250.00
Meera -> Arjun Rs.400.00
nets:
Ravi Rs.616.66
Priya -Rs.383.33
Arjun Rs.216.67
Meera -Rs.450.00
TOTAL 0 <- must be exactly 0
simplified: 3 payments (was 6, bound is n-1 = 3)
Meera -> Ravi Rs.450.00
Priya -> Arjun Rs.216.67
Priya -> Ravi Rs.166.66
after Meera pays Ravi: net(Meera) = Rs.0.00, payments recorded = 1
TOTAL still 0
--------------------------------------------------------------------------- */References & further reading
7 sources- Articlegithub.com
awesome-low-level-design — Splitwise problem
The canonical write-up of this exact interview problem, with the entity list most interviewers have in their head.
- Articlemartinfowler.com
Martin Fowler — Money (from Patterns of Enterprise Application Architecture)
Why money is a value type carrying an amount and a currency, and why the amount is an integer of the smallest unit. Read this before you type
double. - Paperdocs.oracle.com
What Every Computer Scientist Should Know About Floating-Point Arithmetic
Goldberg's classic. The section on why 0.1 has no exact binary representation is the whole argument for integer paise, in more rigour than you will ever need in an interview.
- Articleblog.splitwise.com
Splitwise — how debt simplification works
The real product explaining the real feature, including why it is opt-in and what users find confusing about it.
- Docsdocs.oracle.com
java.math.BigDecimal — Java documentation
The other correct answer for money in Java. Note setScale and RoundingMode — if you use it, you must pick a rounding mode explicitly.
- Articleen.wikipedia.org
Partition problem — the NP-hard core of minimum settlements
Why “minimum number of transactions” is not solvable greedily: it reduces to finding subsets whose balances cancel. Worth being able to name.
- Book
Domain-Driven Design — Eric Evans
Chapters on Value Objects and Aggregates. Money is the textbook value object, and Group is the textbook aggregate root that owns its invariant.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
You split ₹100 equally among three people using doubles. What actually goes wrong?
question 02 / 08
Where should the check “these splits sum to the total” live?
question 03 / 08
Why keep a net balance map instead of a list of “A owes B ₹X” rows?
question 04 / 08
What single rule keeps the balance sheet from ever drifting?
question 05 / 08
Six IOUs between four people. The greedy simplification matches biggest creditor with biggest debtor. Why does it terminate?
question 06 / 08
The interviewer asks whether your simplification produces the minimum possible number of payments. What is the right answer?
question 07 / 08
Two people add an expense to the same group at the same instant. What breaks, and what is the fix?
question 08 / 08
Someone edits an expense that has already been partly settled. What is the cleanest way to handle it?
0/8 answered