Skip to content

Read models you can throw away

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

One shelf of loan cards feeds three boards on the wall, a catalog, a board of members and a chart, while a librarian takes one board down to rebuild it

In part 2 the book became an aggregate: commands decide on events, and state("book-1") folds one book’s events into its state. That is the write side, and it is good at one question: what is the state of this book, so I can decide what happens to it?

The front desk asks other questions. Which books can be borrowed right now? Which books does Alice have? Folding every book on every request would answer them, slowly. The read side of CQRS answers them from read models: data shaped for one query, kept up to date by following the log. effect-cqrs calls the code that keeps one a view model.

Here is the catalog, one entry per book with a flag for whether it is on the shelf. It lives in memory in a Ref, wrapped in a service so that handlers and queries can share it:

examples/blog/library.ts
export interface CatalogEntry {
readonly title: string
readonly author: string
readonly available: boolean
}
/** The catalog: one entry per book, kept in memory. */
export class Catalog extends Context.Service<Catalog, Ref.Ref<ReadonlyMap<string, CatalogEntry>>>()(
"library/Catalog"
) {
static readonly layer = Layer.effect(Catalog, Ref.make<ReadonlyMap<string, CatalogEntry>>(new Map()))
}
const updateEntry = (bookId: string, update: (entry: CatalogEntry) => CatalogEntry) =>
Catalog.use((catalog) =>
Ref.update(catalog, (entries) => {
const entry = entries.get(bookId)
return entry === undefined ? entries : new Map(entries).set(bookId, update(entry))
})
)
export const CatalogView = ViewModel.make({
name: "catalog",
aggregates: [Book],
dispose: Catalog.use((catalog) => Ref.set(catalog, new Map())),
handlers: {
BookAdded: ({ author, title }, { aggregateId }) =>
Catalog.use((catalog) =>
Ref.update(catalog, (entries) => new Map(entries).set(aggregateId, { title, author, available: true }))
),
BookLent: (_, { aggregateId }) => updateEntry(aggregateId, (entry) => ({ ...entry, available: false })),
BookReturned: (_, { aggregateId }) => updateEntry(aggregateId, (entry) => ({ ...entry, available: true }))
}
})

A view model has a name, the aggregates whose events it reads, and a handler for each event it cares about. The handlers get the event and its metadata, which includes the aggregateId, the version, the position in the store, the timestamp, and the correlation and causation ids we’ll meet in part 4. dispose clears what the view model stored, for when it has to be rebuilt.

Notice what is not here. There is no if (book.status === ...), no rule, no refusal. The events have already been decided and stored. A handler only records what happened, in the shape a query wants.

The second question, which books each member has, wants another shape. It is another view model over the same events:

examples/blog/library.ts
/** The books each member has, by member id. */
export class Loans extends Context.Service<Loans, Ref.Ref<ReadonlyMap<string, ReadonlySet<string>>>>()(
"library/Loans"
) {
static readonly layer = Layer.effect(Loans, Ref.make<ReadonlyMap<string, ReadonlySet<string>>>(new Map()))
}
const setOnLoan = (memberId: string, bookId: string, onLoan: boolean) =>
Loans.use((loans) =>
Ref.update(loans, (members) => {
const books = new Set(members.get(memberId))
if (onLoan) books.add(bookId)
else books.delete(bookId)
return new Map(members).set(memberId, books)
})
)
export const LoansView = ViewModel.make({
name: "loans",
aggregates: [Book],
dispose: Loans.use((loans) => Ref.set(loans, new Map())),
handlers: {
BookLent: ({ memberId }, { aggregateId }) => setOnLoan(memberId, aggregateId, true),
BookReturned: ({ memberId }, { aggregateId }) => setOnLoan(memberId, aggregateId, false)
}
})

Read models are cheap. Each one serves its queries and nothing else, so there is no compromise between the catalog page and the member page, and no join at read time. They also don’t share code: changing how the catalog stores a book can’t break the loans, and a read model that needs a search index or raw SQL can have one without the others noticing.

