← назад к разделу

В обычном приложении одна таблица служит и для записи, и для чтения. На чтение приходится добавлять JOIN-ы, группировки, подсчёты — прямо в момент запроса. При росте нагрузки это тормозит: запросы становятся сложными, индексы перекрываются, а оптимизировать одну схему сразу под запись и под чтение очень трудно.

Read-model — это отдельное представление данных, уже сложенных так, как нужно конечному потребителю. Один SELECT без JOIN-ов возвращает готовый объект для UI или API. За это платят небольшой задержкой обновления (данные чуть отстают от реального состояния) и дополнительной инфраструктурой.

Что такое read-model простыми словами

Представьте, что у вас есть три таблицы: order, order_item и customer. Каждый раз, когда нужно показать список заказов пользователя с именем клиента и суммой — делается JOIN по всем трём. Если заказов миллионы, это дорого.

Read-model — это четвёртая таблица order_summary, куда заранее складывается всё нужное: имя клиента, сумма позиций, статус. Запрос к ней — простой SELECT WHERE customer_id = $1, без JOIN-ов.

Эта таблица не редактируется напрямую. Она обновляется автоматически, когда меняется write-сторона — через события.

Где хранить read-model

Хранилище выбирают под конкретный паттерн чтения, а не «чтобы было одно на всё».

Паттерн чтенияХранилище
Список с пагинацией, фильтром, сортировкойДенормализованная PG-таблица
Тяжёлые агрегации (миллионы строк)PG materialized view
Горячий поиск по ключу (по ID)Redis
Полнотекстовый поиск, фильтры с ранжированиемElasticSearch / OpenSearch

PG-таблица — первый шаг

Денормализованная таблица в той же базе данных — почти всегда первое решение. Никакой новой инфраструктуры.

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);

Поле version нужно, чтобы consumer не перезаписал более свежие данные устаревшим событием — об этом подробнее в разделе про обновление через события.

PG materialized view — для тяжёлых агрегаций

Когда нужна сводка вроде «оборот по продуктам за месяц», которая пересчитывается редко:

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 MATERIALIZED VIEW CONCURRENTLY по расписанию через @nestjs/schedule или по событию OrderConfirmed.

Redis — для горячих ключей

Когда одна и та же запись читается на каждый запрос:

// 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,
    );
  }
}

Важный момент: read-model в Redis — это источник ответа, а не кеш. Кеш — fallback; read-model — первичное хранилище для этого паттерна чтения.

ElasticSearch — для полнотекстового поиска

Когда нужны фильтры по многим полям с ранжированием по релевантности:

// 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));
  }
}

Consumer-ы на ProductCreated, ProductPriceChanged, StockUpdated обновляют документ в индексе.

Схема read-model независима от write-стороны

Частая ошибка — проектировать read-model как копию write-таблиц. На деле схема read-model диктуется потребителем, а не агрегатом.

write-схема:                          read-схема (order_summary):
  order(id, customer_id, status)        order_summary(
  order_item(order_id, qty, price)         order_id,
  customer(id, name, email)               customer_name,   ← из customer
                                          customer_email,  ← из customer
                                          status,
                                          item_count       ← из order_item
                                       )

В TypeScript read-DTO делается readonly — данные для чтения не меняются:

// 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 — сырой запрос без транзакции

Read-side использует отдельный <X>ViewRepository. Он читает через DataSource.query() — без транзакции, без relations, без блокировок. Это намеренно: чтение должно быть лёгким и быстрым.

OrderViewRepository — только запросы. Для записи в read-model (consumer, восстановление) — отдельный 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);
  }
}

Query-handler инжектирует ORDER_VIEW_REPOSITORY и не обращается к агрегатам:

// 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);
  }
}

Обновление через события

Read-model обновляется только через события — никогда напрямую внутри write-транзакции. Схема такая:

1. command-handler сохраняет Order, записывает OrderConfirmed → outbox (одна транзакция)
2. outbox-relay публикует событие в Kafka
3. read-side consumer получает OrderConfirmed
4. UPDATE order_summary SET status = 'CONFIRMED', confirmed_at = $1, version = version + 1
   WHERE order_id = $2 AND version < $3

Задержка в обычном режиме — от 100 мс до 1 секунды. Это нормально: это называется eventual consistency, и UI должен это учитывать.

Почему нельзя обновлять read-model синхронно в той же транзакции:

  • Таблица read-model перестаёт быть независимой. Её структурные изменения начинают блокировать write-операции.
  • При откате write-транзакции read-model уже могла поменяться — и это расхождение нечем исправить.
  • Если read-model хранится в другой базе данных — синхронный sync требует двухфазного подтверждения, что крайне сложно в production.

Подробно о механике — в статье Sync через события.

Read-model всегда восстановима

Read-model — это производная от write-стороны. Это значит: если она потеряна (сбой Redis-кластера, случайный DROP TABLE) — её можно пересобрать заново, пройдя по write-side агрегатам.

Для каждой read-model должен существовать скрипт восстановления:

// 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,
    };
  }
}

Скрипт восстановления нужен в трёх ситуациях:

  • Авария. Потеряна таблица или Redis-кластер — восстанавливаем из write-side.
  • Новое хранилище. Добавили ElasticSearch — он пустой, нужно загнать существующие данные.
  • Изменение схемы. Добавили новое поле в order_summary — для старых записей восстановление дозаполнит его.

Без такого скрипта read-model де-факто становится единственным источником правды — что нарушает саму идею CQRS.

Частые ошибки

Бизнес-инварианты в read-таблице. Проверки вроде CHECK (status IN ('NEW', 'CONFIRMED')) в read-таблице — лишние. Инварианты живут в агрегате на write-стороне. Read-model — только проекция.

Read-model без возможности восстановления. Если нет скрипта восстановления, read-model становится первичным хранилищем. При потере — данные пропадают навсегда.

Запись в write-side из read-handler. Чтение и запись — строго разные направления. Query-handler только читает. Если нужно что-то изменить по результату запроса — это уже команда.

Обновление read-model внутри write-транзакции. Данные в read-model должны приходить только через события, не синхронно.

Загрузка агрегата через основной репозиторий для чтения. Агрегат несёт доменную логику и блокировки — для чтения он слишком тяжёлый. Для read-side — отдельный <X>ViewRepository с сырым запросом.

Коротко

  • Read-model — данные в форме, удобной для чтения: денормализованные, без JOIN-ов на горячем пути.
  • Хранилище выбирается под паттерн чтения: PG-таблица, materialized view, Redis, ElasticSearch — не одно на всё.
  • Схема read-model диктуется потребителем, а не write-схемой агрегата.
  • Читается через <X>ViewRepository — сырой запрос через DataSource.query(), без транзакции и блокировок.
  • Обновляется только через события (outbox → Kafka → consumer), не синхронно.
  • Source of truth — write-side агрегаты. Read-model — производная, всегда восстановимая.
  • Для каждой read-model обязателен скрипт восстановления: авария, новое хранилище, изменение схемы.
  • Никакой бизнес-логики в read-model, никакой записи обратно в write-side из query-handler.

Что почитать дальше

  • Sync через события — как outbox и Kafka доставляют события до read-model.
  • Query side — как query-handler читает из read-model через <X>ViewRepository.
  • Command side — что command-handler возвращает и почему не read-DTO.
  • Уровень и эволюция — когда переходить от простого разделения к event-driven read-model.