Skip to main content

Splitwise

"Design an expense-splitting app" packs three separate problems into one prompt: how an expense gets divided among people, how the running total of who-owes-whom is tracked, and how that tangle of pairwise debts gets collapsed into the fewest possible payments. Keeping those three cleanly apart is most of the design.

Requirements

Functional

  • Add an expense: one user pays a total amount on behalf of a set of participants.
  • Support three ways to split that amount: equally, by exact amounts per participant, or by percentage per participant.
  • Report the net balance between any two users - who owes whom, and how much.
  • Simplify debts across the group: reduce the group's tangle of IOUs to the smallest number of actual payments that settles everyone up.

Non-functional

  • Adding a new split type (say, splitting by shares rather than percentages) should mean writing one new class, not editing the expense or balance-tracking code.
  • Looking up the balance between two users should not require replaying every expense ever added - it should be a direct read of a maintained running total.

Design

ExpenseSplit is the one interface doing the real work: it turns "amount + participants + however this particular split is described" into "who owes what." Expense just holds the result and who paid; Ledger never sees a split calculation happen, it only ever receives the finished per-person shares and folds them into running pairwise balances.

ClientExpenseManagerExpenseSplitLedgeraddExpense(paidBy, amount, participants, strategy)1calculateShares(amount, participants)2new Expense(paidBy, amount, shares)3applyShares(paidBy, shares)4simplifyDebts()5simplify()6
  1. 1The client picks which split strategy to use but never computes shares itself.
  2. 2The strategy alone knows whether that means dividing evenly, reading exact amounts, or applying percentages.
  3. 3The manager records the expense with the shares already computed.
  4. 4The ledger updates each participant’s running balance against the payer - it never recalculates a split.
  5. 5A separate request, unrelated to adding an expense.
  6. 6The ledger nets every user to one number and greedily matches creditors to debtors.

Keeping the split calculation, the balance bookkeeping, and the debt-simplification algorithm as three separate collaborators is what lets each one be reasoned about (and tested) without the other two in the room.

Class diagram

«interface»ExpenseSplit+ calculateShares(amount, participants): Map<User, double>User- id: string- name: stringExpense- paidBy: User- amount: double- shares: Map<User, double>EqualSplit+ calculateShares(...)ExactSplit- amounts: Map<User, double>+ calculateShares(...)PercentSplit- percentages: Map<User, double>+ calculateShares(...)Ledger- balances: Map<User, Map<User, double>>+ applyShares(paidBy, shares)+ getBalance(a, b): double+ simplify(): List<Transaction>ExpenseManager- users: List<User>- ledger: Ledger+ addExpense(paidBy, amount, participants, split): Expense+ simplifyDebts(): List<Transaction>
implementsusescreates
ExpenseSplit decides shares; Ledger tracks running balances; ExpenseManager wires the two together.

Code

import java.util.*;
 
class User {
final String id;
final String name;
 
User(String id, String name) {
this.id = id;
this.name = name;
}
}
 
interface ExpenseSplit {
Map<User, Double> calculateShares(double amount, List<User> participants);
}
 
class EqualSplit implements ExpenseSplit {
public Map<User, Double> calculateShares(double amount, List<User> participants) {
double share = amount / participants.size();
Map<User, Double> shares = new HashMap<>();
for (User u : participants) shares.put(u, share);
return shares;
}
}
 
class ExactSplit implements ExpenseSplit {
private final Map<User, Double> amounts;
 
ExactSplit(Map<User, Double> amounts) {
this.amounts = amounts;
}
 
public Map<User, Double> calculateShares(double amount, List<User> participants) {
double sum = amounts.values().stream().mapToDouble(Double::doubleValue).sum();
if (Math.abs(sum - amount) > 0.01) {
throw new IllegalArgumentException("Exact amounts must sum to the total");
}
return amounts;
}
}
 
class PercentSplit implements ExpenseSplit {
private final Map<User, Double> percentages;
 
PercentSplit(Map<User, Double> percentages) {
this.percentages = percentages;
}
 
public Map<User, Double> calculateShares(double amount, List<User> participants) {
double sum = percentages.values().stream().mapToDouble(Double::doubleValue).sum();
if (Math.abs(sum - 100.0) > 0.01) {
throw new IllegalArgumentException("Percentages must sum to 100");
}
Map<User, Double> shares = new HashMap<>();
percentages.forEach((user, pct) -> shares.put(user, amount * pct / 100.0));
return shares;
}
}
 
