Crux Read real event-sourcing snippets — a projection rebuild, a snapshot-aware load, and an upcaster — predict the behaviour, and pick the senior fix.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
The pattern lives or dies in the fold loop, the projection handler, the snapshot load, and the upcaster. Read each snippet, predict what it does under real conditions, then choose the fix a senior makes first.
Goal
Practise reading the four pieces of code every event-sourced system has: rebuilding a read model by replay, keeping a projection idempotent, loading via a snapshot plus tail, and upcasting an old event shape without rewriting history.
Snippet 1 — rebuilding a projection
// "drop and replay" rebuild of the balances read modelasync function rebuildBalances(store: EventStore, db: ReadDb) { await db.exec("TRUNCATE balances"); for await (const e of store.readAll({ from: 0 })) { // every event, in order if (e.type === "MoneyDeposited") { await db.exec( "UPDATE balances SET cents = cents + $1 WHERE acct = $2", e.data.cents, e.data.acct, ); } // MoneyWithdrawn handler omitted }}
Quiz
Completed
The balances table starts empty after TRUNCATE. For an account whose first event is MoneyDeposited, the UPDATE touches zero rows and the balance never appears. What is the correct fix?
Heads-up A full rebuild SHOULD start from empty — that is what makes projections disposable and re-derivable. The bug is that the handler never inserts the row, not that the table was cleared.
Heads-up Event order must be oldest-first for a correct fold; reversing it corrupts state. The missing row comes from an UPDATE with no prior INSERT, not from ordering.
Heads-up A unique index constrains duplicates; it does not make an UPDATE insert a missing row. You need an UPSERT or a creation-event handler.
Snippet 2 — the projection handler under retries
async function onEvent(e: Event, db: ReadDb) { // at-least-once delivery: this can be called twice for the same event await db.exec( "UPDATE counters SET total = total + 1 WHERE name = $1", e.aggregateId, );}
Quiz
Completed
Delivery is at-least-once, so this handler can run twice for the same event. What is the bug and the standard fix?
Heads-up Databases do not dedupe by statement text — running the same increment twice adds two. At-least-once delivery means you must enforce idempotency yourself.
Heads-up True exactly-once across a network and a database is generally unattainable; systems give at-least-once and you make the consumer idempotent. Relying on exactly-once is the trap.
Heads-up Retrying makes duplicate application MORE likely, not less. The fix is a version checkpoint that makes reapplication a no-op, not more retries.
Snippet 3 — loading an aggregate with a snapshot
async function loadAccount(id: string, store: EventStore): Promise<Account> { const snap = await store.latestSnapshot(id); // may be null let state = snap ? snap.state : Account.empty(id); const from = snap ? snap.version : 0; for await (const e of store.read(id, { from })) { // BUG is here state = apply(state, e); } return state;}
Quiz
Completed
The snapshot captured state up to and including version N (snap.version === N). The read uses from = N. What goes wrong?
Heads-up apply is a fold step, not idempotent — applying MoneyDeposited twice adds the amount twice. The snapshot already includes version N, so you must skip it.
Heads-up That defeats the whole point of snapshots, which exist to bound replay cost. The fix is the correct exclusive bound, not discarding the snapshot.
Heads-up read returns events oldest-first from the given version; ordering is fine. The defect is the inclusive lower bound that re-applies version N.
What is the role of this function, and which property is essential for it to be safe to carry indefinitely?
Heads-up It returns a transformed in-memory object and never writes back to the store; the persisted v1 event is untouched. Rewriting the log would violate append-only.
Heads-up It produces a normalized event, not a read-model row. Upcasting happens on the read path of the event store, before the event reaches projections or aggregates.
Heads-up Scattering defaults across every consumer leaks schema history indefinitely. Centralizing it in one tested upcaster is exactly the point — domain code stays on the current shape.
Recap
Every event-sourced system is read in these four pieces of code. A rebuild starts from empty and must reconstruct rows from the log, so handlers UPSERT or honour creation events rather than assuming rows exist. Projections face at-least-once delivery, so they advance a per-stream version checkpoint in the same transaction as the write and ignore already-seen versions. Snapshot loads use an exclusive lower bound (version + 1) so the snapshotted event is not re-folded. And upcasters are pure, tested transformations that lift old shapes to current at read time, never rewriting the persisted log. Read the code, predict the fold, then fix the allocation of truth — never the log itself.