Read models you can throw away
Part 3 of 5 of CQRS with effect-cqrs. November 2026.
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.
A catalog
Section titled “A catalog”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:
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.
Another shape, same events
Section titled “Another shape, same events”The second question, which books each member has, wants another shape. It is another view model over the same events:
/** 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.
Running them
Section titled “Running them”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:
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.
Reading your own writes
Section titled “Reading your own writes”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:
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.
Throw it away
Section titled “Throw it away”A read model is not the truth, the log is. So you can throw one away and build it again:
// 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 eventsreplay 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.
When a handler fails
Section titled “When a handler fails”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.
What we have
Section titled “What we have”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.