ADR-006: The ledger is the sole source of money truth — no derived balance columns
- Status: Accepted
- Date: 2026-05-23
- Deciders: CTO / Founding Engineering / Ledger team
- Relates to: ADR-001, ADR-005
Context
The pre-restructure Prisma schema stored balances directly on entities (Wallet.balance, LedgerAccount.balance). This is the most common silent-corruption bug in fintech: a state-change updates the entity but skips the journal — or vice versa — and the two diverge. Discovering the divergence happens during a dispute, weeks later.
Decision
- The ledger (
services/ledger, today scaffolded; landing as a Go service) is the only source of authoritative money state. Every debit and credit goes throughPostTransaction. - No domain table stores a
balancecolumn for an account whose money is recorded in the ledger. - Balances are derived via
Ledger.GetBalance(accountId). Where read-throughput requires it, derive through a maintained projection (materialized view, read-model maintained by an outbox consumer) — not by writing back into the originating row. - Reads that "feel like" they need a cached balance get a clearly-named projection (
wallet_balance_projection) that is rebuildable from the ledger at any time. Projections are not the source of truth.
Alternatives considered
- Store balance + journal both. Rejected — the entire class of "they disagree" bugs is the bug we're trying to prevent.
- Compute balances on every read with no projection. Acceptable for low-traffic accounts. Will not scale to wallet balances on the employee-web home screen.
- Trust application-level locking to keep balance and journal in sync. Rejected — humans forget. The invariant must be structural.
Consequences
Positive
- One question — "what's the real balance?" — has one answer.
- Reconciliation is a comparison of authoritative state to one or more projections; if a projection diverges, rebuild it. No fishing.
- Carving out the ledger to a separate service (Go) is straightforward because no other system writes to the ledger's data — they only read it.
Negative
- A migration removes the existing
Wallet.balanceandLedgerAccount.balancecolumns. This migration is deferred out of the restructure PR and tracked separately, because it requires:- Standing up the Go ledger service end-to-end.
- Migrating existing balances into journal entries.
- Rewriting every read path that currently hits
wallet.balancedirectly.
- Until migration: existing code stays as-is;
tooling/prisma-lint/lists these columns as known violations grandfathered with a comment.
Follow-ups
- Stand up the Go ledger past
/health:PostTransaction,GetBalance,GetEntries,Reverse. - Write the migration PR (separate ADR will record the cutover plan).
- Document the projection pattern in
docs/runbooks/projection-rebuild.mdonce the first projection lands.