Опирается на правила:
R-CQRS-WHEN-1…R-CQRS-WHEN-3иR-CQRS-WHEN-X1…R-CQRS-WHEN-X2из CQRS Style Guide → раздел 1. Когда CQRS оправдан.
Важно знать
- CQRS — паттерн с ценой: отдельные read-классы, отдельная синхронизация, eventual consistency. Применяем когда выгода покрывает цену, не «потому что красиво».
- Lightweight CQRS (маркеры
Command<R>/Query<R>изcore/usecase.ts) — бесплатное разделение, обязательно начиная с Уровня 2.- Денормализованная read-таблица в той же БД — middle-ground: один PostgreSQL, отдельная таблица для чтения, синхронизация через outbox + Kafka внутри сервиса.
- Full CQRS с разделением хранилищ (write-DB + Redis/ElasticSearch/read-DB) — только при read:write ≥ 10:1, принципиально разной структуре read-проекции или необходимости read-scaling.
- Full CQRS «just in case» для нового сервиса без явной проблемы — карго-культ. Стартуем с lightweight, эволюционируем по метрикам.
- Разделение баз без явной причины добавляет sync complexity, eventual consistency и инфра-стоимость. Должна быть конкретная боль, которую измерили.
- В NestJS enforcement lightweight CQRS — это
@Injectable()handler без транзакции на query-side иTransactionRunnerтолько на command-side.
CQRS (Command Query Responsibility Segregation) — это разделение модели записи и модели чтения. В простом виде — два типа handler-ов с разными транзакционными настройками. В сложном — два физически разных хранилища с синхронизацией через события. Между этими крайностями — спектр, и выбор точки на нём зависит от нагрузки, не от моды. Статья раскрывает раздел 1 гайда в идиомах NestJS + TypeORM.
Три уровня CQRS
R-CQRS-WHEN-1..3 описывают три точки спектра:
| Уровень | Что разделено | Когда применять |
|---|---|---|
| Lightweight (маркеры) | Command<R> / Query<R>; query-handler без транзакции и без записи | Уровень 2+ — всегда |
| Read-projection в той же БД | Отдельная таблица order_summary, отдельный OrderViewRepository с raw select | join-ы 5+ таблиц, GROUP BY миллионов строк |
| Full split | Разные хранилища (PG + ElasticSearch / Redis / read-DB) | read:write ≥ 10:1, поиск, аналитика |
Эволюция всегда снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище.
Уровень 1: Lightweight на маркерах — обязательно с Уровня 2
R-CQRS-WHEN-1: на Уровне 2 (Use Case Pattern) маркерное разделение обязательно. Стоит ноль дополнительной инфраструктуры и даёт enforcement через типы.
// core/order/port/confirm-order.command.ts
export class ConfirmOrder implements Command<OrderId> {
constructor(readonly orderId: OrderId) {}
}
// core/order/port/get-order-summary.query.ts
export class GetOrderSummary implements Query<OrderSummary> {
constructor(readonly orderId: OrderId) {}
}
Handler для command — с TransactionRunner; handler для query — без транзакции, без записи:
// adapters/in/http/order.controller.ts
@Post(':id/confirm')
async confirm(@Param('id') id: string): Promise<{ orderId: string }> {
const orderId = await this.bus.execute(new ConfirmOrder(OrderId.from(id)));
return { orderId: orderId.value };
}
@Get(':id/summary')
async summary(@Param('id') id: string): Promise<OrderSummary> {
return this.bus.execute(new GetOrderSummary(OrderId.from(id)));
}
Что даёт маркер в NestJS-контексте:
- TypeScript различает read и write.
Handler<ConfirmOrder, OrderId>≠Handler<GetOrderSummary, OrderSummary>— Handler типизирован, подмена невозможна без ошибки компилятора. - Enforcement на query-side: query-handler не вызывает
tx.run(...)— нет транзакции, нет lock, нет записи. Это правило соблюдается дисциплиной и code review, не runtime. - Валидация разная. Command —
class-validatorдекораторы на DTO (@IsUUID,@IsNotEmpty); query — обычно только@Min/@Maxна page/size. - Метрики разделимы.
app_command_total{name=ConfirmOrder}≠app_query_total{name=GetOrderSummary}— RED-метрики по семантике, не по HTTP-методу.
Read и write при этом ходят в один и тот же OrderRepository. Никакой дополнительной инфраструктуры нет.
Уровень 2: Денормализованная read-таблица в той же БД — middle-ground
R-CQRS-WHEN-3: когда query становится дорогим, выделяем read-projection в отдельную таблицу той же БД. Не отдельное хранилище — это ещё преждевременно.
Триггеры для перехода:
- 5+ JOIN-ов в типичном query.
OrderSummaryизorder,order_item,product,customer,payment,shipment— каждый запрос нагружает CPU и память. - Тяжёлые aggregations.
GROUP BY customer_id, DATE(created_at)по миллионам строк ради дашборда → каждая загрузка страницы стоит секунду CPU. - Несовпадение структуры. UI хочет
customer_name,item_count,last_status_changeу заказа — собирать это каждый раз из нормализованной схемы лишняя работа. Денормализуем.
Схема:
CREATE TABLE order_summary (
order_id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
customer_name TEXT NOT NULL,
status TEXT NOT NULL,
item_count INTEGER NOT NULL,
total_amount NUMERIC(19,4) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary(customer_id, created_at DESC);
OrderViewRepository в адаптере — raw select без TypeORM relations (соответствует R-TYPEORM-QRY-4):
// adapters/out/persistence/typeorm-order-view.repository.ts
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async summary(orderId: OrderId): Promise<OrderSummary | null> {
const rows = await this.dataSource.query(
`SELECT order_id, customer_id, customer_name, status,
item_count, total_amount, created_at
FROM order_summary
WHERE order_id = $1`,
[orderId.value],
);
return rows[0] ? toOrderSummary(rows[0]) : null;
}
async byCustomer(
customerId: CustomerId,
page: number,
size: number,
): Promise<OrderSummary[]> {
const rows = await this.dataSource.query(
`SELECT order_id, customer_id, customer_name, status,
item_count, total_amount, created_at
FROM order_summary
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[customerId.value, size, page * size],
);
return rows.map(toOrderSummary);
}
}
Запрос GET /customers/:id/orders — один SELECT по индексу, без join-ов.
Уровень 3: Full CQRS с разделением хранилищ — только при метриках
R-CQRS-WHEN-2: full CQRS оправдан только при измеренной проблеме одного из четырёх типов:
- Read:write ratio ≥ 10:1. Типичный e-commerce: 1 заказ → 10 показов карточки в разных сценариях. При таком соотношении read-нагрузка диктует архитектуру.
- Принципиально разная структура. Full-text search по описаниям
Product, фильтры по 20 фасетам, ранжирование по relevance — это не реляционная задача. ElasticSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL сpg_trgm. - Read-нагрузка превышает пропускную способность write-DB. PostgreSQL держит 5k write/s, но 50k read/s того же объёма данных требует кеширования или реплики, которая на read-only выдаст 100k+.
- Независимое масштабирование. Write-DB не должна деградировать от read-нагрузки. Redis-cluster или ElasticSearch масштабируется горизонтально, PostgreSQL — нет.
Пример инфраструктуры: write — PostgreSQL с агрегатом Order, pessimistic lock на load, outbox-таблица. Kafka-relay публикует OrderConfirmed, OrderShipped. Read — ElasticSearch index orders с денормализованным документом; consumer обновляет документ на каждое событие.
// adapters/out/persistence/es-order-view.repository.ts
@Injectable()
export class EsOrderViewRepository implements OrderViewRepository {
constructor(private readonly es: ElasticsearchService) {}
async search(query: string, customerId: CustomerId): Promise<OrderSummary[]> {
const result = await this.es.search({
index: 'orders',
body: {
query: {
bool: {
must: { match: { _all: query } },
filter: { term: { customer_id: customerId.value } },
},
},
},
});
return result.hits.hits.map((h) => toOrderSummary(h._source));
}
}
Это дорогая инфраструктура: два хранилища, мониторинг обоих, eventual consistency, rebuild-сценарии. Окупается только когда read-replica + кеш перестают справляться.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Full CQRS «just in case» для нового сервиса | R-CQRS-WHEN-X1 | lightweight маркеры на старте, эволюция по метрикам |
| Разделение баз без явной измеренной боли | R-CQRS-WHEN-X2 | read-replica + кеш до явного порога |
Маркеры Command<R>/Query<R> пропущены на Уровне 2 | R-CQRS-WHEN-1 | маркеры обязательны; query-handler без tx.run |
OrderViewRepository отсутствует при наличии отдельной read-таблицы | R-CQRS-TIER-X2 | отдельный интерфейс и адаптер для read-проекции |
Особо показательный антипаттерн — запуск нового сервиса Sber для обработки кредитных заявок с ElasticSearch для проекций «потому что будет много чтения». Спустя три месяца — 200 заявок в день, read-нагрузка 3 req/s, команда расследует рассинхронизации и поддерживает rebuild-скрипты без бизнес-выгоды. Lightweight маркеры с одним PostgreSQL закрыли бы эту задачу полностью.
Куда дальше
- Command side — как устроен write-handler с маркером
Command<R>иTransactionRunner. - Query side — read-handler с
Query<R>иOrderViewRepositoryбез транзакции. - Read-model — где хранить и как обновлять денормализованную проекцию.
- Sync через события — синхронизация write → read через outbox + Kafka в NestJS.
- Уровень и эволюция — переход Уровень 1 → 2 → 3 по метрикам.