The 100 problems, solved / Commerce and money / 075 · Design Splitwise

Design Splitwise

Groups, expenses split equally, exactly or by percentage, balances and debt simplification.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

Splitwise is a machine-coding favourite because the graders can check it with real numbers. Rounding, split validation and debt simplification are where working code separates from a good-looking diagram. Agree on the behaviours first.

  • A user creates a group and adds members.
  • A member adds an expense: who paid, how much, and how it splits: equally, by exact amounts, or by percentages.
  • Anyone views balances: each person's net position, and who owes whom.
  • A member settles up by recording a payment to another member.
  • The group can simplify debts, reducing the number of payments while leaving each person's net unchanged.

Out of scope: currencies, real payments, notifications, recurring expenses. Constraints: amounts are integer minor units, so sums are exact and never lose a paisa; shares must add up to the expense exactly, with the rounding remainder assigned deterministically; a new split type must not change the group; expenses are immutable, and corrections are new entries, so the history stays auditable.

2. Core entities and relationships

  • Money: an integer amount in minor units, wrapped as a value with safe arithmetic.
  • SplitStrategy: turns a total and participants into shares: EqualSplit, ExactSplit, PercentSplit.
  • Expense: immutable: id, payer, total, shares.
  • Settlement: a payment of an amount from one member to another.
  • Ledger: the net balance per member, updated from expenses and settlements.
  • Group: the members, the history, and the operations; callers talk only to it.
  • DebtSimplifier: turns net balances into a small list of settlements.
APICalculationRecords0..*returnsGroupnamemembers: set[str]ledger: Ledgerhistory: listadd_member(user)add_expense(id, payer, total, strategy) -> Expensesettle(frm, to, amount)balances() -> dictsuggested_payments() -> listSplitStrategyshares(total, members) -> dictEqualSplitPercentSplitpercents: dictExactSplitamounts: dictLedgernet: dict[str, int]apply_expense(e)apply_settlement(s)DebtSimplifiersimplify(net) -> list[Settlement]Expenseidpayertotalshares: dictSettlementfrmtoamount

A group composes its ledger and its history. A strategy is used, not stored: it does its work once when the expense is created, and the stored expense keeps the resulting shares. Reading this class diagram aloud is a quick check that nothing else needs to reach into Ledger.

3. Interfaces and key methods

from dataclasses import dataclass
from typing import Dict, List, Protocol
import heapq

class SplitStrategy(Protocol):
    def shares(self, total: int, members: List[str]) -> Dict[str, int]:
        """Each member's share in minor units; must sum exactly to total."""

@dataclass(frozen=True)
class Expense:
    id: str
    payer: str
    total: int
    shares: tuple           # sorted (member, amount) pairs: immutable

@dataclass(frozen=True)
class Settlement:
    frm: str
    to: str
    amount: int

class Group:
    def add_expense(self, expense_id: str, payer: str, total: int, members: List[str], strategy: SplitStrategy) -> Expense:
        """Validate, compute shares, update the ledger. A repeated id is a no-op."""
    def settle(self, frm: str, to: str, amount: int) -> None:
        """Record a payment; it moves net balances towards zero."""
    def balances(self) -> Dict[str, int]:
        """Positive means the member is owed money."""
    def suggested_payments(self) -> List[Settlement]:
        """At most n - 1 payments that clear every balance."""

Invariants: the net balances of a group always sum to zero, since every expense and settlement is balanced; an expense's shares sum exactly to its total; expenses never change after creation; simplifying creates suggestions only, and does not alter the ledger until they are recorded as settlements.

4. Design patterns and the choices behind them

Split types as strategies

The force: equal, exact and percent today, by shares or by income tomorrow. Each is a small strategy that returns shares and validates its own input. Group has no if split_type, and a new type touches nothing else. Validation belongs with the strategy, since only ExactSplit knows amounts must sum to the total and only PercentSplit knows percentages must reach 100.

Integer money and the remainder rule