Both view models are written so that handling an event twice changes nothing: setting a map entry or adding to a set again leaves them as they were. That matters, because delivery is at least once. A view model saves how far it has read, its checkpoint, every 500 events and when it stops. After a crash it starts again from the last checkpoint and sees a few events a second time.

ViewModel.layer starts the view models in the background, as long as the layer lives. They need the event store and somewhere to keep their checkpoints:

examples/blog/library.ts
export const ReadSide = ViewModel.layer([CatalogView, LoansView]).pipe(
Layer.provideMerge(Books.layer),
Layer.provideMerge(Layer.mergeAll(EventStore.layerMemory, Checkpoints.layerMemory, Catalog.layer, Loans.layer))
)

Each view model pulls events from the store, from its own checkpoint, rather than waiting for them to be pushed. A view model that was down, or that is new, reads what it missed and then waits for more. Nothing is lost while it isn’t running, and there is no message broker to configure.

The checkpoints here are in memory, which suits read models in memory: both are gone after a restart, and the view models rebuild from the start of the log. A read model kept in a database pairs with checkpoints in the same database. A checkpoint must live exactly as long as what its view model stores, or after a restart it says “done” for data that isn’t there.

The read side runs a moment behind the write side. A command returns when its events are stored, and the view models see them a little later. A user who lends a book and then looks at the catalog could see it still on the shelf.

Every command returns the position of its last event in the store, and caughtUp waits until every view model has handled it:

examples/blog/library.ts
yield* books.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })
yield* books.commands.add("book-2", { title: "Neuromancer", author: "William Gibson" })
const { position } = yield* books.commands.lend("book-1", { loanId: "loan-1", memberId: "alice" })
// The view models run in the background: wait until they have seen the loan.
yield* viewModels.caughtUp(position)
const catalog = yield* Catalog.use(Ref.get)
// Map { "book-1" => { title: "Dune", …, available: false }, "book-2" => { …, available: true } }
const alice = (yield* Loans.use(Ref.get)).get("alice")
// Set { "book-1" }

Without a position, caughtUp() waits for the latest event in the store. Over HTTP, CommandApi answers every command with the position in an Event-Position header, and CommandApi.ReadAfter makes a query wait for the one a client sends back. Three ways to read puts it together with the other two ways of reading a book.

A read model is not the truth, the log is. So you can throw one away and build it again:

examples/blog/library.ts
// Throw the catalog away and build it again from the log.
const { eventCount } = yield* viewModels.replay({ viewModels: ["catalog"] })
const rebuilt = yield* Catalog.use(Ref.get)
// The same entries as before, from 3 events

replay resets the checkpoint, runs dispose, reads the log from the start and returns once the view model has caught up. This is how you fix a read model that a bug damaged, or one whose shape changed: deploy the new handlers and replay. Fix the code, not the data walks through one.

A brand new view model needs no replay at all. Its checkpoint starts at zero, so it begins with the first event ever stored. In part 1, timesLent answered a question nobody had asked when the events were written. In a running system, that is a new view model: write the handlers, deploy, and it fills itself from the whole history.

A handler that fails, say because a database is down, stalls its view model on that event and retries with backoff, up to every 30 seconds. Events are never skipped and never reordered, so a read model is either right or visibly behind. viewModels.status shows each view model’s position and the failure it is retrying.

For read models where a gap is better than a stop, onFailure: ViewModel.park({ attempts }) sets an event aside after a few failures, and retryParked handles it again once the bug is fixed. The view models guide has the details.

The library now has two sides. The write side keeps the rules, one aggregate at a time. The read side follows the log, in as many shapes as the queries need, and can be rebuilt whenever we like.

What it can’t do yet is anything that involves two aggregates. A member may only have three books at a time, and a book can only be with one member. Borrowing has to respect both rules, and a command only changes one aggregate. Part 4 solves this with a saga.