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

В CQRS приложение разделено на две части: команды меняют состояние, запросы — читают его. Query side — это читающая половина. Она работает по другим правилам, чем write side: нет транзакций, нет агрегатов, нет записи в базу. Только чтение — быстрое и прямолинейное.

Почему чтение и запись — разные вещи

Представьте, что вы хотите показать пользователю список его заказов. Для этого нужна строчка: номер заказа, статус, сумма, дата. Ничего сложного.

Но если использовать те же инструменты, что и при создании заказа — ORM, агрегат с бизнес-логикой, транзакцию, — вы загрузите всю структуру заказа из нескольких таблиц, создадите объект со своими правилами, а потом превратите его обратно в простой JSON. Много работы ради одной строки в таблице.

Query side решает это иначе: идёт напрямую к базе с нужным SQL-запросом, получает ровно те поля, которые нужны для отображения, и возвращает их. Без агрегата. Без ORM overhead. Без транзакции.

Query — класс с параметрами запроса

Запрос оформляется как отдельный класс с readonly-полями. Он реализует маркерный интерфейс Query<R>, где R — тип результата. Это помогает системе маршрутизации отличить запрос от команды и выбрать правильный pipeline.

// core/order/port/in/get-order-summary.query.ts
import { Query } from '@core/usecase';
import { OrderSummary } from 'core/order/port/view/order-summary';

export class GetOrderSummaryQuery implements Query<OrderSummary> {
  constructor(readonly orderId: string) {}
}

Для пагинированного списка:

// core/order/port/in/search-orders.query.ts
import { Query } from '@core/usecase';
import { OrderListItem } from 'core/order/port/view/order-list-item';
import { Page } from 'core/pagination';

export class SearchOrdersQuery implements Query<Page<OrderListItem>> {
  constructor(
    readonly customerId: string,
    readonly status: string | null,
    readonly page: number,
    readonly size: number,
  ) {}
}

Несколько правил именования: классы называют Get…Query, Search…Query, List…Query. Всё в readonly — параметры не меняются после создания. Тип результата — read-DTO или Page<ReadDto>, никогда не агрегат.

Read-DTO — данные под форму экрана

Read-DTO — это не агрегат. Его структура продиктована тем, что нужно показать на экране, а не тем, как устроена доменная модель.

Хороший пример — денормализация. Вместо того чтобы хранить только customerId и делать второй запрос за именем клиента, read-DTO сразу содержит customerName. Вместо массива позиций заказа — одно поле itemCount. Один запрос — всё нужное.

// core/order/port/view/order-summary.ts
export interface OrderSummary {
  readonly orderId: string;
  readonly status: 'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';
  readonly customerName: string;   // имя клиента денормализовано — нет второго запроса
  readonly totalAmount: number;
  readonly currency: string;
  readonly itemCount: number;      // количество позиций, а не массив
  readonly createdAt: string;      // ISO-8601
  readonly lastUpdatedAt: string;
}

Для списка достаточно меньше полей:

// core/order/port/view/order-list-item.ts
export interface OrderListItem {
  readonly orderId: string;
  readonly status: string;
  readonly customerName: string;
  readonly totalAmount: number;
  readonly currency: string;
  readonly createdAt: string;
}

Все поля readonly, методов нет — это просто данные, безопасно сериализуемые в JSON.

ViewRepository — отдельный репозиторий для чтения

Для чтения используется отдельный репозиторий — <X>ViewRepository. Он не связан с тем репозиторием, через который записываются данные. Отдельный DI-токен, отдельный интерфейс.

// core/order/port/out/order-view.repository.ts
export const ORDER_VIEW_REPOSITORY = Symbol('OrderViewRepository');

export interface OrderViewRepository {
  summary(orderId: string): Promise<OrderSummary | null>;
  search(customerId: string, status: string | null, page: number, size: number): Promise<Page<OrderListItem>>;
}

Реализация идёт через DataSource.query() — прямой SQL без ORM-сущностей, без транзакции, без блокировок:

// adapters/out/persistence/typeorm-order-view.repository.ts
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
  constructor(private readonly dataSource: DataSource) {}

  async summary(orderId: string): Promise<OrderSummary | null> {
    const rows = await this.dataSource.query<OrderSummaryRow[]>(
      `SELECT o.id           AS "orderId",
              o.status,
              o.customer_name AS "customerName",
              o.total_amount  AS "totalAmount",
              o.currency,
              o.item_count    AS "itemCount",
              o.created_at    AS "createdAt",
              o.updated_at    AS "lastUpdatedAt"
         FROM order_summary o
        WHERE o.id = $1`,
      [orderId],
    );
    return rows[0] ? toOrderSummary(rows[0]) : null;
  }

  async search(
    customerId: string,
    status: string | null,
    page: number,
    size: number,
  ): Promise<Page<OrderListItem>> {
    const offset = page * size;
    const rows = await this.dataSource.query<OrderListItemRow[]>(
      `SELECT o.id           AS "orderId",
              o.status,
              o.customer_name AS "customerName",
              o.total_amount  AS "totalAmount",
              o.currency,
              o.created_at    AS "createdAt"
         FROM order_summary o
        WHERE o.customer_id = $1
          AND ($2::text IS NULL OR o.status = $2)
        ORDER BY o.created_at DESC
        LIMIT $3 OFFSET $4`,
      [customerId, status ?? null, size, offset],
    );
    const [{ total }] = await this.dataSource.query<[{ total: string }]>(
      `SELECT COUNT(*) AS total FROM order_summary WHERE customer_id = $1`,
      [customerId],
    );
    return { items: rows.map(toOrderListItem), total: Number(total), page, size };
  }
}

