Event Sourcing for Financial Ledgers
Immutable events, rebuildable projections, double-entry invariants enforced at write time, replay for audit and the migration pitfalls that catch teams adopting the pattern.
A financial ledger has two jobs: record what happened and answer what the balance is. Systems that store only the second answer, a mutable balance column, lose the first, and eventually someone asks how a balance came to be and there is no way to tell. Event sourcing inverts this. The system of record is an immutable, ordered log of events describing what happened; balances and every other view are projections computed from that log. For ledgers in exchanges, payment systems and treasury tools this is the pattern we recommend, with some caveats that come from having migrated systems to it.
Events are facts, not commands
An event records something that has already happened and is stated in the past tense: funds credited, funds debited, hold placed, hold released, fee charged. It carries everything needed to interpret it without consulting other state: account identifiers, asset, amount as an integer in the smallest unit, a unique event identifier, a causation identifier linking it to the command or external transaction that produced it, and a timestamp. Events are never updated or deleted. A mistake is corrected by appending a compensating event that references the original.
Commands are different. A command is a request to do something, such as "withdraw 100 units", which may be rejected. The ledger validates the command against current state, and only if it is valid does it append the resulting events. Keeping commands and events distinct is what allows the log to be trusted: everything in it is something that was allowed to happen.
Double-entry as an invariant
Double-entry bookkeeping is not a convention to be layered on top; it is the invariant that keeps the ledger consistent. Every transaction consists of two or more entries whose amounts sum to zero per asset. A customer deposit credits the customer's account and debits the omnibus deposit account; a fee debits the customer and credits the fee revenue account. Enforce the zero-sum rule at write time, in the same atomic append as the events, and reject any transaction that violates it. Once this rule holds, the sum of all balances across the system is a constant, and any discrepancy is a bug you can locate rather than a mystery.
-- Append a balanced transaction atomically; the check rejects unbalanced entries.
WITH tx AS (
INSERT INTO ledger_transactions (id, causation_id, created_at)
VALUES ($1, $2, now()) RETURNING id
),
entries AS (
INSERT INTO ledger_entries (tx_id, account_id, asset, amount)
SELECT tx.id, e.account_id, e.asset, e.amount
FROM tx, unnest($3::ledger_entry[]) AS e
RETURNING asset, amount
)
SELECT asset, sum(amount) AS net FROM entries GROUP BY asset
HAVING sum(amount) <> 0; -- any row here means the caller must roll backThe snippet illustrates the shape rather than a complete implementation. In practice the balance check is a constraint trigger or is performed in application code inside the same transaction, and accounts carry a sign convention so that the amount column is unambiguous.
Projections are disposable
A projection is any read model derived from the log: current balances, per-account statements, daily aggregates, regulatory reports. Projections are stored for speed but are never the source of truth. That means they can be rebuilt from scratch whenever the code that computes them changes, and they can be versioned so that a new projection runs alongside the old one until it is verified. Balance projections should be rebuilt periodically and compared to the running values as a consistency check. A difference indicates either a projection bug or a write that bypassed the log, and both are serious.
Holds and available balance
Financial systems need more than one balance per account. A trading account has a total balance, an amount held for open orders and an available balance for new orders. Model holds as events (hold placed, hold released, hold consumed) and compute available balance as a projection. Treating available balance as a separate mutable number is the most common way ledgers drift.
Replay and audit
Because the log is complete and ordered, any state at any point in time can be reconstructed by replaying up to that point. This is what makes audit trails trustworthy: the auditor is not reading a report someone generated; they are re-deriving it. It also makes incident analysis tractable. When a balance looks wrong, replay the account's events and find the one that introduced the discrepancy.
Replay needs to be practical. Snapshot projections at intervals so a rebuild starts from a recent state, partition the log by account or by shard so replay can be parallel, and keep the event schema stable enough that old events can still be interpreted. Schema changes are handled with upcasters that translate old event versions on read, never by rewriting the log.
Migration pitfalls
Most ledgers are not built event-sourced from day one; they are migrated. The pitfalls are consistent enough to list.
- Importing balances without history. Starting the log with an opening-balance event per account is acceptable, but record the source system, the extraction time and a hash of the source data so the opening state is itself auditable.
- Dual writes. During migration, teams write to both the old balance table and the new log. Inevitably one write succeeds and the other fails. Route all writes through the new system and derive the old table as a projection instead.
- Events that encode decisions instead of facts. An event named "withdrawal approved" that hides the amount and account behind a reference to another system cannot be replayed without that system. Include the data.
- Floating point anywhere. Amounts are integers in the smallest unit; conversions happen only at the display layer.
- Unbounded event payloads. Events that carry entire request bodies balloon the log and leak data. Store what the ledger needs and reference the rest.
- Ignoring ordering guarantees. If events for one account can be appended by multiple writers, define the ordering mechanism (a per-account sequence, a single writer per shard) before the first write, not after the first race.
Operational consequences
Event sourcing changes how the team works. Bugs are fixed forward with compensating events rather than by editing rows, which requires a small set of well-tested correction transactions and a process for authorising them. Storage grows monotonically, so archival of old partitions to cheaper storage needs planning, with the constraint that archived events must remain retrievable for replay. Reports are computed from projections, so report changes are code changes with tests. Teams that accept these constraints get a ledger where every number has a provenance, which is the only kind of ledger worth operating in a regulated business.