The force: 100 split three ways is 33.33 three times, which loses a paisa with rounding and can never balance with floats. Work in integers: divmod gives a base share and a remainder, and the first few members in sorted order each take one more unit. The sum is then exact, and the assignment is stable, which makes tests possible. Treating an amount as a value and an expense as an immutable record means the history cannot be silently changed.

Net balances, then a graph

Pairwise debts form a directed graph, one of the graphs inside objects, but only each person's net matters for settling up. Reducing the graph to one number per person is the key insight. The greedy then matches the largest creditor with the largest debtor, settling at least one person per payment, so n people need at most n − 1 payments. Note the limit honestly: the true minimum is NP-hard, because it requires finding subsets that sum to zero. For small groups an exact search works; the greedy is the standard answer.

Tell, don't ask, and what graders look for

Group calls ledger.apply_expense(expense) and never does arithmetic on a ledger's dictionary. That is the law of Demeter, and it keeps the zero-sum invariant inside one class. The use cases supply the classes. The machine-coding rubric rewards exactly this: a working run with real numbers, and a new split type that adds a class without editing old ones.

5. Core flow and code

  1. add_expense checks the id is new, and that the payer and all members are in the group.
  2. The strategy computes shares, and the group verifies they sum to the total.
  3. An immutable Expense is stored, and Ledger.apply_expense credits the payer and debits each share.
  4. suggested_payments passes the net balances to DebtSimplifier, which pairs creditors with debtors using two heaps.
  5. Each settlement recorded with settle moves the two balances towards zero.
import threading

class EqualSplit:
    def shares(self, total: int, members: List[str]) -> Dict[str, int]:
        base, extra = divmod(total, len(members))
        ordered = sorted(members)
        return {m: base + (1 if i < extra else 0) for i, m in enumerate(ordered)}

class ExactSplit:
    def __init__(self, amounts: Dict[str, int]) -> None:
        self.amounts = dict(amounts)
    def shares(self, total: int, members: List[str]) -> Dict[str, int]:
        if set(self.amounts) != set(members) or sum(self.amounts.values()) != total:
            raise ValueError("exact amounts must cover every member and sum to the total")
        return dict(self.amounts)

