The most surprising thing about CQRS materialized views is that they’re not really about optimizing reads at all; they’re about isolating them.

Let’s say you have a standard CRUD system. When a user updates a customer’s address, you write to the Customers table. Then, to display that customer’s details on a dashboard, you read from the Customers table. Simple, right? But what if that dashboard query is complex? It might involve joins to Orders, Invoices, and SupportTickets tables, all filtered by date ranges and aggregated. Now, every time a customer’s address changes, you’re not just writing to Customers, you’re potentially triggering a complex, slow read query to update the dashboard.

CQRS (Command Query Responsibility Segregation) tackles this by separating the "write" (command) side from the "read" (query) side. The command side handles all state changes, and the query side handles all data retrieval. Materialized views are a key technique for the query side.

Imagine this:

Command Side (The "Write" Model):

When a ChangeCustomerAddressCommand arrives, it’s validated and then processed by a CustomerCommandHandler. This handler updates the Customer aggregate in the event store, emitting a CustomerAddressChangedEvent.

{
  "eventId": "...",
  "eventType": "CustomerAddressChangedEvent",
  "timestamp": "2023-10-27T10:00:00Z",
  "aggregateId": "customer-123",
  "payload": {
    "street": "123 Main St",
    "city": "Anytown",
    "zipCode": "12345"
  }
}

Event Stream: The command side persists these events in an append-only log, the event store. This is the single source of truth.

Query Side (The "Read" Model):

Now, for our fast dashboard query, we don’t want to hit the event store directly. Instead, we build a materialized view. This view is a pre-computed, optimized data structure specifically designed for the dashboard’s read needs.

Let’s say our dashboard needs a list of all customers, their latest address, and the total value of their open orders. This is a complex query that would involve joining and aggregating data from multiple sources if we were to compute it on demand.

With a materialized view, we have a dedicated data store (could be a relational DB, a NoSQL document store, or even a search index) that holds this information, denormalized and optimized for that specific query.

How it’s built:

We subscribe to the event stream. Whenever a CustomerAddressChangedEvent or an OrderPlacedEvent (for example) occurs, an "event handler" on the query side picks it up. This handler then updates the materialized view.

Here’s a simplified representation of what the materialized view might look like in a document database like MongoDB:

{
  "_id": "customer-123",
  "customerId": "customer-123",
  "name": "Acme Corp",
  "address": {
    "street": "123 Main St",
    "city": "Anytown",
    "zipCode": "12345"
  },
  "openOrderTotal": 1500.75
}

When the CustomerAddressChangedEvent for customer-123 arrives, the event handler updates the address field for that _id. If an OrderPlacedEvent arrives and modifies openOrderTotal, that field is updated.

The "Fast Read" Part:

The dashboard query now simply reads from this denormalized customer_views collection. A query like db.customer_views.find({ "address.city": "Anytown" }) is lightning fast because all the data is in one place, in the exact shape needed. No joins, no complex aggregations at query time.

The Control Levers:

  1. Event Handlers: These are the components that listen to events from the command side and update the materialized views. You write these handlers to transform event data into the desired view structure.
  2. Materialized View Data Store: This is where your pre-computed data lives. You choose this based on your read query patterns. For simple lookups, a document DB is great. For complex aggregations or spatial queries, a relational DB or a specialized analytical store might be better.
  3. Event Store: While not directly controlled for read optimization, its reliability and throughput are critical for ensuring your materialized views are eventually consistent.
  4. Query Models: Each materialized view is a "query model" – a specific data structure optimized for a particular read operation or UI component. You can have many query models, each built from the same event stream but tailored for different needs.

The key is that the command side only cares about the integrity of its aggregates and emitting events. The query side only cares about efficiently serving reads from its pre-computed views. They are completely decoupled.

The real magic of materialized views in CQRS is not just speed, but the ability to evolve your read models independently of your write models. You can add new read views, or change the structure of existing ones, without touching the command side at all. As long as you can replay events and rebuild a view, your read side has immense flexibility.

The next logical step is to consider what happens when events are delivered out of order or duplicated, and how your event handlers must be designed to be idempotent.

Want structured learning?

Take the full Cqrs course →