class Expense {
final User paidBy;
final double amount;
final Map<User, Double> shares;
 
Expense(User paidBy, double amount, Map<User, Double> shares) {
this.paidBy = paidBy;
this.amount = amount;
this.shares = shares;
}
}
 
class Ledger {
// balances[a][b] = amount that a owes b, net. Negative means b owes a instead.
private final Map<User, Map<User, Double>> balances = new HashMap<>();
 
void applyShares(User paidBy, Map<User, Double> shares) {
shares.forEach((participant, share) -> {
if (participant == paidBy) return;
adjust(participant, paidBy, share);
});
}
 
private void adjust(User debtor, User creditor, double amount) {
balances.computeIfAbsent(debtor, k -> new HashMap<>())
.merge(creditor, amount, Double::sum);
balances.computeIfAbsent(creditor, k -> new HashMap<>())
.merge(debtor, -amount, Double::sum);
}
 
double getBalance(User a, User b) {
return balances.getOrDefault(a, Map.of()).getOrDefault(b, 0.0);
}
 
List<String> simplify() {
Map<User, Double> net = new HashMap<>();
balances.forEach((debtor, owedTo) ->
owedTo.forEach((creditor, amount) -> net.merge(debtor, -amount, Double::sum)));
 
PriorityQueue<Map.Entry<User, Double>> debtors = new PriorityQueue<>(
(a, b) -> Double.compare(a.getValue(), b.getValue()));
PriorityQueue<Map.Entry<User, Double>> creditors = new PriorityQueue<>(
(a, b) -> Double.compare(b.getValue(), a.getValue()));
net.forEach((user, amount) -> {
var entry = Map.entry(user, amount);
if (amount < -0.01) debtors.add(entry);
else if (amount > 0.01) creditors.add(entry);
});
 
List<String> transactions = new ArrayList<>();
while (!debtors.isEmpty() && !creditors.isEmpty()) {
var debtor = debtors.poll();
var creditor = creditors.poll();
double settled = Math.min(-debtor.getValue(), creditor.getValue());
transactions.add(debtor.getKey().name + " pays " + creditor.getKey().name + " " + settled);
double remainingDebt = debtor.getValue() + settled;
double remainingCredit = creditor.getValue() - settled;
if (Math.abs(remainingDebt) > 0.01) debtors.add(Map.entry(debtor.getKey(), remainingDebt));
if (Math.abs(remainingCredit) > 0.01) creditors.add(Map.entry(creditor.getKey(), remainingCredit));
}
return transactions;
}
}
 
class ExpenseManager {
private final Ledger ledger = new Ledger();
 
Expense addExpense(User paidBy, double amount, List<User> participants, ExpenseSplit split) {
Map<User, Double> shares = split.calculateShares(amount, participants);
ledger.applyShares(paidBy, shares);
return new Expense(paidBy, amount, shares);
}
 
List<String> simplifyDebts() {
return ledger.simplify();
}
}

Design decisions

  • ExpenseSplit is an interface with three implementations, not an enum switched over inside Expense. EqualSplit, ExactSplit and PercentSplit each take different input (nothing, a map of amounts, a map of percentages) and validate it differently - ExactSplit checks the amounts sum to the total, PercentSplit checks the percentages sum to 100. Cramming that into one method with a type tag would mean every new split type edits a function that already has two other types' validation logic living in it.
  • Ledger stores running pairwise balances instead of a list of expenses to replay. Recomputing "what does Alice owe Bob" by walking every expense in the group's history is O(number of expenses) per lookup and gets slower the longer the group has existed. Updating a running balance when an expense is added is O(participants) once, and every later lookup is O(1).
  • Debt simplification nets each person to a single number before matching anyone up. Cancelling pairwise IOUs directly (Alice owes Bob, Bob owes Charlie) misses the transitive shortcut - Alice could just pay Charlie directly. Reducing everyone to one net number (positive if owed money, negative if owing it) and greedily pairing the largest creditor with the largest debtor is what actually minimizes the number of payments, because it operates on the group's true net position rather than the order expenses happened to be entered in.
  • What's missing for a real system: currency rounding (splits have to sum to exactly the total charged, which needs the last participant's share adjusted for any rounding remainder rather than silently drifting by a cent), concurrent expense additions to the same group needing the ledger update to be atomic, and multi-currency groups, which this page's single-currency scope leaves out.
0%0 of 122 pages studied