Опирается на правила:
R-CQRS-QRY-1…R-CQRS-QRY-4иR-CQRS-QRY-X1…R-CQRS-QRY-X3→ раздел 3. Query side.
Важно знать
- Query — класс с
readonly-полями, реализуетQuery<R>изcore/usecase.ts. Без побочных эффектов.- Query-handler читает через
<X>ViewRepository— отдельный@Injectable(), не тот же репозиторий, что write-side.<X>ViewRepositoryделает raw select черезDataSource.query()без транзакции и без lock (R-TYPEORM-QRY-4).- Read-DTO — frozen plain object или
readonly-класс; структура под UI/API, не под агрегат.- Query-handler не вызывает доменные методы (
order.confirm()). Только read.- Запрещено: write внутри query, загрузка агрегата через
relations/lock ради маппинга, возврат агрегата или Entity наружу.- NestJS не имеет декларативных read-only транзакций: отсутствие
TransactionRunnerв handler'е и есть контракт «без транзакции».
Query-side — читающая половина CQRS. Она оптимизирована под чтение: денормализованные read-DTO, отдельный репозиторий с raw select, никакого ORM overhead на загрузку агрегата. Главное: query никогда не пользуется write-инструментами. Статья раскрывает раздел 3 гайда в идиомах NestJS / TypeORM.
Query — класс с маркером Query
R-CQRS-QRY-1: query — класс с readonly-полями, реализует Query<R>, где R — тип read-DTO.
// core/order/port/in/get-order-summary.query.ts
import { Query } from 'core/usecase';
import { OrderId } from 'core/order/domain/order-id';
import { OrderSummary } from 'core/order/port/view/order-summary';
export class GetOrderSummaryQuery implements Query<OrderSummary> {
constructor(readonly orderId: OrderId) {}
}
// core/order/port/in/search-orders.query.ts
import { Query } from 'core/usecase';
import { OrderStatus } from 'core/order/domain/order-status';
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: OrderStatus | null,
readonly page: number,
readonly size: number,
) {}
}
Что важно:
- Только
readonly-поля. Query — параметры запроса, никакой логики и мутаций. - Параметр
R— тип read-DTO илиPage<ReadDto>. Не агрегат, не Entity. - Имя:
Get…Query/Search…Query/List…Query— глагол + предметная область + суффиксQuery. Соответствует REST-глаголу GET. - Маркер
Query<R>отличает query от command на уровне типов; dispatcher выбирает разный pipeline.
Структура query-handler
R-CQRS-QRY-2: handler читает через <X>ViewRepository, без TransactionRunner, без записи.
// core/order/usecase/get-order-summary.handler.ts
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Handler } from 'core/usecase';
import { ORDER_VIEW_REPOSITORY } from 'core/order/port/out/order-view.repository';
import type { OrderViewRepository } from 'core/order/port/out/order-view.repository';
import { GetOrderSummaryQuery } from 'core/order/port/in/get-order-summary.query';
import { OrderSummary } from 'core/order/port/view/order-summary';
@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 NotFoundException(`Order ${query.orderId} not found`);
return summary;
}
}
Что важно:
- Нет
TransactionRunner. В NestJS нет@Transactional(readOnly = true)из коробки; отсутствие TX-враппера и есть контракт «без транзакции».DataSource.query()вViewRepositoryидёт без явной транзакции. ORDER_VIEW_REPOSITORY— отдельный DI-токен, не тот жеORDER_REPOSITORY, что write-side. Это инфраструктурный контракт.- Возвращает read-DTO, не агрегат. DTO — frozen plain object или
readonly-класс.
Для пагинированного query:
// core/order/usecase/search-orders.handler.ts
@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);
}
}
Read-DTO — денормализованный readonly-тип
R-CQRS-QRY-3: read-DTO лежит в core/<bc>/port/view/ или core/<bc>/domain/repository/view/. Структура продиктована UI/API, не агрегатом.
// core/order/port/view/order-summary.ts
export interface OrderSummary {
readonly orderId: string;
readonly status: 'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';
readonly customerName: string; // денормализовано — не нужен отдельный запрос к Customer
readonly totalAmount: number;
readonly currency: string;
readonly itemCount: number; // pre-computed; не массив OrderItem
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;
}
Что хорошего:
customerNameденормализован. Если бы это был агрегат, нужен был бы отдельный запрос кCustomer. Здесь — одно поле, никаких JOIN в handler'е.itemCount, а неOrderItem[]. Для UI-списка нужно «3 позиции», не сами позиции. ОдинCOUNTдешевле массива строк.- Все поля
readonly— plain интерфейс без методов, безопасно сериализуется в JSON.
Структура модулей:
core/
└── order/
├── domain/
│ ├── order.ts # агрегат
│ └── order-item.ts # внутренний Entity
├── 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
ViewRepository — отдельный интерфейс с raw select
R-CQRS-QRY-2 требует отдельного <X>ViewRepository. Реализация — raw select через DataSource (R-TYPEORM-QRY-4).
// core/order/port/out/order-view.repository.ts
import { OrderId } from 'core/order/domain/order-id';
import { OrderSummary } from 'core/order/port/view/order-summary';
import { OrderListItem } from 'core/order/port/view/order-list-item';
import { Page } from 'core/pagination';
import { OrderStatus } from 'core/order/domain/order-status';
export const ORDER_VIEW_REPOSITORY = Symbol('OrderViewRepository');
export interface OrderViewRepository {
summary(orderId: OrderId): Promise<OrderSummary | null>;
search(customerId: string, status: OrderStatus | null, page: number, size: number): Promise<Page<OrderListItem>>;
recentByCustomer(customerId: string, limit: number): Promise<OrderListItem[]>;
}
Реализация в adapters/out/persistence/:
// adapters/out/persistence/typeorm-order-view.repository.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { OrderViewRepository } from 'core/order/port/out/order-view.repository';
import { OrderId } from 'core/order/domain/order-id';
import { OrderSummary } from 'core/order/port/view/order-summary';
import { OrderListItem } from 'core/order/port/view/order-list-item';
import { OrderStatus } from 'core/order/domain/order-status';
import { Page } from 'core/pagination';
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async summary(orderId: OrderId): 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: OrderStatus | 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 };
}
async recentByCustomer(customerId: string, limit: number): Promise<OrderListItem[]> {
const rows = await this.dataSource.query<OrderListItemRow[]>(
`SELECT o.id, 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
ORDER BY o.created_at DESC
LIMIT $2`,
[customerId, limit],
);
return rows.map(toOrderListItem);
}
}
Когда отдельной read-таблицы order_summary нет и read-model хранится в write-таблицах, запрос усложняется JOIN'ом, но интерфейс не меняется:
async summary(orderId: OrderId): 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 не вызывает доменные методы
R-CQRS-QRY-4: внутри query-handler нет бизнес-методов агрегата. Никакого order.confirm(), никаких событий.
// ПЛОХО — query вызывает доменный метод и мутирует агрегат
async execute(query: GetOrderSummaryQuery): Promise<OrderSummary> {
const order = await this.orders.byId(query.orderId); // write-repo, грузит агрегат
if (order.shouldBeArchived()) {
order.archive(); // мутация внутри read-handler
await this.orders.save(order); // запись в query — нарушение R-CQRS-QRY-X1
}
return toSummary(order);
}
Что не так:
- Мутация в read-handler. Нарушение
R-CQRS-QRY-X1: query сделал write. - Загрузка агрегата. Нарушение
R-CQRS-QRY-X2: грузим write-агрегат через основной репозиторий. - Логика «когда архивировать» принадлежит отдельному scheduled command-handler, не UI-чтению.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Query-handler делает INSERT / UPDATE / DELETE | R-CQRS-QRY-X1 | Перенести в отдельный command |
Query грузит агрегат через <X>Repository с relations / lock ради маппинга | R-CQRS-QRY-X2 | <X>ViewRepository с raw select нужных полей |
| Query возвращает агрегат или Entity наружу | R-CQRS-QRY-X3 | Read-DTO как readonly-интерфейс или frozen object |
Query вызывает доменный метод (order.archive()) | R-CQRS-QRY-4 | Отдельный scheduled command-handler |
Handler инжектирует ORDER_REPOSITORY вместо ORDER_VIEW_REPOSITORY | R-CQRS-QRY-2 | Отдельный DI-токен ORDER_VIEW_REPOSITORY |
| Read-DTO повторяет поля write-агрегата 1-в-1, без денормализации | R-CQRS-QRY-3 | Денормализация: customerName, itemCount и т. п. |
Куда дальше
- Command side — пишущая половина: handler через агрегат и
TransactionRunner. - Read-model — где и в каком виде хранить read-данные на Node.
- Sync через события — как
order_summaryзаполняется из событий write-side через outbox и Kafka. - Уровень и эволюция — эволюция от lightweight до event-driven в NestJS.
- Когда CQRS оправдан — когда применять CQRS, когда достаточно одного Repository.