Если отдельной read-таблицы order_summary нет — можно читать из write-таблиц через JOIN. Интерфейс репозитория при этом не меняется:

async summary(orderId: string): Promise<OrderSummary | null> {
  const rows = await this.dataSource.query<OrderSummaryRow[]>(
    `SELECT o.id           AS "orderId",
            o.status,
            c.name         AS "customerName",
            o.total_amount AS "totalAmount",
            o.currency,
            (SELECT COUNT(*) FROM order_item WHERE order_id = o.id) AS "itemCount",
            o.created_at   AS "createdAt",
            o.updated_at   AS "lastUpdatedAt"
       FROM orders o
       JOIN customers c ON c.id = o.customer_id
      WHERE o.id = $1`,
    [orderId],
  );
  return rows[0] ? toOrderSummary(rows[0]) : null;
}

Query handler — только читает

Handler для запроса прост: получает query-объект, идёт в ViewRepository, возвращает read-DTO. Никакой логики, никакой записи.

// core/order/usecase/get-order-summary.handler.ts
@Injectable()
export class GetOrderSummaryHandler implements Handler<GetOrderSummaryQuery, OrderSummary> {
  constructor(
    @Inject(ORDER_VIEW_REPOSITORY)
    private readonly orderView: OrderViewRepository,
  ) {}

  async execute(query: GetOrderSummaryQuery): Promise<OrderSummary> {
    const summary = await this.orderView.summary(query.orderId);
    if (!summary) throw new OrderNotFoundError(query.orderId);
    return summary;
  }
}

Для пагинированного поиска:

@Injectable()
export class SearchOrdersHandler implements Handler<SearchOrdersQuery, Page<OrderListItem>> {
  constructor(
    @Inject(ORDER_VIEW_REPOSITORY)
    private readonly orderView: OrderViewRepository,
  ) {}

  async execute(query: SearchOrdersQuery): Promise<Page<OrderListItem>> {
    return this.orderView.search(query.customerId, query.status, query.page, query.size);
  }
}

В handler нет TransactionRunner — и это не случайность. NestJS не поддерживает декларативные read-only транзакции из коробки, а DataSource.query() во ViewRepository работает без явной транзакции. Это и есть контракт: query side не транзакционный.

Частая ошибка: бизнес-логика внутри чтения

Иногда возникает соблазн добавить что-то в query handler — например, архивировать устаревший заказ, пока он читается:

// Так делать не надо
async execute(query: GetOrderSummaryQuery): Promise<OrderSummary> {
  const order = await this.orders.byId(query.orderId); // write-репозиторий
  if (order.shouldBeArchived()) {
    order.archive();                   // мутация в read-handler
    await this.orders.save(order);     // запись внутри запроса
  }
  return toSummary(order);
}

Здесь сразу несколько проблем: handler изменяет данные, загружает агрегат вместо read-DTO, смешивает чтение и запись. Такая логика принадлежит отдельному scheduled command handler — не query side.

Ещё одна частая ошибка — возвращать агрегат или ORM-Entity вместо read-DTO. Агрегат несёт бизнес-правила и внутренние связи, которые не нужны в ответе API, а сериализация Entity может включить нежелательные данные или вызвать lazy-loading.

Структура файлов

core/
└── order/
    ├── domain/
    │   └── order.ts                        # агрегат (write side)
    ├── port/
    │   ├── in/
    │   │   ├── get-order-summary.query.ts
    │   │   └── search-orders.query.ts
    │   ├── out/
    │   │   ├── order.repository.ts         # write-side репозиторий
    │   │   └── order-view.repository.ts    # read-side репозиторий
    │   └── view/
    │       ├── order-summary.ts            # read-DTO для деталей
    │       └── order-list-item.ts          # read-DTO для списка
    └── usecase/
        ├── get-order-summary.handler.ts
        └── search-orders.handler.ts

Коротко

  • Query side — читающая половина CQRS. Только чтение, без записи, без бизнес-логики агрегата.
  • Query — класс с readonly-полями и маркером Query<R>, где R — тип read-DTO.
  • ViewRepository — отдельный репозиторий с прямым SQL через DataSource.query(), без ORM-сущностей и без транзакций.
  • Read-DTO — плоский readonly-интерфейс под нужды экрана: денормализованные поля (customerName), агрегированные значения (itemCount).
  • Handler получает query, вызывает ViewRepository, возвращает read-DTO. Никаких вызовов доменных методов, никакой записи.
  • Если нет отдельной read-таблицы — читают из write-таблиц через JOIN, но интерфейс ViewRepository остаётся тем же.

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

  • Command side — пишущая половина: handler через агрегат и TransactionRunner.
  • Read-model — где и в каком виде хранить данные для чтения.
  • Синхронизация через события — как order_summary заполняется из событий write side.
  • Когда CQRS оправдан — когда применять CQRS, когда достаточно одного репозитория.