Skip to content

ADR 0011 Balance Incremental Row

Tiana_ edited this page May 30, 2026 · 1 revision

ADR-0011: Account balance as an incremental row, not a per-post materialized view

Status: Accepted Date: 2026-05-30 Decider: Maintainer

Context

GET /accounts/{id}/balance must be fast and exact. A balance is, by definition, SUM(entries.amount) per account and currency. The question is how to serve it.

Options considered:

  1. On-the-fly aggregation - compute SUM(entries.amount) on every read. Always exact, no extra storage, but read cost grows with account history.
  2. Full-refresh materialized view - keep account_balances as a materialized view and REFRESH MATERIALIZED VIEW CONCURRENTLY after each post. This re-aggregates the entire entries table on every refresh, cannot run inside the posting transaction, and serializes under load. Unviable past a few hundred thousand entries.
  3. Incremental balance row - keep account_balances as a real table, one row per (account, currency), updated by balance += amount inside the posting transaction.

Decision

Use an incremental balance row.

  • account_balances is a normal table keyed by (account_id, currency).
  • During posting, after the entries are inserted and the deferred SUM=0 trigger is satisfied, the balance row is updated balance = balance + amount in the same transaction, under SELECT ... FOR UPDATE on that row (consistent with the READ COMMITTED + row-lock posting strategy, see decisions.md D2).
  • Reads are O(1): a single indexed row lookup.
  • Time-travel (asOf) does not use this row; it computes SUM(entries.amount) WHERE posted_at <= asOf directly from entries, which is the only source that can answer a historical question (see ADR note on asOf).
  • A full-refresh materialized view remains available as an optional, scheduled reconciliation/reporting mode, off by default.

Consequences

Positive:

  • O(1) current-balance reads; meets the p99 < 50ms budget.
  • Exactness: the update is in the same transaction as the entries, so the balance can never drift from the entry sum within a committed transaction.
  • No full-table re-aggregation, no refresh serialization, no mid-transaction MV refresh problem.

Negative / cost:

  • The balance row is a contention point per account; posting takes a row lock on it. Acceptable because postings to a single account are already serialized by the account lock, and high-fan-out accounts are rare in the ledger model.
  • A scheduled reconciliation job should periodically assert account_balances.balance == SUM(entries.amount) to catch any logic bug; this is also a strong correctness test.

Alternatives rejected

  • Full-refresh MV per post: does not scale; re-aggregates all entries each post.
  • On-the-fly only: simplest, acceptable for the sandbox, but read latency grows with history; the incremental row gives flat latency for the same write cost.

Related

  • Data-Model - account_balances
  • ADR-0007
  • Implementation: docs/plans/ledger-domain-foundation/decisions.md (D1, D4)

Clone this wiki locally