Money as a value object
Splitwise
Friends split a dinner of 1200 INR. A paid. B and C owe equal shares. The app must store who owes whom without floating-point doubles for currency, and without stuffing print statements into the domain.
Splitwise-style designs store as an exact value object, apply a to produce shares, then update balances on a ledger (the place balances live). The hard part is ownership: who owns balances, and never using doubles for rupees.
What actually happens after the bill
An Expense (a bill with identity) has a payer, participants, and a SplitStrategy. The strategy returns shares. A ledger records who owes whom. Simplifying debts is a query over current balances: do not put it on Expense, and do not put parse-IO or print statements on ExpenseManager.
User and Expense are entities (objects with identity over time). Money is a value object: never double. See common value objects. Groups are optional; start with people and expenses, then add Group if interviewers ask.
"B paid for C's drinks" is another Expense, not a mutation of the dinner row. Two expenses with the same totals are still different expenses. Two Money(400, INR) values are the same value. The ledger applies each expense once.
Simple rule of thumb: three split shapes, one seam
Once shares leave the strategy, the ledger only cares that they sum to the total. Equal, exact, and percent are three implementations of the same seam (see the split seam), not branches inside ExpenseManager.doEverything().
Equal
Remainder minor units must be assigned, not lost. Last participant can absorb leftover paise.
Exact
Caller supplies shares. Reject if they do not sum to total. Do not silently rescale.
Percent
Must add to 100. Then convert to minor units with the same remainder rule as equal.
Rule of thumb
Money is long minor units plus currency. Expense is an entity. Split is a strategy. The ledger owns balances. IO stays in main. If ExpenseManager still prints and splits, you have not started the design.
Where each type earns its keep
The hero asked where a balance lives. This table is the answer: Money does arithmetic, SplitStrategy produces shares, Expense carries identity, and the ledger (balance store) applies them.
| Type | Does | Does not |
|---|---|---|
| Money | plus/minus in one currency | Store on User as double |
| SplitStrategy | Produce shares that sum | Write the ledger |
| Expense | Identity of a bill + chosen split | Print, parse stdin, simplify |
| Ledger | Apply shares; answer balances | Know percent vs exact |
| ExpenseService | addExpense controller | IO + simplify + split in one class |
Java API sketch
record Money(long minorUnits, String currency) {
Money {
if (currency == null || currency.isBlank()) throw new IllegalArgumentException("currency");
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new CurrencyMismatch();
long sum = Math.addExact(minorUnits, other.minorUnits);
return new Money(sum, currency);
}
static Money ofPaise(long paise, String currency) {
return new Money(paise, currency);
}
static Money zero(String currency) {
return new Money(0, currency);
}
}
interface SplitStrategy {
List<Share> split(Money total, List<UserId> participants, SplitInput input);
}
class Expense {
private final ExpenseId id;
private final UserId payer;
private final Money total;
private final SplitStrategy split;
}
class Ledger {
void apply(Expense expense) { /* shares must sum to total; update balances */ }
Money balance(UserId a, UserId b) { /* query */ }
List<Settlement> simplify() { /* query: min transfers; not a command on Expense */ }
}Worked dinner: from 1200 to balances
With the types above, walk the dinner numbers. Total 1200 INR, three people, equal split. 1200 / 3 = 400 exactly in minor units if you use paise: 120000 / 3 = 40000. The ugly case is 100 INR among three people: 10000 paise / 3 = 3333 remainder 1. Assign the leftover paise to a deterministic participant (payer, or first id). Never drop it. Never use double and hope.
| Step | Object that moves |
|---|---|
| addExpense equal 1200 | SplitStrategy → three Share(400). Ledger.apply. Balances A←B 400, A←C 400. |
| Second expense: B paid 300 for C | New Expense id. Ledger adds B←C 300. Dinner row untouched. |
| simplify() | Query. May net C’s debts. Must not rewrite expense history. |
Numbers to know
- Dinner: 1200 INR equal three ways becomes 400 each when paid by A.
- Ugly remainder: 100 INR among three leaves one paise that must be assigned.
- Second bill: B paid 300 for C is a new Expense id, not a dinner edit.
Tests that prove the seams
The seam test below proves equal shares sum to total in minor units. It never opens the ledger type.
@Test
void equalSplitSumsToTotal() {
Money total = Money.ofPaise(120000, "INR");
List<Share> shares = new EqualSplit().split(total, List.of(A, B, C), SplitInput.none());
Money sum = shares.stream().map(Share::amount).reduce(Money.zero("INR"), Money::plus);
assertEquals(total, sum);
}Invalid cases, and what still breaks after the fix
Putting the pieces together, these are the commands that must fail, the concurrency limit of one process, and the ways a correct object split can still go wrong.
Invalid cases
- Percent 40 + 40 + 10 does not add to 100; reject, do not post a 90% dinner.
- Exact shares 500 + 500 when total is 1200 fail the sum check in minor units.
- Money INR plus Money USD must throw; a converter is explicit, not plus().
- Using double 0.1 + 0.2 as a rupee is the classic trap; use long minor units.
- Simplify must not mutate historical expenses; it is a query over current balances.
Concurrency
Two addExpense calls that share participants must not lose a share. Ledger.apply should be serialized per group (or per pair) so balances are not a lost update. Machine coding: one process, a lock on the ledger, or a concurrent map of pair to Money with atomic replace. Not a payments cluster.
Still fails when…
- Remainder paise are dropped instead of assigned to a deterministic participant.
- ExpenseManager prints, parses stdin, and splits in one God class.
- Settle-up rewrites expense history instead of querying append-only balances.
Trade-offs
- Pair-map balances are simple to lock; a graph shines when you simplify for display.
- Groups own a ledger slice later; start with people so the first demo stays small.
- A new split type is a strategy class; multi-currency needs Money plus a converter port.
Wrap-up
You have seen the dinner story, the ownership split, and the seam test. Use the timed lab to check the ideas stuck, then follow the related pages for domain modeling, value objects, Strategy, and entity versus value.
How to explain this in an interview
Say Money is long plus currency; Expense is an entity; split is a strategy; the ledger is where balances live; IO is main. Percent not adding to 100 is a rejected command. Remainder paise are assigned, never dropped. Double is the classic trap. Converter is a port, not a field on User.
Exercises: forty-five minutes
- 0–8Money in paise plus currency; mismatch on plus() throws.
- 8–20Equal split of 1200 three ways, then ledger balances for A, B, C.
- 20–32Remainder paise on 100 among three, assigned to one participant.
- 32–40Percent 90 rejects; a second expense gets a new Expense id.
- 40–45simplify as a query only; no IO on Expense or ExpenseManager.
What the follow-up changes
- Multi-currency needs Money plus a converter port, not silent plus() across codes.
- Groups need a Group entity that owns a ledger slice for that circle.
- Simplify is a min-cash-flow query you can swap without editing Expense.
- A new split type is a new strategy class on the same seam.
Read next
You're done when
Equal, exact, and percent splits produce balances that sum to zero within your rounding policy. Money never uses floating point for rupees. Settle-up is a query over balances, not a destructive rewrite of history.