Case Studies

Splitwise

Design an expense-sharing application — track shared expenses and balances among groups of users.

4 min readDesigns · LLD & Patterns

Interview practice: try designing this yourself before reading. Go to the exercise ↓

Problem Statement

Design an app that:

  • Tracks expenses paid by one or more users for a group
  • Splits each expense (equally, by share, by exact amount, by percentage)
  • Maintains pairwise balances (who owes whom)
  • Settles up debts
  • Supports groups, friends, and personal expenses

Requirements

Functional

  • Add user, friend, group
  • Add expense: payer, amount, participants, split type
  • Compute net balances per user
  • Settle debt between two users
  • View transaction history

Non-Functional

  • Concurrent expense additions
  • Currency support (one currency per group)
  • Audit log of every expense

Domain Model


Money

Always use integer cents for money. double is wrong for finance — 0.1 + 0.2 != 0.3.

public final class Money implements Comparable<Money> {
    private final long cents;
    private final Currency currency;

    public Money(long cents, Currency c) { this.cents = cents; this.currency = c; }

    public Money plus(Money other) {
        check(other);
        return new Money(cents + other.cents, currency);
    }
    public Money minus(Money other) {
        check(other);
        return new Money(cents - other.cents, currency);
    }

    private void check(Money m) {
        if (!currency.equals(m.currency)) throw new IllegalArgumentException();
    }

    public long cents() { return cents; }
    // hashCode, equals, compareTo, toString
}

Split Strategies

public interface SplitStrategy {
    Map<User, Money> split(Money total, List<Participant> participants);
}

public class EqualSplit implements SplitStrategy {
    public Map<User, Money> split(Money total, List<Participant> ps) {
        long perHead = total.cents() / ps.size();
        long remainder = total.cents() % ps.size();
        Map<User, Money> result = new HashMap<>();
        for (int i = 0; i < ps.size(); i++) {
            // First `remainder` people pay an extra cent to absorb rounding
            long share = perHead + (i < remainder ? 1 : 0);
            result.put(ps.get(i).user, new Money(share, total.currency()));
        }
        return result;
    }
}

public class ExactSplit implements SplitStrategy {
    public Map<User, Money> split(Money total, List<Participant> ps) {
        long sum = ps.stream().mapToLong(p -> p.exactAmount.cents()).sum();
        if (sum != total.cents()) throw new IllegalArgumentException("Shares must sum to total");
        return ps.stream().collect(Collectors.toMap(p -> p.user, p -> p.exactAmount));
    }
}

public class PercentSplit implements SplitStrategy {
    public Map<User, Money> split(Money total, List<Participant> ps) {
        double sum = ps.stream().mapToDouble(p -> p.percent).sum();
        if (Math.abs(sum - 100.0) > 0.001) throw new IllegalArgumentException("Percents must sum to 100");

        Map<User, Money> result = new HashMap<>();
        long allocated = 0;
        for (int i = 0; i < ps.size() - 1; i++) {
            long share = Math.round(total.cents() * ps.get(i).percent / 100.0);
            result.put(ps.get(i).user, new Money(share, total.currency()));
            allocated += share;
        }
        // Last person absorbs rounding
        result.put(ps.get(ps.size() - 1).user, new Money(total.cents() - allocated, total.currency()));
        return result;
    }
}

The rounding policy matters — never lose or gain a cent across splits.


Expense

public class Expense {
    private final String id;
    private final String description;
    private final Money amount;
    private final User paidBy;
    private final Map<User, Money> shares;
    private final LocalDateTime date;

    public Expense(String desc, Money amount, User paidBy,
                   List<Participant> participants, SplitStrategy strategy) {
        this.id = UUID.randomUUID().toString();
        this.description = desc;
        this.amount = amount;
        this.paidBy = paidBy;
        this.shares = strategy.split(amount, participants);
        this.date = LocalDateTime.now();
    }
}

Balance Sheet

The core data structure: a directed graph of who-owes-whom.

public class BalanceSheet {
    // balances[A][B] = amount A owes B (negative = B owes A)
    private final Map<User, Map<User, Money>> balances = new ConcurrentHashMap<>();

    public synchronized void apply(Expense e) {
        User payer = e.getPaidBy();
        for (Map.Entry<User, Money> entry : e.getShares().entrySet()) {
            User u = entry.getKey();
            Money owed = entry.getValue();
            if (u.equals(payer)) continue;   // payer doesn't owe themselves

            adjust(u, payer, owed);
        }
    }

    private void adjust(User from, User to, Money delta) {
        Money current = get(from, to);
        Money updated = current.plus(delta);
        balances.computeIfAbsent(from, k -> new HashMap<>()).put(to, updated);

        // Mirror: to -> from is the negation
        Money mirror = get(to, from).minus(delta);
        balances.computeIfAbsent(to, k -> new HashMap<>()).put(from, mirror);
    }

    public Money get(User from, User to) {
        return balances.getOrDefault(from, Map.of())
                       .getOrDefault(to, Money.zero(USD));
    }

    /** Net amount user owes everyone else (positive = owes, negative = is owed). */
    public Money netOwed(User user) {
        return balances.getOrDefault(user, Map.of()).values().stream()
            .reduce(Money.zero(USD), Money::plus);
    }
}

Two-way bookkeeping prevents bugs: every adjustment writes the mirror, so balances[A][B] = -balances[B][A] always.


Settlement: Minimum Cash Flow

To minimize transactions when settling a group, use a greedy algorithm:

public List<Transaction> settle(BalanceSheet sheet, List<User> users) {
    Map<User, Long> net = new HashMap<>();
    for (User u : users) net.put(u, sheet.netOwed(u).cents());

    List<Transaction> result = new ArrayList<>();
    PriorityQueue<Map.Entry<User, Long>> debtors = new PriorityQueue<>(
        Map.Entry.<User, Long>comparingByValue());        // most negative first
    PriorityQueue<Map.Entry<User, Long>> creditors = new PriorityQueue<>(
        Map.Entry.<User, Long>comparingByValue().reversed());   // most positive first

    for (var entry : net.entrySet()) {
        if (entry.getValue() < 0) creditors.add(entry);
        else if (entry.getValue() > 0) debtors.add(entry);
    }

    while (!debtors.isEmpty() && !creditors.isEmpty()) {
        var debtor = debtors.poll();
        var creditor = creditors.poll();
        long pay = Math.min(debtor.getValue(), -creditor.getValue());
        result.add(new Transaction(debtor.getKey(), creditor.getKey(),
                                   new Money(pay, USD)));
        if (debtor.getValue() - pay > 0) debtors.add(Map.entry(debtor.getKey(), debtor.getValue() - pay));
        if (creditor.getValue() + pay < 0) creditors.add(Map.entry(creditor.getKey(), creditor.getValue() + pay));
    }
    return result;
}

Result: at most N − 1 transactions for N users, far fewer than naive pairwise.


Service (Facade)

public class SplitwiseService {
    private final Map<String, Group> groups = new ConcurrentHashMap<>();

    public Expense addExpense(String groupId, String desc, Money amount,
                              User payer, List<Participant> participants,
                              SplitStrategy strategy) {
        Group g = groups.get(groupId);
        Expense e = new Expense(desc, amount, payer, participants, strategy);
        synchronized (g) {
            g.addExpense(e);
            g.getBalanceSheet().apply(e);
        }
        return e;
    }

    public List<Transaction> settleGroup(String groupId) {
        Group g = groups.get(groupId);
        synchronized (g) {
            return MinCashFlow.compute(g.getBalanceSheet(), g.getMembers());
        }
    }
}

Edge Cases

CaseHandling
Splitting $10 among 3 people3.34, 3.33, 3.33 — extra cent to first member
Different currencies in one groupReject; one currency per group
User leaves group with non-zero balanceSettlement before leave
Adding self to participants twiceDeduplicate, sum if exact
Expense modificationReverse old expense, apply new
Concurrent expense addSynchronize per-group

Design Patterns Used

PatternWhere
StrategySplitStrategy (Equal / Exact / Percent)
FacadeSplitwiseService
ObserverNotify users when added to expense
MementoReverse expense for edits/undo
RepositoryBalanceRepository, ExpenseRepository
BuilderBuild complex Expense (many participants)

Interview Tips

  • Use integer cents — never double for money. Mention this proactively.
  • Talk about rounding policy explicitly: "first N participants get an extra cent."
  • The balance sheet is bidirectional — explain why the mirror keeps invariants.
  • Bring up min-cash-flow settlement — interviewers love seeing the optimization.
  • For large groups (events, trips), partition expenses by sub-group to bound balance graph size.

Practice

Try it yourself

Design the classes for Splitwise: users add shared expenses and see who owes whom.

Your design should cover

  • Add an expense paid by one user and split among several
  • Split equally, by exact amounts or by percentages
  • Show each user's balance with every other user and within a group
  • Simplify debts so the group settles with as few payments as possible
  • Money must never be off by a rounding cent
0 / 8,000 characters · saved in this browser

Finished reading?

Mark it done to track your progress in Designs.