In a typical application one table serves both writes and reads. That is convenient while the data is small. But when the orders page pulls information from six tables through five JOINs, and this happens thousands of times per second, the database starts to choke. This is exactly where CQRS offers a separate "view" of the data, tailored for reads only.
What a read model is
Imagine you have a warehouse (the write side) and a shop window (the read model). In the warehouse data is kept in normalized form — each entity in its own table. In the shop window everything is already laid out the way the buyer wants it: one item contains everything needed, nothing has to be looked up elsewhere.
A read model is a denormalized representation of data, where information is already assembled into a form convenient for the end consumer: one SELECT with no JOINs returns a ready-made object for the UI or API.
The price of this convenience: the data in the "window" lags slightly behind the "warehouse" (eventual consistency, usually 100ms–1s). But the payoff is an incomparably lighter load on the database and a response that is many times faster.
Where to store the read model
The choice of storage depends on how the data will be read. Not "we already have Redis, let's put it there," but "what read pattern do we need."
A PG table — the first step
A denormalized table in the same PostgreSQL is almost always the right start. No new infrastructure, familiar transactions and indexes, updates through the outbox.
For example, instead of JOINing order, order_item, and customer every time, we create a single table with everything needed:
CREATE TABLE order_summary (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
status TEXT NOT NULL,
item_count INTEGER NOT NULL,
total_amount NUMERIC(19,4) NOT NULL,
currency TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
confirmed_at TIMESTAMPTZ,
shipped_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX ix_os_customer ON order_summary (customer_id, created_at DESC);
CREATE INDEX ix_os_status_date ON order_summary (status, created_at DESC);
The version field is needed for idempotent updates — if the same event arrives twice, the second UPDATE will not apply.
Materialized view — for heavy aggregations
When you need a summary like "revenue by product over the last month" and recomputing it every time is expensive:
CREATE MATERIALIZED VIEW product_revenue_daily AS
SELECT
p.product_id,
p.name,
DATE(oi.created_at) AS day,
SUM(oi.quantity * oi.unit_price) AS revenue,
COUNT(DISTINCT o.id) AS order_count
FROM order_item oi
JOIN product p ON p.product_id = oi.product_id
JOIN "order" o ON o.id = oi.order_id
WHERE o.status IN ('CONFIRMED', 'SHIPPED', 'DELIVERED')
GROUP BY p.product_id, p.name, DATE(oi.created_at);
CREATE UNIQUE INDEX ux_prd_pk ON product_revenue_daily (product_id, day);
It is refreshed on a schedule (REFRESH MATERIALIZED VIEW CONCURRENTLY every 5 minutes) or on an event. Queries against the view are fast — the data is already computed.
Redis — for hot keys
When data is read on every user request and minimal latency is required. For example, the projection "user → their current subscription plan":
SET customer:42:plan {"plan":"PRO","expires_at":"2026-12-01"} EX 3600
The event consumer updates the key on every subscription change. An important subtlety: here Redis is the source of the answer, not a cache accelerator. These are different roles: a cache can be dropped and re-read from the database, whereas a read model in Redis is the primary storage for that particular view.
ElasticSearch — for search
Full-text search over descriptions, filters across 20+ attributes with relevance ranking — relational databases handle this poorly. ElasticSearch stores a ready-made document:
{
"product_id": 12345,
"name": "Pixel 9 Pro 256GB",
"category_path": ["Electronics", "Smartphones", "Google"],
"price": 99990,
"in_stock": true,
"rating": 4.7,
"description": "..."
}
The event consumers ProductCreated, ProductPriceChanged, StockUpdated update the document. The query GET /products?q=pixel&min_rating=4.5 goes straight to ES.
The read-model schema does not depend on the write side
The read schema can — and often should — differ from the write schema. That is its main advantage.
write schema: read schema (order_summary):
order(id, customer_id, status) order_summary(
order_item(order_id, ..., qty) order_id,
customer(id, name, email) customer_name, ← from the customer table
customer_email, ← from the customer table
status,
item_count ← computed from order_item
)
What this gives you:
- Reads without JOINs.
SELECT * FROM order_summary WHERE order_id = ?instead of three JOINs. - Indexes tailored to specific queries. No need to compromise between read and write needs — each side has its own indexes.
- Simple mapping in code. The read DTO matches the read schema one to one.
The price: if the customer's name changes, that change must reach order_summary too — through an event and a consumer. This is a fair cost for fast reads.
Updating through events
The read model is not updated in the same transaction as the write. Only through outbox + Kafka + event consumer.
1. command-handler changes Order, saves it,
registers OrderConfirmed → outbox table (atomically)
2. outbox-relay publishes the event to Kafka
3. the read-side consumer catches OrderConfirmed
4. UPDATE order_summary SET status = 'CONFIRMED', confirmed_at = ... WHERE order_id = ?
The delay is usually 100ms–1s under normal conditions. With Kafka trouble it can be more — that is expected and normal. The UI must account for this.
Why not update synchronously in the same transaction:
- The read model loses its independence. Changing the
order_summaryschema starts to block write transactions. - If the write transaction rolls back, the read model may already have changed (especially if the read store is in a different database).
- For different databases, a synchronous simultaneous update would require two-phase commit (2PC) — a complex protocol that brings more problems than it solves.
More on the event delivery mechanism — in Synchronizing the read model through events.
The read model can always be rebuilt
This is an important principle: for any read model there must exist a script that rebuilds the projection from scratch out of the write side. If you lost data in Redis or need to reload data into a new ElasticSearch index — the script walks over all aggregates and assembles the projection from scratch:
@Component
@RequiredArgsConstructor
public class OrderSummaryRebuilder {
private final OrderRepository orderRepository;
private final OrderSummaryRepository orderSummaryRepository;
public void rebuildAll() {
long lastId = 0;
int batchSize = 1000;
while (true) {
List<Order> batch = orderRepository.findAllAfter(lastId, batchSize);
if (batch.isEmpty()) break;
List<OrderSummaryRow> rows = batch.stream().map(this::toSummaryRow).toList();
orderSummaryRepository.upsertBatch(rows);
lastId = batch.getLast().id().value();
}
}
}
When you need this:
- An incident. The Redis cluster went down, the ElasticSearch index was lost, a migration happened.
- New storage. You decided to add ElasticSearch — it is empty, historical data needs loading.
- A read-model schema change. You added a field to
order_summary— for old records it is empty, the script backfills it.
If there is no rebuild script, the read model has effectively become the sole source of truth — and that is already wrong.
Common mistakes
Business logic in the read table. A read model is a projection, not a place for business rules. Adding CHECK constraints with business invariants to a read table is dangerous: if the write side created data for business reasons that fail the check in the read table, the event consumer will crash with an error. Invariants live in aggregates; the read model simply stores what arrives.
The read model as the sole source of truth. If data exists only in the read model and there is no write side to rebuild it from — that is no longer CQRS, just two inconsistent systems. There is always a single source of truth — the write-side aggregates.
A reverse data flow. The flow goes one way: write → events → read. The read model must not make changes to the write side. If the read side detects an inconsistency — log it or restart the rebuild for that record, but do not write directly to the write tables.
Synchronously updating the read model inside the write transaction. This couples the two sides: changing the read table's schema starts to affect write performance. The rule is simple: only through events.
In short
- A read model is a denormalized projection of data, optimized for specific read queries. One SELECT, no JOINs.
- Storage is chosen to fit the read pattern: a PG table for tabular queries, a materialized view for heavy aggregations, Redis for hot keys, ElasticSearch for full-text search.
- The read-model schema is independent of the write schema — that is normal and correct.
- It is updated only through events (outbox + Kafka + consumer), not synchronously inside the write transaction.
- An update delay of 100ms–1s is not a bug but expected behavior (eventual consistency).
- Every read model should have a script to rebuild it from the write side.
- A read model is a projection, not a source of truth. The source of truth is the write-side aggregates.
Further reading
- Synchronizing the read model through events — how outbox + Kafka delivers events to the read model.
- The query side in CQRS — how the query handler reads from the read model.
- CQRS tier and evolution — when to introduce a separate read model.