Event Sourcing for Auditable Ledgers
Building a CQRS Transaction History Store
Building a CQRS Transaction History Store
Every ledger I've worked on eventually gets the same question from an auditor: "show me exactly how this balance came to be, in order, with nothing missing." A system that only stores current balances can't answer that — it can show you where the number landed, not how it got there. Event sourcing exists for exactly this question, and CQRS is what keeps answering it fast from making every other query slow. This is a walkthrough of building both correctly, including the part where "just use Kafka as your event store" needs a caveat most tutorials skip.
A conventional ledger table stores current balance and overwrites it on every transaction. That's fine for reading a balance quickly and terrible for proving how it was reached — the history of how you got there is gone the moment the update commits, unless you've separately built an audit log to shadow it. Event sourcing inverts the model: the append-only sequence of events — FundsDeposited, FundsWithdrawn, TransferInitiated — is the source of truth. A balance isn't stored; it's derived, by replaying events in order. Nothing is ever updated or deleted. That single design choice is what makes the audit question trivial to answer instead of expensive to reconstruct.
CQRS is what keeps that design fast in practice. The write side (the event store) only ever appends. The read side (a projection, optimized for the query you actually need — "what's this account's balance right now") is built by consuming those events and materializing them into a queryable shape. Splitting the two means the write path stays simple and the read path can be denormalized however a dashboard or a statement generator needs it, without either constraining the other.
1 public sealed interface AccountEvent permits FundsDeposited, FundsWithdrawn {
2 UUID accountId();
3 long version();
4 Instant occurredAt();
5 }
6
7 public record FundsDeposited(
8 UUID accountId, long version, Instant occurredAt, BigDecimal amount) implements AccountEvent {}
9
10 public record FundsWithdrawn(
11 UUID accountId, long version, Instant occurredAt, BigDecimal amount) implements AccountEvent {}
version is the field that matters most and gets skipped most often in tutorials: a strictly increasing sequence number per aggregate (per account, here), starting at 1. It's what makes optimistic concurrency possible — and optimistic concurrency is what stops two concurrent withdrawals from silently corrupting the same account's history.
This is the caveat worth being explicit about, because "use Kafka as your event store" is common advice that glosses over something Kafka doesn't do: it has no native way to reject a conditional append. A Kafka topic will happily accept two producers both writing "version 6" for the same account, in whichever order they arrive — there's no broker-side check equivalent to a unique constraint. For a ledger, that gap is exactly the race condition that produces a corrupted, unreconstructable balance history.
The pattern that actually holds is the same one we've written about for Kafka payment pipelines: the database is the real source of truth for the atomic append, with a unique constraint enforcing the optimistic concurrency check; Kafka's role is distributing those events out to projections and other services, not arbitrating who wins the write.
1 @Entity
2 @Table(name = "account_events",
3 uniqueConstraints = @UniqueConstraint(columnNames = {"account_id", "version"}))
4 public class AccountEventEntity {
5 @Id @GeneratedValue private UUID id;
6 private UUID accountId;
7 private long version;
8 private String eventType;
9 private String payload; // serialized event
10 private Instant occurredAt;
11 }
12
13 @Transactional
14 public void append(UUID accountId, long expectedVersion, AccountEvent event) {
15 try {
16 eventRepository.saveAndFlush(
17 new AccountEventEntity(accountId, expectedVersion, event));
18 outboxRepository.save(new OutboxEntry(accountId, serialize(event))); // same DB transaction
19 } catch (DataIntegrityViolationException e) {
20 throw new ConcurrentModificationException(
21 "Account " + accountId + " was modified concurrently — reload and retry");
22 }
23 }
The unique constraint on (account_id, version) is what actually enforces the guarantee. A caller that read the account at version 5 and tries to append version 6 while someone else's version-6 write already landed gets a constraint violation, not a silently corrupted sequence — the same defense-in-depth pattern we've used for idempotency, applied here to append ordering instead of duplicate requests. The outbox row written in the same transaction is what gets relayed to Kafka via CDC, exactly as described in our piece on Kafka transactional pipelines — so events reach downstream projections reliably without ever making Kafka responsible for the concurrency check it isn't built to make.
Rebuilding a balance by replaying every event since account opening works fine for a new account and gets slow for one with ten years and forty thousand transactions behind it. A snapshot is a periodic materialization of an aggregate's state at a given version, so a rebuild only has to replay events since the snapshot:
1 public Account load(UUID accountId) {
2 Optional snapshot = snapshotRepository.findLatest(accountId);
3 long fromVersion = snapshot.map(Snapshot::version).orElse(0L);
4 Account account = snapshot.map(Snapshot::toAccount).orElseGet(Account::empty);
5
6 eventRepository.findByAccountIdAndVersionGreaterThan(accountId, fromVersion)
7 .forEach(account::apply);
8 return account;
9 }
10
11 @Scheduled(cron = "0 0 2 * * *")
12 public void snapshotActiveAccounts() {
13 accountRepository.findModifiedSince(LAST_SNAPSHOT_RUN).forEach(account -> {
14 if (account.version() % SNAPSHOT_FREQUENCY == 0) {
15 snapshotRepository.save(Snapshot.of(account));
16 }
17 });
18 }
Snapshots are a performance optimization, never a replacement for the event log — they're disposable and rebuildable from events at any time. If a snapshot is ever found to be wrong (a bug in apply logic, say), deleting it and rebuilding from events is always safe. That property — snapshots are cache, events are truth — is the whole reason this design stays audit-grade even after snapshotting is added.
The projection that actually answers "what's this account's balance" lives on the Kafka-consuming read side, built from the same events the write-side event store produced:
1 @KafkaListener(topics = "account-events", groupId = "balance-projector")
2 public void project(AccountEvent event) {
3 BalanceView view = balanceViewRepository.findByAccountId(event.accountId())
4 .orElseGet(() -> BalanceView.empty(event.accountId()));
5 view.apply(event); // same domain logic as the write-side aggregate
6 balanceViewRepository.save(view);
7 }
This consumer can be dropped and rebuilt from the beginning of the topic at any time — replaying it against the full event history is how you fix a bug in the projection logic, or stand up a new read model (a monthly statement view, a regulator-facing export) without touching the write side at all. That's the actual payoff of the CQRS split: the read model is disposable and the write model isn't, which is the opposite of how a conventional balance-column ledger is forced to treat both.
Immutability has to be enforced, not just assumed by convention — a database role with INSERT-only privilege on the events table, no UPDATE or DELETE grant at all, is what actually stops a well-meaning engineer's one-off data fix from quietly rewriting history. Replay has to be deterministic — an apply() function with no external dependency, no current-time lookup, nothing that could produce a different result run twice against the same event sequence. And the event schema itself needs the same evolution discipline as any other long-lived contract: additive changes only, versioned event types, because an event written five years ago has to still deserialize and replay correctly today. None of that is exotic. It's the same discipline that makes any audit trail trustworthy — applied here to the ledger itself, not bolted onto it afterward.