Design Splitwise
Splitwise is a data-management problem in its canonical form: a shared ledger where users record expenses and the system tracks who owes whom as the log grows. The design goal is keeping the financial rules consistent across a set of collaborating classes — how an expense is divided, and how balances stay correct and fast to read, are the decisions the walkthrough works through.
See It in Action
The demo shows the two split strategies and how the ledger accumulates net balances across Alice, Bob, and Carol as expenses are added. Choose Equal to divide a bill evenly (the remainder cents handed out one at a time), or Exact to enter each person's share directly.
Clarifying Requirements
"Design a simplified Splitwise" is brief enough to hide a few money-handling decisions worth
surfacing first. The functional requirements we commit to: users can record an expense paid by one person and
split among named participants; the split can be equal (divided evenly, with remainder cents
distributed deterministically) or exact (each participant's share stated in cents); the system
can report a single user's net balance across all expenses; and a show command lists all
non-zero net balances in sorted order.
Decision checkpoint
Splitting a $10.00 bill equally three ways is $3.333… each, which no float represents exactly. How should amounts be stored, and how do you divide a bill so not one cent is created or lost?
So all amounts are stored as integer cents, never floats, and the lone rounding step — the
leftover cents of an uneven equal split — is handled with divmod so the shares always sum back
to the total.
The two core workflows — recording an expense and querying a balance — follow distinct paths through the system.
Core Entities
Scanning the requirements for nouns that carry state or enforce rules gives a short list.
A User is the thinnest entity: just a name. The financial logic lives elsewhere, so User needs no methods beyond identity. An Expense records who paid, the total amount in cents, and how the cost is divided among participants — the shares. The one thing that differs between expense types is how a bill is divided among members; that rule has to live somewhere, which is the first decision below. A Ledger holds the running balances the queries read, so the system can report who is owed and who owes — what exactly it stores, and how it keeps reads fast, are the decisions after that. A SplitwiseApp is the orchestrator — it accepts commands, applies the dividing rule, creates Expense objects, and updates the Ledger.
One class that was not modeled: a "Group" would add a layer of indirection without adding logic here, because balance is global across all expenses in this design, not scoped to a group. Model the state the requirements demand, not every noun in the prompt.
The relationships between entities are straightforward: the app delegates to a strategy and a ledger; the ledger consumes expenses; expenses reference users only by name.
Designing the Classes
With requirements and entities settled, derive state and behavior for each class in turn. The guiding principle is Tell, Don't Ask: a class decides about its own state rather than exposing raw data for callers to interpret. For a financial system, this keeps every rule in exactly one place so auditing a wrong balance has a single code path to inspect.
User
The requirements say users have names. Nothing else. No financial logic lives here — a user cannot compute a balance or split an expense. That rule lives elsewhere, which makes User the simplest possible entity.
State: name: str. Behavior: __repr__ for readable output. No other methods are needed,
because the User does not make decisions — it is a named participant that other classes
reference.
Expense
An expense records what happened when a payer covered a bill. The requirements demand three facts: who paid, how much they paid (in cents), and how the cost is divided. The shares are already resolved before the Expense is constructed — the split strategy runs first and hands the result to the Expense.
State: payer: str, amount: int, shares: dict[str, int]. Behavior: none beyond
construction. The Expense is a value object — a record of a completed action. It does not
compute anything; the Ledger reads from it.
SplitStrategy, EqualSplit, and ExactSplit
The requirements list two ways to divide a bill — equal and exact — and the Extensions section hints at more (percentage splits, tiered splits). The dividing logic is the one thing that varies between expense types. Before reading on, decide where that logic should live.
Decision checkpoint
Equal and exact divide a bill differently, with more split types coming. Should the dividing logic live on Expense, or somewhere Expense doesn't have to change when a new split type appears?
That variability is the cue for a Strategy interface. The interface commits to one method signature:
compute(amount: int, members: list[str]) -> dict[str, int]
The caller — SplitwiseApp — never checks which concrete type it received. It calls compute
and passes the result to the Expense constructor. Adding a third split type in the future
means implementing compute in a new class, not touching any existing code.
EqualSplit state: none. It is stateless because the full context (amount, members) arrives
at call time. Behavior: compute uses divmod(amount, n) to get a base share and a remainder.
The remainder is distributed one extra cent per member starting at index 0. This is deterministic:
the same input always produces the same distribution, so the same expense always reconciles to
the same balances.
ExactSplit state: _pairs: list[tuple[str, int]] — the pre-specified per-member amounts,
captured at construction time because the caller parses them from the command before instantiating
the strategy. Behavior: compute ignores the amount and members arguments and reads directly
from _pairs. The caller guarantees the amounts sum to the total.
The reason split logic is a Strategy rather than a method on Expense: Expense is a finalized record. If split logic lived there, adding a new split type would require changing a class that represents a completed transaction — a violation of the open/closed principle. Separating the computation from the record means the record never changes.
Ledger
The Ledger holds the balance state, and two decisions shape it: what granularity to store, and how to keep queries fast. Take granularity first. The requirements ask for each user's net balance and a sorted list of non-zero balances — not "how much does Alice owe Bob specifically." Before reading on, decide what the Ledger should store given that.
Decision checkpoint
The system must report each user's net balance and the list of non-zero balances. Should the Ledger store a pairwise map of who-owes-whom — (debtor, creditor) -> cents — or a single net amount per user?
So the Ledger stores one signed net per user. That settles granularity; the second decision is keeping the query fast as the expense log grows. There are two ways to answer a balance query: replay every expense each time, or keep the running net updated as expenses arrive. Decide which before reading on.
Decision checkpoint
A balance query must be fast no matter how many expenses exist. Should the Ledger recompute each user's balance from the full expense log on every query, or keep a running net per user?
The answer is to maintain a running net per user and update it incrementally each time an expense is recorded, so balance reads are always O(1) lookups.
State: _net: dict[str, int] — a map from user name to integer cents. Positive means owed,
negative means owes. Behavior: record_expense(expense) credits the payer and debits each
participant; net_balance(user) returns the current net; all_users() returns sorted keys
for the show command.
Debt simplification, if added later, derives its transfers from exactly these net balances (see Extensions), so the net-per-user model serves that follow-up too.
Amounts are stored as integer cents so the arithmetic stays exact. The only non-integer
division in the whole system is EqualSplit's remainder distribution, and divmod converts
that to integer arithmetic immediately.
SplitwiseApp
The orchestrator owns one Ledger and exposes three commands to the outside world: add an expense, query one balance, and show all non-zero balances. It parses raw command arguments into typed objects, constructs the right strategy, and delegates to the Ledger. No financial logic lives here — the app routes commands, it does not compute results.
State: _ledger: Ledger. Behavior: add_expense(payer, amount, strategy, members) runs the
strategy and records the result; balance(user) formats the net as a readable string;
show() iterates sorted users and filters zeros.
The public surface is deliberately three methods. The outside world never touches the Ledger or the strategies directly — it issues a command and receives a string or list. That narrow interface is what makes the design testable and safe to extend.
Try It Yourself
Before reading the implementation, build it yourself. Implement a function
splitwise(instructions) that replays a sequence of commands and returns one result line per
balance or show command.
Each instruction is a list of strings. The commands are:
["expense", payer, amount_cents, "equal", member1, member2, ...] — record an expense paid by
payer for amount_cents cents, split equally among the listed members. If the amount does
not divide evenly, distribute the remainder cents one per member starting from the first member
in the list (index 0 gets the extra cent first, then index 1, and so on).
["expense", payer, amount_cents, "exact", member1, amount1, member2, amount2, ...] — record an
expense with explicit per-member amounts. The remaining args alternate member name and integer
cent amount. The caller guarantees the amounts sum to amount_cents.
["balance", user] — append one line: "<user> is owed N" if net positive, "<user> owes N"
if net negative, or "<user> settled" if zero. N is the absolute value in cents.
["show"] — append one line per user with a non-zero net balance, in sorted (alphabetical)
order, using the same format as balance.
Run your solution against the test cases. The reference implementation follows below.