Design Splitwise¶
TL;DR¶
- You build groups that record expenses, split them four ways (equal, exact, percent, shares), keep per-member balances, and produce a settle-up plan.
- Three decisions carry the interview: money is integer cents and splits go through
Money.allocate, the expense and the balances commit in one Unit of Work, and an edit is a reversal plus a new version, not a mutation. - Strategy, Factory, Unit of Work, Command and Observer all earn their place. Debt simplification is greedy, and you say out loud that greedy is not minimal.
Problem statement¶
"Design the backend of a shared-expense app. People form groups — a trip, a flat, a team lunch — and record who paid for what and how it should be divided. The app shows each person what they owe and what they are owed, inside a group and overall, and suggests the smallest sensible set of payments to square everyone up. Expenses get corrected and deleted after the fact. Focus on the classes, the split rules, and what happens when two people record an expense at the same time."
Requirements¶
Functional
- Users, and groups with a member list. A user belongs to many groups.
- Add an expense with one or several payers and a split over the participants; the split is equal, exact amounts, percentages, or relative shares.
- Validate that what the payers contributed and what the participants owe both equal the expense total.
- Per-member balances inside a group, and a global balance across every group a member belongs to.
- Simplify debts: turn the group's balances into a short list of transfers.
- Settle up: record that one member paid another outside the app.
- Edit and delete an expense, with the balances recalculated as if the old version never existed.
- An activity feed per group: added, edited, deleted, restored, settled.
Non-functional and constraints
- No cent is ever created or destroyed. Money is
common.Money(integer cents); a float is an automatic fail here. - Two members adding an expense to the same group concurrently must both land, and the balances must be exactly the sum of both.
- An expense that fails validation leaves nothing behind: no expense row, no ledger row, no balance movement.
- In-memory and single process; the store is behind a small interface you could back with SQL.
- Deterministic and testable: clock and id generator are injected.
Out of scope: authentication, push delivery, multi-currency conversion (one currency per group), real payment rails (that is the payment gateway problem), receipts and OCR.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Can an expense have more than one payer? | Yes. paid_by is a map of member to amount, and the invariant is that it sums to the total. |
| How are percentages represented? | As basis points — hundredths of a percent — so 33.33% is the integer 3333 and no float ever touches money. |
| Who absorbs the leftover cent when 100.01 splits three ways? | The first participants in a deterministic order, via Money.allocate. Two runs of the same expense produce identical splits. |
| Does "simplify debts" have to be the true minimum number of transfers? | No, and you should say why: minimising it is NP-hard. Greedy gives at most n-1 transfers, usually fewer. |
| Does an edit rewrite history? | No. The old version is kept as SUPERSEDED and a new version replaces it, so the ledger stays auditable. |
| One currency or many? | One per group. Cross-currency is a follow-up because it needs rate snapshots per expense. |
| Do balances need to be exact across concurrent writers? | Yes. That is what the per-group lock and the Unit of Work are for. |
Core entities and relationships¶
- User — a frozen value object. Group — a name, a currency and a member set;
require_memberis the one place membership is enforced. - Expense — frozen: id, group, description, total,
paid_bysplits,owed_bysplits, split type, creator, timestamp,ExpenseStatusandreplaces_id.validate()asserts both sides equal the total;net_by_user()reduces it topaid - owedper member. - Split — one line,
(user_id, Money). The same type describes what someone paid and what someone owes, which is why the invariant reads as one line of code. - SplitStrategy with
EqualSplit,ExactSplit,PercentSplit,ShareSplit, built bySplitStrategyFactory. Every one of them ends inMoney.allocate. - BalanceSheet — one per group. It stores pairwise net debt keyed by the ordered pair
(low_id, high_id), so "A owes B 5.00" and "B owes A 5.00" collapse to nothing.net(user)is one pass;between(a, b)is one lookup. - LedgerEntry — the append-only audit row behind every balance movement; a reversal is the same row with the two ids swapped.
- DebtSimplifier — turns balances into
Transferobjects using a max-creditor and a max-debtor heap. - GroupState — group, balances, expenses, ledger, settlements: the unit the GroupUnitOfWork copies, mutates and publishes. SplitwiseStore holds one
GroupStateand one lock per group. - ExpenseService — the only writer. ActivityFeed observes it. AddExpenseCommand / DeleteExpenseCommand wrap the two undoable operations for
CommandHistory.
Multiplicities: group 1 -> * members, group 1 -> 1 balance sheet, expense 1 -> * splits on each side, expense 1 -> * ledger entries, group 1 -> * settlements.
Class diagram¶
The domain: what an expense is made of and where the balances live.
classDiagram
class User {
+id: str
+name: str
+email: str
}
class Group {
+id: str
+currency: str
+member_ids: set
+require_member(user_id)
}
class Split {
+user_id: str
+amount: Money
}
class Expense {
+id: str
+amount: Money
+paid_by: tuple~Split~
+owed_by: tuple~Split~
+status: ExpenseStatus
+replaces_id: str
+validate()
+net_by_user() Dict~str,Money~
+with_status(status) Expense
}
class BalanceSheet {
+pairs: Dict~tuple,int~
+record(debtor_id, creditor_id, amount)
+settle(payer_id, payee_id, amount)
+between(user_id, other_id) Money
+net(user_id) Money
}
class LedgerEntry {
+expense_id: str
+debtor_id: str
+creditor_id: str
+amount: Money
}
class Settlement {
+payer_id: str
+payee_id: str
+amount: Money
}
class Transfer {
+debtor_id: str
+creditor_id: str
+amount: Money
}
class Activity {
+kind: ActivityKind
+summary: str
}
class SplitType {
<<enumeration>>
EQUAL
EXACT
PERCENT
SHARE
}
class ExpenseStatus {
<<enumeration>>
ACTIVE
SUPERSEDED
DELETED
}
Group "1" o-- "many" User
Group "1" *-- "1" BalanceSheet
Expense "1" *-- "many" Split
Expense --> SplitType
Expense --> ExpenseStatus
Expense "1" --> "many" LedgerEntry : produces
BalanceSheet ..> Transfer : simplifies into
Group "1" o-- "many" Settlement
Group "1" o-- "many" Activity
The services: four strategies, one transaction boundary, two undoable commands.
classDiagram
class SplitStrategy {
<<interface>>
+split(total, participant_ids, weights) List~Split~
}
class EqualSplit
class ExactSplit
class PercentSplit
class ShareSplit
class SplitStrategyFactory {
+create(split_type) SplitStrategy
}
class ExpenseService {
+add_expense(...) Expense
+edit_expense(...) Expense
+delete_expense(group_id, expense_id, actor_id) Expense
+settle_up(group_id, payer_id, payee_id, amount) Settlement
+simplify(group_id) List~Transfer~
+global_balance(user_id) Money
}
class DebtSimplifier {
+simplify(nets) List~Transfer~
}
class SplitwiseStore {
-_registry_lock: Lock
+group_lock(group_id) Lock
+snapshot(group_id) GroupState
+publish(state)
}
class GroupState {
+group: Group
+balances: BalanceSheet
+copy() GroupState
}
class UnitOfWork {
<<interface>>
+commit()
+rollback()
}
class GroupUnitOfWork {
+state: GroupState
+commit()
+rollback()
}
class ActivityListener {
<<interface>>
+on_activity(activity)
}
class ActivityFeed {
+for_group(group_id) List~Activity~
+render(group_id, limit) str
}
class ExpenseCommand {
<<interface>>
+execute() Expense
+undo() Expense
}
class AddExpenseCommand
class DeleteExpenseCommand
class CommandHistory {
+run(command) Expense
+undo_last() Expense
}
SplitStrategy <|.. EqualSplit
SplitStrategy <|.. ExactSplit
SplitStrategy <|.. PercentSplit
SplitStrategy <|.. ShareSplit
SplitStrategyFactory ..> SplitStrategy : creates
ExpenseService --> SplitStrategyFactory
ExpenseService --> DebtSimplifier
ExpenseService --> GroupUnitOfWork
ExpenseService --> "many" ActivityListener : notifies
UnitOfWork <|.. GroupUnitOfWork
GroupUnitOfWork --> SplitwiseStore
GroupUnitOfWork --> GroupState
ActivityListener <|.. ActivityFeed
ExpenseCommand <|.. AddExpenseCommand
ExpenseCommand <|.. DeleteExpenseCommand
CommandHistory o-- "many" ExpenseCommand
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy | SplitStrategy and its four implementations |
"Now add split-by-adjustment" is the follow-up you will get. It is one new class plus one registry line; ExpenseService does not change. |
| Factory Method | SplitStrategyFactory.create |
The API receives the string "percent". The registry maps it to a class, so the service never grows an if/elif ladder on split type. |
| Unit of Work | GroupUnitOfWork |
The expense row, the ledger rows and the balance sheet must become visible together. One copy, one commit, one publish. |
| Repository (light) | SplitwiseStore |
snapshot and publish are the only two verbs the service knows. Swapping in SQL means implementing those two against a transaction. |
| Command | AddExpenseCommand, DeleteExpenseCommand, CommandHistory |
Undo is a product requirement, not decoration: the stack replays the inverse operation. Settling up is deliberately not a command. |
| Observer | ActivityListener / ActivityFeed |
The feed, push notifications and an analytics sink are all listeners. The service publishes after committing, outside the lock. |
| Value objects | Money, Split, Expense, Transfer |
Frozen dataclasses make "an edit is a new version" the natural implementation rather than a discipline you have to remember. |
What was deliberately not used: a State class hierarchy for ExpenseStatus. Three states with two legal transitions are an enum and a guard clause; classes would add three files and no behaviour. Also no Memento for undo — the command re-derives the inverse from the expense itself, which is smaller and cannot drift out of sync with the ledger.
Key flows¶
Adding an expense: strategy, validation, ledger, balances, commit, then notify.
sequenceDiagram
autonumber
actor A as Alice
participant S as ExpenseService
participant K as SplitwiseStore
participant U as GroupUnitOfWork
participant P as SplitStrategy
participant D as DebtSimplifier
participant F as ActivityFeed
A->>S: add_expense(goa, Hotel, alice pays 300.00, 4 members, equal)
S->>K: group_lock(goa)
S->>U: open (snapshot of GroupState)
S->>P: split(300.00, members, weights)
P-->>S: 4 splits of 75.00 via Money.allocate
S->>S: expense.validate() paid == owed == total
S->>D: simplify(net_by_user)
D-->>S: 3 transfers, each member owes Alice 75.00
S->>U: append ledger rows, record balances, store expense
S->>U: commit (publish the working copy)
U-->>S: committed
S-->>A: Expense(ACTIVE)
S-)F: on_activity(EXPENSE_ADDED) outside the lock
Expense lifecycle. SUPERSEDED is what makes an edit auditable: the old row stays, the balances no longer reflect it, and replaces_id links the two.
stateDiagram-v2
[*] --> ACTIVE : add_expense
ACTIVE --> SUPERSEDED : edit_expense replaces it
ACTIVE --> DELETED : delete_expense reverses it
DELETED --> ACTIVE : restore_expense (undo)
SUPERSEDED --> [*]
DELETED --> [*]
Implementation¶
Write it in the order you would in the room: the vocabulary, the entities, the balance sheet, the split rules, the transaction boundary, then the service.
The enums pin the vocabulary — note the comment on each SplitType member saying what its weights mean — and the errors subclass the shared hierarchy so an API layer can map ValidationError to 400 without knowing about expenses:
class SplitType(StrEnum):
EQUAL = "equal" # no weights
EXACT = "exact" # weights are cents and must add up to the total
PERCENT = "percent" # weights are basis points and must add up to 10_000
SHARE = "share" # weights are arbitrary share units (2:1:1)
class ExpenseStatus(StrEnum):
ACTIVE = "active" # counted in the balances
SUPERSEDED = "superseded" # replaced by an edited version, kept for the audit trail
DELETED = "deleted" # reversed out of the balances
class ActivityKind(StrEnum):
EXPENSE_ADDED = "expense_added"
EXPENSE_EDITED = "expense_edited"
EXPENSE_DELETED = "expense_deleted"
EXPENSE_RESTORED = "expense_restored"
SETTLED = "settled"
class UnbalancedExpenseError(ValidationError):
"""What was paid and what is owed do not add up to the same total."""
class GroupMembershipError(ValidationError):
"""A payer or participant does not belong to the group."""
class ExpenseStateError(InvalidStateError):
"""The expense is not in a state that allows the operation (editing a deleted one)."""
class UnknownEntityError(NotFoundError):
"""No such user, group or expense."""
Expense is frozen. That single choice removes a whole class of bugs: an edit cannot half-mutate an expense, and with_status is the only transition. validate() is the invariant an interviewer will ask you to state out loud.
@dataclass(frozen=True, slots=True)
class User:
id: str
name: str
email: str
@dataclass(slots=True)
class Group:
"""A set of members that share expenses. One currency per group, on purpose."""
id: str
name: str
member_ids: set[str] = field(default_factory=set)
currency: str = "USD"
def add_member(self, user_id: str) -> None:
self.member_ids.add(user_id)
def require_member(self, user_id: str) -> None:
if user_id not in self.member_ids:
raise GroupMembershipError(f"{user_id} is not a member of group {self.id}")
@dataclass(frozen=True, slots=True)
class Split:
"""One line of an expense: this user paid, or owes, this much."""
user_id: str
amount: Money
def total_of(splits: Iterable[Split], currency: str) -> Money:
total = Money(0, currency)
for split in splits:
total = total + split.amount
return total
@dataclass(frozen=True, slots=True)
class Expense:
"""Immutable by design: an edit produces a new version and supersedes this one.
``paid_by`` supports several payers, so "Alice put 200 on her card and Bob
added 100 in cash" is one expense rather than two.
"""
id: str
group_id: str
description: str
amount: Money
paid_by: tuple[Split, ...]
owed_by: tuple[Split, ...]
split_type: SplitType
created_by: str
created_at: float
status: ExpenseStatus = ExpenseStatus.ACTIVE
replaces_id: str | None = None
def validate(self) -> None:
"""Both sides of the expense must equal the amount. This is the invariant."""
paid = total_of(self.paid_by, self.amount.currency)
owed = total_of(self.owed_by, self.amount.currency)
if paid != self.amount:
raise UnbalancedExpenseError(f"payers contributed {paid}, expense is {self.amount}")
if owed != self.amount:
raise UnbalancedExpenseError(f"splits add up to {owed}, expense is {self.amount}")
def net_by_user(self) -> dict[str, Money]:
"""``paid - owed`` per participant; positive means the group owes them."""
zero = Money(0, self.amount.currency)
nets: dict[str, Money] = {}
for split in self.paid_by:
nets[split.user_id] = nets.get(split.user_id, zero) + split.amount
for split in self.owed_by:
nets[split.user_id] = nets.get(split.user_id, zero) - split.amount
return {user_id: net for user_id, net in sorted(nets.items()) if not net.is_zero()}
def with_status(self, status: ExpenseStatus) -> Expense:
if self.status is ExpenseStatus.DELETED and status is not ExpenseStatus.ACTIVE:
raise ExpenseStateError(f"expense {self.id} is deleted")
if self.status is ExpenseStatus.SUPERSEDED:
raise ExpenseStateError(f"expense {self.id} was superseded by an edit")
return replace(self, status=status)
@dataclass(frozen=True, slots=True)
class Transfer:
"""One line of a settle-up plan: ``debtor_id`` should pay ``creditor_id``."""
debtor_id: str
creditor_id: str
amount: Money
@dataclass(frozen=True, slots=True)
class LedgerEntry:
"""Append-only audit row. A reversal is a row with the two ids swapped."""
id: str
group_id: str
expense_id: str
debtor_id: str
creditor_id: str
amount: Money
at: float
@dataclass(frozen=True, slots=True)
class Settlement:
id: str
group_id: str
payer_id: str
payee_id: str
amount: Money
at: float
@dataclass(frozen=True, slots=True)
class Activity:
"""What the feed shows. Frozen, so a listener cannot corrupt the history."""
kind: ActivityKind
group_id: str
actor_id: str
subject_id: str
summary: str
at: float
The balance sheet is the part candidates usually get wrong by storing a debt row per direction. One ordered pair per couple means a mutual debt cancels itself, and net never double counts.
@dataclass(slots=True)
class BalanceSheet:
"""Pairwise net debt inside one group, in cents.
The key is the ordered pair ``(low_id, high_id)`` and the value is what
``high_id`` owes ``low_id``. Storing one direction per pair means "A owes B
5.00" and "B owes A 5.00" cancel to nothing instead of accumulating two
rows, and it makes ``net`` a single pass over the dict.
"""
group_id: str
currency: str = "USD"
pairs: dict[tuple[str, str], int] = field(default_factory=dict)
def record(self, debtor_id: str, creditor_id: str, amount: Money) -> None:
"""``debtor_id`` now owes ``creditor_id`` ``amount`` more than before."""
if debtor_id == creditor_id:
raise ValidationError("a member cannot owe themselves")
if amount.currency != self.currency:
raise ValidationError(f"group {self.group_id} settles in {self.currency}")
low, high = sorted((debtor_id, creditor_id))
delta = amount.cents if debtor_id == high else -amount.cents
updated = self.pairs.get((low, high), 0) + delta
if updated == 0:
self.pairs.pop((low, high), None)
else:
self.pairs[(low, high)] = updated
def settle(self, payer_id: str, payee_id: str, amount: Money) -> None:
"""Cash changed hands: the payer's debt to the payee shrinks by ``amount``."""
self.record(payee_id, payer_id, amount)
def between(self, user_id: str, other_id: str) -> Money:
"""How much ``user_id`` owes ``other_id`` (negative means the other way round)."""
low, high = sorted((user_id, other_id))
value = self.pairs.get((low, high), 0)
return Money(value if user_id == high else -value, self.currency)
def net(self, user_id: str) -> Money:
"""Positive means the group owes this member money."""
cents = 0
for (low, high), value in self.pairs.items():
if user_id == low:
cents += value
elif user_id == high:
cents -= value
return Money(cents, self.currency)
def nets(self, member_ids: Iterable[str]) -> dict[str, Money]:
return {user_id: self.net(user_id) for user_id in sorted(member_ids)}
def copy(self) -> BalanceSheet:
return BalanceSheet(self.group_id, self.currency, dict(self.pairs))
Every strategy funnels into Money.allocate, which hands the remainder to the first shares. Percentages are basis points; exact splits are checked against the total rather than recomputed.
class SplitStrategy(Protocol):
"""Turns a total into one ``Split`` per participant. Stateless and thread-safe.
``weights`` means something different per strategy (cents, basis points,
share units) and is ignored by the equal split. Keeping one signature is
what lets ``ExpenseService`` stay free of ``if split_type == ...`` ladders.
"""
def split(
self, total: Money, participant_ids: Sequence[str], weights: Sequence[int] | None
) -> list[Split]: ...
def _check_participants(participant_ids: Sequence[str]) -> None:
if not participant_ids:
raise ValidationError("an expense needs at least one participant")
if len(set(participant_ids)) != len(participant_ids):
raise ValidationError("a participant may appear only once in a split")
def _check_weights(participant_ids: Sequence[str], weights: Sequence[int] | None) -> Sequence[int]:
if weights is None:
raise ValidationError("this split type needs one weight per participant")
if len(weights) != len(participant_ids):
raise ValidationError(f"expected {len(participant_ids)} weights, got {len(weights)}")
if any(weight < 0 for weight in weights):
raise ValidationError("weights cannot be negative")
return weights
class EqualSplit:
"""Everyone pays the same, cents included: 100.01 over three is 33.34/33.34/33.33."""
def split(
self, total: Money, participant_ids: Sequence[str], weights: Sequence[int] | None
) -> list[Split]:
_check_participants(participant_ids)
shares = total.allocate([1] * len(participant_ids))
return [Split(user_id, share) for user_id, share in zip(participant_ids, shares, strict=True)]
class ExactSplit:
"""The caller states every share in cents; the strategy only checks the sum."""
def split(
self, total: Money, participant_ids: Sequence[str], weights: Sequence[int] | None
) -> list[Split]:
_check_participants(participant_ids)
amounts = _check_weights(participant_ids, weights)
if sum(amounts) != total.cents:
stated = Money(sum(amounts), total.currency)
raise UnbalancedExpenseError(f"exact shares add up to {stated}, expense is {total}")
return [
Split(user_id, Money(cents, total.currency))
for user_id, cents in zip(participant_ids, amounts, strict=True)
]
class PercentSplit:
"""Percentages arrive as basis points, so 33.33% is the integer 3333, never a float."""
def split(
self, total: Money, participant_ids: Sequence[str], weights: Sequence[int] | None
) -> list[Split]:
_check_participants(participant_ids)
points = _check_weights(participant_ids, weights)
if sum(points) != TOTAL_BASIS_POINTS:
raise UnbalancedExpenseError(
f"percentages add up to {sum(points) / 100:.2f}%, they must add up to 100%"
)
shares = total.allocate(list(points))
return [Split(user_id, share) for user_id, share in zip(participant_ids, shares, strict=True)]
class ShareSplit:
"""Relative shares: a couple counts as 2, a single person as 1, no percentages needed."""
def split(
self, total: Money, participant_ids: Sequence[str], weights: Sequence[int] | None
) -> list[Split]:
_check_participants(participant_ids)
units = _check_weights(participant_ids, weights)
if sum(units) == 0:
raise ValidationError("share weights cannot all be zero")
shares = total.allocate(list(units))
return [Split(user_id, share) for user_id, share in zip(participant_ids, shares, strict=True)]
class SplitStrategyFactory:
"""Factory: the API receives a ``SplitType`` string, the registry maps it to a strategy."""
_registry: dict[SplitType, type] = {
SplitType.EQUAL: EqualSplit,
SplitType.EXACT: ExactSplit,
SplitType.PERCENT: PercentSplit,
SplitType.SHARE: ShareSplit,
}
@classmethod
def create(cls, split_type: SplitType | str) -> SplitStrategy:
try:
strategy_class = cls._registry[SplitType(split_type)]
except ValueError as exc:
raise ValidationError(f"unknown split type: {split_type!r}") from exc
return strategy_class()
The store is deliberately dumb: hand out a copy, take back a whole state. All the transactional reasoning lives in the Unit of Work above it.
@dataclass(slots=True)
class GroupState:
"""Everything one group owns, and therefore the unit the Unit of Work copies."""
group: Group
balances: BalanceSheet
expenses: dict[str, Expense] = field(default_factory=dict)
ledger: list[LedgerEntry] = field(default_factory=list)
settlements: list[Settlement] = field(default_factory=list)
def copy(self) -> GroupState:
clone = Group(self.group.id, self.group.name, set(self.group.member_ids), self.group.currency)
return GroupState(
clone, self.balances.copy(), dict(self.expenses), list(self.ledger), list(self.settlements)
)
class SplitwiseStore:
"""Reads hand out snapshots; writes arrive as whole, already-consistent states."""
def __init__(self) -> None:
self._registry_lock = threading.Lock()
self._users: dict[str, User] = {}
self._groups: dict[str, GroupState] = {}
self._group_locks: dict[str, threading.Lock] = {}
def add_user(self, user: User) -> User:
with self._registry_lock:
self._users[user.id] = user
return user
def user(self, user_id: str) -> User:
with self._registry_lock:
try:
return self._users[user_id]
except KeyError:
raise UnknownEntityError(f"unknown user {user_id}") from None
def create_group(self, group: Group) -> Group:
with self._registry_lock:
for member_id in group.member_ids:
if member_id not in self._users:
raise UnknownEntityError(f"unknown user {member_id}")
self._groups[group.id] = GroupState(group, BalanceSheet(group.id, group.currency))
self._group_locks[group.id] = threading.Lock()
return group
def group_lock(self, group_id: str) -> threading.Lock:
with self._registry_lock:
try:
return self._group_locks[group_id]
except KeyError:
raise UnknownEntityError(f"unknown group {group_id}") from None
def snapshot(self, group_id: str) -> GroupState:
with self._registry_lock:
try:
return self._groups[group_id].copy()
except KeyError:
raise UnknownEntityError(f"unknown group {group_id}") from None
def publish(self, state: GroupState) -> None:
with self._registry_lock:
self._groups[state.group.id] = state
def group_ids(self) -> list[str]:
with self._registry_lock:
return sorted(self._groups)
class UnitOfWork(Protocol):
"""One transaction boundary. ``state`` is the working copy you mutate."""
state: GroupState
def commit(self) -> None: ...
def rollback(self) -> None: ...
class GroupUnitOfWork:
"""Expenses, ledger rows and balances of one group commit together or not at all.
Construction copies the group's state; everything inside the ``with`` block
mutates the copy. ``commit`` publishes that copy back into the store in a
single assignment. Leaving the block without committing -- a failed
validation, any raised exception -- throws the copy away, so an expense that
moved balances but was never stored cannot exist.
"""
def __init__(self, store: SplitwiseStore, group_id: str) -> None:
self._store = store
self._group_id = group_id
self._committed = False
self.state = store.snapshot(group_id)
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
if not self._committed:
self.rollback()
def commit(self) -> None:
self._store.publish(self.state)
self._committed = True
def rollback(self) -> None:
self.state = self._store.snapshot(self._group_id)
Debt simplification is two heaps and a loop. The docstring carries the sentence that earns you the point: greedy is n-1, not minimal.
class DebtSimplifier:
"""Greedy minimum cash flow: settle the largest debtor against the largest creditor.
Two heaps keep the extremes at hand, so the plan costs O(n log n) and never
needs more than n-1 transfers. Say this out loud: the result is *not*
guaranteed to be the minimum number of transfers -- deciding that is
NP-hard, it is subset-sum in disguise. Greedy is what production systems
ship because "at most n-1, usually far fewer" is already a huge win.
"""
def simplify(self, nets: Mapping[str, Money]) -> list[Transfer]:
if not nets:
return []
currency = next(iter(nets.values())).currency
if sum(net.cents for net in nets.values()) != 0:
raise ValidationError("balances do not sum to zero; the ledger is corrupt")
# (-cents, user_id): the heap root is the biggest amount, ties broken by id.
creditors = [(-net.cents, user) for user, net in sorted(nets.items()) if net.cents > 0]
debtors = [(net.cents, user) for user, net in sorted(nets.items()) if net.cents < 0]
heapq.heapify(creditors)
heapq.heapify(debtors)
transfers: list[Transfer] = []
while creditors and debtors:
credit, creditor = heapq.heappop(creditors)
debit, debtor = heapq.heappop(debtors)
amount = min(-credit, -debit)
transfers.append(Transfer(debtor, creditor, Money(amount, currency)))
if -credit > amount:
heapq.heappush(creditors, (credit + amount, creditor))
if -debit > amount:
heapq.heappush(debtors, (debit + amount, debtor))
return transfers
The service is the only writer. Read add_expense, edit_expense and _apply together: an edit is _apply(old, -1) then _apply(new, +1) inside one transaction, and because DebtSimplifier is deterministic the reversal cancels the original exactly.
class ExpenseService:
"""Add, edit, delete and settle. The only writer of balances in the system.
Every mutating method takes the group lock, opens a ``GroupUnitOfWork``,
computes, commits, and only then notifies listeners.
"""
def __init__(
self,
store: SplitwiseStore,
clock: Clock | None = None,
ids: IdGenerator | None = None,
listeners: Iterable[ActivityListener] = (),
simplifier: DebtSimplifier | None = None,
) -> None:
self._store = store
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("E")
self._listeners = list(listeners)
self._simplifier = simplifier or DebtSimplifier()
def add_expense(
self,
group_id: str,
description: str,
paid_by: Mapping[str, Money],
participant_ids: Sequence[str],
split_type: SplitType | str = SplitType.EQUAL,
weights: Sequence[int] | None = None,
actor_id: str | None = None,
) -> Expense:
with self._store.group_lock(group_id):
with GroupUnitOfWork(self._store, group_id) as uow:
expense = self._build(uow.state, group_id, description, paid_by, participant_ids, split_type, weights, actor_id)
uow.state.expenses[expense.id] = expense
self._apply(uow.state, expense, sign=1)
uow.commit()
self._notify(ActivityKind.EXPENSE_ADDED, expense, f"{expense.created_by} added {expense.description} for {expense.amount}")
return expense
def edit_expense(
self,
group_id: str,
expense_id: str,
description: str,
paid_by: Mapping[str, Money],
participant_ids: Sequence[str],
split_type: SplitType | str = SplitType.EQUAL,
weights: Sequence[int] | None = None,
actor_id: str | None = None,
) -> Expense:
"""Reverse the old version, post the new one, supersede the old -- in one transaction."""
with self._store.group_lock(group_id):
with GroupUnitOfWork(self._store, group_id) as uow:
old = self._active(uow.state, expense_id)
self._apply(uow.state, old, sign=-1)
uow.state.expenses[old.id] = old.with_status(ExpenseStatus.SUPERSEDED)
new = self._build(uow.state, group_id, description, paid_by, participant_ids, split_type, weights, actor_id, replaces_id=old.id)
uow.state.expenses[new.id] = new
self._apply(uow.state, new, sign=1)
uow.commit()
self._notify(ActivityKind.EXPENSE_EDITED, new, f"{new.created_by} edited {new.description} to {new.amount}")
return new
def delete_expense(self, group_id: str, expense_id: str, actor_id: str) -> Expense:
with self._store.group_lock(group_id):
with GroupUnitOfWork(self._store, group_id) as uow:
expense = self._active(uow.state, expense_id)
self._apply(uow.state, expense, sign=-1)
deleted = expense.with_status(ExpenseStatus.DELETED)
uow.state.expenses[expense_id] = deleted
uow.commit()
self._notify(ActivityKind.EXPENSE_DELETED, deleted, f"{actor_id} deleted {deleted.description}")
return deleted
def restore_expense(self, group_id: str, expense_id: str, actor_id: str) -> Expense:
"""Undo of a delete: put the same version back and re-post its transfers."""
with self._store.group_lock(group_id):
with GroupUnitOfWork(self._store, group_id) as uow:
expense = uow.state.expenses.get(expense_id)
if expense is None:
raise UnknownEntityError(f"unknown expense {expense_id}")
if expense.status is not ExpenseStatus.DELETED:
raise ExpenseStateError(f"expense {expense_id} is {expense.status}, not deleted")
restored = expense.with_status(ExpenseStatus.ACTIVE)
uow.state.expenses[expense_id] = restored
self._apply(uow.state, restored, sign=1)
uow.commit()
self._notify(ActivityKind.EXPENSE_RESTORED, restored, f"{actor_id} restored {restored.description}")
return restored
def settle_up(self, group_id: str, payer_id: str, payee_id: str, amount: Money) -> Settlement:
if amount.cents <= 0:
raise ValidationError("a settlement must be positive")
with self._store.group_lock(group_id):
with GroupUnitOfWork(self._store, group_id) as uow:
uow.state.group.require_member(payer_id)
uow.state.group.require_member(payee_id)
settlement = Settlement(self._ids.next_id(), group_id, payer_id, payee_id, amount, self._clock.now())
uow.state.balances.settle(payer_id, payee_id, amount)
uow.state.settlements.append(settlement)
uow.commit()
self._publish(Activity(ActivityKind.SETTLED, group_id, payer_id, settlement.id, f"{payer_id} paid {payee_id} {amount}", settlement.at))
return settlement
# -- queries ---------------------------------------------------------------------
def balances(self, group_id: str) -> dict[str, Money]:
state = self._store.snapshot(group_id)
return state.balances.nets(state.group.member_ids)
def balance_between(self, group_id: str, user_id: str, other_id: str) -> Money:
return self._store.snapshot(group_id).balances.between(user_id, other_id)
def global_balance(self, user_id: str, currency: str = "USD") -> Money:
"""Net across every group the user belongs to -- the number on the home screen."""
total = Money(0, currency)
for group_id in self._store.group_ids():
state = self._store.snapshot(group_id)
if user_id in state.group.member_ids:
total = total + state.balances.net(user_id)
return total
def simplify(self, group_id: str) -> list[Transfer]:
state = self._store.snapshot(group_id)
nets = {u: n for u, n in state.balances.nets(state.group.member_ids).items() if not n.is_zero()}
return self._simplifier.simplify(nets)
def expense(self, group_id: str, expense_id: str) -> Expense:
try:
return self._store.snapshot(group_id).expenses[expense_id]
except KeyError:
raise UnknownEntityError(f"unknown expense {expense_id}") from None
def ledger(self, group_id: str) -> list[LedgerEntry]:
return self._store.snapshot(group_id).ledger
# -- internals -------------------------------------------------------------------
def _build(
self,
state: GroupState,
group_id: str,
description: str,
paid_by: Mapping[str, Money],
participant_ids: Sequence[str],
split_type: SplitType | str,
weights: Sequence[int] | None,
actor_id: str | None,
replaces_id: str | None = None,
) -> Expense:
if not description.strip():
raise ValidationError("an expense needs a description")
if not paid_by:
raise ValidationError("an expense needs at least one payer")
for user_id in (*paid_by, *participant_ids):
state.group.require_member(user_id)
currency = state.group.currency
total = Money(0, currency)
for amount in paid_by.values():
total = total + amount
if total.cents <= 0:
raise ValidationError("an expense must be positive")
strategy = SplitStrategyFactory.create(split_type)
owed = strategy.split(total, list(participant_ids), weights)
expense = Expense(
id=self._ids.next_id(),
group_id=group_id,
description=description.strip(),
amount=total,
paid_by=splits_from(paid_by),
owed_by=tuple(owed),
split_type=SplitType(split_type),
created_by=actor_id or next(iter(sorted(paid_by))),
created_at=self._clock.now(),
replaces_id=replaces_id,
)
expense.validate()
return expense
@staticmethod
def _active(state: GroupState, expense_id: str) -> Expense:
expense = state.expenses.get(expense_id)
if expense is None:
raise UnknownEntityError(f"unknown expense {expense_id}")
if expense.status is not ExpenseStatus.ACTIVE:
raise ExpenseStateError(f"expense {expense_id} is {expense.status}")
return expense
def _apply(self, state: GroupState, expense: Expense, sign: int) -> None:
"""Post (sign=+1) or reverse (sign=-1) an expense against the balance sheet.
The transfers are re-derived from the expense, and ``DebtSimplifier`` is
deterministic, so a reversal cancels the original exactly -- no need to
look the old ledger rows up.
"""
at = self._clock.now()
for transfer in self._simplifier.simplify(expense.net_by_user()):
debtor, creditor = (transfer.debtor_id, transfer.creditor_id) if sign > 0 else (transfer.creditor_id, transfer.debtor_id)
state.balances.record(debtor, creditor, transfer.amount)
state.ledger.append(
LedgerEntry(self._ids.next_id(), expense.group_id, expense.id, debtor, creditor, transfer.amount, at)
)
def _notify(self, kind: ActivityKind, expense: Expense, summary: str) -> None:
self._publish(Activity(kind, expense.group_id, expense.created_by, expense.id, summary, expense.created_at))
def _publish(self, activity: Activity) -> None:
# Outside every lock: a slow listener must not stall the group.
for listener in self._listeners:
listener.on_activity(activity)
The feed is a plain observer, and the two undoable operations are commands over the service — nothing in the service knows they exist:
class ActivityListener(Protocol):
"""Observer interface: anything that wants to hear about group activity."""
def on_activity(self, activity: Activity) -> None: ...
class ActivityFeed:
"""Append-only feed, newest last. Thread-safe because several groups write to it."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._events: list[Activity] = []
def on_activity(self, activity: Activity) -> None:
with self._lock:
self._events.append(activity)
def for_group(self, group_id: str) -> list[Activity]:
with self._lock:
return [event for event in self._events if event.group_id == group_id]
def render(self, group_id: str, limit: int = 5) -> str:
return "\n".join(event.summary for event in self.for_group(group_id)[-limit:])
class ExpenseCommand(Protocol):
"""A request as an object: it can run, and it knows how to take itself back."""
def execute(self) -> Expense: ...
def undo(self) -> Expense: ...
class AddExpenseCommand:
"""Adds an expense on ``execute``; deletes exactly that expense on ``undo``."""
def __init__(
self,
service: ExpenseService,
group_id: str,
description: str,
paid_by: Mapping[str, Money],
participant_ids: Sequence[str],
split_type: SplitType | str = SplitType.EQUAL,
weights: Sequence[int] | None = None,
actor_id: str | None = None,
) -> None:
self._service = service
self._group_id = group_id
self._args = (description, dict(paid_by), list(participant_ids), split_type, weights, actor_id)
self._actor_id = actor_id or "system"
self._expense_id: str | None = None
def execute(self) -> Expense:
description, paid_by, participants, split_type, weights, actor_id = self._args
expense = self._service.add_expense(
self._group_id, description, paid_by, participants, split_type, weights, actor_id
)
self._expense_id = expense.id
return expense
def undo(self) -> Expense:
if self._expense_id is None:
raise InvalidStateError("cannot undo a command that never ran")
return self._service.delete_expense(self._group_id, self._expense_id, self._actor_id)
class DeleteExpenseCommand:
"""Deletes an expense on ``execute``; restores the same version on ``undo``."""
def __init__(self, service: ExpenseService, group_id: str, expense_id: str, actor_id: str) -> None:
self._service = service
self._group_id = group_id
self._expense_id = expense_id
self._actor_id = actor_id
def execute(self) -> Expense:
return self._service.delete_expense(self._group_id, self._expense_id, self._actor_id)
def undo(self) -> Expense:
return self._service.restore_expense(self._group_id, self._expense_id, self._actor_id)
class CommandHistory:
"""A per-user undo stack. The lock keeps two devices from popping the same command."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._done: list[ExpenseCommand] = []
def run(self, command: ExpenseCommand) -> Expense:
result = command.execute()
with self._lock:
self._done.append(command)
return result
def undo_last(self) -> Expense:
with self._lock:
if not self._done:
raise InvalidStateError("nothing to undo")
command = self._done.pop()
return command.undo()
def depth(self) -> int:
with self._lock:
return len(self._done)
The demo walks a trip: three expenses using two split types, an edit, a simplification and one settlement.
"""A trip to Goa: four members, three split types, an edit, a simplification, a settlement."""
from common import FakeClock, Money, SequentialIdGenerator
from lld.splitwise.commands import AddExpenseCommand, CommandHistory
from lld.splitwise.models import Group, SplitType, User
from lld.splitwise.services import ActivityFeed, ExpenseService, SplitwiseStore
MEMBERS = ("alice", "bob", "carol", "dave")
def build(clock: FakeClock) -> tuple[ExpenseService, ActivityFeed]:
store = SplitwiseStore()
for name in MEMBERS:
store.add_user(User(name, name.title(), f"{name}@example.com"))
store.create_group(Group("goa", "Goa Trip", set(MEMBERS)))
feed = ActivityFeed()
service = ExpenseService(store, clock=clock, ids=SequentialIdGenerator("X"), listeners=[feed])
return service, feed
def main() -> None:
clock = FakeClock(start=1_700_000_000)
service, feed = build(clock)
history = CommandHistory()
hotel = history.run(
AddExpenseCommand(service, "goa", "Hotel", {"alice": Money.of("300.00")}, MEMBERS, actor_id="alice")
)
print(f"{hotel.id} Hotel {hotel.amount} paid by alice, equal: " + ", ".join(f"{s.user_id} {s.amount}" for s in hotel.owed_by))
dinner = service.add_expense("goa", "Dinner", {"bob": Money.of("100.01")}, ("alice", "bob", "carol"), actor_id="bob")
print(f"{dinner.id} Dinner {dinner.amount} paid by bob, equal over 3: " + ", ".join(f"{s.user_id} {s.amount}" for s in dinner.owed_by))
cab = service.add_expense(
"goa", "Cab", {"carol": Money.of("120.00")}, ("alice", "carol", "dave"),
split_type=SplitType.PERCENT, weights=(5000, 2500, 2500), actor_id="carol",
)
print(f"{cab.id} Cab {cab.amount} paid by carol, percent 50/25/25: " + ", ".join(f"{s.user_id} {s.amount}" for s in cab.owed_by))
print("balances: " + ", ".join(f"{u} {m}" for u, m in service.balances("goa").items()))
clock.advance(3600)
edited = service.edit_expense("goa", hotel.id, "Hotel", {"alice": Money.of("280.00")}, MEMBERS, actor_id="alice")
print(f"edited {hotel.id} -> {edited.id}: hotel is now {edited.amount}")
print("balances: " + ", ".join(f"{u} {m}" for u, m in service.balances("goa").items()))
plan = service.simplify("goa")
print(f"simplify: {len(plan)} transfers for 4 members")
for transfer in plan:
print(f" {transfer.debtor_id} pays {transfer.creditor_id} {transfer.amount}")
first = plan[0]
service.settle_up("goa", first.debtor_id, first.creditor_id, first.amount)
print(f"after {first.debtor_id} settles: " + ", ".join(f"{u} {m}" for u, m in service.balances("goa").items()))
print(f"alice across every group: {service.global_balance('alice')}")
print("--- activity feed ---")
print(feed.render("goa", limit=4))
if __name__ == "__main__":
main()
Running python -m lld.splitwise.demo prints:
X-1 Hotel 300.00 USD paid by alice, equal: alice 75.00 USD, bob 75.00 USD, carol 75.00 USD, dave 75.00 USD
X-5 Dinner 100.01 USD paid by bob, equal over 3: alice 33.34 USD, bob 33.34 USD, carol 33.33 USD
X-8 Cab 120.00 USD paid by carol, percent 50/25/25: alice 60.00 USD, carol 30.00 USD, dave 30.00 USD
balances: alice 131.66 USD, bob -8.33 USD, carol -18.33 USD, dave -105.00 USD
edited X-1 -> X-14: hotel is now 280.00 USD
balances: alice 116.66 USD, bob -3.33 USD, carol -13.33 USD, dave -100.00 USD
simplify: 3 transfers for 4 members
dave pays alice 100.00 USD
carol pays alice 13.33 USD
bob pays alice 3.33 USD
after dave settles: alice 16.66 USD, bob -3.33 USD, carol -13.33 USD, dave 0.00 USD
alice across every group: 16.66 USD
--- activity feed ---
bob added Dinner for 100.01 USD
carol added Cab for 120.00 USD
alice edited Hotel to 280.00 USD
dave paid alice 100.00 USD
The 100.01 dinner is the line to point at: 33.34 + 33.34 + 33.33 is exactly 100.01. Expense ids jump because expenses and ledger rows draw from the same injected generator.
Concurrency and edge cases¶
Which lock protects what. There are two, and the granularity is the answer being graded:
SplitwiseStore.group_lock(group_id)is taken byExpenseServicefor the entire read-modify-write. It is the group that has an invariant — balances must reflect exactly the set of active expenses — so the group is the unit of locking. Two members adding to the same trip serialise; two different trips never contend.SplitwiseStore._registry_lockguards only the user, group and lock registries, and is held for a dict lookup. An uncontended mutex costs about 17 ns (see the latency cheatsheet), so this lock is free next to the snapshot copy it protects.
Why a snapshot copy is affordable. GroupState.copy() copies the balance dict and three lists. A 20-member group holds at most 20 x 19 / 2 = 190 pairs, each two ids and an int — far below the ~1 KB the cheatsheet budgets for a profile row, so tens of kilobytes per transaction. If a group ever grew past that, the SQL implementation of the same UnitOfWork interface takes over and the copy disappears.
Rollback is the interesting case. add_expense builds the expense inside the with block, so a bad percentage or a non-member participant raises after the working copy exists but before commit. __exit__ discards the copy; the store never saw it. The test asserts exactly that: after a rejected expense, balances are all zero and the ledger is empty.
Cents. Every split is Money.allocate, which gives the remainder to the first shares deterministically. Never divide with /, never use round(), never store a float. A three-way split of 100.01 is 33.34, 33.34, 33.33 — and the same expense recorded twice produces the same three numbers, which is what makes the reversal on edit exact.
Edit and delete recalculation. Because DebtSimplifier.simplify is a pure function of the expense's own nets, _apply(expense, sign=-1) regenerates the same transfers and records them backwards. There is no need to look up the old ledger rows, and no drift if the balance sheet was touched in between by other expenses.
Greedy simplification. Two heaps, O(n log n), at most n-1 transfers. It is not the minimum: the minimum-transaction problem is NP-hard because you would have to find subsets that already sum to zero. Say that sentence and offer the cheap improvement — first cancel any exact pairwise offsets, then run greedy.
Common mistake
Computing splits with floats and rounding at the end. 300 / 4 looks fine until the total is 100.01, and then the balances drift by a cent per expense until someone notices they owe -0.03. Say "integer cents, and the remainder goes to the first shares deterministically" in the first two minutes; it is the single strongest signal on this problem.
Other edge cases handled: an expense with several payers; a participant who is also a payer (their net is paid - owed, and a zero net is dropped); percentages that do not total 100%; exact amounts that do not total the expense; a non-member payer or participant; deleting an already-deleted expense; editing a superseded version; a settlement of zero or a negative amount; a member who owes themselves.
Extensibility and follow-ups¶
- A new split rule (split by adjustment, "Bob pays 5.00 extra and the rest is equal"): one class implementing
SplitStrategy, one line in the factory registry. Nothing inExpenseServicechanges. That is the seam to name when asked. - Multiple currencies: put the FX rate on the expense at creation time and keep the group's balance sheet in the group currency. Balances must never be re-converted, or yesterday's dinner changes price overnight.
- Recurring expenses: a
RecurringExpensetemplate plus a scheduler that callsadd_expense; the service is already idempotent per call, so the scheduler only needs a "last generated" marker. - Real payments:
settle_upcurrently records that cash moved. Point it at a wallet transfer and the settlement becomes the local half of a distributed transaction — that is the payment gateway and wallet problem, and the honest answer is an outbox row plus a webhook, not a two-phase commit. - Persistence: implement
UnitOfWorkover a SQL transaction.snapshot/publishbecomeSELECT ... FOR UPDATEand anUPDATE, and the per-group lock becomes row-level locking on the group. - Scale: at millions of groups you shard by group id, which the design already anticipates — nothing in a transaction crosses groups. Global balances become a fan-out read or a per-user rollup maintained by an event consumer.
Interview tip
When you draw the balance sheet, say why the key is an ordered pair rather than a directed row. It shows you have thought about the data structure, not just the classes, and it pre-empts the "what if A owes B and B owes A" question before it is asked.
Tests¶
tests/test_splitwise.py has 12 cases. The two worth walking through are the concurrency test (40 expenses, eight threads, one group — everyone ends square) and the undo test, which proves that delete-then-restore returns the balance sheet to exactly where it was:
def test_concurrent_expenses_keep_the_group_balanced(clock: FakeClock) -> None:
service = make_service(clock)
def add(i: int) -> None:
payer = MEMBERS[i % len(MEMBERS)]
service.add_expense("g1", f"Round {i}", {payer: Money.of("10.00")}, MEMBERS, actor_id=payer)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(add, range(40)))
balances = service.balances("g1")
# 40 expenses of 10.00, each split four ways, ten paid by each member: everyone is square.
assert sum(m.cents for m in balances.values()) == 0
assert balances == {name: Money(0) for name in MEMBERS}
assert len(service.ledger("g1")) == 40 * 3 # three transfers per single-payer expense
def test_delete_reverses_the_balances_and_undo_puts_them_back(clock: FakeClock) -> None:
service = make_service(clock, ("alice", "bob"))
history = CommandHistory()
history.run(AddExpenseCommand(service, "g1", "Taxi", {"alice": Money.of("40.00")}, ("alice", "bob")))
expense_id = service.ledger("g1")[0].expense_id
assert service.balance_between("g1", "bob", "alice") == Money.of("20.00")
history.run(DeleteExpenseCommand(service, "g1", expense_id, "bob"))
assert service.balance_between("g1", "bob", "alice") == Money(0)
assert service.expense("g1", expense_id).status is ExpenseStatus.DELETED
history.undo_last() # restore the delete
assert service.balance_between("g1", "bob", "alice") == Money.of("20.00")
history.undo_last() # undo the original add
assert service.balance_between("g1", "bob", "alice") == Money(0)
The rest cover: the 100.01 three-way split keeping every cent; all four split types via parametrize, each asserted to total 10 000 cents; exact shares that do not add up and a non-member participant, both leaving the store untouched; editing recalculating balances and marking the old version SUPERSEDED; a simplify plan never exceeding n-1 transfers and zeroing every balance once executed; the simplifier being deterministic and rejecting a ledger whose nets do not sum to zero; and global balance summing two groups. Run them with uv run pytest code/lld/splitwise -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0-5 | Clarify | Several payers? Which split types? Percent representation? Is simplify required to be minimal? Out of scope: multi-currency, real payments. |
| 5-10 | Entities | Nouns: User, Group, Expense, Split, BalanceSheet, LedgerEntry, Settlement. Say "money is integer cents" now, not later. |
| 10-18 | Class diagram | Domain first, then hang SplitStrategy off ExpenseService and mark the group lock and the transaction boundary. |
| 18-33 | Code | Money.allocate splits, then Expense.validate, then BalanceSheet.record, then add_expense inside the Unit of Work. Narrate "compute, validate, post, commit, notify". |
| 33-39 | Concurrency and correctness | The per-group lock, the rollback path, the deterministic reversal on edit. |
| 39-45 | Simplify and extensions | Two heaps, n-1, NP-hard caveat; then currencies, recurring expenses, and sharding by group id. |
Related¶
- Strategy — the four split rules behind one interface
- Unit of Work — the transaction boundary this page reuses
- Command — undo for add and delete
- Design a payment gateway and digital wallet — what
settle_upbecomes when real money moves - Design a payment system and digital wallet — the distributed version of the same ledger
- Concurrency for LLD in Python — lock granularity and why the group is the unit