All problems (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · Model the Domain

Design an Expense Splitter

IntermediateExpensesBalancesStrategySettle up⏱ 2-3 h

A Splitwise-style app: equal, exact and percentage splits, a balance sheet per group, and settling who owes whom with the fewest payments.

Learn mode: the full solution is shown below.

Problem introduction

DifficultyIntermediate
Time2–3 hours to design and code; about 45 minutes in an interview
PrerequisitesInterfaces, maps, integer arithmetic, a little about locks
ConceptsStrategy for split types, one net balance per person, rounding without losing taka, greedy settle-up
Real worldSplitwise, group trips, flatmates sharing rent and bazaar, office lunch funds

An expense splitter looks like simple maths, which is why it's a good interview question: the design is about where the rules live (each split type), what you store (net balances, not a list of "who owes whom"), and the money edge cases (100 taka between three people).

Problem statement

Four friends, Rahim, Nadia, Tania and Sabbir, go to Cox's Bazar. Different people pay for different things: Rahim books the hotel, Nadia pays for a dinner three of them ate, Tania pays for a boat ride that two of them took, Sabbir buys tea.

At the end of the trip they want two answers: how much does each person owe or get back, and what's the smallest set of payments that settles everything?

What we are building: the core of an expense-sharing app: groups, expenses with different ways of splitting, balances and a settle-up plan.

In one line: record who paid what and how to split it, keep everyone's balance right to the taka, and suggest as few payments as possible to clear it.

Requirements

Functional requirements

  1. A group has members.
  2. An expense has a payer, an amount and a split.
  3. Split types: equal (among chosen members), exact (each share in taka, must add up to the amount), percent (must add up to 100).
  4. Shares must add up exactly to the amount; leftover taka from rounding go to someone, never disappear.
  5. An invalid expense (shares don't add up, non-member) is refused and changes nothing.
  6. Show each member's net balance.
  7. Suggest payments that settle all balances, using few payments.

Non-functional requirements

RequirementWhat it means here
CorrectnessBalances always add up to zero; no taka created or lost
ExtensibilityNew split types (by shares, "I pay for my kids") without editing the group
Correctness under concurrencyTwo phones adding expenses at once mustn't lose one
TestabilityEvery split and the settle-up testable with plain values

Assumptions and out of scope

  • One currency (taka), whole taka. No real payments, no notifications, no multiple groups per expense.

What to hand in

  • Entities and where each rule lives.
  • Code for adding expenses (three split types), balances and settle-up.
  • Tests for rounding, invalid expenses, zero-sum balances and settle-up.
Hint 1: what to store?

Storing every "A owes B" pair grows fast and is hard to settle. Try one number per person: how much they're up or down overall.

Hint 2: split types

Each split type turns (amount, people) into shares and has its own validation. That's one interface with three implementations.

Hint 3: 100 taka between three

Give everyone 33, then hand out the leftover 1 taka in a fixed order. Shares must add up to exactly 100.

Hint 4: settling up

Repeatedly let the person who owes the most pay the person who's owed the most, as much as possible.

Core entities

EntityJob in one sentenceHoldsDoes
GroupOwns members and their net balancesmembers (in order), balance per member, the lockAddExpense, Balances, SettleUp
Split (interface)Turns an amount into sharesdepends on the typeShares(amount, people), Describe()
Equal, Exact, PercentThe three split rulesexact shares or percentagesvalidate and compute shares
PaymentOne suggested transferfrom, to, amountnothing: a value

Why there's no Expense class here: the app only needs the effect of each expense on balances. A real app would also store expenses (for history and edits), but balances are derived from them; the interview core is the split and the balances.

Relationships: Group uses a Split for each expense (Strategy). It owns the balances. SettleUp reads a copy of the balances and returns Payment values without changing anything.

Diagrams

Class diagram

Group- members: []string- balance: map[string]int- mu: sync.Mutex+ AddExpense(payer, amount, people, s) error+ Balances() []Payment+ SettleUp() []PaymentPayment+ From, To: string+ Amount: int«interface»Split+ Shares(amount, people) (map, error)+ Describe() stringEqual+ Shares(...)+ Describe()Exactmap[string]int+ Shares(...)+ Describe()Percent+ People: []string+ Pct: []int+ Shares(...)+ Describe()usesSettleUp returnsthree split rules behind one interface (Strategy)AddExpense asks the Split for shares and never checks which one it gotGroup keeps one net balance per person, under one lockSettleUp turns the balances into Payments
Press play. First the three split rules light up with the interface they implement, then Group's link to it, then Group itself, then the Payments it returns.

How to read it: Group depends on the Split interface, never on Equal, Exact or Percent directly; that dashed arrow is the whole Strategy pattern. A new split type is one more box at the bottom with its own dashed triangle, and nothing above it changes. Payment is a plain value that SettleUp returns.

Notation: + is public, - is private. A filled diamond ◆ means owns (the part lives and dies with its owner), a plain arrow means holds a reference to, a dashed arrow means uses, and a dashed arrow with a hollow triangle means implements this interface.

Sequence: one good expense, one refused

AppGroupExactAddExpense(Nadia, 1200, Exact)lock, check membersShares(1200, people)300+400+500 = 1200Rahim 300, Nadia 400, Tania 500Nadia +1200, each -sharenilAddExpense(taxi 1200, Exact)Shares(1200, people)error: 600+500 = 1100error, balances unchanged
Press play. The dinner's exact shares add up to 1200, so balances change. The taxi's shares add up to 1100, so Exact returns an error and Group changes nothing.

How to read it: Each vertical dashed line is one object, and time runs downwards. A solid arrow is a call, a dashed arrow is the answer coming back, and the thin bars show when an object is busy. Group asks for the shares before touching any balance. In the second call the error comes back from Exact, and Group passes it on without having written anything: that ordering is what makes an expense all-or-nothing.

Strategy: three ways to split

AddExpensepayer, amountSplitinterfaceEqual4000 / 4 = 1000 eachExact300 + 400 + 500Percent40% 400, 60% 600hotel 4000dinner 1200boat 1000shares always add up to the amount
Press play. The same AddExpense call hands each amount to whichever Split it was given: the hotel to Equal, the dinner to Exact, the boat to Percent. Each one returns shares that add up exactly to the amount.

How to read it: AddExpense never checks which split it has. It calls Shares on the interface, and each type applies its own rule and validation. A new split type is a new box on the right, with no change to the left.

Settle up

Rahim+2266 → 0Nadia-200Tania-533Sabbir-15331533533200biggest debtor pays biggest creditor3 payments, every balance is 0
Press play. Sabbir owes the most, so he pays Rahim 1533; then Tania pays 533; then Nadia pays 200. Three payments and every balance is zero.

How to read it: after all expenses, Rahim is +2266 and the others are negative. Each step takes the biggest debtor and the biggest creditor and moves as much as possible between them. With four people, it never needs more than three payments.

Design principles

  • Store net balances, not pairs. One number per member is easy to keep right (it must add up to zero) and easy to settle.
  • All or nothing. AddExpense computes and validates the shares first, and only then changes balances. A refused expense leaves no trace.
  • Open/closed. New split types implement Split; Group doesn't change.
  • Integers for money. Shares are whole taka; rounding is explicit and deterministic.

Design patterns

Strategy for split types

  1. Why: equal, exact and percent each have their own maths and their own validation.
  2. What it solves: keeps AddExpense free of if splitType == ….
  3. Without it: every new type (by shares, by nights stayed) edits AddExpense.
  4. Trade-off: one interface and three small types; worth it as soon as there are two variants.
  5. Where: split.go; Group.AddExpense calls s.Shares(amount, people).

Greedy settle-up

Not a GoF pattern, but a classic algorithm: match the largest debtor with the largest creditor. It always clears everyone in at most n − 1 payments. Finding the true minimum is a much harder problem (it's NP-hard in general), and greedy is what real apps use.

Complete solution in Go

Project structure

splitter/
├── go.mod
├── split.go         the Split strategy: Equal, Exact, Percent, and rounding
├── group.go         Group: balances, AddExpense, SettleUp, the lock
├── group_test.go    tests, including a concurrency test
└── cmd/demo/main.go the Cox's Bazar trip

Step 1: the split strategies and rounding

spread gives everyone their rounded-down share and hands out the leftover taka one by one, in list order, so shares always add up to the amount.

// split.go
package splitter

import (
	"errors"
	"fmt"
)

// Split is the Strategy: each way of dividing an expense turns (amount, people) into shares.
type Split interface {
	Shares(amount int, people []string) (map[string]int, error)
	Describe() string
}

// spread divides amount by weights, rounding down, and gives the leftover taka one by one
// to the first people in the list, so shares always add up exactly to the amount.
func spread(amount int, people []string, weights []int) map[string]int {
	total := 0
	for _, w := range weights {
		total += w
	}
	shares, given := map[string]int{}, 0
	for i, p := range people {
		shares[p] = amount * weights[i] / total
		given += shares[p]
	}
	for i := 0; given < amount; i++ {
		shares[people[i%len(people)]]++
		given++
	}
	return shares
}

type Equal struct{}

func (Equal) Describe() string { return "split equally" }
func (Equal) Shares(amount int, people []string) (map[string]int, error) {
	if len(people) == 0 {
		return nil, errors.New("no one to split with")
	}
	w := make([]int, len(people))
	for i := range w {
		w[i] = 1
	}
	return spread(amount, people, w), nil
}

// Exact lists each person's share in taka; they must add up to the amount.
type Exact map[string]int

func (Exact) Describe() string { return "exact split" }
func (e Exact) Shares(amount int, _ []string) (map[string]int, error) {
	sum := 0
	for _, v := range e {
		sum += v
	}
	if sum != amount {
		return nil, fmt.Errorf("exact shares add up to %d, not %d", sum, amount)
	}
	return map[string]int(e), nil
}

// Percent lists each person's percentage; they must add up to 100.
type Percent struct {
	People []string
	Pct    []int
}

func (p Percent) Describe() string { return "percent split" }
func (p Percent) Shares(amount int, _ []string) (map[string]int, error) {
	sum := 0
	for _, v := range p.Pct {
		sum += v
	}
	if sum != 100 {
		return nil, fmt.Errorf("percentages add up to %d, not 100", sum)
	}
	return spread(amount, p.People, p.Pct), nil
}

Step 2: the group

// group.go
package splitter

import (
	"errors"
	"fmt"
	"sync"
)

type Payment struct {
	From, To string
	Amount   int
}

// Group owns the members and one net balance per member:
// positive = others owe them, negative = they owe others. The balances always add up to 0.
type Group struct {
	mu      sync.Mutex
	members []string // order is kept for stable output
	balance map[string]int
}

func NewGroup(members ...string) *Group {
	g := &Group{members: members, balance: map[string]int{}}
	for _, m := range members {
		g.balance[m] = 0
	}
	return g
}

func (g *Group) isMember(p string) bool { _, ok := g.balance[p]; return ok }

// AddExpense records that payer paid amount, divided by the split. It changes balances
// only if the whole expense is valid.
func (g *Group) AddExpense(payer string, amount int, people []string, s Split) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if amount <= 0 {
		return errors.New("amount must be positive")
	}
	if !g.isMember(payer) {
		return fmt.Errorf("%s is not in the group", payer)
	}
	shares, err := s.Shares(amount, people)
	if err != nil {
		return err
	}
	for p := range shares {
		if !g.isMember(p) {
			return fmt.Errorf("%s is not in the group", p)
		}
	}
	g.balance[payer] += amount
	for p, share := range shares {
		g.balance[p] -= share
	}
	return nil
}

// Balances returns each member's net balance, in member order.
func (g *Group) Balances() []Payment {
	g.mu.Lock()
	defer g.mu.Unlock()
	out := make([]Payment, 0, len(g.members))
	for _, m := range g.members {
		out = append(out, Payment{From: m, Amount: g.balance[m]})
	}
	return out
}

// SettleUp suggests payments that clear every balance: the biggest debtor pays the biggest
// creditor, repeatedly. It needs at most (members - 1) payments. Balances aren't changed.
func (g *Group) SettleUp() []Payment {
	g.mu.Lock()
	left := map[string]int{}
	for k, v := range g.balance {
		left[k] = v
	}
	members := g.members
	g.mu.Unlock()

	var pays []Payment
	for {
		debtor, creditor := "", ""
		for _, m := range members { // ties go to the earlier member, so the result is stable
			if left[m] < 0 && (debtor == "" || left[m] < left[debtor]) {
				debtor = m
			}
			if left[m] > 0 && (creditor == "" || left[m] > left[creditor]) {
				creditor = m
			}
		}
		if debtor == "" || creditor == "" {
			return pays
		}
		amt := min(-left[debtor], left[creditor])
		pays = append(pays, Payment{debtor, creditor, amt})
		left[debtor] += amt
		left[creditor] -= amt
	}
}

Step 3: run it

// cmd/demo/main.go
package main

import (
	"fmt"

	"splitter"
)

func main() {
	g := splitter.NewGroup("Rahim", "Nadia", "Tania", "Sabbir")
	all := []string{"Rahim", "Nadia", "Tania", "Sabbir"}

	expenses := []struct {
		payer, what string
		amount      int
		people      []string
		split       splitter.Split
	}{
		{"Rahim", "hotel", 4000, all, splitter.Equal{}},
		{"Nadia", "dinner", 1200, nil, splitter.Exact{"Rahim": 300, "Nadia": 400, "Tania": 500}},
		{"Tania", "boat", 1000, nil, splitter.Percent{People: []string{"Rahim", "Sabbir"}, Pct: []int{40, 60}}},
		{"Sabbir", "tea", 100, []string{"Rahim", "Tania", "Sabbir"}, splitter.Equal{}},
		{"Rahim", "taxi", 1200, nil, splitter.Exact{"Rahim": 600, "Nadia": 500}},
	}
	for i, e := range expenses {
		if err := g.AddExpense(e.payer, e.amount, e.people, e.split); err != nil {
			fmt.Printf("expense %d refused: %v\n", i+1, err)
			continue
		}
		fmt.Printf("expense %d: %s paid %d for %s, %s\n", i+1, e.payer, e.amount, e.what, e.split.Describe())
	}

	fmt.Println("balances:")
	for _, b := range g.Balances() {
		fmt.Printf("  %-6s %+6d\n", b.From, b.Amount)
	}
	pays := g.SettleUp()
	fmt.Printf("settle up (%d payments):\n", len(pays))
	for _, p := range pays {
		fmt.Printf("  %s pays %s %d\n", p.From, p.To, p.Amount)
	}
}
$ go run ./cmd/demo
expense 1: Rahim paid 4000 for hotel, split equally
expense 2: Nadia paid 1200 for dinner, exact split
expense 3: Tania paid 1000 for boat, percent split
expense 4: Sabbir paid 100 for tea, split equally
expense 5 refused: exact shares add up to 1100, not 1200
balances:
  Rahim   +2266
  Nadia    -200
  Tania    -533
  Sabbir  -1533
settle up (3 payments):
  Sabbir pays Rahim 1533
  Tania pays Rahim 533
  Nadia pays Rahim 200

Reading the output:

  • Hotel 4000 equally: 1000 each; Rahim paid 4000, so he's up 3000 after this one.
  • Dinner 1200 exact: Rahim 300, Nadia 400, Tania 500.
  • Boat 1000, 40/60: Rahim 400, Sabbir 600.
  • Tea 100 among three: 33 each plus one leftover taka to the first person (Rahim 34).
  • Taxi refused: 600 + 500 = 1100, not 1200. Nothing changed.
  • Balances: Rahim paid 4000 and owes 1000 + 300 + 400 + 34 = 1734, so +2266. The four numbers add up to 0.
  • Settle up: three payments, all to Rahim, largest first.

The other five languages

from __future__ import annotations

import threading
from typing import Protocol


class SplitError(Exception):
    pass


def spread(amount: int, people: list[str], weights: list[int]) -> dict[str, int]:
    """Divide by weights, rounding down; leftover taka go one by one to the first people."""
    total = sum(weights)
    shares = {p: amount * w // total for p, w in zip(people, weights)}
    given, i = sum(shares.values()), 0
    while given < amount:
        shares[people[i % len(people)]] += 1
        given, i = given + 1, i + 1
    return shares


class Split(Protocol):
    """The Strategy: each way of dividing an expense turns (amount, people) into shares."""
    def shares(self, amount: int, people: list[str]) -> dict[str, int]: ...
    def describe(self) -> str: ...


class Equal:
    def describe(self) -> str:
        return "split equally"

    def shares(self, amount: int, people: list[str]) -> dict[str, int]:
        if not people:
            raise SplitError("no one to split with")
        return spread(amount, people, [1] * len(people))


class Exact:
    def __init__(self, shares: dict[str, int]):
        self._shares = shares

    def describe(self) -> str:
        return "exact split"

    def shares(self, amount: int, people: list[str]) -> dict[str, int]:
        total = sum(self._shares.values())
        if total != amount:
            raise SplitError(f"exact shares add up to {total}, not {amount}")
        return dict(self._shares)


class Percent:
    def __init__(self, people: list[str], pct: list[int]):
        self.people, self.pct = people, pct

    def describe(self) -> str:
        return "percent split"

    def shares(self, amount: int, people: list[str]) -> dict[str, int]:
        if sum(self.pct) != 100:
            raise SplitError(f"percentages add up to {sum(self.pct)}, not 100")
        return spread(amount, self.people, self.pct)


class Group:
    """Net balance per member: positive = others owe them. Balances always add up to 0."""

    def __init__(self, *members: str):
        self._lock = threading.Lock()
        self.members = list(members)
        self._balance = {m: 0 for m in members}

    def add_expense(self, payer: str, amount: int, people: list[str], split: Split) -> None:
        with self._lock:
            if amount <= 0:
                raise SplitError("amount must be positive")
            if payer not in self._balance:
                raise SplitError(f"{payer} is not in the group")
            shares = split.shares(amount, people)
            for p in shares:
                if p not in self._balance:
                    raise SplitError(f"{p} is not in the group")
            self._balance[payer] += amount  # only now, when everything is valid
            for p, share in shares.items():
                self._balance[p] -= share

    def balances(self) -> list[tuple[str, int]]:
        with self._lock:
            return [(m, self._balance[m]) for m in self.members]

    def settle_up(self) -> list[tuple[str, str, int]]:
        """Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member."""
        with self._lock:
            left = dict(self._balance)
        pays = []
        while True:
            debtors = [m for m in self.members if left[m] < 0]
            creditors = [m for m in self.members if left[m] > 0]
            if not debtors or not creditors:
                return pays
            d = min(debtors, key=lambda m: left[m])  # min() keeps the first of equals
            c = max(creditors, key=lambda m: left[m])
            amt = min(-left[d], left[c])
            pays.append((d, c, amt))
            left[d] += amt
            left[c] -= amt


g = Group("Rahim", "Nadia", "Tania", "Sabbir")
everyone = ["Rahim", "Nadia", "Tania", "Sabbir"]
expenses = [
    ("Rahim", "hotel", 4000, everyone, Equal()),
    ("Nadia", "dinner", 1200, [], Exact({"Rahim": 300, "Nadia": 400, "Tania": 500})),
    ("Tania", "boat", 1000, [], Percent(["Rahim", "Sabbir"], [40, 60])),
    ("Sabbir", "tea", 100, ["Rahim", "Tania", "Sabbir"], Equal()),
    ("Rahim", "taxi", 1200, [], Exact({"Rahim": 600, "Nadia": 500})),
]
for i, (payer, what, amount, people, split) in enumerate(expenses, 1):
    try:
        g.add_expense(payer, amount, people, split)
        print(f"expense {i}: {payer} paid {amount} for {what}, {split.describe()}")
    except SplitError as e:
        print(f"expense {i} refused: {e}")

print("balances:")
for m, b in g.balances():
    print(f"  {m:<6} {b:+6d}")
pays = g.settle_up()
print(f"settle up ({len(pays)} payments):")
for frm, to, amt in pays:
    print(f"  {frm} pays {to} {amt}")

What changes from language to language: every version has the same Split interface (a Protocol in Python, a trait in Rust, an abstract class in C++). Ties in settle-up are broken by member order in all six, which is why the output is identical. Rust's balances live inside the Mutex; Java uses synchronized and copies the balances before settling; TypeScript needs no lock.

Testing

// group_test.go
package splitter

import (
	"fmt"
	"sync"
	"testing"
)

func bal(g *Group) map[string]int {
	out := map[string]int{}
	for _, b := range g.Balances() {
		out[b.From] = b.Amount
	}
	return out
}

func TestEqualSplitRoundsWithoutLosingTaka(t *testing.T) {
	s, _ := Equal{}.Shares(100, []string{"A", "B", "C"})
	if s["A"] != 34 || s["B"] != 33 || s["C"] != 33 {
		t.Fatalf("got %v", s)
	}
}

func TestPercentMustAddTo100(t *testing.T) {
	if _, err := (Percent{[]string{"A", "B"}, []int{40, 50}}).Shares(1000, nil); err == nil {
		t.Fatal("expected an error")
	}
}

func TestInvalidExpenseChangesNothing(t *testing.T) {
	g := NewGroup("A", "B")
	if err := g.AddExpense("A", 100, nil, Exact{"A": 50, "B": 40}); err == nil {
		t.Fatal("expected an error")
	}
	if err := g.AddExpense("A", 100, nil, Exact{"A": 50, "Z": 50}); err == nil {
		t.Fatal("non-member share should be refused")
	}
	for k, v := range bal(g) {
		if v != 0 {
			t.Fatalf("%s balance %d after refused expenses", k, v)
		}
	}
}

func TestBalancesAlwaysSumToZero(t *testing.T) {
	g := NewGroup("A", "B", "C")
	g.AddExpense("A", 100, []string{"A", "B", "C"}, Equal{})
	g.AddExpense("B", 77, []string{"A", "C"}, Equal{})
	g.AddExpense("C", 250, nil, Percent{[]string{"A", "B"}, []int{30, 70}})
	sum := 0
	for _, v := range bal(g) {
		sum += v
	}
	if sum != 0 {
		t.Fatalf("balances sum to %d", sum)
	}
}

func TestSettleUpClearsEverything(t *testing.T) {
	g := NewGroup("A", "B", "C", "D")
	g.AddExpense("A", 900, []string{"A", "B", "C"}, Equal{})
	g.AddExpense("D", 400, []string{"B", "D"}, Equal{})
	left := bal(g)
	pays := g.SettleUp()
	for _, p := range pays {
		left[p.From] += p.Amount
		left[p.To] -= p.Amount
	}
	for k, v := range left {
		if v != 0 {
			t.Fatalf("%s left with %d", k, v)
		}
	}
	if len(pays) > 3 {
		t.Fatalf("%d payments for 4 people", len(pays))
	}
}

// Expenses added from many goroutines must all count. Run with -race.
func TestConcurrentExpenses(t *testing.T) {
	g := NewGroup("A", "B")
	var wg sync.WaitGroup
	for i := range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := g.AddExpense("A", 10, []string{"A", "B"}, Equal{}); err != nil {
				panic(fmt.Sprint(i, err))
			}
		}()
	}
	wg.Wait()
	if b := bal(g); b["A"] != 500 || b["B"] != -500 {
		t.Fatalf("got %v", b)
	}
}
$ go test -race -v ./...
=== RUN   TestEqualSplitRoundsWithoutLosingTaka
--- PASS: TestEqualSplitRoundsWithoutLosingTaka (0.00s)
=== RUN   TestPercentMustAddTo100
--- PASS: TestPercentMustAddTo100 (0.00s)
=== RUN   TestInvalidExpenseChangesNothing
--- PASS: TestInvalidExpenseChangesNothing (0.00s)
=== RUN   TestBalancesAlwaysSumToZero
--- PASS: TestBalancesAlwaysSumToZero (0.00s)
=== RUN   TestSettleUpClearsEverything
--- PASS: TestSettleUpClearsEverything (0.00s)
=== RUN   TestConcurrentExpenses
--- PASS: TestConcurrentExpenses (0.00s)
PASS
ok  	splitter	1.012s
?   	splitter/cmd/demo	[no test files]
TestChecks
TestEqualSplitRoundsWithoutLosingTaka100 among three is 34/33/33
TestPercentMustAddTo10040 + 50 is refused
TestInvalidExpenseChangesNothingWrong totals and non-members leave balances at zero
TestBalancesAlwaysSumToZeroAfter odd amounts and splits, balances still sum to 0
TestSettleUpClearsEverythingApplying the suggested payments zeroes everyone, in at most 3 payments
TestConcurrentExpenses100 goroutines adding expenses: none lost

Without the lock in AddExpense, the race detector reported 19 data races and the concurrency test failed.

Design review

What's good: one number per person keeps the model small and checkable; validation before mutation; the Strategy keeps split rules apart.

"What if…"What to change
Edit or delete an expenseStore expenses and rebuild balances from them (or apply a reverse expense). Balances become a cache.
Multiple currenciesKeep balances per currency; settle per currency or convert at a stored rate.
Split by shares (2:1:1)A new ByShares type that calls spread with the weights.
Huge groupsSettle-up is O(n²) with this loop; use two heaps (debtors, creditors) for O(n log n).
Simplify debts across groupsMerge balances per person across groups before settling; a product decision as much as a technical one.

Interview questions

Practice questions

Beginner

  1. Why store one net balance per person instead of who-owes-whom pairs?
  2. How do you split 100 taka among three people?

Intermediate

  1. How do you add a "split by shares" type?
  2. How do you make sure an invalid expense changes nothing?

Advanced

  1. Is greedy settle-up always optimal? What would optimal cost?
  2. How would you support editing an old expense?

Common design mistakes

  • Floating-point money, which drifts by paisa.
  • Losing the leftover taka when dividing.
  • A switch on split type inside the group.
  • Updating balances before validating the whole expense.

Answers

1. Why net balances?

Pairs grow as n² and two-way debts have to be netted anyway. A single number per person is enough to settle, and the zero-sum check catches bugs.

2. 100 among three?

Integer division gives 33 each (99), then the leftover 1 taka goes to the first person in a fixed order: 34/33/33. Shares always add up to the amount.

3. Split by shares?

A new type implementing Split that turns weights into shares with spread. Nothing else changes.

4. Invalid expense changes nothing?

Compute and validate all shares (totals, membership) first, and only then touch balances, all under one lock.

5. Is greedy optimal?

Not always: the true minimum number of payments is NP-hard in general (it relates to splitting people into zero-sum subgroups). Greedy is simple, fast and needs at most n − 1 payments, which is good enough for real apps.

6. Editing an old expense?

Keep expenses as the source of truth and treat balances as derived: either recompute from the expense list, or apply the old expense reversed and the new one forward, in one transaction.

What interviewers look for: the net-balance model, a Strategy for split types, exact rounding, validation before mutation, and a sensible settle-up.

Designed it yourself and understood the solution? Mark it to track your progress.