В 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, когда достаточно одного репозитория.