Practice · Low Level Design · Model the Domain
Design an Expense Splitter
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
| Difficulty | Intermediate |
| Time | 2–3 hours to design and code; about 45 minutes in an interview |
| Prerequisites | Interfaces, maps, integer arithmetic, a little about locks |
| Concepts | Strategy for split types, one net balance per person, rounding without losing taka, greedy settle-up |
| Real world | Splitwise, 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
- A group has members.
- An expense has a payer, an amount and a split.
- Split types: equal (among chosen members), exact (each share in taka, must add up to the amount), percent (must add up to 100).
- Shares must add up exactly to the amount; leftover taka from rounding go to someone, never disappear.
- An invalid expense (shares don't add up, non-member) is refused and changes nothing.
- Show each member's net balance.
- Suggest payments that settle all balances, using few payments.
Non-functional requirements
| Requirement | What it means here |
|---|---|
| Correctness | Balances always add up to zero; no taka created or lost |
| Extensibility | New split types (by shares, "I pay for my kids") without editing the group |
| Correctness under concurrency | Two phones adding expenses at once mustn't lose one |
| Testability | Every 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
| Entity | Job in one sentence | Holds | Does |
|---|---|---|---|
Group | Owns members and their net balances | members (in order), balance per member, the lock | AddExpense, Balances, SettleUp |
Split (interface) | Turns an amount into shares | depends on the type | Shares(amount, people), Describe() |
Equal, Exact, Percent | The three split rules | exact shares or percentages | validate and compute shares |
Payment | One suggested transfer | from, to, amount | nothing: 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
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
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
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
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.
AddExpensecomputes and validates the shares first, and only then changes balances. A refused expense leaves no trace. - Open/closed. New split types implement
Split;Groupdoesn't change. - Integers for money. Shares are whole taka; rounding is explicit and deterministic.
Design patterns
Strategy for split types
- Why: equal, exact and percent each have their own maths and their own validation.
- What it solves: keeps
AddExpensefree ofif splitType == …. - Without it: every new type (by shares, by nights stayed) edits
AddExpense. - Trade-off: one interface and three small types; worth it as soon as there are two variants.
- Where:
split.go;Group.AddExpensecallss.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}")// Divide by weights, rounding down; leftover taka go one by one to the first people.
function spread(amount: number, people: string[], weights: number[]): Map<string, number> {
const total = weights.reduce((a, b) => a + b, 0);
const shares = new Map(people.map((p, i) => [p, Math.floor((amount * weights[i]!) / total)] as [string, number]));
let given = [...shares.values()].reduce((a, b) => a + b, 0);
for (let i = 0; given < amount; i++, given++) {
const p = people[i % people.length]!;
shares.set(p, shares.get(p)! + 1);
}
return shares;
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
interface Split {
shares(amount: number, people: string[]): Map<string, number>;
describe(): string;
}
class Equal implements Split {
describe() {
return "split equally";
}
shares(amount: number, people: string[]) {
if (people.length === 0) throw new Error("no one to split with");
return spread(amount, people, people.map(() => 1));
}
}
class Exact implements Split {
readonly given: Map<string, number>;
constructor(given: Record<string, number>) {
this.given = new Map(Object.entries(given));
}
describe() {
return "exact split";
}
shares(amount: number) {
const total = [...this.given.values()].reduce((a, b) => a + b, 0);
if (total !== amount) throw new Error(`exact shares add up to ${total}, not ${amount}`);
return new Map(this.given);
}
}
class Percent implements Split {
readonly people: string[];
readonly pct: number[];
constructor(people: string[], pct: number[]) {
this.people = people;
this.pct = pct;
}
describe() {
return "percent split";
}
shares(amount: number) {
const total = this.pct.reduce((a, b) => a + b, 0);
if (total !== 100) throw new Error(`percentages add up to ${total}, not 100`);
return spread(amount, this.people, this.pct);
}
}
// Net balance per member: positive = others owe them. Balances always add up to 0.
// No lock: one thread, and nothing here awaits.
class Group {
readonly members: string[];
readonly #balance: Map<string, number>;
constructor(...members: string[]) {
this.members = members;
this.#balance = new Map(members.map((m) => [m, 0]));
}
addExpense(payer: string, amount: number, people: string[], split: Split): void {
if (amount <= 0) throw new Error("amount must be positive");
if (!this.#balance.has(payer)) throw new Error(`${payer} is not in the group`);
const shares = split.shares(amount, people);
for (const p of shares.keys()) if (!this.#balance.has(p)) throw new Error(`${p} is not in the group`);
this.#balance.set(payer, this.#balance.get(payer)! + amount); // only now, when everything is valid
for (const [p, s] of shares) this.#balance.set(p, this.#balance.get(p)! - s);
}
balances(): [string, number][] {
return this.members.map((m) => [m, this.#balance.get(m)!]);
}
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
settleUp(): [string, string, number][] {
const left = new Map(this.#balance);
const pays: [string, string, number][] = [];
for (;;) {
let debtor = "";
let creditor = "";
for (const m of this.members) {
if (left.get(m)! < 0 && (debtor === "" || left.get(m)! < left.get(debtor)!)) debtor = m;
if (left.get(m)! > 0 && (creditor === "" || left.get(m)! > left.get(creditor)!)) creditor = m;
}
if (debtor === "" || creditor === "") return pays;
const amt = Math.min(-left.get(debtor)!, left.get(creditor)!);
pays.push([debtor, creditor, amt]);
left.set(debtor, left.get(debtor)! + amt);
left.set(creditor, left.get(creditor)! - amt);
}
}
}
const g = new Group("Rahim", "Nadia", "Tania", "Sabbir");
const everyone = ["Rahim", "Nadia", "Tania", "Sabbir"];
const expenses: [string, string, number, string[], Split][] = [
["Rahim", "hotel", 4000, everyone, new Equal()],
["Nadia", "dinner", 1200, [], new Exact({ Rahim: 300, Nadia: 400, Tania: 500 })],
["Tania", "boat", 1000, [], new Percent(["Rahim", "Sabbir"], [40, 60])],
["Sabbir", "tea", 100, ["Rahim", "Tania", "Sabbir"], new Equal()],
["Rahim", "taxi", 1200, [], new Exact({ Rahim: 600, Nadia: 500 })],
];
expenses.forEach(([payer, what, amount, people, split], i) => {
try {
g.addExpense(payer, amount, people, split);
console.log(`expense ${i + 1}: ${payer} paid ${amount} for ${what}, ${split.describe()}`);
} catch (e) {
console.log(`expense ${i + 1} refused: ${(e as Error).message}`);
}
});
console.log("balances:");
for (const [m, b] of g.balances()) console.log(` ${m.padEnd(6)} ${(b >= 0 ? "+" + b : String(b)).padStart(6)}`);
const pays = g.settleUp();
console.log(`settle up (${pays.length} payments):`);
for (const [from, to, amt] of pays) console.log(` ${from} pays ${to} ${amt}`);use std::collections::HashMap;
use std::sync::Mutex;
// Divide by weights, rounding down; leftover taka go one by one to the first people.
fn spread(amount: i64, people: &[&'static str], weights: &[i64]) -> HashMap<&'static str, i64> {
let total: i64 = weights.iter().sum();
let mut shares: HashMap<_, _> = people.iter().zip(weights).map(|(&p, &w)| (p, amount * w / total)).collect();
let mut given: i64 = shares.values().sum();
let mut i = 0;
while given < amount {
*shares.get_mut(people[i % people.len()]).unwrap() += 1;
given += 1;
i += 1;
}
shares
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
trait Split {
fn shares(&self, amount: i64, people: &[&'static str]) -> Result<HashMap<&'static str, i64>, String>;
fn describe(&self) -> &str;
}
struct Equal;
impl Split for Equal {
fn describe(&self) -> &str { "split equally" }
fn shares(&self, amount: i64, people: &[&'static str]) -> Result<HashMap<&'static str, i64>, String> {
if people.is_empty() { return Err("no one to split with".into()); }
Ok(spread(amount, people, &vec![1; people.len()]))
}
}
struct Exact(Vec<(&'static str, i64)>);
impl Split for Exact {
fn describe(&self) -> &str { "exact split" }
fn shares(&self, amount: i64, _: &[&'static str]) -> Result<HashMap<&'static str, i64>, String> {
let total: i64 = self.0.iter().map(|(_, v)| v).sum();
if total != amount { return Err(format!("exact shares add up to {total}, not {amount}")); }
Ok(self.0.iter().copied().collect())
}
}
struct Percent { people: Vec<&'static str>, pct: Vec<i64> }
impl Split for Percent {
fn describe(&self) -> &str { "percent split" }
fn shares(&self, amount: i64, _: &[&'static str]) -> Result<HashMap<&'static str, i64>, String> {
let total: i64 = self.pct.iter().sum();
if total != 100 { return Err(format!("percentages add up to {total}, not 100")); }
Ok(spread(amount, &self.people, &self.pct))
}
}
// Net balance per member: positive = others owe them. Balances always add up to 0.
struct Group { members: Vec<&'static str>, balance: Mutex<HashMap<&'static str, i64>> }
impl Group {
fn new(members: &[&'static str]) -> Self {
Group { members: members.to_vec(), balance: Mutex::new(members.iter().map(|&m| (m, 0)).collect()) }
}
fn add_expense(&self, payer: &str, amount: i64, people: &[&'static str], split: &dyn Split) -> Result<(), String> {
let mut bal = self.balance.lock().unwrap();
if amount <= 0 { return Err("amount must be positive".into()); }
if !bal.contains_key(payer) { return Err(format!("{payer} is not in the group")); }
let shares = split.shares(amount, people)?;
if let Some(p) = shares.keys().find(|p| !bal.contains_key(*p)) { return Err(format!("{p} is not in the group")); }
*bal.get_mut(payer).unwrap() += amount; // only now, when everything is valid
for (p, s) in shares { *bal.get_mut(p).unwrap() -= s; }
Ok(())
}
fn balances(&self) -> Vec<(&'static str, i64)> {
let bal = self.balance.lock().unwrap();
self.members.iter().map(|&m| (m, bal[m])).collect()
}
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
fn settle_up(&self) -> Vec<(&'static str, &'static str, i64)> {
let mut left = self.balance.lock().unwrap().clone();
let mut pays = vec![];
loop {
let (mut debtor, mut creditor): (Option<&'static str>, Option<&'static str>) = (None, None);
for &m in &self.members {
if left[m] < 0 && debtor.map_or(true, |d| left[m] < left[d]) { debtor = Some(m); }
if left[m] > 0 && creditor.map_or(true, |c| left[m] > left[c]) { creditor = Some(m); }
}
let (Some(d), Some(c)) = (debtor, creditor) else { return pays };
let amt = (-left[d]).min(left[c]);
pays.push((d, c, amt));
*left.get_mut(d).unwrap() += amt;
*left.get_mut(c).unwrap() -= amt;
}
}
}
fn main() {
let everyone = ["Rahim", "Nadia", "Tania", "Sabbir"];
let g = Group::new(&everyone);
let expenses: Vec<(&str, &str, i64, Vec<&'static str>, Box<dyn Split>)> = vec![
("Rahim", "hotel", 4000, everyone.to_vec(), Box::new(Equal)),
("Nadia", "dinner", 1200, vec![], Box::new(Exact(vec![("Rahim", 300), ("Nadia", 400), ("Tania", 500)]))),
("Tania", "boat", 1000, vec![], Box::new(Percent { people: vec!["Rahim", "Sabbir"], pct: vec![40, 60] })),
("Sabbir", "tea", 100, vec!["Rahim", "Tania", "Sabbir"], Box::new(Equal)),
("Rahim", "taxi", 1200, vec![], Box::new(Exact(vec![("Rahim", 600), ("Nadia", 500)]))),
];
for (i, (payer, what, amount, people, split)) in expenses.iter().enumerate() {
match g.add_expense(payer, *amount, people, split.as_ref()) {
Ok(()) => println!("expense {}: {payer} paid {amount} for {what}, {}", i + 1, split.describe()),
Err(e) => println!("expense {} refused: {e}", i + 1),
}
}
println!("balances:");
for (m, b) in g.balances() { println!(" {m:<6} {b:+6}"); }
let pays = g.settle_up();
println!("settle up ({} payments):", pays.len());
for (from, to, amt) in pays { println!(" {from} pays {to} {amt}"); }
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Main {
static final class SplitException extends Exception {
private static final long serialVersionUID = 1L;
SplitException(String msg) { super(msg); }
}
// Divide by weights, rounding down; leftover taka go one by one to the first people.
static Map<String, Integer> spread(int amount, List<String> people, List<Integer> weights) {
int total = weights.stream().mapToInt(Integer::intValue).sum();
Map<String, Integer> shares = new LinkedHashMap<>();
int given = 0;
for (int i = 0; i < people.size(); i++) {
int s = amount * weights.get(i) / total;
shares.put(people.get(i), s);
given += s;
}
for (int i = 0; given < amount; i++, given++) shares.merge(people.get(i % people.size()), 1, Integer::sum);
return shares;
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
interface Split {
Map<String, Integer> shares(int amount, List<String> people) throws SplitException;
String describe();
}
static final class Equal implements Split {
public String describe() { return "split equally"; }
public Map<String, Integer> shares(int amount, List<String> people) throws SplitException {
if (people.isEmpty()) throw new SplitException("no one to split with");
return spread(amount, people, people.stream().map(p -> 1).toList());
}
}
record Exact(Map<String, Integer> given) implements Split {
public String describe() { return "exact split"; }
public Map<String, Integer> shares(int amount, List<String> people) throws SplitException {
int total = given.values().stream().mapToInt(Integer::intValue).sum();
if (total != amount) throw new SplitException("exact shares add up to " + total + ", not " + amount);
return new HashMap<>(given);
}
}
record Percent(List<String> people, List<Integer> pct) implements Split {
public String describe() { return "percent split"; }
public Map<String, Integer> shares(int amount, List<String> ignored) throws SplitException {
int total = pct.stream().mapToInt(Integer::intValue).sum();
if (total != 100) throw new SplitException("percentages add up to " + total + ", not 100");
return spread(amount, people, pct);
}
}
record Payment(String from, String to, int amount) {}
// Net balance per member: positive = others owe them. Balances always add up to 0.
static final class Group {
final List<String> members;
private final Map<String, Integer> balance = new HashMap<>();
Group(String... members) {
this.members = List.of(members);
for (String m : members) balance.put(m, 0);
}
synchronized void addExpense(String payer, int amount, List<String> people, Split split) throws SplitException {
if (amount <= 0) throw new SplitException("amount must be positive");
if (!balance.containsKey(payer)) throw new SplitException(payer + " is not in the group");
Map<String, Integer> shares = split.shares(amount, people);
for (String p : shares.keySet()) if (!balance.containsKey(p)) throw new SplitException(p + " is not in the group");
balance.merge(payer, amount, Integer::sum); // only now, when everything is valid
shares.forEach((p, s) -> balance.merge(p, -s, Integer::sum));
}
synchronized int balanceOf(String m) { return balance.get(m); }
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
List<Payment> settleUp() {
Map<String, Integer> left;
synchronized (this) { left = new HashMap<>(balance); }
List<Payment> pays = new ArrayList<>();
while (true) {
String debtor = null, creditor = null;
for (String m : members) {
if (left.get(m) < 0 && (debtor == null || left.get(m) < left.get(debtor))) debtor = m;
if (left.get(m) > 0 && (creditor == null || left.get(m) > left.get(creditor))) creditor = m;
}
if (debtor == null || creditor == null) return pays;
int amt = Math.min(-left.get(debtor), left.get(creditor));
pays.add(new Payment(debtor, creditor, amt));
left.merge(debtor, amt, Integer::sum);
left.merge(creditor, -amt, Integer::sum);
}
}
}
record Expense(String payer, String what, int amount, List<String> people, Split split) {}
public static void main(String[] args) {
Group g = new Group("Rahim", "Nadia", "Tania", "Sabbir");
List<String> everyone = List.of("Rahim", "Nadia", "Tania", "Sabbir");
List<Expense> expenses = List.of(
new Expense("Rahim", "hotel", 4000, everyone, new Equal()),
new Expense("Nadia", "dinner", 1200, List.of(), new Exact(Map.of("Rahim", 300, "Nadia", 400, "Tania", 500))),
new Expense("Tania", "boat", 1000, List.of(), new Percent(List.of("Rahim", "Sabbir"), List.of(40, 60))),
new Expense("Sabbir", "tea", 100, List.of("Rahim", "Tania", "Sabbir"), new Equal()),
new Expense("Rahim", "taxi", 1200, List.of(), new Exact(Map.of("Rahim", 600, "Nadia", 500))));
for (int i = 0; i < expenses.size(); i++) {
Expense e = expenses.get(i);
try {
g.addExpense(e.payer(), e.amount(), e.people(), e.split());
System.out.printf("expense %d: %s paid %d for %s, %s%n", i + 1, e.payer(), e.amount(), e.what(), e.split().describe());
} catch (SplitException ex) {
System.out.printf("expense %d refused: %s%n", i + 1, ex.getMessage());
}
}
System.out.println("balances:");
for (String m : g.members) System.out.printf(" %-6s %+6d%n", m, g.balanceOf(m));
List<Payment> pays = g.settleUp();
System.out.printf("settle up (%d payments):%n", pays.size());
for (Payment p : pays) System.out.printf(" %s pays %s %d%n", p.from(), p.to(), p.amount());
}
}#include <algorithm>
#include <cstdio>
#include <map>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <tuple>
#include <vector>
using Shares = std::map<std::string, int>;
struct SplitError : std::runtime_error {
using std::runtime_error::runtime_error;
};
// Divide by weights, rounding down; leftover taka go one by one to the first people.
Shares spread(int amount, const std::vector<std::string>& people, const std::vector<int>& weights) {
int total = 0, given = 0;
for (int w : weights) total += w;
Shares shares;
for (size_t i = 0; i < people.size(); i++) given += shares[people[i]] = amount * weights[i] / total;
for (size_t i = 0; given < amount; i++, given++) shares[people[i % people.size()]]++;
return shares;
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
class Split {
public:
virtual ~Split() = default;
virtual Shares shares(int amount, const std::vector<std::string>& people) const = 0;
virtual std::string describe() const = 0;
};
class Equal : public Split {
public:
std::string describe() const override { return "split equally"; }
Shares shares(int amount, const std::vector<std::string>& people) const override {
if (people.empty()) throw SplitError("no one to split with");
return spread(amount, people, std::vector<int>(people.size(), 1));
}
};
class Exact : public Split {
public:
explicit Exact(Shares given) : given_(std::move(given)) {}
std::string describe() const override { return "exact split"; }
Shares shares(int amount, const std::vector<std::string>&) const override {
int total = 0;
for (const auto& [p, v] : given_) total += v;
if (total != amount)
throw SplitError("exact shares add up to " + std::to_string(total) + ", not " + std::to_string(amount));
return given_;
}
private:
Shares given_;
};
class Percent : public Split {
public:
Percent(std::vector<std::string> people, std::vector<int> pct) : people_(std::move(people)), pct_(std::move(pct)) {}
std::string describe() const override { return "percent split"; }
Shares shares(int amount, const std::vector<std::string>&) const override {
int total = 0;
for (int p : pct_) total += p;
if (total != 100) throw SplitError("percentages add up to " + std::to_string(total) + ", not 100");
return spread(amount, people_, pct_);
}
private:
std::vector<std::string> people_;
std::vector<int> pct_;
};
// Net balance per member: positive = others owe them. Balances always add up to 0.
class Group {
public:
explicit Group(std::vector<std::string> members) : members(std::move(members)) {
for (const auto& m : this->members) balance_[m] = 0;
}
const std::vector<std::string> members;
void addExpense(const std::string& payer, int amount, const std::vector<std::string>& people, const Split& split) {
std::lock_guard lock(mu_);
if (amount <= 0) throw SplitError("amount must be positive");
if (!balance_.count(payer)) throw SplitError(payer + " is not in the group");
Shares shares = split.shares(amount, people);
for (const auto& [p, s] : shares)
if (!balance_.count(p)) throw SplitError(p + " is not in the group");
balance_[payer] += amount; // only now, when everything is valid
for (const auto& [p, s] : shares) balance_[p] -= s;
}
int balanceOf(const std::string& m) {
std::lock_guard lock(mu_);
return balance_.at(m);
}
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
std::vector<std::tuple<std::string, std::string, int>> settleUp() {
Shares left;
{
std::lock_guard lock(mu_);
left = balance_;
}
std::vector<std::tuple<std::string, std::string, int>> pays;
for (;;) {
std::string debtor, creditor;
for (const auto& m : members) {
if (left[m] < 0 && (debtor.empty() || left[m] < left[debtor])) debtor = m;
if (left[m] > 0 && (creditor.empty() || left[m] > left[creditor])) creditor = m;
}
if (debtor.empty() || creditor.empty()) return pays;
int amt = std::min(-left[debtor], left[creditor]);
pays.emplace_back(debtor, creditor, amt);
left[debtor] += amt;
left[creditor] -= amt;
}
}
private:
std::mutex mu_;
Shares balance_;
};
int main() {
std::vector<std::string> everyone{"Rahim", "Nadia", "Tania", "Sabbir"};
Group g(everyone);
struct Expense {
std::string payer, what;
int amount;
std::vector<std::string> people;
std::unique_ptr<Split> split;
};
std::vector<Expense> expenses;
expenses.push_back({"Rahim", "hotel", 4000, everyone, std::make_unique<Equal>()});
expenses.push_back({"Nadia", "dinner", 1200, {}, std::make_unique<Exact>(Shares{{"Rahim", 300}, {"Nadia", 400}, {"Tania", 500}})});
expenses.push_back({"Tania", "boat", 1000, {}, std::make_unique<Percent>(std::vector<std::string>{"Rahim", "Sabbir"}, std::vector<int>{40, 60})});
expenses.push_back({"Sabbir", "tea", 100, {"Rahim", "Tania", "Sabbir"}, std::make_unique<Equal>()});
expenses.push_back({"Rahim", "taxi", 1200, {}, std::make_unique<Exact>(Shares{{"Rahim", 600}, {"Nadia", 500}})});
for (size_t i = 0; i < expenses.size(); i++) {
const auto& e = expenses[i];
try {
g.addExpense(e.payer, e.amount, e.people, *e.split);
std::printf("expense %zu: %s paid %d for %s, %s\n", i + 1, e.payer.c_str(), e.amount, e.what.c_str(), e.split->describe().c_str());
} catch (const SplitError& err) {
std::printf("expense %zu refused: %s\n", i + 1, err.what());
}
}
std::printf("balances:\n");
for (const auto& m : g.members) std::printf(" %-6s %+6d\n", m.c_str(), g.balanceOf(m));
auto pays = g.settleUp();
std::printf("settle up (%zu payments):\n", pays.size());
for (const auto& [from, to, amt] : pays) std::printf(" %s pays %s %d\n", from.c_str(), to.c_str(), 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]
| Test | Checks |
|---|---|
TestEqualSplitRoundsWithoutLosingTaka | 100 among three is 34/33/33 |
TestPercentMustAddTo100 | 40 + 50 is refused |
TestInvalidExpenseChangesNothing | Wrong totals and non-members leave balances at zero |
TestBalancesAlwaysSumToZero | After odd amounts and splits, balances still sum to 0 |
TestSettleUpClearsEverything | Applying the suggested payments zeroes everyone, in at most 3 payments |
TestConcurrentExpenses | 100 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 expense | Store expenses and rebuild balances from them (or apply a reverse expense). Balances become a cache. |
| Multiple currencies | Keep 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 groups | Settle-up is O(n²) with this loop; use two heaps (debtors, creditors) for O(n log n). |
| Simplify debts across groups | Merge balances per person across groups before settling; a product decision as much as a technical one. |
Interview questions
Practice questions
Beginner
- Why store one net balance per person instead of who-owes-whom pairs?
- How do you split 100 taka among three people?
Intermediate
- How do you add a "split by shares" type?
- How do you make sure an invalid expense changes nothing?
Advanced
- Is greedy settle-up always optimal? What would optimal cost?
- 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.