Design an Expense Sharing App (Splitwise)

Friends on a trip, or flatmates sharing a house, keep paying for things on behalf of each other. Design the core of an app where they create a group, log who paid for what, and say how each bill is shared: equally, by exact amounts, or by percentages. Not everyone is part of every expense.

At any moment the app shows how much each person owes or is owed. When the group wants to settle, it should suggest a short list of payments that clears everything, rather than making everyone repay every single bill.

In 45 minutes, agree on the rules, design the classes for expenses, split types and balances, and write code that adds a few expenses and prints a settle-up plan. Expect questions about rounding and how a new split type would be added.

Use the LLD board for your class diagram and the LLD code tab to write and run your classes.

Design Path · Low-Level Design · problem 69 of 70

Ask before you design. Try answering each yourself, then compare.

  1. Which split types do we need?
    Typical answer

    Equal, exact amounts and percentages. Shares-by-weight would be a later addition.

  2. Does every expense involve the whole group?
    Typical answer

    No. Each expense names its participants; the payer may or may not be one of them.

  3. How do we handle cents that do not divide evenly?
    Typical answer

    Store integer cents and hand leftover cents to participants deterministically so shares always sum to the total.

  4. One currency or many?
    Typical answer

    One currency per group for now.

  5. Must settle-up be the mathematically minimal number of payments?
    Typical answer

    No. A short plan with at most n - 1 payments is fine.

  6. Can expenses be edited or deleted?
    Typical answer

    Yes eventually; for now design so that it is possible (keep expenses, not only balances).

Each split type needs different input but produces the same output. What shape is that output?
Floats and money do not mix. What unit should every amount be in?
For settle-up, does it matter who paid whom originally, or only where each person ends up?
With every net balance known, try pairing the person owed the most with the person who owes the most.

Requirements

Functional

  • Create a group of users.
  • Add an expense with a payer, total, participants and a split type (equal, exact, percent).
  • Validate splits: exact amounts must sum to the total; percentages must sum to 100.
  • Show each member's net balance.
  • Suggest a simplified set of transfers to settle the group, and record payments.

Non-functional

  • Balances are exact to the cent; shares always sum to the expense total.
  • Adding an expense and reading balances take under 50 ms for groups of up to 100 people.
  • New split types plug in without changing Group or Expense.

Out of scope

  • Multi-currency and exchange rates.
  • Real money movement and payment providers.
  • Notifications, receipts and image uploads.

Class design

Core objects. A Group holds its members (User), the list of Expenses and payments, a BalanceSheet and a DebtSimplifier. An expense has a payer, a total in integer cents and a map of shares per participant.

Splits. Each SplitStrategy (EqualSplit, ExactSplit, PercentSplit) takes the total and the participants and returns cents per user. Validation and rounding live inside each strategy, and the shares are computed once when the expense is created, so later rule changes do not rewrite history.

Balances. Adding an expense credits the payer with the total and debits each participant by their share; a recorded payment does the reverse. Net balances always sum to zero, which is a handy invariant to assert.

Settle-up. Only net balances matter. The simplifier repeatedly matches the largest creditor with the largest debtor using two heaps and emits a Transfer for the smaller of the two amounts. Each step zeroes at least one person, so there are at most n - 1 transfers.

Extensibility. A shares-by-weight split is one new strategy. Editing an expense is reversing its old effect and applying the new one, which works because expenses are stored, not just balances.

Open the LLD board and press Reference to see the class diagram.

Patterns used

  • Strategy: equal, exact and percent splits share one interface, so new split types need no changes elsewhere.
  • Single Responsibility: BalanceSheet only keeps net positions; DebtSimplifier only plans transfers.
  • Value object: Transfer and integer-cent amounts keep money handling simple and exact.

Reference implementation

from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass
import heapq
import itertools


class User:
    def __init__(self, user_id: str, name: str):
        self.id, self.name = user_id, name


