PortfolioTech Notebook
Menu
patterns

cqrs-read-models

CQRS & Read Models (MediatR · @nestjs/cqrs · Redis)

The problem: one model can't be good at both jobs

The write side of the account service wants a small, strongly-consistent aggregate that enforces invariants one stream at a time. The read side wants the opposite: denormalised, list-shaped, fan-out-friendly views ("all accounts", "this account's history") served fast and cheap. Forcing both through one model means every read drags the write model's constraints around, and scaling reads (the 99% of traffic) means scaling the write store too.

CQRS (Command Query Responsibility Segregation) splits them: commands change state, queries read it, and the two may use entirely different models and stores.

CQRS ≠ event sourcing. They pair beautifully but are independent. You can do CQRS over a plain CRUD database (transfer-service does — it stays on MongoDB), and you can event-source without CQRS. Here the account service does both: an event-sourced write model and Redis read models.

The pattern

  • Commands (OpenAccountCommand, RebuildReadModelsCommand) are dispatched through MediatR; their handlers append events to the Marten write store.
  • Queries (GetAccountQuery, ListAccountsQuery, GetLedgerQuery) are dispatched through MediatR; their handlers read Redis read models — a store deliberately separate from the write store.
  • A projector materialises the read models from the event log: RedisReadModelProjector is a second, independent subscriber of Marten's event sequence (the outbox relay is the first), with its own Redis checkpoint. It maintains:
    • acct:{id} (hash) — the account summary the dashboard lists;
    • acct:{id}:ledger (list) — transfer history per account.
  • Because the read store owns no truth, it is rebuildable: wipe the keys, reset the checkpoint, and the projector replays the event store into fresh views. That is the materialised-view pattern — the read model is a cache of a fold.

Where it lives in our code

  • account-service/src/AccountService/Application/AccountCqrs.cs — the MediatR commands, queries, and handlers.
  • ReadModels/RedisReadModelStore.cs — the Redis views + checkpoint + ClearAsync.
  • ReadModels/RedisReadModelProjector.cs — the BackgroundService that folds the event log into Redis.
  • Api/AccountEndpoints.cs — endpoints are thin: they only dispatch via IMediator. POST /accounts/read-models/rebuild triggers a projection rebuild.
  • transfer-service/src/transfers/cqrs/* + transfers.controller.ts — the Node twin: CommandBus/QueryBus from @nestjs/cqrs, the controller translating HTTP ↔ commands/queries while handlers own the logic.

Eventual consistency is the headline, not the footnote

The read store trails the write store by at most one projector poll. We embrace it honestly:

  • POST /accounts returns a strongly-consistent summary built from the command result, so the creator gets read-your-write.
  • Everyone else reads the eventually-consistent Redis view. The integration tests model a real client by polling (WaitForBalanceAsync) until the view catches up, rather than pretending reads are instant.

This is the honest cost of CQRS, and naming it is half the interview.

Trade-offs

  • More moving parts. A separate read store, a projector, a checkpoint, and a rebuild path — all to serve reads. Justified when reads dwarf writes or need shapes the write model can't cheaply produce; overkill for a simple CRUD entity.
  • Eventual consistency everywhere except the creator's own write. The UI must tolerate "I just made a transfer and don't see it for 200ms".
  • Rebuilds need care under load. Our rebuild wipes + replays; if writes are flowing during a rebuild, the projector's checkpoint can race the wipe. The clean version quiesces or rebuilds into a shadow keyspace and swaps. We document the caveat rather than pretend it away.
  • Two CQRS flavours on purpose. account-service reads from a different store (Redis); transfer-service's command/query split still reads its own MongoDB. Both are CQRS — the separation is about responsibility, not necessarily storage.

Interview Q&A

Q: What exactly is CQRS — is it just "separate read and write classes"? At minimum, yes: commands and queries are different models dispatched on different paths. The payoff comes when they also use different stores tuned for their job, as the account service does (Marten for writes, Redis for reads).

Q: Is CQRS the same as event sourcing? No. They compose but are orthogonal. CQRS is about splitting read/write responsibility; event sourcing is about storing state as events. transfer-service is CQRS without event sourcing; you could also event-source and serve reads from the same snapshot (no CQRS).

Q: Where do the Redis read models come from, and what if they're wrong? A projector folds the event log into them. Because they own no truth, you fix the projector and rebuild from the event store — POST .../read-models/rebuild wipes and replays. No data loss, because the events are still the source of truth.

Q: How do you handle a user reading right after they write? Read-your-write for the writer (return the command's result directly), eventual consistency for everyone else. Alternatives: read from the write model for the just-written entity, or version/ETag the read and have the client wait for it.

Q: Won't the read and write models drift? They are supposed to differ in shape — that's the point. They can't drift in content because the read model is a pure function of the same event log; rebuild re-derives it exactly.

Q: Why MediatR / @nestjs/cqrs instead of calling a service method? Decoupling and a single dispatch seam: handlers are discovered and invoked through a bus, so cross-cutting concerns (logging, validation, transactions) attach in one place and controllers carry no business logic.

Try to break it

  1. Rebuild fidelity. Drive a few transfers, snapshot the read models, then POST /accounts/read-models/rebuild. The summaries and histories must come back identical — if the projector folds non-deterministically or double-appends to the ledger list, rebuild won't match.
  2. The consistency gap. POST /accounts then immediately GET /accounts/{id} from a different client. You may 404 or see a stale balance for one poll interval. Decide whether your UX can take it — then make the test poll, the way WaitForBalanceAsync does.
  3. Lost-update across stores. Confirm the read model never leads the write store: it can only ever reflect events that are already durably appended, never a balance that the write store hasn't committed.
  4. Projector restart. Kill account-service mid-stream; on restart the projector resumes from its Redis checkpoint, not from zero, and the views are unchanged. Delete the checkpoint key and watch it correctly rebuild everything.