Skip to content
Engineering10 min read

An Event-Sourced Audit Ledger for Financial Transactions

How to architect a financial ledger where every balance is a projection of an immutable event log, not a mutable row — and why regulators tend to like that.

  • Event sourcing
  • CQRS
  • Kafka
  • PostgreSQL
  • Optimistic concurrency
  • Idempotency keys
  • Snapshotting

By TRAGenX Engineering

Why a mutable balance column isn't enough

A conventional ledger table with a `balance` column that gets updated in place answers one question well — what is the balance right now — and answers almost nothing else. It can't tell you why the balance changed, in what order, under whose authority, or what the balance was at 09:14:03 last Tuesday before a disputed reversal. For a financial system under audit, that second set of questions is often the one that actually matters: to a regulator, an auditor, or a customer disputing a charge, the history is the product, not a side effect of storing it.

Event sourcing flips the storage model: the durable, authoritative record is an append-only sequence of domain events — `FundsDebited`, `FundsCredited`, `HoldPlaced`, `HoldReleased`, `TransactionReversed` — and the current balance is a derived value, recomputed (or incrementally maintained) by replaying those events. Nothing is ever updated or deleted; corrections are new events, not edits to old ones. This isn't a stylistic preference. It's the only storage model that gives you, for free, a complete and tamper-evident causal history of every cent that moved, which is exactly what audit and dispute-resolution workflows need and what a mutable-row design structurally cannot provide without bolting on a parallel change-log.

The core data model: streams, events, and aggregates

The unit of consistency is an event stream, typically one per account or per ledger entity, identified by a stable ID. Each event carries a monotonically increasing sequence number scoped to its stream, an event type, a payload, and metadata — actor, correlation ID, causation ID, timestamp, and a schema version. The stream itself is the aggregate boundary: you never need cross-stream transactions to decide whether a single account's history is internally consistent, which is what makes the model horizontally scalable.

The schema of the event store is intentionally boring: `(stream_id, sequence_no, event_type, payload, metadata, recorded_at)` with a uniqueness constraint on `(stream_id, sequence_no)`. That constraint is not incidental — it's the mechanism that turns 'two concurrent writers try to append to the same account' into a database-level conflict instead of a silent lost update. Payloads are stored as versioned, schema-validated blobs (Avro or JSON Schema both work) rather than loosely-typed JSON, because five years from now something will need to deserialize an event written today, and 'we'll just infer the shape' is not an audit-safe answer. Domain aggregates — an `Account`, a `Wallet`, a `SettlementBatch` — are not persisted directly; they're rebuilt in memory by folding their event stream through a pure reducer function, which is also the single place where the business rules for 'is this transition legal' actually live.

The write path: commands, idempotency, and optimistic concurrency

A command — 'debit account A 4,000 minor units for order 9182' — is not an event. It's a request that gets validated against the current aggregate state (rebuilt from its event stream, or from a snapshot plus the tail of the stream) before it's allowed to produce one. This separation matters because it's where business invariants get enforced: sufficient balance, account not frozen, transaction not already applied. Only if the command passes does the handler emit one or more events and attempt to append them.

The append is guarded by optimistic concurrency: the writer read the stream at sequence N, and the append is conditioned on the stream still being at N. If another writer got there first, the append is rejected, the aggregate is rehydrated with the new events, and the command is re-evaluated — not blindly retried, because the world may have changed underneath it. Every incoming command additionally carries a client-supplied idempotency key, and the handler checks a small side index of previously-processed keys before doing anything else. This is what makes retries — from a flaky network, an at-least-once message queue, or a user double-tapping 'pay' — safe: the same command replayed twice produces one event, not two. Idempotency and optimistic concurrency are solving different problems (duplicate requests vs. concurrent requests) and a production ledger needs both; relying on only one is a common and expensive mistake.

The read path: projections and CQRS

Nobody wants to replay ten thousand events to answer 'what's my balance.' The write side stays a pure, narrow event log; a separate set of projections — read-optimized materialized views — subscribe to the event stream and maintain the shapes the rest of the system actually queries: current balances, a paginated transaction history per account, daily settlement totals, a search index over counterparty names. This is CQRS: commands and queries are served by structurally different models, connected by the event stream as the single source of truth.

Projections are disposable and rebuildable by construction — if a projection has a bug, or the read model needs a new shape, you drop it and replay the event log into a corrected version, rather than attempting an in-place data migration on a live table. That property is worth designing for deliberately: it turns 'we need a new report' from a migration project into a replay job. The trade-off is a propagation delay between an event being appended and a projection reflecting it — usually milliseconds to low seconds via a change-data-capture feed or an event bus like Kafka — which means the read side is eventually consistent and the write side needs to be the one place that enforces strict invariants like non-negative balances, since a stale projection cannot be allowed to gate that decision.

Consistency, correctness, and failure handling

Because history is immutable, correcting a mistake is a new fact, not an edit. A wrongly-applied debit is reversed by a `TransactionReversed` event referencing the original, not by deleting or rewriting the original row — the ledger should read like a court transcript, with corrections entered on the record rather than the original testimony erased. This is also what makes the model genuinely audit-friendly: a regulator asking 'show me exactly what happened and in what order' gets a literal answer instead of a reconstruction effort.

Replaying an entire stream from event zero to rebuild an aggregate gets expensive as history grows, so long-lived streams get periodic snapshots — a serialized aggregate state plus the sequence number it was taken at — and rehydration becomes 'load the latest snapshot, replay only the events after it.' Snapshots are a pure performance optimization and must never become a source of truth; if a snapshot and a replay from raw events ever disagree, the raw events win, always. Schema evolution is the other failure mode worth planning for explicitly: an event type will need a new field or a corrected meaning years after millions of instances of the old shape are already durably stored, so every event carries a version tag and the reducer needs upcasting logic — transforming old-shape events into the current shape at read time — rather than a database migration touching immutable history. Exactly-once delivery to downstream consumers (a general-ledger export, a regulatory feed) is handled the way it always has to be in distributed systems: not by a delivery guarantee that doesn't really exist, but by making the consumer's apply operation idempotent against the event's unique ID.

Trade-offs we made, and what we'd push back on

Storage grows monotonically and forever — there's no update-in-place to reclaim space, and 'just delete old rows' is precisely the operation this architecture exists to prevent. That's an accepted cost, not an oversight, and it needs a real retention and cold-storage strategy (tiering older segments to cheaper storage, keyed by stream) planned in from day one rather than bolted on when the primary event store gets uncomfortably large. Query flexibility is a real cost too: ad hoc analytical queries against raw events are awkward by design, which is exactly why projections exist — but that means every new reporting need requires either an existing projection to already cover it or a deliberate decision to build a new one, not a quick ad hoc SQL query against the source of truth.

What we would push back on is applying this pattern uniformly across an entire system. Event sourcing earns its complexity for the ledger core, where the history is the audit trail and the business asset. It is very likely overkill for, say, a user-preferences table or a session store, where a simple CRUD row with a separate audit log bolted on is cheaper to build, cheaper to reason about, and just as defensible. The judgment call is deciding where the audit trail is the product and where it's incidental — and building event sourcing everywhere 'for consistency' is itself a form of over-engineering this architecture is meant to guard against, not require.

Building something like this?

This is the kind of foundational systems work TRAGenX takes on. If you have a project that needs a core other things can trust, tell us about it and we'll get back to you.