Skip to content

Why CQRS and event sourcing

Part 1 of 5 of CQRS with effect-cqrs. November 2026.

A librarian stamps loan cards onto a long paper ribbon that runs across the library to display boards where readers look at what it records

Most applications keep the current state of things. A library keeps a table of books, and lending one updates a row:

UPDATE books SET lent_to = 'bob' WHERE id = 'book-1';

The row is now right, and everything it said before is gone. Who had the book before Bob? How many times has it been lent this year? Was it brought back late? Nobody asked those questions when the table was designed, so the answers were thrown away one UPDATE at a time. You can add an audit table, a loans history and a returned_late flag, but each one has to be planned before the data it needs comes in, and kept in step with the row by hand.

This series is about the other way of doing it: keep what happened, and work out the current state from that. Over five posts we build a small lending library with effect-cqrs, from the first event to a saga, tests and a database:

  1. Why CQRS and event sourcing, this post: the idea, by hand, without a framework.
  2. Your first aggregate: events, commands and typed errors.
  3. Read models you can throw away: view models, checkpoints and replays.
  4. Sagas: processes that span several aggregates.
  5. Tests, schema changes and production: fixtures, upcasting, SQLite and PostgreSQL, and forgetting a person.

Instead of a row, a book is the list of things that happened to it. Each event is a fact in the past tense:

examples/blog/by-hand.ts
type BookEvent =
| { readonly _tag: "BookAdded"; readonly title: string }
| { readonly _tag: "BookLent"; readonly memberId: string }
| { readonly _tag: "BookReturned" }

The current state is not stored anywhere. It is what you get when you apply the events one after another, starting from an empty book. That is a fold, reduce in JavaScript:

examples/blog/by-hand.ts
interface Book {
readonly title: string
readonly lentTo: string | null
}
const initial: Book = { title: "", lentTo: null }
// Applies one event to the state: what happened, folded into what is true now.
const evolve = (state: Book, event: BookEvent): Book => {
switch (event._tag) {
case "BookAdded":
return { ...state, title: event.title }
case "BookLent":
return { ...state, lentTo: event.memberId }
case "BookReturned":
return { ...state, lentTo: null }
}
}
export const currentState = (events: ReadonlyArray<BookEvent>): Book => events.reduce(evolve, initial)

Changing a book means deciding on new events. A command looks at the state, checks what the rules allow, and either returns the events that happen next or refuses:

examples/blog/by-hand.ts
// Looks at the state and decides what happens next, or refuses.
export const lend = (state: Book, memberId: string): ReadonlyArray<BookEvent> => {
if (state.lentTo !== null) throw new Error(`Already lent to ${state.lentTo}`)
return [{ _tag: "BookLent", memberId }]
}

Put together, the log only ever grows:

examples/blog/by-hand.ts
const log: Array<BookEvent> = [{ _tag: "BookAdded", title: "Dune" }]
log.push(...lend(currentState(log), "alice"))
log.push({ _tag: "BookReturned" })
log.push(...lend(currentState(log), "bob"))
currentState(log) // { title: "Dune", lentTo: "bob" }
timesLent(log) // 2

Two functions carry the whole model: evolve turns events into state, and lend turns state and a request into events. Neither of them touches a database, a clock or the network, so both are trivial to test.

The questions from the start of this post are now easy, because the facts they need were never thrown away:

examples/blog/by-hand.ts
// A question nobody asked when the events were written, answered from the same log.
export const timesLent = (events: ReadonlyArray<BookEvent>): number =>
events.filter((event) => event._tag === "BookLent").length

timesLent was written after the events. It works on every book ever lent, including the ones lent before anyone thought of counting. That is the main argument for event sourcing: the log answers questions you haven’t thought of yet. Some more:

  • History for free. Every change has a time, an order and, as we’ll see, a cause. An audit trail is not a feature you add, it is the storage.
  • Time travel. The state of a book last March is the fold of the events up to last March.
  • Debugging by replay. A bug report comes with the exact sequence of events that led to it, and you can run them again in a test.
  • Decisions you can explain. A refused command can say which earlier event it was refused because of.

