Design Splitwise: Expense Splitting and Debt Simplification

Splitwise sounds like a bookkeeping problem at first. Track who paid for what, figure out who owes whom, and keep the numbers straight. But when you think through the mechanics, there are at least three genuinely interesting design problems hiding inside it.

The first is split calculation. How do you split a $90 dinner equally among three people when $90 does not divide evenly? The answer seems obvious until you ask what happens to the rounding error. The second is balance tracking. Naively you store a balance for every ordered pair of users: Alice owes Bob $10, Carol owes Alice $5. But that table grows quadratically with the number of users in a group, and it is full of redundant information. The third is debt simplification. If Alice owes Bob $10 and Bob owes Carol $10, you can settle that with two transactions. But you can also do it with one (Alice pays Carol directly). The question is: when should you simplify, and how far do you take it?

Let me reason through each of these from scratch.

Requirements

Functional:

  • Add users and groups
  • Record an expense paid by one user, split among a subset of the group
  • Support four split types: equal, exact amount, percentage, and shares
  • Show what each user owes or is owed in total
  • Simplify debts within a group to minimize the number of transactions needed

Non-functional:

  • Balance totals must always be consistent (if Alice owes $10 net, someone else is owed $10 net)
  • Adding a new split type should not require changing expense recording or balance logic
  • The debt simplification step is optional and should not affect the underlying balances

Core Entities

User is a simple value object: an ID, a name, and an email. It does not own expenses or balances. Those belong elsewhere.

Group holds a set of users. It does not calculate anything. It is a membership container.

Expense records what happened: who paid, the total amount, and a list of Split objects that describe how the amount breaks down per participant. The expense does not know how to calculate splits. That logic lives in the split types.

Split is an abstract class. Concrete types are EqualSplit, ExactSplit, PercentageSplit, and SharesSplit. Each one knows how to compute the amount owed by its user given the total expense amount. This is the Strategy pattern applied at the split level.

ExpenseManager handles the write path: validate an expense, ask each split to compute its amount, and push the resulting deltas into the balance sheet. It owns no state of its own beyond a reference to the balance sheet.

BalanceSheet is a simple mapping from user ID to net balance. A positive balance means you are owed money. A negative balance means you owe money. This is the collapsed form: you do not track who owes whom, just the per-user net.

DebtSimplifier takes a BalanceSheet and returns the minimum set of transactions that settles all debts. It does not modify the balance sheet. It produces a read-only result.

The reason for separating these three responsibilities is that they have different reasons to change. ExpenseManager changes when you add a new expense type (recurring, split by category). BalanceSheet changes when you reconsider how to store balances (maybe you need per-group balances vs. global). DebtSimplifier changes when you want a smarter simplification algorithm. With SRP, each of those changes is isolated to one class.

ASCII Class Diagram

+-----------------+         +------------------+
|   User          |         |   Group          |
|-----------------|         |------------------|
| - user_id: str  |<--------| - group_id: str  |
| - name: str     |  member | - members: set   |
| - email: str    |         +------------------+
+-----------------+

+-----------------+         +------------------+
|   Expense       |-------->|   Split          |  (abstract)
|-----------------|  has    |------------------|
| - expense_id    |  many   | - user: User     |
| - paid_by: User |         | + amount(total)  |
| - total: float  |         |   -> float       |
| - splits: list  |   +-----+------------------+
+-----------------+   |
                       |
          +------------+------------+
          |            |            |
    EqualSplit  ExactSplit  PercentageSplit  SharesSplit

+---------------------+     +-------------------+
|  ExpenseManager     |     |  BalanceSheet      |
|---------------------|     |-------------------|
| - balance_sheet     |---->| - balances: dict  |
| + add_expense(...)  |     | + credit(uid, amt)|
+---------------------+     | + debit(uid, amt) |
                             | + net(uid) -> flt |
+---------------------+     | + all() -> dict   |
|  DebtSimplifier     |     +-------------------+
|---------------------|
| + simplify(sheet)   |
|   -> list[Transfer] |
+---------------------+

Key Implementation

from __future__ import annotations

import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import NamedTuple


@dataclass
class User:
    user_id: str
    name: str
    email: str


@dataclass
class Group:
    group_id: str
    name: str
    members: set[str] = field(default_factory=set)  # set of user_ids

    def add_member(self, user: User) -> None:
        self.members.add(user.user_id)


class Split(ABC):
    def __init__(self, user: User) -> None:
        self.user = user

    @abstractmethod
    def amount(self, total: float) -> float:
        """Returns the amount this user owes for the given expense total."""
        pass


class EqualSplit(Split):
    """Divides the total equally; the caller must pass the correct total/n."""

    def __init__(self, user: User, n_participants: int) -> None:
        super().__init__(user)
        self._n = n_participants

    def amount(self, total: float) -> float:
        return round(total / self._n, 2)


class ExactSplit(Split):
    """The exact amount is fixed regardless of total."""

    def __init__(self, user: User, exact_amount: float) -> None:
        super().__init__(user)
        self._exact = exact_amount

    def amount(self, total: float) -> float:
        return self._exact


class PercentageSplit(Split):
    def __init__(self, user: User, percentage: float) -> None:
        super().__init__(user)
        self._pct = percentage

    def amount(self, total: float) -> float:
        return round(total * self._pct / 100, 2)


class SharesSplit(Split):
    """
    Proportional split based on share counts.
    The amount per user = total * (user_shares / total_shares).
    """

    def __init__(self, user: User, shares: int, total_shares: int) -> None:
        super().__init__(user)
        self._shares = shares
        self._total_shares = total_shares

    def amount(self, total: float) -> float:
        return round(total * self._shares / self._total_shares, 2)


@dataclass
class Expense:
    expense_id: str
    description: str
    paid_by: User
    total: float
    splits: list[Split]


