Three ways to read
A post after the series CQRS with effect-cqrs, on its lending library. November 2026.
Alice borrows Dune at the front desk, and the catalog on the screen still shows it on the shelf. She reloads, and it’s gone. Nothing is broken: the catalog is a read model, and read models run a moment behind the commands.
“CQRS means eventual consistency” is repeated so often that it sounds like a rule. It isn’t.
Delay comes from reading something that is updated after the write, and that is one way
of reading among several. In effect-cqrs a read is one of three kinds, and each query picks
the one it needs. Every snippet below runs in
examples/blog/reading.test.ts.
1. The aggregate: always current
Section titled “1. The aggregate: always current”The first way doesn’t use a read model at all. It folds the book’s own events, as part 2 did:
yield* books.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })yield* books.commands.lend("book-1", { loanId: "loan-1", memberId: "alice" })
const book = yield* books.state("book-1")// { status: "lent", …, loan: { memberId: "alice", … } }, every timestate reads the store, from the state the service remembers onwards, so it sees every
event that was appended, by this process or another. It is the same state the next command
on the book will be decided on. It can’t be behind, because there is nothing in between to
fall behind.
The cost is that it answers one question only: the state of this book, by its id. It can’t list the available books or find Alice’s loans without loading every book. For the page of one thing, though, such as a book, an order or an account, it is often all you need. That is also the page a user tends to land on right after acting on it.
2. A read model as it is: a moment behind
Section titled “2. A read model as it is: a moment behind”The catalog of part 3 is a view model. It handles events in a fiber of its own, after the command has stored them:
const rightNow = (yield* Catalog.use(Ref.get)).get("book-1")// { available: false }, { available: true } or undefined: the catalog may not have// seen the loan, or even the book, yetRight after the command, the view model may or may not have run. The gap is usually
milliseconds. A slow handler, a restart or a handler that keeps failing makes it longer,
and viewModels.status says how far each view model has got.
For most reads that doesn’t matter. Nobody can tell whether the catalog they just opened is twenty milliseconds old, and a search, a dashboard or a monthly total is no different. This is the cheapest read there is: no fold, no wait, data already in the shape of the page.
3. A read model after the command: your own writes
Section titled “3. A read model after the command: your own writes”The trouble is only with the person who just acted. Alice brings Dune back, and the next screen she sees is the catalog. She knows what she did, and expects to see it.
Every command returns the position of its last event in the store. caughtUp waits until
every view model of the process has handled that position:
const { position } = yield* books.commands.returnBook("book-1", {})yield* viewModels.caughtUp(position)
const afterReturn = (yield* Catalog.use(Ref.get)).get("book-1")// { title: "Dune", author: "Frank Herbert", available: true }, every timeThe catalog now includes the return, and every command stored before it. Commands that others ran after it may be there or not. This is “read your own writes”, not a lock on the library, and it only costs a wait for the one read that needs it.
Over HTTP
Section titled “Over HTTP”In an application, the command and the query are two requests, often from a browser. The
position has to make the trip between them. Here is the library as an HTTP API, the book’s
commands from CommandApi.group next to a catalog
endpoint of our own:
export const BookState = Schema.Struct({ status: Schema.Literals(["unknown", "available", "lent"]), title: Schema.String, author: Schema.String, loan: Schema.NullOr(Schema.Struct({ loanId: Schema.String, memberId: Schema.String, dueOn: Schema.DateTimeUtc }))})
export const CatalogEntry = Schema.Struct({ bookId: Schema.String, title: Schema.String, author: Schema.String, available: Schema.Boolean})
export class LibraryApi extends HttpApi.make("library") // POST /book/:id/add, /lend and /returnBook, and GET /book/:id, which folds the events .add( CommandApi.group(Book, { state: BookState, errors: [BookAlreadyAdded, BookNotFound, BookAlreadyLent, BookNotLent] }) ) // GET /catalog, from the view model, after the position in Read-After if there is one .add( HttpApiGroup.make("catalog") .add(HttpApiEndpoint.get("list", "/catalog", { success: Schema.Array(CatalogEntry) })) .middleware(CommandApi.ReadAfter) ){}CommandApi.ReadAfter is middleware for the query endpoints. It needs the view models, and
the handlers need the catalog:
const CatalogHandlers = HttpApiBuilder.group(LibraryApi, "catalog", (handlers) => Effect.gen(function*() { const catalog = yield* Catalog return handlers.handle("list", () => Ref.get(catalog).pipe(Effect.map((entries) => Array.from(entries, ([bookId, entry]) => ({ bookId, ...entry })) ))) }))
export const Server = HttpApiBuilder.layer(LibraryApi).pipe( Layer.provide([CommandApi.layer(LibraryApi, "book", Effect.service(Books)), CatalogHandlers]), // The catalog's handlers need the middleware, and the middleware needs the view models. Layer.provide(CommandApi.layerReadAfter({ timeout: "2 seconds" })), Layer.provide(ReadSide))Every command answers with its position, in the body and in an Event-Position header. A
query that sends it back in a Read-After header waits until the view models have handled
it. Here is a client, the way a browser would call it:
const post = (path: string, body: object) => fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
await post("/book/book-1/add", { title: "Dune", author: "Frank Herbert" })const lent = await post("/book/book-1/lend", { loanId: "loan-1", memberId: "alice" })const position = lent.headers.get("event-position")!
// The book's own page reads the aggregate: always current.const book = await (await fetch("/book/book-1")).json()
// The catalog page sends the position back, and waits until the catalog has the loan.const catalog = await (await fetch("/catalog", { headers: { "read-after": position } })).json()// [{ bookId: "book-1", title: "Dune", author: "Frank Herbert", available: false }]The client chooses, request by request. The book’s page reads the aggregate, and is always current. The catalog page after Alice’s loan sends the position, and includes the loan. Someone who is just browsing the catalog sends nothing, and gets an answer at once.
A few details matter in production:
- A timeout. A view model that is stalled on a failing handler would hold the request
forever. After
timeoutthe request is answered with503andNotCaughtUp, and the client can try again, or read without the header and accept an older answer. - One process.
ReadAfterwaits for the view models of the process that serves the request. With several replicas, send the read to one that runs the view models it needs. - Browsers on another origin only see
Event-Positionif the server lists it inAccess-Control-Expose-Headers.
Why not update the read model with the command?
Section titled “Why not update the read model with the command?”Some CQRS setups update their read tables in the same transaction as the write, so a read is never behind. effect-cqrs doesn’t, on purpose.
- Every command would wait for every read model. A slow handler would slow down loans, and a failing one would refuse them. A broken catalog should show an old catalog, not stop the library.
- Read models are rebuilt from the log. A replay, or a new view model that starts from the first event, runs the same handlers on the same events. A projection that also runs inside commands is a second path that has to give exactly the same result.
- They live elsewhere. A read model in a search index or another database can’t join a transaction with the event store.
When a screen must be exact, it reads the aggregate. When it must include the user’s own change, it waits for the position. Either way the cost falls on the one read that needs it, not on every command.
Which one when
Section titled “Which one when”| The question | Read | Up to date |
|---|---|---|
| What is the state of this book? | state(id), GET /book/:id |
Always |
| The catalog, right after I lent a book | The catalog after caughtUp(position), or with Read-After |
Includes my loan |
| Which books are on the shelf? | The catalog as it is | A moment behind |
The read side is not one property of the system, fixed when it was designed. It is a choice each query makes, and the three choices sit side by side in one application. The view models guide and the HTTP guide have the reference.