In an ordinary application a single table serves both writing and reading. For reading you have to add JOINs, groupings, counts — right at query time. As load grows this slows down: queries become complex, indexes overlap, and optimizing one schema for both writing and reading at once is very hard.
A read-model is a separate representation of data already assembled the way the end consumer needs it. A single SELECT without JOINs returns a ready object for the UI or API. You pay for this with a small update delay (the data lags slightly behind the real state) and extra infrastructure.
What a read-model is in plain terms
Imagine you have three tables: order, order_item, and customer. Every time you need to show a user's list of orders with the customer name and total — you do a JOIN across all three. If there are millions of orders, that's expensive.
A read-model is a fourth table, order_summary, where everything needed is stored in advance: the customer name, the sum of the line items, the status. A query against it is a simple SELECT WHERE customer_id = $1, with no JOINs.
This table is not edited directly. It's updated automatically when the write side changes — through events.
Where to store the read-model
You choose storage to fit a specific read pattern, not "so there's one for everything".
| Read pattern | Storage |
|---|---|
| List with pagination, filtering, sorting | Denormalized PG table |
| Heavy aggregations (millions of rows) | PG materialized view |
| Hot lookup by key (by ID) | Redis |
| Full-text search, filters with ranking | ElasticSearch / OpenSearch |
A PG table — the first step
A denormalized table in the same database is almost always the first solution. No new infrastructure.
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,
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 so a consumer doesn't overwrite fresher data with a stale event — more on this in the section about updating via events.
A PG materialized view — for heavy aggregations
When you need a summary like "revenue by product for the month" that is recomputed rarely:
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);
Refresh — REFRESH MATERIALIZED VIEW CONCURRENTLY on a schedule via @nestjs/schedule or on the OrderConfirmed event.
Redis — for hot keys
When the same record is read on every request:
// adapters/out/persistence/redis-subscription-view.repository.ts
@Injectable()
export class RedisSubscriptionViewRepository implements SubscriptionViewRepository {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
async findByCustomer(customerId: CustomerId): Promise<SubscriptionPlan | null> {
const raw = await this.redis.get(`customer:${customerId.value}:plan`);
return raw ? JSON.parse(raw) as SubscriptionPlan : null;
}
async upsert(customerId: CustomerId, plan: SubscriptionPlan): Promise<void> {
await this.redis.set(
`customer:${customerId.value}:plan`,
JSON.stringify(plan),
'EX', 3600,
);
}
}
An important point: a read-model in Redis is the source of the answer, not a cache. A cache is a fallback; a read-model is the primary storage for this read pattern.
ElasticSearch — for full-text search
When you need filters across many fields with ranking by relevance:
// adapters/out/search/elasticsearch-product-view.repository.ts
@Injectable()
export class ElasticsearchProductViewRepository implements ProductSearchRepository {
constructor(@Inject(ES_CLIENT) private readonly es: Client) {}
async search(params: ProductSearchQuery): Promise<ProductSearchResult[]> {
const { hits } = await this.es.search({
index: 'products',
query: {
bool: {
must: [{ match: { name: params.q } }],
filter: [
...(params.minRating ? [{ range: { rating: { gte: params.minRating } } }] : []),
...(params.inStock ? [{ term: { in_stock: true } }] : []),
],
},
},
});
return hits.hits.map(h => toProductSearchResult(h._source));
}
}
Consumers on ProductCreated, ProductPriceChanged, StockUpdated update the document in the index.
The read-model schema is independent of the write side
A common mistake is to design the read-model as a copy of the write tables. In reality the read-model schema is dictated by the consumer, not the aggregate.
write schema: read schema (order_summary):
order(id, customer_id, status) order_summary(
order_item(order_id, qty, price) order_id,
customer(id, name, email) customer_name, ← from customer
customer_email, ← from customer
status,
item_count ← from order_item
)
In TypeScript the read-DTO is made readonly — data for reading doesn't change:
// core/order/port/out/view/order-summary.view.ts
export class OrderSummaryView {
constructor(
readonly orderId: string,
readonly customerId: string,
readonly customerName: string,
readonly status: string,
readonly itemCount: number,
readonly totalAmount: string,
readonly currency: string,
readonly createdAt: Date,
readonly confirmedAt: Date | null,
) {}
}
export function toOrderSummaryView(row: Record<string, unknown>): OrderSummaryView {
return new OrderSummaryView(
String(row['order_id']),
String(row['customer_id']),
String(row['customer_name']),
String(row['status']),
Number(row['item_count']),
String(row['total_amount']),
String(row['currency']),
new Date(row['created_at'] as string),
row['confirmed_at'] ? new Date(row['confirmed_at'] as string) : null,
);
}
ViewRepository — a raw query without a transaction
The read side uses a separate <X>ViewRepository. It reads via DataSource.query() — without a transaction, without relations, without locks. This is deliberate: reading should be light and fast.
OrderViewRepository — queries only. For writing to the read-model (the consumer, rebuilding) there's a separate OrderSummaryRepository.
// core/order/port/out/order-view.repository.ts
export interface OrderViewRepository {
summary(orderId: string): Promise<OrderSummaryView | null>;
listByCustomer(customerId: string, limit: number, offset: number): Promise<OrderSummaryView[]>;
}
export const ORDER_VIEW_REPOSITORY = Symbol('ORDER_VIEW_REPOSITORY');
// core/order/port/out/order-summary.repository.ts
export interface OrderSummaryRepository {
upsertBatch(rows: OrderSummaryUpsert[]): Promise<void>;
}
export const ORDER_SUMMARY_REPOSITORY = Symbol('ORDER_SUMMARY_REPOSITORY');
// adapters/out/persistence/typeorm-order-view.repository.ts
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async summary(orderId: string): Promise<OrderSummaryView | null> {
const rows = await this.dataSource.query(
`SELECT order_id, customer_id, customer_name, customer_email,
status, item_count, total_amount, currency,
created_at, confirmed_at
FROM order_summary
WHERE order_id = $1`,
[orderId],
);
return rows[0] ? toOrderSummaryView(rows[0]) : null;
}
async listByCustomer(customerId: string, limit: number, offset: number): Promise<OrderSummaryView[]> {
const rows = await this.dataSource.query(
`SELECT order_id, customer_id, customer_name, customer_email,
status, item_count, total_amount, currency,
created_at, confirmed_at
FROM order_summary
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[customerId, limit, offset],
);
return rows.map(toOrderSummaryView);
}
}
The query-handler injects ORDER_VIEW_REPOSITORY and never touches aggregates:
// core/order/usecase/get-order-summary.handler.ts
@Injectable()
export class GetOrderSummaryHandler implements Handler<GetOrderSummary, OrderSummaryView | null> {
constructor(
@Inject(ORDER_VIEW_REPOSITORY) private readonly orderView: OrderViewRepository,
) {}
async execute(query: GetOrderSummary): Promise<OrderSummaryView | null> {
return this.orderView.summary(query.orderId);
}
}
Updating via events
The read-model is updated only through events — never directly inside the write transaction. The flow is like this:
1. the command-handler saves the Order and writes OrderConfirmed → outbox (one transaction)
2. the outbox-relay publishes the event to Kafka
3. the read-side consumer receives OrderConfirmed
4. UPDATE order_summary SET status = 'CONFIRMED', confirmed_at = $1, version = version + 1
WHERE order_id = $2 AND version < $3
The delay in normal mode is from 100 ms to 1 second. That's normal: it's called eventual consistency, and the UI must account for it.
Why the read-model can't be updated synchronously in the same transaction:
- The read-model table stops being independent. Its structural changes start blocking write operations.
- If the write transaction rolls back, the read-model may already have changed — and there's nothing to fix that divergence with.
- If the read-model lives in another database — synchronous sync requires two-phase commit, which is extremely hard in production.
The mechanics in detail are in the article Sync via events.
The read-model is always rebuildable
The read-model is a derivative of the write side. That means: if it's lost (a Redis cluster failure, an accidental DROP TABLE) — it can be reassembled from scratch by walking the write-side aggregates.
For every read-model there must be a rebuild script:
// core/order/service/order-summary-rebuilder.ts
@Injectable()
export class OrderSummaryRebuilder {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
@Inject(ORDER_SUMMARY_REPOSITORY) private readonly summaries: OrderSummaryRepository,
) {}
async rebuildAll(): Promise<void> {
let lastId = 0n;
const batchSize = 500;
while (true) {
const batch = await this.orders.findAllAfter(lastId, batchSize);
if (batch.length === 0) break;
const rows = batch.map(order => this.toSummaryRow(order));
await this.summaries.upsertBatch(rows);
lastId = batch[batch.length - 1].id.value;
}
}
private toSummaryRow(order: Order): OrderSummaryUpsert {
return {
orderId: order.id.value.toString(),
customerId: order.customerId.value.toString(),
customerName: order.snapshot().customerName,
customerEmail: order.snapshot().customerEmail,
status: order.status,
itemCount: order.items.length,
totalAmount: order.totalAmount.amount.toFixed(4),
currency: order.totalAmount.currency,
createdAt: order.createdAt,
confirmedAt: order.confirmedAt ?? null,
updatedAt: new Date(),
version: 0n,
};
}
}
A rebuild script is needed in three situations:
- A disaster. A table or the Redis cluster was lost — we rebuild from the write side.
- New storage. You added ElasticSearch — it's empty and the existing data needs to be loaded into it.
- A schema change. You added a new field to
order_summary— for old records the rebuild backfills it.
Without such a script the read-model de facto becomes the only source of truth — which breaks the very idea of CQRS.
Common mistakes
Business invariants in the read table. Checks like CHECK (status IN ('NEW', 'CONFIRMED')) in a read table are superfluous. Invariants live in the aggregate on the write side. The read-model is just a projection.
A read-model with no way to rebuild it. If there's no rebuild script, the read-model becomes primary storage. On loss — the data is gone forever.
Writing to the write side from a read-handler. Reading and writing are strictly opposite directions. The query-handler only reads. If you need to change something based on a query's result — that's already a command.
Updating the read-model inside the write transaction. Data in the read-model must arrive only through events, not synchronously.
Loading the aggregate through the main repository for reading. An aggregate carries domain logic and locks — it's too heavy for reading. For the read side there's a separate <X>ViewRepository with a raw query.
In short
- Read-model — data in a form convenient for reading: denormalized, without JOINs on the hot path.
- Storage is chosen to fit the read pattern: a PG table, a materialized view, Redis, ElasticSearch — not one for everything.
- The read-model schema is dictated by the consumer, not by the aggregate's write schema.
- It's read through a
<X>ViewRepository— a raw query viaDataSource.query(), without a transaction and without locks. - It's updated only through events (outbox → Kafka → consumer), not synchronously.
- The source of truth is the write-side aggregates. The read-model is a derivative, always rebuildable.
- Every read-model must have a rebuild script: a disaster, new storage, a schema change.
- No business logic in the read-model, no writing back to the write side from a query-handler.
What to read next
- Sync via events — how the outbox and Kafka deliver events to the read-model.
- Query side — how the query-handler reads from the read-model via a
<X>ViewRepository. - Command side — what the command-handler returns and why not a read-DTO.
- Tier and evolution — when to move from a simple split to an event-driven read-model.