# Splitwise — decision log

The Phase 5 answer key. Every entry is a question an interviewer actually asks ("why is that a
separate class?") and the answer that earns the mark: **what change the seam anticipates, and what
the change would cost without it.**

Fourteen classes, none over ~90 lines and most well under 50. `SplitwiseSystem` holds the
*sequence* of an operation and no rule that a requirement could reach.

| File | One reason to change |
|---|---|
| `SplitwiseSystem` | the *sequence* an operation follows, or the order refusals are checked in, changes |
| `SplitwiseFactory` | which calculators exist changes |
| `Group` | what a group tracks about itself (membership, history, ledger) changes |
| `Ledger` | how a pairwise balance is stored or read changes |
| `SplitEngine` | how a split type is chosen changes |
| `SplitCalculator`, `EqualSplit`, `ExactSplit`, `PercentageSplit` | what a split type means changes |
| `Shares` | what "the shares must match the participants" means changes |
| `RemainderAllocator` | the remainder rule itself changes |
| `Demo` | what we show when we run it changes |

## The measurement, first, because it is the argument

Three requirement changes, each absorbed with the reference's own diff measured by
`server/lib/diff.mjs`:

| Curveball | Where it landed | Measured cost |
|---|---|---|
| a fourth split type, by weight | one new file + one line in `SplitwiseFactory` | **3 lines** |
| a group-wide settlement plan | one new file + one new method in `SplitwiseSystem` | **10 lines** |
| an expense in a foreign currency | one new file + one new method in `SplitwiseSystem` | **15 lines** |

`SplitCalculator`'s three built-in implementations, `Group`, and `Ledger` were **not opened by any
of the three.** That is the claim the design makes, and it is measurable rather than aesthetic.

**The one curveball that did not measure cheaply on the first try** is the currency one — see
"Fixing a seam instead of a budget" below, because that is the more useful story than a clean
number would have been.

---

## Money · integer minor units, and exactly two rounding rules

**Every amount is a `long` of minor units.** No `double`, no `float`, anywhere, including in the
records that cross the API boundary — see `SplitwiseApi`'s money section for why: a type that can
lose a fraction of a cent has already lost someone's trust, and in a ledger people expect to be
repaid to the unit, that is the whole product failing at its one job.

There are exactly two places a remainder can arise, and one rule, implemented once, handles both:

1. **An equal split that does not divide evenly.** 1000 across 3 is 334, 333, 333.
2. **A percentage split whose per-participant floor doesn't sum back to the total.** 10 split
   33/33/34% floors to 3, 3, 3 — one short.

**The remainder rule: round every share down, then give the leftover minor units, one each, to the
first participants listed, until none are left.** `RemainderAllocator.allocate` is the case where
every share starts equal; `RemainderAllocator.closeGap` is the general form, closing whatever gap
independent rounding left behind — and it is the same method `PercentageSplit` uses, curveball 01's
`WeightedSplit` reuses untouched, and curveball 03's currency conversion reuses again for a third
kind of rounding gap it had never seen before. **Three uses, one implementation, never
re-derived** is the whole argument for pulling this out into its own file rather than writing the
division inline in `EqualSplit` where nothing else could ever have found it.

**Why the first participants, and not the payer or the last one.** The contract states it because
it is arbitrary and every other choice is equally plausible until someone actually asks. "The
payer absorbs the rounding" quietly gives the payer a self-interested reason to list themselves
last on a request they control; "the last participant" is indistinguishable from "whoever the
underlying map happens to iterate last" the day someone implements it with a `HashMap`. Tying the
rule to `participantIds` order — which the caller wrote down, in a list, on purpose — is the one
option nobody can quietly game.

## Seam 1 · `SplitCalculator` — the one the assignment calls load-bearing

**Anticipates:** any new way to divide an amount. A fourth split type is `SplitType.SHARES`
(curveball 01); a fifth is whatever a group asks for next.

Three decisions, each with a plausible shortcut that would have cost more the day a fourth split
type actually arrived:

**One interface, one pure method, three implementations.** The alternative — an
`if (splitType == EQUAL) ... else if (splitType == EXACT) ...` chain wherever an expense is
recorded — passes every base test, because the base problem ships with exactly the types it
tests. It is also exactly the design curveball 01 is aimed at: a fourth branch inside a method
that already works, next to the third branch nobody has looked at since it was written.

**Dispatch is a `Map<SplitType, SplitCalculator>`, not a `switch`.** A `switch` with no `default`
would force `SplitEngine` to grow a case the moment the contract grows an enum constant — an edit
to a file that had no reason to change otherwise. A `switch` with a `default` would compile
silently and misbehave at runtime the day a type was added and forgotten. The map registered in
`SplitwiseFactory.standardCalculators()` fails immediately, by name, if a type is unregistered,
and adding one is exactly one call to `Map.of(...)` growing by one entry.

**Validation lives inside each calculator, not in a shared pre-check.** `EqualSplit` refusing
non-empty shares, `ExactSplit` and `PercentageSplit` refusing a sum that is wrong — these are facts
about what each split type *means*, not a generic rule about shapes. Pulling them into one
"validate the shares" method that branches on type would have re-created the `if/else` chain this
seam exists to avoid, one level up.

**Cost without it:** curveball 01 measured **3 lines** — a new file plus one registration. The
alternative, an `if/else` chain, would have needed the same new logic plus an edit to whatever
method already branched on split type, for no compensating benefit.

## `Shares` and `RemainderAllocator` — the two facts every calculator needed, factored out once

`Shares.byParticipant` is "the shares list must name exactly the given participants, one each, no
more, no fewer" — a fact three calculators need to check identically, and a fact `WeightedSplit`
(curveball 01) needed unchanged. Writing it three times risks it drifting into three slightly
different error messages and, eventually, three different bugs about what "matching" means.

`RemainderAllocator` is described above. Both classes are static utility holders rather than
instantiated collaborators, deliberately: neither one has state, a lifecycle, or more than one
reasonable implementation, so an interface here would be exactly the D3-adjacent mistake of adding
a seam nothing needs a second implementation of.

## Seam 2 · `Ledger` — one signed number per pair, and nothing about a third person

**Anticipates:** anything that changes *how a pairwise balance is stored or read* — a different
data structure, a persistence layer, an audit trail of every adjustment rather than a running
total.

**A single signed number per unordered pair, not two independent numbers.** The tempting
alternative — a `Map<String, Map<String, Long>>` recording "A owes B" and "B owes A" separately —
reads naturally and is wrong the moment one side is updated and the other is not: nothing stops
the two disagreeing about who owes whom. Keying by an unordered pair so `(A, B)` and `(B, A)`
resolve to the same storage slot makes that disagreement structurally impossible, not merely
avoided by discipline.

**Every method takes exactly two ids.** This is not an oversight, it is the class's whole reason
to exist separately from `Group`: a ledger that could also answer a group-wide question would be
answering two questions with one method, and the pairwise question and the group-wide one
(curveball 02) change for different reasons and at different times. Keeping the boundary where the
contract itself draws it — "balances are pairwise, and stay that way" — is what let curveball 02
add its algorithm without `Ledger` needing to know it existed.

**Cost of the boundary being where it is:** curveball 02 needed no change to `Ledger` at all —
see below.

## Seam 3 · `Group` — membership, history and the ledger, together, because they grow together

**Anticipates:** nothing about *how* an expense is split, only what the *group itself* tracks.

Membership is fixed at construction and there is no method that could add or remove a member,
because the contract states that is out of scope — and a method nobody's requirement can reach is
worse than no method, since it is a promise the class does not actually keep correctly (what
happens to old expenses' balances if a member could be removed?). `Group.record(Expense)` is the
one place an expense's splits become ledger entries, and it is four lines because everything hard
about that — what the splits *are* — was already decided by `SplitEngine` before `Group` ever sees
them.

## Seam 4 · `SplitwiseFactory` — construction is a job, and it has an owner

**Anticipates:** a new split type, or any future variation on what a standard system is made of.

`standardCalculators()` is exposed on its own, not only folded into `standard()`, for the same
reason `CartFactory.standardCatalog()` was in the shopping-cart problem this one followed the shape
of: a variation reuses what it is not changing instead of copying it. Curveball 01's one line is
only one line because this method existed to have a line added to it.

## Curveball 02 · the one flagged as possibly needing a new collaborator, and it did

`simplifiedDebts` is a genuinely different algorithm from anything the base problem needed:
greedy-match the largest creditor with the largest debtor, repeatedly, until everyone nets to
zero. It lives in `DebtSimplifier`, a new file that takes a `Map<String, Long>` and returns a
`List<Settlement>` — it does not know what a group, an expense, or a member id even is beyond a
`String` key, which is exactly what makes it independently testable and exactly why it did not
need to live inside `Group` or `Ledger`.

**Two designs were measured for the four lines of plumbing this curveball still needed** (see
`curveballs/02-simplified-group-debts/reference-patch/PATCH.md` for the full comparison): a
`Group.netPositions()` query method cost 14 lines across two files; building the same map inline
in `SplitwiseSystem` from methods `Group` already exposed (`members()`, `netBalanceOf(String)`)
cost 10 lines in one file. The cheaper one was kept. Neither is wrong; the point of measuring both
is that "it works" is not the same claim as "it is the cheaper design," and only one of those two
claims is what a diff budget is checking.

## Curveball 03 · fixing a seam instead of a budget

This is the curveball worth reading in full in its own `PATCH.md`, because the first honest
attempt at it **failed the corpus's own rule.** `addExpense`'s validation — member checks, the
duplicate-participant check, the positive-amount check — was originally written inline in one
method. The foreign-currency overload needed exactly that validation, in the foreign currency, and
had nowhere to get it from except copying it — which measured **30 charged lines**, over the
20-line signal this process treats as proof of the wrong seam, not proof of an expensive
requirement.

The fix was not a bigger budget. It was pulling that validation out of `addExpense` into a private
`validateAndResolve(Group, ExpenseRequest)`, **committed to the base reference**, that both
overloads now call. The base suite is still green after that refactor — behaviour did not change,
only where it lives — and the curveball's real cost, once the seam existed, was **15 lines**: a
single new override that validates in the request's own currency, converts the result through the
new `CurrencyConversion`, and stores it.

**The general lesson:** a second overload of an existing operation tests whether that operation's
validation was ever actually separable from doing the thing. If pulling it out takes more than a
few minutes, that is worth knowing before the curveball arrives, not after a 30-line diff says so.

**`CurrencyConversion` converts already-resolved splits, not the raw request.** The alternative —
convert `amountMinor` and, for `EXACT`, every share, before handing anything to `SplitEngine` —
needs two different rules (`EXACT`'s shares are money and need converting; `EQUAL`'s and
`PERCENTAGE`'s do not) and, worse, validates the request *after* altering its numbers, which is
exactly backwards: the contract requires an exact split's shares to sum correctly in the *original*
currency, before any conversion, so a badly-typed request is refused for being wrong, not silently
repaired into something that happens to add up in the wrong units. Converting `SplitEngine`'s
*output* instead needs one rule for every split type, because every split type's output is the
same shape — amounts that sum to a total — and validation naturally happens before conversion
because resolving the split is the thing that validates it.

## Errors · `IllegalArgumentException`, `IllegalStateException`, and no domain hierarchy

The contract fixes the exception types, which is worth saying out loud rather than pretending the
choice was free — a domain exception hierarchy (syllabus item A6) is constrained out by the
contract itself, exactly as it was in the shopping-cart and library problems this one follows.
What is left is the convention, applied consistently:

- **A bad argument is `IllegalArgumentException`:** an unknown group, an unknown user, a
  non-positive amount, a duplicate participant, shares that do not match their split type,
  comparing or settling a user against themselves.
- **A bad situation is `IllegalStateException`:** creating a group whose id already exists. The id
  was a fine argument; the group already existing is what is wrong.
- **`addExpense`'s checks are in a fixed, stated order** — unknown group, unknown payer, empty or
  duplicate participants, unknown participant, non-positive amount, then the split's own shape —
  written down once on the interface rather than left to whichever check happened to be written
  first. Unlike the library problem, this order is **not independently testable by exception
  type**, because every one of those violations shares `IllegalArgumentException`; there is no
  distinct subtype per failure the way library's `UnknownMemberException` and
  `BorrowingLimitReachedException` are distinct. The order is real — the reference implements it,
  and a differently-ordered implementation would report a different, equally valid complaint about
  a multiply-bad call — but it is documentation for whoever implements this problem, not a
  hidden-test assertion. Stating it anyway is still the right call: an interviewer states this kind
  of thing out loud, and a candidate who is never told it exists has no way to match it by luck.
- **Settling more than is owed is deliberately not an error.** It flips who owes whom. A settlement
  records something that already happened; refusing to record an overpayment because it was
  generous would be the ledger inventing an opinion the real world does not have.
- **Comparing or settling a user with themselves is refused, not answered with zero.** Zero is the
  right number for "these two are square," and the wrong number for "this question does not make
  sense" — the two are different failure modes and this problem is precise about not conflating
  them, the same way library is precise about `UnknownLoanException` covering an id that never
  existed and one that has already been returned rather than picking one meaning for both.

## Immutability at the boundary

Every type crossing the API is a record that validates in its compact constructor: `Share` refuses
a negative value, `Split` refuses a negative amount, and `Expense` refuses splits that do not sum
to its own `amountMinor` — the single most useful invariant in the whole design, because it means
an expense whose arithmetic disagrees with itself cannot exist even transiently, and the exception
that catches it names the exact numbers that disagree rather than surfacing as a mysterious wrong
balance two calls later.

Collections never escape: `membersOf`, `expenses` and `simplifiedDebts` all return `List.copyOf`
snapshots, `Group` copies the member list it is built with, and nothing in this design ever hands
out a live reference to something it still owns.

## Time is absent, deliberately

There is no clock anywhere in this problem, and this is the corpus's weakest tag: **C3** is listed
for the dependency injection genuinely present — `SplitwiseSystem` is constructed with a
`SplitEngine` handed to it, and `SplitEngine` with a `Map<SplitType, SplitCalculator>` handed to
it, rather than either one reaching for a concrete implementation itself — but the clock half of
C3's title ("including injecting the clock") does not apply here at all, because nothing in this
domain depends on when anything happens. An expense has no due date, no expiry, no time-of-day
rule. Saying so out loud, rather than inventing a timestamp nothing reads to look more thorough,
is the same call the shopping-cart problem made about its own missing clock, and for the same
reason: a speculative dependency is the same mistake as a speculative interface.

## What was deliberately *not* built

- **No global user registry.** A user is an id, scoped to whichever groups it was added to at
  creation. Building a `User` entity with a name and an email would be inventing scope nobody
  asked for; the contract says as much.
- **No way to add or remove a group member after creation.** A realistic feature, and one that
  raises a genuinely hard question this problem does not need answered: what happens to a removed
  member's existing balances? Leaving it out is not an oversight, it is the honest boundary of what
  this contract asks for.
- **No editing or deleting an expense, and no undoing a settlement.** Both are real product
  features and both are a distinct, harder problem than the one this contract asks for: undoing an
  expense means reversing exactly the ledger changes it caused and no others, which needs the
  ledger to remember *why* a number is what it is, not just what it is. Nothing here asks for that
  memory, so nothing here builds it.
- **No caching of a net balance or a simplified plan.** Every query is computed on demand from the
  ledger, which is a handful of longs per pair for any group size this problem is scoped to; a
  cache here buys nothing measurable and adds an invalidation obligation on every mutator, which is
  exactly the shopping-cart problem's own argument against caching `total()`.
- **No locks.** The contract states one caller at a time. The shared mutable state, named rather
  than guarded speculatively, is the `groups` map inside `SplitwiseSystem` and each `Group`'s own
  member set, expense list and `Ledger`.
