← Back to the section

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 patternStorage
List with pagination, filtering, sortingDenormalized PG table
Heavy aggregations (millions of rows)PG materialized view
Hot lookup by key (by ID)Redis
Full-text search, filters with rankingElasticSearch / 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.

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 via DataSource.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.
  • 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.