class BalanceSheet:
    """
    Tracks net balance per user across all expenses.
    Positive = owed money. Negative = owes money.
    """

    def __init__(self) -> None:
        self._balances: dict[str, float] = {}

    def credit(self, user_id: str, amount: float) -> None:
        self._balances[user_id] = round(
            self._balances.get(user_id, 0) + amount, 2
        )

    def debit(self, user_id: str, amount: float) -> None:
        self._balances[user_id] = round(
            self._balances.get(user_id, 0) - amount, 2
        )

    def net(self, user_id: str) -> float:
        return self._balances.get(user_id, 0)

    def all_balances(self) -> dict[str, float]:
        return dict(self._balances)


class ExpenseManager:
    def __init__(self, balance_sheet: BalanceSheet) -> None:
        self._sheet = balance_sheet

    def add_expense(self, expense: Expense) -> None:
        total_split = sum(s.amount(expense.total) for s in expense.splits)
        if abs(total_split - expense.total) > 0.01:
            raise ValueError(
                f"Splits sum to {total_split}, expected {expense.total}."
            )
        # The payer is credited the full amount they paid.
        self._sheet.credit(expense.paid_by.user_id, expense.total)
        # Each participant is debited their share.
        for split in expense.splits:
            self._sheet.debit(split.user.user_id, split.amount(expense.total))


class Transfer(NamedTuple):
    from_user_id: str
    to_user_id: str
    amount: float


class DebtSimplifier:
    """
    Computes the minimum number of transfers to settle all debts.

    Algorithm: reduce balances to a net-balance vector, then greedily
    match the largest debtor with the largest creditor.

    This is not always the globally optimal solution (that's NP-hard in
    the general case), but it is correct and produces a near-minimal
    result in practice.
    """

    def simplify(self, sheet: BalanceSheet) -> list[Transfer]:
        balances = {
            uid: amt
            for uid, amt in sheet.all_balances().items()
            if abs(amt) > 0.001
        }

        debtors = sorted(
            [(uid, amt) for uid, amt in balances.items() if amt < 0],
            key=lambda x: x[1],
        )
        creditors = sorted(
            [(uid, amt) for uid, amt in balances.items() if amt > 0],
            key=lambda x: -x[1],
        )

        transfers: list[Transfer] = []
        i, j = 0, 0

        while i < len(debtors) and j < len(creditors):
            debtor_id, debt = debtors[i]
            creditor_id, credit = creditors[j]
            settles = min(-debt, credit)

            transfers.append(Transfer(debtor_id, creditor_id, round(settles, 2)))

            debt += settles
            credit -= settles

            debtors[i] = (debtor_id, debt)
            creditors[j] = (creditor_id, credit)

            if abs(debt) < 0.001:
                i += 1
            if abs(credit) < 0.001:
                j += 1

        return transfers

Design Decisions and Trade-offs

Why net balance instead of pairwise balance?

The intuitive model stores a balance for every ordered pair of users: how much does Alice owe Bob, how much does Bob owe Carol, and so on. This looks more precise, but it creates a quadratic number of entries and makes the debt simplification step harder because you have to merge across chains of obligation. The net balance model collapses everything to a single number per user. The invariant is simple: the sum of all net balances is always zero. Simplification is then a straightforward problem of matching debtors against creditors.

The trade-off is that you lose the per-pair history. If Alice wants to know specifically what she owes Bob (not Carol), the net balance model does not store that. For Splitwise’s UI purposes you often need that granularity, so a real implementation would store both: pairwise balances for display and net balances for simplification. For this LLD exercise, keeping the net balance model makes the algorithm clean without loss of conceptual clarity.

The rounding problem in EqualSplit.

Splitting $10 three ways gives $3.33, $3.33, $3.33, which sums to $9.99. Someone is short by a cent. The validation in ExpenseManager.add_expense() allows a 0.01 tolerance for exactly this reason. In a real system you’d assign the rounding remainder to the payer or to a designated “last in the list” participant. The important thing is that the rounding policy lives in one place and is explicit.

When to simplify vs. not.

The DebtSimplifier is deliberately decoupled from ExpenseManager and BalanceSheet. Simplification is not something that happens automatically on every expense. It is a separate read operation you run when a user wants a settlement summary. There are two reasons for this. First, simplification changes who pays whom, not the underlying balances. If Alice agrees to pay Carol instead of Bob because that reduces transactions, the underlying record of who owed what does not change. Second, simplification is lossy in the sense that it replaces a chain of named obligations with a set of direct transfers. If Bob wants to see an itemized history of what he’s owed, you need the original expense records, not the simplified view.

Is the greedy simplification algorithm optimal?

Not always. The exact problem of minimizing cash flow is NP-hard in the general case (it reduces to a problem related to set cover). The greedy approach here, matching the largest debtor with the largest creditor at each step, produces a near-minimal result and is O(n log n) due to sorting. For the group sizes Splitwise actually deals with (tens of people, not thousands), this is entirely sufficient. The alternative of exploring all possible matchings is not worth the complexity.

What I’d Extend Next

A recurring expense type that auto-splits on a schedule, a per-group balance sheet so you can track within a trip vs. the global view, and a settlement event that records when a transfer actually happened and adjusts the balance sheet accordingly. The DebtSimplifier already returns Transfer objects that could feed directly into a payment request flow.


If the debt simplification algorithm clicked for you or you have a cleaner way to handle the rounding edge case, I’d genuinely like to hear it. Reach out on Twitter or LinkedIn.

Tags:

#lld #machine-coding #object-oriented-design #python #fintech

Related Posts

Let's Connect! 💬

Whether you're looking to hire, want to collaborate on a project, or just want to chat about tech—I'd love to hear from you!