Skip to content

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_member is the one place membership is enforced.
  • Expense — frozen: id, group, description, total, paid_by splits, owed_by splits, split type, creator, timestamp, ExpenseStatus and replaces_id. validate() asserts both sides equal the total; net_by_user() reduces it to paid - owed per 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 by SplitStrategyFactory. Every one of them ends in Money.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 Transfer objects 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 GroupState and 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:

code/lld/splitwise/models.py — enums
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"
code/lld/splitwise/models.py — errors
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.

code/lld/splitwise/models.py — entities
@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.

code/lld/splitwise/models.py — balance sheet
@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.

code/lld/splitwise/strategies.py
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.

code/lld/splitwise/store.py — store
@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)
code/lld/splitwise/store.py — unit of work
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.

code/lld/splitwise/services.py — debt simplification
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.

code/lld/splitwise/services.py — expense service
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:

code/lld/splitwise/services.py — activity feed
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:])
code/lld/splitwise/commands.py
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.

code/lld/splitwise/demo.py
"""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:

  1. SplitwiseStore.group_lock(group_id) is taken by ExpenseService for 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.
  2. SplitwiseStore._registry_lock guards 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 in ExpenseService changes. 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 RecurringExpense template plus a scheduler that calls add_expense; the service is already idempotent per call, so the scheduler only needs a "last generated" marker.
  • Real payments: settle_up currently 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 UnitOfWork over a SQL transaction. snapshot/publish become SELECT ... FOR UPDATE and an UPDATE, 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:

code/lld/splitwise/tests/test_splitwise.py — concurrency
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
code/lld/splitwise/tests/test_splitwise.py — undo
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.