Why not keep a loans table instead? With one, timesLent is a SELECT count(*), and just as right. The difference is when the table was made and what it holds. It has the loans recorded since someone created it, in the columns they thought of then, and the next question, about late returns or renewals, needs another table that starts empty. It also says the same thing as books.lent_to, so the two are kept in step by hand, and when they disagree nothing tells you which one is right. The log is the one place a fact is written, and every other shape is worked out from it. That doesn’t take the table away: with CQRS, below, a loans table becomes a read model filled by replaying the log from the first event, so it holds the loans from before it existed, and you can drop it and build it again when it is wrong, as Fix the code, not the data shows.

The log is a great way to write, and a poor way to read. “Which books are available right now?” would mean folding every book in the library on every request. “Which books does Alice have?” would mean folding them all and filtering.

CQRS, Command Query Responsibility Segregation, splits the two:

  • The write side takes commands. It loads one thing, an aggregate such as a book, folds its events, decides, and appends new events. It only cares about the rules, and it only ever needs one aggregate’s events at a time.
  • The read side follows the log and keeps read models, each shaped for the queries it serves: a catalog with an available flag, a map from member to the books they have, a count of loans per month. They are projections of the log, so you can throw one away and rebuild it, or add a new one that starts from the first event ever written.

The two ideas are separate, and each works without the other. Plenty of applications do CQRS on ordinary tables: commands update rows, and queries read a view or a second table, often in the same database, with no queue in between. What CQRS asks for is that the code that changes things and the code that answers questions don’t share a model. effect-cqrs does both because they fit: once the log is the truth, every read model is a projection of it, which you can rebuild, change or add at any time without migrating data.

This is the path a command takes in effect-cqrs, and the outline of the next three posts:

How a command flows

  1. Command

    A typed call, or JSON from a transport through dispatch. The payload is checked against its schema first.

    Commands guide
  2. Aggregate

    The service rebuilds the aggregate's state from its events, and the handler decides what happened, or fails with a typed error.

    Aggregates guide
  3. Event store

    The events are appended, but only if nobody wrote to that aggregate since its state was loaded.

    Storage guide
  4. View models

    Each one reads the new events from its own checkpoint. caughtUp waits until your own write is there.

    View models guide

The price is that the read side is a moment behind the write side. After a command, a read model may not have seen its events yet. Part 3 shows how to wait for your own write when you need to, and Three ways to read when you don’t need a read model at all.

The version above works on an array in memory, for one user at a time. A real application needs more, and each of these is what the rest of the series is about:

  • Storage and concurrency. Two librarians lending the same copy at the same moment must not both succeed. The store has to append only if nobody wrote in between.
  • Typed, validated input. lend above trusts its arguments and refuses with a thrown Error. Refusals are part of the domain, and the caller should know which ones can come.
  • Read models that keep up. They must follow the log as it grows, pick up where they stopped after a restart, and never miss an event.
  • Processes. Borrowing a book involves the book and the member, who may only have three books at a time. One command changes one aggregate, so something has to coordinate the two.
  • Change. Events written today will be read in five years by code that looks different. The log can’t be rewritten, so old events have to be read as new ones.
  • Forgetting. The GDPR lets a person ask to be forgotten, in a log that is never edited.

Event sourcing is not free. You trade a table you can query with SQL for a log plus read models you write yourself, and most reads run a moment behind the writes. Events are a contract with your own future, so their shape needs more care than a column does.

It pays off where the history is the domain, such as loans, accounts, orders, bookings or anything audited, and where the rules are rich enough that “what happened” matters more than “what it looks like now”. A settings page or a list of tags is happier as a table. Nothing stops you from mixing the two in one application.

In part 2 we write the book as an aggregate with effect-cqrs: the same evolve and decide, with schemas, typed errors and a store that handles concurrent writers.