-
Notifications
You must be signed in to change notification settings - Fork 0
ADR 0011 Balance Incremental Row
Tiana_ edited this page May 30, 2026
·
1 revision
Status: Accepted Date: 2026-05-30 Decider: Maintainer
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:
-
On-the-fly aggregation - compute
SUM(entries.amount)on every read. Always exact, no extra storage, but read cost grows with account history. -
Full-refresh materialized view - keep
account_balancesas a materialized view andREFRESH MATERIALIZED VIEW CONCURRENTLYafter each post. This re-aggregates the entireentriestable on every refresh, cannot run inside the posting transaction, and serializes under load. Unviable past a few hundred thousand entries. -
Incremental balance row - keep
account_balancesas a real table, one row per (account, currency), updated bybalance += amountinside the posting transaction.
Use an incremental balance row.
-
account_balancesis 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 + amountin the same transaction, underSELECT ... FOR UPDATEon 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 computesSUM(entries.amount) WHERE posted_at <= asOfdirectly fromentries, which is the only source that can answer a historical question (see ADR note onasOf). - A full-refresh materialized view remains available as an optional, scheduled reconciliation/reporting mode, off by default.
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.
- 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.
-
Data-Model -
account_balances - ADR-0007
- Implementation:
docs/plans/ledger-domain-foundation/decisions.md(D1, D4)
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO