SDE Path

Walkthrough: Design Splitwise

10 min read

Model users, expenses, and splits

An expense is paid by someone and split among participants. The split can be equal, exact amounts, or percentages. Model money carefully — always integer cents, never floating dollars.

class User { final String id; User(String id) { this.id = id; } }

class Split {
  final User user;
  final long owedCents;          // how much this user owes for the expense
  Split(User user, long owedCents) { this.user = user; this.owedCents = owedCents; }
}

class Expense {
  final User paidBy;
  final long amountCents;
  final List<Split> splits;
  Expense(User paidBy, long amountCents, List<Split> splits) {
    this.paidBy = paidBy; this.amountCents = amountCents; this.splits = splits;
  }
}

Using long cents avoids the classic 0.1 + 0.2 != 0.3 rounding bug that silently corrupts balances over time.

<!--STEP-->

Splits are a strategy

Equal / exact / percentage are three algorithms for turning an amount into per-user Splits. Hide them behind an interface so a new split type never edits the expense code.

interface SplitStrategy {
  List<Split> split(long amountCents, List<User> members, long[] args);
}

class EqualSplit implements SplitStrategy {
  public List<Split> split(long amount, List<User> members, long[] args) {
    long base = amount / members.size();
    long rem  = amount % members.size();          // distribute the leftover cents
    List<Split> out = new ArrayList<>();
    for (int i = 0; i < members.size(); i++)
      out.add(new Split(members.get(i), base + (i < rem ? 1 : 0)));
    return out;
  }
}

Note the remainder handling: split 1000 cents three ways and someone must owe the extra penny, or the parts won't sum to the whole. Adding PercentSplit later is a new class — Open/Closed again.

<!--STEP-->

Validate before you record

A split is only legal if its parts reconcile with the amount. Reject bad input at the door — a corrupted expense poisons every downstream balance.

static void validate(long amount, List<Split> splits) {
  long sum = splits.stream().mapToLong(s -> s.owedCents).sum();
  if (sum != amount)
    throw new IllegalArgumentException("splits sum " + sum + " != amount " + amount);
}

For a percentage split you'd first assert the percentages total 100; for exact splits, that the amounts total the bill. The invariant is one line but essential: the parts must equal the whole, checked at creation, not discovered later in a wrong balance.

<!--STEP-->

Track net balances, not raw transactions

Don't store a log and recompute — maintain a running who-owes-whom map. For each expense, everyone except the payer owes their share to the payer.

// balances.get(a).get(b) > 0  means a owes b
private final Map<String, Map<String, Long>> balances = new HashMap<>();

void record(Expense e) {
  for (Split s : e.splits) {
    if (s.user.id.equals(e.paidBy.id)) continue;
    adjust(s.user.id, e.paidBy.id,  s.owedCents);   // debtor owes payer
    adjust(e.paidBy.id, s.user.id, -s.owedCents);   // mirror entry
  }
}
void adjust(String from, String to, long delta) {
  balances.computeIfAbsent(from, k -> new HashMap<>()).merge(to, delta, Long::sum);
}

Keeping a net balance per pair makes "how much does Alice owe Bob?" an O(1) lookup, and offsetting debts (Alice owes Bob, Bob owes Alice) cancel automatically.

<!--STEP-->

Settle up with fewer transfers

Everyone's balances net to zero. To minimise transfers, compute each person's net position (paid minus owed), then repeatedly settle the biggest creditor against the biggest debtor.

class ExpenseManager {
  private final BalanceSheet sheet = new BalanceSheet();
  void addExpense(User paidBy, long amount, SplitStrategy strat,
                  List<User> members, long[] args) {
    List<Split> splits = strat.split(amount, members, args);
    BalanceSheet.validate(amount, splits);
    sheet.record(new Expense(paidBy, amount, splits));
  }
  // simplifyDebts(): greedily match max-creditor with max-debtor until all net to zero
}

The greedy max-creditor/max-debtor match won't always hit the theoretical minimum number of transfers (that problem is NP-hard), but it's simple, correct, and yields dramatically fewer payments than settling each expense individually — exactly the trade-off to state aloud.

Practice it for real: the Splitwise problem in the ladder tests split types, balances, and settlement against hidden cases.

Check yourself

5 questions — graded when you submit.

Question 1

Why represent money as integer cents (long) instead of dollars in a double?

Question 2

What does a SplitStrategy interface (plus careful remainder handling in EqualSplit) provide?

Question 3

Why validate that the splits reconcile with the amount at creation time?

Question 4

Why maintain a net who-owes-whom balance per pair instead of storing every raw transaction?

Question 5

What is the right way to describe the debt-simplification (settle-up) algorithm?

Finished reading?

Sign in to save your progress across lessons.