event-sourcing
Event Sourcing (Marten)
The problem: the balance is a side effect, not a fact
In Phase 1 the account was a row: balance = 7500. Every debit overwrote that
number and, as a courtesy, appended a ledger_entries row so we could reconstruct
how we got there. But the row was the truth and the ledger was a copy — so the
two could drift, "why is this balance what it is?" meant trusting a log nobody
enforced, and a bug that wrote the wrong balance left no way to recompute the
right one.
Event sourcing inverts that. The events are the truth; the balance is a fold of them and is never stored authoritatively. You can throw the balance away and recompute it, because the only writes that ever happened are the events.
The pattern
- An account is a stream of events (
AccountOpened,AccountDebited,AccountCredited,DebitReversed, plusDebitRejected/CreditRejected). - State is rebuilt by folding:
Apply(AccountDebited e) => BalanceMinor -= e.AmountMinor. The current balance isevents.Aggregate(seed, Apply). - Replaying the whole stream on every read is wasteful, so Marten keeps an
inline snapshot: a materialised
Accountdocument updated inside the same transaction as the append. Reads are O(1); the stream is still the source of truth the snapshot is derived from. - Concurrent writers are handled with optimistic concurrency: appends carry
an expected version and the event table has
UNIQUE(stream_id, version). Two debits that both load version 4 and both try to write version 5 — one wins, the other gets aConcurrencyExceptionand the message is retried. This is the event-sourced equivalent of the Phase 1xminrow-version check.
Where it lives in our code
account-service/src/AccountService/Domain/AccountEvents.cs— the events.Domain/Account.cs— the aggregate. Two halves kept apart on purpose:- Decisions (
Open/Debit/Credit/ReverseDebit) look at current state and a command and return the event(s) to append. They never mutate — that is what keeps "balance never goes negative" enforceable in exactly one place. Applyfolds an already-decided event into state. By the time an event exists, it happened;Applycannot reject anything.
- Decisions (
Program.cs—AddMarten(...)withProjections.Snapshot<Account>(Inline)and per-event metadata (correlationId + headers) enabled.Consumers/*.cs— each saga stepFetchForWriting<Account>(id)(which captures the expected version), asks the aggregate for the event,AppendOne, and saves.
The key insight: the event store IS the outbox
Phase 1 needed a transactional outbox because state and events lived in two places and had to be written atomically. With event sourcing there is only one write — the append — so the dual-write problem disappears. The Marten event store is already an ordered, durable log, so we publish from it instead of from a second table:
Infrastructure/OutboxRelay.cs is a BackgroundService that walks Marten's global
event Sequence from a persisted RelayCheckpoint and publishes each event's
integration counterpart to RabbitMQ. It is safe without a distributed transaction:
- the append is the only write — nothing to reconcile;
- delivery is at-least-once (publish, then advance the checkpoint — a crash in between re-publishes on restart);
- re-publishing is harmless because the integration
messageIdis the Marten event Id — stable across replays — and every consumer deduplicates on messageId. At-least-once + idempotent = effectively once.
Failures are events too (DebitRejected on a transfer-keyed stream), so
DebitFailed/CreditFailed are relayed with the same guarantee as successes —
there is no second, less-reliable publish path for the unhappy case.
Idempotent consumption uses a Marten inbox document (SagaSession): the
incoming messageId is Inserted in the same transaction as the append, so a
redelivery rolls the whole thing back.
Trade-offs
- Event sourcing is not an audit log. An audit log is written alongside state for humans to read; an event stream is the state and the system replays it. You can delete the snapshot; you cannot delete the events.
- Schema evolution is forever. You can never "migrate" old events — they are immutable history. New shapes are handled with upcasting / versioned events (see event contracts & versioning).
- Denormalised post-balance in the event. Each balance event also stores
BalanceAfterMinor. That is a convenience for the relay and the ledger view; the fold still derives the balance from the running total, so the stored value is a checksum, not the source of truth. A "pure" delta-only stream avoids the redundancy at the cost of a re-fold on every projection. - Single-writer relay. One account-service instance runs the relay; a second would double-publish (still correct, just wasteful). Production hardening is leader election via a Postgres advisory lock.
Interview Q&A
Q: Event sourcing vs. an append-only audit/ledger table? The ledger in Phase 1 was a copy kept beside the authoritative balance row — it could drift and nothing replayed it. Here the events are the only writes and the balance is recomputed from them; there is no other source to drift from.
Q: How do you get the current balance without replaying millions of events?
A snapshot/projection. Marten updates an inline Account document in the same
transaction as the append, so reads are O(1). The snapshot is disposable — delete
it and it rebuilds from the stream.
Q: Two debits hit the same account at once — what stops a lost update?
Optimistic concurrency on append: UNIQUE(stream_id, version). Both load version
N, both try to write N+1, one wins, the loser gets ConcurrencyException and the
message is retried against the new state.
Q: With event sourcing, do you still need a transactional outbox?
No — that is the elegant part. The store is already the log. A checkpointed relay
publishes from it; because the broker messageId is the stable event Id,
at-least-once delivery is idempotent end-to-end.
Q: A failed debit changes no state — how does it still reach the saga reliably?
It is recorded as a DebitRejected event (on a transfer-keyed stream) and relayed
like any other. Modelling rejections as events means there is one delivery path,
not a reliable one for successes and a fire-and-forget one for failures.
Q: How do you fix a bug that produced wrong balances?
Fix the projection/Apply logic and rebuild the snapshot (and read models) from
the event log. The events are correct; only the derived view was wrong.
Try to break it
- Replay determinism. Wipe the
Accountsnapshot table and let it rebuild from the stream. The balances must come back identical. If a fold is non-deterministic (e.g. readsDateTimeOffset.UtcNow), replay diverges — that is why decisions, notApply, own all non-determinism. - The concurrency window. Fire two simultaneous debits at one account. Exactly
one
AccountDebitedshould land per balance; the other must retry, not silently overwrite. Confirm withUNIQUE(stream_id, version)inmt_events. - At-least-once, not at-most-once. Kill account-service after the relay
publishes but before it writes the checkpoint. On restart the event is published
again — and transfer-service's inbox must drop it because the
messageId(= event Id) repeats. Change the relay to mint a freshmessageIdand watch the duplicate slip through. - Snapshot identity. The aggregate's
Id/state need public setters or Marten loads them as defaults (we hit exactly this: aprivate setleftIdempty on load). MakeBalanceMinorprivate-set again and watch every read return 0.