class SplitStrategy(ABC):
    """Turns a total (in cents) into each participant's share. Shares always sum to the total."""
    @abstractmethod
    def shares(self, total: int, users: list) -> dict:
        ...


class EqualSplit(SplitStrategy):
    def shares(self, total, users):
        base, extra = divmod(total, len(users))
        # The first `extra` people pay one cent more, so nothing is lost to rounding.
        return {u.id: base + (1 if i < extra else 0) for i, u in enumerate(users)}


class ExactSplit(SplitStrategy):
    def __init__(self, amounts: dict):
        self.amounts = amounts  # user id -> cents

    def shares(self, total, users):
        if sum(self.amounts.values()) != total or set(self.amounts) != {u.id for u in users}:
            raise ValueError("exact amounts must cover every participant and add up to the total")
        return dict(self.amounts)


class PercentSplit(SplitStrategy):
    def __init__(self, percents: dict):
        self.percents = percents  # user id -> percent

    def shares(self, total, users):
        if abs(sum(self.percents.values()) - 100) > 1e-9:
            raise ValueError("percentages must add up to 100")
        out = {u.id: total * self.percents[u.id] // 100 for u in users}
        leftover = total - sum(out.values())
        for u in users[:leftover]:  # hand out remaining cents one by one
            out[u.id] += 1
        return out


class Expense:
    _ids = itertools.count(1)

    def __init__(self, description: str, paid_by: User, total: int, users: list, split: SplitStrategy):
        if total <= 0:
            raise ValueError("amount must be positive")
        self.id, self.description = next(Expense._ids), description
        self.paid_by, self.total = paid_by, total
        self.shares = split.shares(total, users)  # validated once, at creation


@dataclass
class Transfer:
    from_id: str
    to_id: str
    amount: int

    def __str__(self):
        return f"{self.from_id} pays {self.to_id} ${self.amount / 100:.2f}"


class BalanceSheet:
    """Net position per user: positive means the group owes them money."""
    def __init__(self):
        self.net = defaultdict(int)

    def apply_expense(self, e: Expense):
        self.net[e.paid_by.id] += e.total
        for uid, share in e.shares.items():
            self.net[uid] -= share

    def apply_payment(self, t: Transfer):
        self.net[t.from_id] += t.amount
        self.net[t.to_id] -= t.amount


class DebtSimplifier:
    """Greedy: biggest debtor pays biggest creditor. At most n - 1 transfers."""
    def settle(self, net: dict) -> list:
        creditors = [(-amt, uid) for uid, amt in net.items() if amt > 0]
        debtors = [(amt, uid) for uid, amt in net.items() if amt < 0]
        heapq.heapify(creditors)
        heapq.heapify(debtors)
        transfers = []
        while creditors and debtors:
            credit, cid = heapq.heappop(creditors)
            debt, did = heapq.heappop(debtors)
            amount = min(-credit, -debt)
            transfers.append(Transfer(did, cid, amount))
            if -credit > amount:
                heapq.heappush(creditors, (credit + amount, cid))
            if -debt > amount:
                heapq.heappush(debtors, (debt + amount, did))
        return transfers


class Group:
    def __init__(self, name: str, members: list, simplifier: DebtSimplifier = None):
        self.name, self.members = name, {m.id: m for m in members}
        self.expenses, self.payments = [], []
        self.sheet, self.simplifier = BalanceSheet(), simplifier or DebtSimplifier()

    def add_expense(self, description, paid_by_id, total, user_ids=None, split=None) -> Expense:
        users = [self.members[i] for i in (user_ids or self.members)]
        e = Expense(description, self.members[paid_by_id], total, users, split or EqualSplit())
        self.expenses.append(e)
        self.sheet.apply_expense(e)
        return e

    def record_payment(self, from_id: str, to_id: str, amount: int):
        t = Transfer(from_id, to_id, amount)
        self.payments.append(t)
        self.sheet.apply_payment(t)

    def balances(self) -> dict:
        return {uid: self.sheet.net[uid] for uid in self.members}

    def settle_up(self) -> list:
        return self.simplifier.settle(self.balances())


if __name__ == "__main__":
    def show(group):
        for uid, cents in group.balances().items():
            state = "is owed" if cents > 0 else "owes" if cents < 0 else "is settled up"
            print(f"   {uid:6} {state}" + (f" ${abs(cents) / 100:.2f}" if cents else ""))

    people = [User("ana", "Ana"), User("bo", "Bo"), User("cy", "Cy"), User("dev", "Dev")]
    trip = Group("Lisbon trip", people)

    trip.add_expense("Apartment", "ana", 40000)                     # $400 split 4 ways
    trip.add_expense("Dinner", "bo", 10000, split=EqualSplit(), user_ids=["ana", "bo", "cy"])
    trip.add_expense("Tram passes", "cy", 3600,
                     split=ExactSplit({"ana": 900, "bo": 900, "cy": 900, "dev": 900}))
    trip.add_expense("Surf lesson", "dev", 15000, user_ids=["bo", "dev"],
                     split=PercentSplit({"bo": 60, "dev": 40}))
    trip.add_expense("Car rental", "cy", 24000)
    for e in trip.expenses:
        parts = ", ".join(f"{k} {v / 100:.2f}" for k, v in e.shares.items())
        print(f"#{e.id} {e.description:12} paid by {e.paid_by.name:4} ${e.total / 100:7.2f} -> {parts}")
    print("Balances:")
    show(trip)

    plan = trip.settle_up()
    direct = {(uid, e.paid_by.id) for e in trip.expenses for uid in e.shares if uid != e.paid_by.id}
    print(f"Simplified settle-up: {len(plan)} transfers (repaying each payer directly needs {len(direct)}):")
    for t in plan:
        print("  ", t)
    for t in plan:
        trip.record_payment(t.from_id, t.to_id, t.amount)
    print("After everyone pays:")
    show(trip)
    try:
        trip.add_expense("Bad split", "ana", 1000, split=PercentSplit({"ana": 50, "bo": 30, "cy": 10, "dev": 5}))
    except ValueError as err:
        print("Rejected:", err)

Trade-offs

  • Caching net balances makes reads instant but must be kept in sync on edits; recomputing from expenses is always correct but slower for long histories.
  • Greedy settle-up is fast and gives at most n - 1 payments, but it is not always the smallest possible number.
  • Simplifying debts across the group minimises payments but can make someone pay a person they never shared a bill with.
  • Computing shares at creation freezes them for history, while recomputing them would follow rule changes but rewrite the past.

What a strong answer shows

  • Models split types polymorphically with a common output (cents per user).
  • Uses integer cents and distributes remainders so totals always match.
  • Validates exact and percent inputs at creation time.
  • Reduces settle-up to net balances and explains the greedy matching and its n - 1 bound.
  • Keeps expenses as records so edits, deletes and history remain possible.

Mistakes to avoid

  • Using floats, so shares like 33.33 + 33.33 + 33.33 leave a cent unaccounted for.
  • Keeping a pairwise "A owes B" matrix as the source of truth and struggling to simplify it.
  • A switch on split type inside Expense instead of separate strategies.
  • Claiming the greedy settle-up is always minimal; finding the true minimum is a hard combinatorial problem.
  • Storing only balances, which makes editing or deleting an expense impossible.

Follow-ups to expect

  • How would you support editing an expense? (Reverse its effect on the sheet, then apply the new version; keep an audit log.)
  • How would you add multiple currencies? (Store each expense in its currency; convert at a recorded rate when computing balances.)
  • Can settle-up be made truly minimal? (Look for subsets whose balances sum to zero; exponential, fine for small groups.)
  • Two people add expenses at the same time on different phones. What breaks? (Nothing if balances are derived from an append-only expense log.)

Practice next

Next Post Previous Post
No Comment
Add Comment
comment url