Skip to main content

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 through PostTransaction.
  • No domain table stores a balance column 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.balance and LedgerAccount.balance columns. 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.balance directly.
  • 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.md once the first projection lands.