Lesson 81Engineering deeperAdvanced

Event sourcing vs CQRS for the ledger

Your journal IS an event log. Don't fight that fact.

By Solomon Ajayi · Free to read, no signup

A double-entry ledger is naturally an EVENT-SOURCED system: every change is recorded as an immutable journal entry; the current state (wallet balance, account totals) is a PROJECTION derived from the entries. CQRS (Command Query Responsibility Segregation) takes the next step: separate the WRITE path (post entries, validate balance, enforce idempotency) from the READ path (precomputed projections like wallet.balance, statement, trial balance). This lesson posts an entry that updates BOTH the event log (journal_line) AND the projection (wallet.balance, materialised summary row), and shows why splitting them into write/read responsibilities scales better than trying to query the journal directly on every read.

A double-entry ledger is already an event-sourced system; most engineers just have not named it that way. Every journal entry is an immutable event, the journal is an append-only log, and the wallet balance is not a stored fact but a projection derived by replaying those events. You are not choosing whether to adopt event sourcing. You are choosing whether to work with the grain of what your ledger already is or to fight it.

CQRS takes the natural next step: split the write path from the read path because they want opposite things. The write path is the command side, where you validate that debits equal credits, enforce idempotency on the event id, and append. The read path is the query side, where wallet.balance and statements are maintained as separate materialised projections. Summing the journal on every read is O(n) and gets slower forever; reading a maintained projection is O(1) no matter how long the history grows.

Because the projection is derived and never authoritative, you can always rebuild it by replaying the journal, which is exactly what makes corruption recovery possible: if wallet.balance drifts or a bug poisons a view, you delete it and replay. The cost you pay for this is that projections often update asynchronously, opening an eventual-consistency window where a fresh write is in the log but not yet reflected in the balance. That window is a feature you manage, not a bug.

Worked example, step by step

WRITE: post journal entry (the source of truth)

Standard deposit ₦5,000. The COMMAND side: validate balance (debits = credits), enforce idempotency (unique key on event id), append to journal. The journal is APPEND-ONLY, never updated, never deleted, just added to. This is the immutable event log.

Deposit ₦5,000 (event-sourced write)
AccountDebitCredit
Bank Account (1200)₦5,000.00
User Wallet (2000)₦5,000.00

Bank Account UP ₦5,000 (debit). User Wallet UP ₦5,000 (credit). Two-line append to journal_line. No mutation of any other table. This is the event-sourcing essence, every change is a NEW row, never an update.

READ projection: wallet.balance materialised view UPDATES

Downstream of the journal write, a PROJECTION JOB (often in the same transaction for simple cases, or async via Outbox for high-volume) updates the materialised view. wallet.balance, statement.running_total, etc. are NOT recomputed from journal scratch on every read, they're maintained incrementally.

Marker: projection updated (wallet.balance += ₦5k)
AccountDebitCredit
Bank Account (1200)₦0.01
User Wallet (2000)₦0.01

The journal entry is unchanged. The projection is a SEPARATE table that says: 'User X has ₦Y balance as of T.' It's derived from the journal but stored separately for read performance. Lesson 38 covered this; here we frame it as the QUERY SIDE of CQRS. Querying wallet.balance is O(1); querying journal_line and summing is O(n).

Takeaway

Event sourcing + CQRS is the architecture YOUR LEDGER ALREADY WANTS. The journal is the immutable event log; wallet.balance and other materialised views are the projections. The WRITE path validates and appends; the READ path consults the projection. The corruption-recovery story (lesson 40) is a direct consequence, you can ALWAYS rebuild any projection by replaying the journal. The complexity tax is async projection updates (and the eventual-consistency window that creates); the payoff is constant-time reads at any scale and a ledger you can audit forever. Resist 'just update wallet.balance directly' shortcuts; they undo half the benefit.

Practice this on a real ledger

Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.

More in this section

Search lessons

Type to find any of the 85 lessons. Press Enter to open.