Design Splitwise: Expense Splitting and Debt Simplification
Splitwise sounds like a bookkeeping problem at first. Track who paid for what, figure out who owes whom, and keep the numbers straight. But when you think through the mechanics, there are at least three genuinely interesting design problems hiding inside it.
The first is split calculation. How do you split a $90 dinner equally among three people when $90 does not divide evenly? The answer seems obvious until you ask what happens to the rounding error. The second is balance tracking. Naively you store a balance for every ordered pair of users: Alice owes Bob $10, Carol owes Alice $5. But that table grows quadratically with the number of users in a group, and it is full of redundant information. The third is debt simplification. If Alice owes Bob $10 and Bob owes Carol $10, you can settle that with two transactions. But you can also do it with one (Alice pays Carol directly). The question is: when should you simplify, and how far do you take it?
Let me reason through each of these from scratch.
Requirements
Functional:
- Add users and groups
- Record an expense paid by one user, split among a subset of the group
- Support four split types: equal, exact amount, percentage, and shares
- Show what each user owes or is owed in total
- Simplify debts within a group to minimize the number of transactions needed
Non-functional:
- Balance totals must always be consistent (if Alice owes $10 net, someone else is owed $10 net)
- Adding a new split type should not require changing expense recording or balance logic
- The debt simplification step is optional and should not affect the underlying balances
Core Entities
User is a simple value object: an ID, a name, and an email. It does not own expenses or balances. Those belong elsewhere.
Group holds a set of users. It does not calculate anything. It is a membership container.
Expense records what happened: who paid, the total amount, and a list of Split objects that describe how the amount breaks down per participant. The expense does not know how to calculate splits. That logic lives in the split types.
Split is an abstract class. Concrete types are EqualSplit, ExactSplit, PercentageSplit, and SharesSplit. Each one knows how to compute the amount owed by its user given the total expense amount. This is the Strategy pattern applied at the split level.
ExpenseManager handles the write path: validate an expense, ask each split to compute its amount, and push the resulting deltas into the balance sheet. It owns no state of its own beyond a reference to the balance sheet.
BalanceSheet is a simple mapping from user ID to net balance. A positive balance means you are owed money. A negative balance means you owe money. This is the collapsed form: you do not track who owes whom, just the per-user net.
DebtSimplifier takes a BalanceSheet and returns the minimum set of transactions that settles all debts. It does not modify the balance sheet. It produces a read-only result.
The reason for separating these three responsibilities is that they have different reasons to change. ExpenseManager changes when you add a new expense type (recurring, split by category). BalanceSheet changes when you reconsider how to store balances (maybe you need per-group balances vs. global). DebtSimplifier changes when you want a smarter simplification algorithm. With SRP, each of those changes is isolated to one class.
ASCII Class Diagram
+-----------------+ +------------------+
| User | | Group |
|-----------------| |------------------|
| - user_id: str |<--------| - group_id: str |
| - name: str | member | - members: set |
| - email: str | +------------------+
+-----------------+
+-----------------+ +------------------+
| Expense |-------->| Split | (abstract)
|-----------------| has |------------------|
| - expense_id | many | - user: User |
| - paid_by: User | | + amount(total) |
| - total: float | | -> float |
| - splits: list | +-----+------------------+
+-----------------+ |
|
+------------+------------+
| | |
EqualSplit ExactSplit PercentageSplit SharesSplit
+---------------------+ +-------------------+
| ExpenseManager | | BalanceSheet |
|---------------------| |-------------------|
| - balance_sheet |---->| - balances: dict |
| + add_expense(...) | | + credit(uid, amt)|
+---------------------+ | + debit(uid, amt) |
| + net(uid) -> flt |
+---------------------+ | + all() -> dict |
| DebtSimplifier | +-------------------+
|---------------------|
| + simplify(sheet) |
| -> list[Transfer] |
+---------------------+
Key Implementation
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import NamedTuple
@dataclass
class User:
user_id: str
name: str
email: str
@dataclass
class Group:
group_id: str
name: str
members: set[str] = field(default_factory=set) # set of user_ids
def add_member(self, user: User) -> None:
self.members.add(user.user_id)
class Split(ABC):
def __init__(self, user: User) -> None:
self.user = user
@abstractmethod
def amount(self, total: float) -> float:
"""Returns the amount this user owes for the given expense total."""
pass
class EqualSplit(Split):
"""Divides the total equally; the caller must pass the correct total/n."""
def __init__(self, user: User, n_participants: int) -> None:
super().__init__(user)
self._n = n_participants
def amount(self, total: float) -> float:
return round(total / self._n, 2)
class ExactSplit(Split):
"""The exact amount is fixed regardless of total."""
def __init__(self, user: User, exact_amount: float) -> None:
super().__init__(user)
self._exact = exact_amount
def amount(self, total: float) -> float:
return self._exact
class PercentageSplit(Split):
def __init__(self, user: User, percentage: float) -> None:
super().__init__(user)
self._pct = percentage
def amount(self, total: float) -> float:
return round(total * self._pct / 100, 2)
class SharesSplit(Split):
"""
Proportional split based on share counts.
The amount per user = total * (user_shares / total_shares).
"""
def __init__(self, user: User, shares: int, total_shares: int) -> None:
super().__init__(user)
self._shares = shares
self._total_shares = total_shares
def amount(self, total: float) -> float:
return round(total * self._shares / self._total_shares, 2)
@dataclass
class Expense:
expense_id: str
description: str
paid_by: User
total: float
splits: list[Split]
class BalanceSheet:
"""
Tracks net balance per user across all expenses.
Positive = owed money. Negative = owes money.
"""
def __init__(self) -> None:
self._balances: dict[str, float] = {}
def credit(self, user_id: str, amount: float) -> None:
self._balances[user_id] = round(
self._balances.get(user_id, 0) + amount, 2
)
def debit(self, user_id: str, amount: float) -> None:
self._balances[user_id] = round(
self._balances.get(user_id, 0) - amount, 2
)
def net(self, user_id: str) -> float:
return self._balances.get(user_id, 0)
def all_balances(self) -> dict[str, float]:
return dict(self._balances)
class ExpenseManager:
def __init__(self, balance_sheet: BalanceSheet) -> None:
self._sheet = balance_sheet
def add_expense(self, expense: Expense) -> None:
total_split = sum(s.amount(expense.total) for s in expense.splits)
if abs(total_split - expense.total) > 0.01:
raise ValueError(
f"Splits sum to {total_split}, expected {expense.total}."
)
# The payer is credited the full amount they paid.
self._sheet.credit(expense.paid_by.user_id, expense.total)
# Each participant is debited their share.
for split in expense.splits:
self._sheet.debit(split.user.user_id, split.amount(expense.total))
class Transfer(NamedTuple):
from_user_id: str
to_user_id: str
amount: float
class DebtSimplifier:
"""
Computes the minimum number of transfers to settle all debts.
Algorithm: reduce balances to a net-balance vector, then greedily
match the largest debtor with the largest creditor.
This is not always the globally optimal solution (that's NP-hard in
the general case), but it is correct and produces a near-minimal
result in practice.
"""
def simplify(self, sheet: BalanceSheet) -> list[Transfer]:
balances = {
uid: amt
for uid, amt in sheet.all_balances().items()
if abs(amt) > 0.001
}
debtors = sorted(
[(uid, amt) for uid, amt in balances.items() if amt < 0],
key=lambda x: x[1],
)
creditors = sorted(
[(uid, amt) for uid, amt in balances.items() if amt > 0],
key=lambda x: -x[1],
)
transfers: list[Transfer] = []
i, j = 0, 0
while i < len(debtors) and j < len(creditors):
debtor_id, debt = debtors[i]
creditor_id, credit = creditors[j]
settles = min(-debt, credit)
transfers.append(Transfer(debtor_id, creditor_id, round(settles, 2)))
debt += settles
credit -= settles
debtors[i] = (debtor_id, debt)
creditors[j] = (creditor_id, credit)
if abs(debt) < 0.001:
i += 1
if abs(credit) < 0.001:
j += 1
return transfers
Design Decisions and Trade-offs
Why net balance instead of pairwise balance?
The intuitive model stores a balance for every ordered pair of users: how much does Alice owe Bob, how much does Bob owe Carol, and so on. This looks more precise, but it creates a quadratic number of entries and makes the debt simplification step harder because you have to merge across chains of obligation. The net balance model collapses everything to a single number per user. The invariant is simple: the sum of all net balances is always zero. Simplification is then a straightforward problem of matching debtors against creditors.
The trade-off is that you lose the per-pair history. If Alice wants to know specifically what she owes Bob (not Carol), the net balance model does not store that. For Splitwise’s UI purposes you often need that granularity, so a real implementation would store both: pairwise balances for display and net balances for simplification. For this LLD exercise, keeping the net balance model makes the algorithm clean without loss of conceptual clarity.
The rounding problem in EqualSplit.
Splitting $10 three ways gives $3.33, $3.33, $3.33, which sums to $9.99. Someone is short by a cent. The validation in ExpenseManager.add_expense() allows a 0.01 tolerance for exactly this reason. In a real system you’d assign the rounding remainder to the payer or to a designated “last in the list” participant. The important thing is that the rounding policy lives in one place and is explicit.
When to simplify vs. not.
The DebtSimplifier is deliberately decoupled from ExpenseManager and BalanceSheet. Simplification is not something that happens automatically on every expense. It is a separate read operation you run when a user wants a settlement summary. There are two reasons for this. First, simplification changes who pays whom, not the underlying balances. If Alice agrees to pay Carol instead of Bob because that reduces transactions, the underlying record of who owed what does not change. Second, simplification is lossy in the sense that it replaces a chain of named obligations with a set of direct transfers. If Bob wants to see an itemized history of what he’s owed, you need the original expense records, not the simplified view.
Is the greedy simplification algorithm optimal?
Not always. The exact problem of minimizing cash flow is NP-hard in the general case (it reduces to a problem related to set cover). The greedy approach here, matching the largest debtor with the largest creditor at each step, produces a near-minimal result and is O(n log n) due to sorting. For the group sizes Splitwise actually deals with (tens of people, not thousands), this is entirely sufficient. The alternative of exploring all possible matchings is not worth the complexity.
What I’d Extend Next
A recurring expense type that auto-splits on a schedule, a per-group balance sheet so you can track within a trip vs. the global view, and a settlement event that records when a transfer actually happened and adjusts the balance sheet accordingly. The DebtSimplifier already returns Transfer objects that could feed directly into a payment request flow.
If the debt simplification algorithm clicked for you or you have a cleaner way to handle the rounding edge case, I’d genuinely like to hear it. Reach out on Twitter or LinkedIn.
Tags:
Related Posts
Design a Vending Machine: State Machines in Practice
A first-principles LLD walkthrough of a vending machine using the State pattern. Covers state transitions, inventory management, payment handling, and why State beats if/else chains when behavior must vary by state.
Design an ATM Machine: Transactions, State, and Security in LLD
A first-principles LLD walkthrough of an ATM covering state management, transaction types, card and account entities, cash dispensing logic, and why atomicity matters even in a machine-coding exercise.
Design a Logging Library Like log4j: Filters, Appenders, and Formatters
A first-principles LLD walkthrough of a logging library covering logger hierarchy, log levels, appenders, formatters, and the Chain of Responsibility pattern for filter chains. Why appenders must be independent of formatters.