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 write path: a command Withdraw 30 goes to the command handler, which loads the aggregate by folding the events v1 AccountOpened, v2 MoneyDeposited +100 into balance 100, checks the rules and appends v3 MoneyWithdrawn −30 with expected version 2. The read path: the projector reads events after its checkpoint and updates the account_balance table, which the reader queries. The read model is at v2 while the store is at v3, a lag of one event
Writes become events in the store; the read side is a separate table that the projector updates later, so it can lag behind.

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:

  1. loads the aggregate: reads the stream and folds the events, oldest first, into the current state (open, balance, version);
  2. checks the rules on that state: open only once, deposit and withdraw only when open, withdraw only if the balance is high enough;
  3. 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).

versioneventstate after it
v1AccountOpened (owner Ann)open, balance 0
v2MoneyDeposited +100open, balance 100
v3MoneyWithdrawn −30open, 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).

Time flows down. A and B both load acc-42 at v2 with balance 100. A appends MoneyWithdrawn 70 with expected version 2: accepted, the stream is now v3. B appends MoneyWithdrawn 50 with expected version 2: refused with WrongExpectedVersion. B reloads v3, balance 30, and rejects Withdraw 50 because 30 is less than 50
Both writers loaded v2; only the first append matches the stream, the second must reload and decide again.

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 sidequery side
storesevents, append-onlytables shaped for the screens
consistencystrong, per stream (expected version)eventual, behind by the lag
source of truthyesno: can be dropped and rebuilt
scales bysplitting streamsmore 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 tableevent sourcing + CQRS
history and auditlost unless kept separatelybuilt in: every change is an event
read after writeimmediatestale until the projector catches up
changing the shape of datamigrate the tableevents are forever: version them, upcast old ones
deleting personal dataDELETE the rowhard: crypto-shredding or rewriting streams
moving partsone databaseevent 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.

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.