class PercentSplit:
    def __init__(self, percents: Dict[str, int]) -> None:
        self.percents = dict(percents)
    def shares(self, total: int, members: List[str]) -> Dict[str, int]:
        if set(self.percents) != set(members) or sum(self.percents.values()) != 100:
            raise ValueError("percentages must cover every member and sum to 100")
        ordered = sorted(members)
        shares = {m: total * self.percents[m] // 100 for m in ordered}
        leftover = total - sum(shares.values())
        for m in ordered[:leftover]:
            shares[m] += 1
        return shares

class Ledger:
    def __init__(self) -> None:
        self.net: Dict[str, int] = {}

    def _add(self, member: str, amount: int) -> None:
        self.net[member] = self.net.get(member, 0) + amount

    def apply_expense(self, e: Expense) -> None:
        self._add(e.payer, e.total)
        for member, share in e.shares:
            self._add(member, -share)

    def apply_settlement(self, s: Settlement) -> None:
        self._add(s.frm, s.amount)
        self._add(s.to, -s.amount)

class DebtSimplifier:
    def simplify(self, net: Dict[str, int]) -> List[Settlement]:
        creditors = [(-amt, m) for m, amt in net.items() if amt > 0]
        debtors = [(amt, m) for m, amt in net.items() if amt < 0]
        heapq.heapify(creditors)
        heapq.heapify(debtors)
        payments = []
        while creditors and debtors:
            credit, to = heapq.heappop(creditors)
            debt, frm = heapq.heappop(debtors)
            amount = min(-credit, -debt)
            payments.append(Settlement(frm, to, amount))
            if -credit > amount:
                heapq.heappush(creditors, (credit + amount, to))
            if -debt > amount:
                heapq.heappush(debtors, (debt + amount, frm))
        return payments

class Group:
    def __init__(self, name: str, members: List[str]) -> None:
        self.name = name
        self.members = set(members)
        self.ledger = Ledger()
        self.history: list = []
        self._expense_ids: set = set()
        self._lock = threading.Lock()

    def add_member(self, user: str) -> None:
        with self._lock:
            self.members.add(user)

    def add_expense(self, expense_id: str, payer: str, total: int, members: List[str], strategy: SplitStrategy) -> Expense:
        if total <= 0:
            raise ValueError("an expense must be positive")
        with self._lock:
            existing = next((e for e in self.history if isinstance(e, Expense) and e.id == expense_id), None)
            if existing is not None:
                return existing                  # idempotent: a retried request is harmless
            unknown = (set(members) | {payer}) - self.members
            if unknown:
                raise ValueError(f"not in the group: {sorted(unknown)}")
            shares = strategy.shares(total, members)
            if sum(shares.values()) != total:
                raise ValueError("shares do not sum to the total")
            expense = Expense(expense_id, payer, total, tuple(sorted(shares.items())))
            self.ledger.apply_expense(expense)
            self.history.append(expense)
            return expense

    def settle(self, frm: str, to: str, amount: int) -> None:
        with self._lock:
            settlement = Settlement(frm, to, amount)
            self.ledger.apply_settlement(settlement)
            self.history.append(settlement)

    def balances(self) -> Dict[str, int]:
        with self._lock:
            return {m: v for m, v in self.ledger.net.items() if v}

    def suggested_payments(self) -> List[Settlement]:
        return DebtSimplifier().simplify(self.balances())

if __name__ == "__main__":
    g = Group("trip", ["ann", "bo", "cy"])
    g.add_expense("e1", "ann", 100, ["ann", "bo", "cy"], EqualSplit())
    assert g.balances() == {"ann": 66, "bo": -33, "cy": -33}     # ann's share took the extra unit
    g.add_expense("e1", "ann", 100, ["ann", "bo", "cy"], EqualSplit())
    assert g.balances()["ann"] == 66                             # retry changes nothing
    g.add_expense("e2", "bo", 90, ["ann", "bo"], PercentSplit({"ann": 50, "bo": 50}))
    assert sum(g.balances().values()) == 0
    for p in g.suggested_payments():
        g.settle(p.frm, p.to, p.amount)
    assert g.balances() == {}

The last two lines run the full cycle with real numbers, and a grader will do the same. Reporting the net balance at each step during the interview is the quickest proof that the rounding is right.

6. Extensions, concurrency and what interviewers probe

  • "Add a split by shares (2:1:1)." One new SplitStrategy; it reuses the remainder approach from PercentSplit.
  • "Show who owes whom, not just nets." Keep a pairwise map as well, or derive it by replaying the history; the immutable records make replay safe.
  • "Edit or delete an expense." Never mutate: append a reversing expense, so balances and the audit trail both remain correct.
  • "Is the greedy minimal?" No. With nets of +1, +4, +5, −3, −3 and −4, the greedy uses five payments, but four are enough: +4 and −4 cancel on their own, and the other four people clear in three. Say the exact version is exponential and fine for a group of ten.
  • Concurrency: one lock per group, covering the ledger and history together; the expense id makes a retried request idempotent. A real system would index ids rather than scan the history.
  • Common mistake: floats for money, and dropping the rounding remainder so a group's balances no longer sum to zero.

02Where this question was reported

  • DesignGurus · DesignGurus case study
  • GitHub · awesome-low-level-design, Hard
  • workat.tech · workat.tech machine coding (SDE I/II)
  • systemdesign.academy · systemdesign.academy solved
  • LLD Canvas · Min-cash-flow simplification
  • interviewcrafted.com · InterviewCrafted example 'Expense Sharing'
  • thita.ai · thita.ai LLD sheet, foundational
  • Instamock · Tagged Google (SDE1, 60 min); modelling money and balances
  • GeeksforGeeks · Google internship Round 2 LLD: design Splitwise; ran over an hour; rejected
  • vamsilabs.netlify.app · 'Asked at Google, Amazon, Uber, Flipkart; L4-L6; 45 min'; probes split Strategy, rounding, debt simplification, SOLID
  • YouTube · Video 'Design Splitwise'; description frames LLD/machine-coding at Google among others