The idea: store what happened, not the current value
A normal CRUD table keeps one row per account and overwrites the balance on every change. With event sourcing the account is never stored as a value. It is stored as the list of things that happened to it: AccountOpened, MoneyDeposited +100, MoneyWithdrawn −30. The balance, 70, is computed by replaying these events in order. Events are facts in the past tense. They are only ever appended, never changed or deleted.
CQRS (Command Query Responsibility Segregation) splits the code that changes data from the code that reads it. Writes go to the event store. Reads go to a separate read model, a plain table that a projector fills from the events. The two ideas are often used together, as on this page.
The page has three parts. On the command side two app instances, A and B, handle commands for one bank account, acc-42. In the middle is the event store with the stream acc-42 and a snapshot store. On the query side a projector keeps the table account_balance up to date, and a reader queries it. Pick a command and press Send Command, then Run Projector and Query Balance, or run a demo.
Commands, aggregates and events
A command is a request in the imperative: Withdraw 30. It can be refused. An event is what was decided: MoneyWithdrawn 30. It cannot be refused, because it already happened.
The aggregate is the object that decides. To handle a command, the app instance:
- loads the aggregate: reads the stream and folds the events, oldest first, into the current state (open, balance, version);
- checks the rules on that state: open only once, deposit and withdraw only when open, withdraw only if the balance is high enough;
- if the rules allow it, appends the new event; if not, rejects the command and writes nothing.
The app instances keep nothing between commands. Every command loads the aggregate again (Demo 1: open, deposit, withdraw). A command that breaks a rule leaves the stream exactly as it was (Demo 3: a rejected command writes nothing).
| version | event | state after it |
|---|---|---|
| v1 | AccountOpened (owner Ann) | open, balance 0 |
| v2 | MoneyDeposited +100 | open, balance 100 |
| v3 | MoneyWithdrawn −30 | open, balance 70 |
Optimistic concurrency: the expected version
Two instances can load the same account at the same time. Both see version 2 and balance 100. A wants to withdraw 70, B wants to withdraw 50. Each check passes on its own copy. If both events were written, the balance would be −20.
The store prevents this with the expected version. Every append says which version the writer loaded: append(acc-42, [MoneyWithdrawn 70], expectedVersion = 2). The store accepts it only if the stream is still at that version. A's append makes the stream v3. B's append, still expecting v2, is refused with WrongExpectedVersion. B then reloads, sees balance 30, checks the rule again and rejects its command (Demo 4: two writers, one expected version).
No lock is held while a command is handled. This is optimistic concurrency: conflicts are rare, so detect them at write time and retry. The check is per stream, so writes to different accounts never conflict.
CQRS: the read side and its lag
Folding events on every read would be slow, and most screens need data shaped differently from the events (a list of balances, a monthly statement). So the query side keeps its own read model. The projector reads the events after its checkpoint (the last version it applied), updates the table and moves the checkpoint forward.
The projector runs on its own, after the write has already been confirmed to the client. Until it catches up, a query returns old data. On the page the lag is shown as a number of events. This is eventual consistency: the read model is correct for some earlier version, and will be correct for the latest one soon (Demo 2: the read model lags behind). User interfaces deal with it by showing the result of the command itself, by waiting until the read model reaches the returned version, or by simply telling the user the change is being processed.
| command side | query side | |
|---|---|---|
| stores | events, append-only | tables shaped for the screens |
| consistency | strong, per stream (expected version) | eventual, behind by the lag |
| source of truth | yes | no: can be dropped and rebuilt |
| scales by | splitting streams | more read replicas or more projections |
Rebuilding and new projections
The read model holds nothing the events don't. Empty the table, set the checkpoint to 0 and replay every event: the same table comes back (Demo 5: rebuild the read model by replay). The same trick fixes a projector bug (deploy the fix, rebuild) and adds a new view of old data (a new projection that starts at v1). Projectors must therefore be deterministic and must not call out to other services while replaying.
Snapshots
A stream with thousands of events is slow to load. A snapshot saves the folded state at some version. Loading then reads the snapshot plus only the events after it (Demo 6: a snapshot shortens the load). A snapshot is a cache: it can be deleted at any time and rebuilt from the events. Many systems take one every N events, and many never need one, because most streams stay short.
What it costs
| CRUD table | event sourcing + CQRS | |
|---|---|---|
| history and audit | lost unless kept separately | built in: every change is an event |
| read after write | immediate | stale until the projector catches up |
| changing the shape of data | migrate the table | events are forever: version them, upcast old ones |
| deleting personal data | DELETE the row | hard: crypto-shredding or rewriting streams |
| moving parts | one database | event store, projectors, checkpoints, read stores |
It pays off where the history itself matters (money, orders, bookings) or where many different read views are needed. For a simple settings screen it is mostly overhead.
Events leaving the service
Other services often need the same events. Publishing them from the handler after the append is a dual write, with the same problems as in Transactional Outbox and Inbox. With event sourcing the stream itself can act as the outbox: a subscription reads it and forwards each event, much as Change Data Capture tails a database log. A Kafka topic can carry the events (How Kafka Moves a Message), but it is not a good event store by itself: it has no per-stream expected-version check, and log compaction keeps only the latest record per key, which is the opposite of keeping every event. Commands as objects also appear in the Command pattern, and the event store is a typical secondary adapter in Hexagonal Architecture.
What the page leaves out
Many streams and a global position across them; catch-up and persistent subscriptions; several projectors with their own checkpoints; projector crashes and idempotent projections; event schema versioning and upcasting; commands that touch several aggregates (sagas and process managers); a command that emits several events; retries with backoff after a conflict; and real products such as EventStoreDB, Axon or Marten.