Loading
Loading
Modelling Case Studies
Groups, expenses, splits and settling up: money as a value object, split rules as strategies, balances as derived data, and simplifying who pays whom.
By the end of this chapter
Friends share costs on a trip. Anyone in a group can record an expense they paid and say who it was for, split equally, by exact amounts, or by percentages. The app shows each person's balance, suggests a short list of payments to settle everything, and records settlements. Moving real money is out of scope; multiple currencies are an extension.
The first decision is the most consequential. Binary floating point cannot represent most decimal fractions exactly, so 0.1 + 0.2 is not 0.3 in a double, and errors accumulate across thousands of expenses. Store amounts as integer minor units (cents, paise) or a decimal type, and never let an amount travel without its currency.
Before and after
double, rounded per share
Money.allocate(3)
Rounding each share independently loses the remainder. Allocation hands the leftover minor units out one at a time, so the parts always add back up to the total.
public record Money(long minor, Currency currency) { public static Money zero(Currency currency) { return new Money(0, currency); } public Money plus(Money other) { requireSameCurrency(other); return new Money(Math.addExact(minor, other.minor), currency); } /** n parts that differ by at most one minor unit and sum exactly. Assumes minor >= 0. */ public List<Money> allocate(int n) { long base = minor / n, remainder = minor % n; List<Money> parts = new ArrayList<>(n); for (int i = 0; i < n; i++) parts.add(new Money(base + (i < remainder ? 1 : 0), currency)); return parts; } private void requireSameCurrency(Money other) { if (!currency.equals(other.currency)) throw new IllegalArgumentException(currency + " vs " + other.currency); }}def allocate(total_cents: int, weights: list[int]) -> list[int]: """Split by weights; leftover cents go to the largest fractional parts.""" whole = sum(weights) raw = [total_cents * w for w in weights] parts = [r // whole for r in raw] leftover = total_cents - sum(parts) by_fraction = sorted(range(len(weights)), key=lambda i: raw[i] % whole, reverse=True) for i in by_fraction[:leftover]: parts[i] += 1 return parts allocate(10_000, [1, 1, 1]) # [3334, 3333, 3333]allocate(10_000, [50, 30, 20]) # [5000, 3000, 2000]| Entity | Is the only thing that knows |
|---|---|
| Member | Who a person is within a group |
| Group | Who is in it and which expenses belong to it |
| Expense | Who paid, how much, and the resulting share per member — immutable once recorded |
| SplitStrategy | How a total becomes per-member shares |
| Settlement | A recorded payment from one member to another |
| Ledger | Net balances, derived from expenses and settlements |
| DebtSimplifier | How to turn balances into a short list of transfers |
interface SplitStrategy { Map<MemberId, Money> shares(Money total, List<MemberId> participants);} final class EqualSplit implements SplitStrategy { public Map<MemberId, Money> shares(Money total, List<MemberId> people) { List<Money> parts = total.allocate(people.size()); Map<MemberId, Money> out = new LinkedHashMap<>(); for (int i = 0; i < people.size(); i++) out.put(people.get(i), parts.get(i)); return out; }} final class ExactSplit implements SplitStrategy { private final Map<MemberId, Money> amounts; ExactSplit(Map<MemberId, Money> amounts) { this.amounts = Map.copyOf(amounts); } public Map<MemberId, Money> shares(Money total, List<MemberId> people) { Money sum = amounts.values().stream() .reduce(Money.zero(total.currency()), Money::plus); if (!sum.equals(total)) throw new InvalidSplit("shares sum to " + sum + ", expense is " + total); return amounts; }}Each member's net balance is what they paid minus what they owe. Rather than keeping mutable balance counters that every expense must update correctly — and that two simultaneous expenses can corrupt — compute balances from the list of expenses and settlements. Cache the result if it gets slow; the list stays the source of truth.
Group {A, B, C}. A pays 90 for all three. B pays 30 for B and C.Expense 1: A paid 90Shares 30 each. A: +90 − 30 = +60; B: −30; C: −30.Expense 2: B paid 30Shares 15 each for B and C. B: −30 + 30 − 15 = −15; C: −30 − 15 = −45.Check the invariant+60 − 15 − 45 = 0. Any other sum means a bug.Net: A +60, B −15, C −45. The three balances sum to zero.Recording who owes whom per expense would leave B owing A, C owing A and C owing B: three payments. Using net balances instead, a greedy pass repeatedly matches the biggest creditor with the biggest debtor and settles the smaller of the two amounts. Each step zeroes at least one person, so a group of n people needs at most n − 1 transfers.
List<Transfer> simplify(Map<MemberId, Long> net) { // > 0 is owed, < 0 owes PriorityQueue<Map.Entry<MemberId, Long>> creditors = new PriorityQueue<>(Map.Entry.<MemberId, Long>comparingByValue().reversed()); PriorityQueue<Map.Entry<MemberId, Long>> debtors = new PriorityQueue<>(Map.Entry.comparingByValue()); // most negative first net.forEach((who, amount) -> { if (amount > 0) creditors.add(Map.entry(who, amount)); if (amount < 0) debtors.add(Map.entry(who, amount)); }); List<Transfer> transfers = new ArrayList<>(); while (!creditors.isEmpty()) { var c = creditors.poll(); var d = debtors.poll(); long amount = Math.min(c.getValue(), -d.getValue()); transfers.add(new Transfer(d.getKey(), c.getKey(), amount)); if (c.getValue() > amount) creditors.add(Map.entry(c.getKey(), c.getValue() - amount)); if (-d.getValue() > amount) debtors.add(Map.entry(d.getKey(), d.getValue() + amount)); } return transfers;}Net: A +60, B −15, C −45Match A (+60) with C (−45)Settle 45. A is still owed 15; C is done.Match A (+15) with B (−15)Settle 15. Everyone is at zero.Two transfers: C pays A 45, B pays A 15Approaches
Never as a suggestion — it is how the data is recorded, not how people should settle.
Almost always. At most n − 1 transfers, fast, and easy to explain to users.
Small groups where saving one payment matters. The minimum relates to splitting people into the most zero-sum subgroups, which is NP-hard in general.
Sign in to keep a note here. It is free, and nobody else can see it.
Sign inWhat this chapter was for: