CQRS расшифровывается как Command Query Responsibility Segregation — разделение ответственности команд и запросов. В простом виде это два типа обработчиков с разными правилами. В сложном — два физически разных хранилища с синхронизацией через события.
Между этими крайностями целый спектр. Статья поможет понять, где на этом спектре ваша задача.
Почему нельзя просто «включить CQRS» везде
CQRS — это не бесплатная фича, которую можно добавить «на всякий случай». Каждый уровень разделения несёт цену:
- отдельные классы для чтения и записи — больше кода;
- отдельная таблица для чтения — нужна синхронизация;
- отдельное хранилище — два сервера, eventual consistency, скрипты восстановления данных.
Применяем CQRS там, где выгода покрывает цену, а не потому что «так правильно архитектурно».
Хорошая новость: CQRS не бинарный выбор. Это эволюция снизу вверх — начинаете с минимума и добавляете сложность только когда есть конкретная боль.
Уровень 1: Маркеры команд и запросов — начало всегда здесь
Самый дешёвый вид CQRS — просто обозначить, что есть команды (меняют состояние) и запросы (только читают). Никакой дополнительной инфраструктуры.
// 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) {}
}
Обработчик команды работает в транзакции и может писать в базу. Обработчик запроса — без транзакции, только чтение:
// 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.of(id)));
return { orderId: orderId.value };
}
@Get(':id/summary')
async summary(@Param('id') id: string): Promise<OrderSummary> {
return this.bus.execute(new GetOrderSummary(OrderId.of(id)));
}
Что это даёт:
- TypeScript не даст перепутать обработчики местами — типы разные.
- Сразу видно, где транзакции нужны, а где нет.
- Метрики можно разделить по семантике:
command_totalиquery_totalотдельно. - Читать и писать всё ещё ходят в одну и ту же таблицу — никакой лишней инфраструктуры.
Это разделение стоит ноль усилий и всегда имеет смысл, как только приложение чуть сложнее «CRUD-эндпоинта».
Уровень 2: Отдельная таблица для чтения — когда запросы становятся тяжёлыми
Представьте страницу «Мои заказы» у пользователя. Чтобы показать список, нужно собрать данные из шести таблиц: order, order_item, product, customer, payment, shipment. На каждый запрос страницы — тяжёлый JOIN.
Или дашборд с агрегатами: GROUP BY customer_id, DATE(created_at) по миллионам строк, и это на каждую загрузку страницы.
В таких случаях помогает отдельная денормализованная таблица специально для чтения:
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);
Теперь для страницы заказов — один простой SELECT по индексу вместо шести JOIN-ов. Структура таблицы повторяет то, что нужно UI, а не нормализованную схему базы.
Отдельный репозиторий для чтения с прямыми SQL-запросами:
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async byCustomer(
customerId: CustomerId,
page: number,
size: number,
): Promise<OrderSummary[]> {
const rows = await this.dataSource.query(
`SELECT order_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);
}
}
Обе таблицы живут в одном PostgreSQL — это не два хранилища, а просто денормализация внутри одной базы. Синхронизация order_summary при изменении заказа — дополнительный шаг в транзакции команды или через события внутри сервиса.
Переходить на этот уровень стоит когда:
- типичный запрос делает JOIN пяти и более таблиц;
- тяжёлые агрегации заметно нагружают базу;
- структура, нужная UI, сильно отличается от нормализованной схемы.
Уровень 3: Разные хранилища — только при измеренной проблеме
Иногда PostgreSQL физически не справляется с нагрузкой на чтение, или задача требует возможностей другого хранилища. Тогда write и read расходятся в разные системы.
Типичные случаи:
- Соотношение read:write ≥ 10:1. В e-commerce на один оформленный заказ приходится десятки просмотров карточки. При таком соотношении именно чтение диктует архитектуру.
- Полнотекстовый поиск. Поиск по описаниям с фасетами, ранжированием и подсветкой — это задача для ElasticSearch с инвертированным индексом, а не для PostgreSQL.
- Горизонтальное масштабирование чтения. PostgreSQL держит 5k read/s, но Redis или ElasticSearch могут держать 100k+. Если read-нагрузка превышает возможности write-базы — выделяем read отдельно.
Схема: write — PostgreSQL с транзакциями и блокировками. После каждого изменения публикуется событие (через Kafka или внутренний outbox). Отдельный consumer обновляет read-хранилище.
@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: { multi_match: { query, fields: ['description', 'customer_name', 'status'] } },
filter: { term: { customer_id: customerId.value } },
},
},
},
});
return result.hits.hits.map((h) => toOrderSummary(h._source));
}
}
Цена этого уровня высокая: два хранилища, мониторинг обоих, eventual consistency (данные в read-хранилище могут отставать), скрипты перестройки индекса при сбоях. Это окупается только когда предыдущие уровни уже не справляются.
Частая ошибка: full CQRS с первого дня
Новый сервис, ещё не запущен в продакшн. Команда решает использовать ElasticSearch для read-проекций «потому что будет много чтения». Через три месяца: 200 заявок в день, нагрузка на чтение — 3 запроса в секунду, команда разбирает рассинхронизации и поддерживает скрипты восстановления без какой-либо реальной пользы.
Маркеры команд и запросов с одним PostgreSQL закрыли бы эту задачу полностью.
Правило простое: начинайте с маркеров, добавляйте сложность только при конкретной измеренной боли.
Коротко
- CQRS — это спектр, а не бинарный выбор. Эволюция идёт снизу вверх.
- Уровень 1 (маркеры) — разделить
Command<R>иQuery<R>в коде. Бесплатно, всегда имеет смысл, одна база. - Уровень 2 (отдельная таблица) — денормализованная read-таблица в той же базе. Помогает при тяжёлых JOIN-ах и агрегациях.
- Уровень 3 (разные хранилища) — write-DB + ElasticSearch/Redis. Только при read:write ≥ 10:1 или специфической задаче вроде полнотекстового поиска.
- Запускать новый сервис сразу с разными хранилищами без измеренной проблемы — дорого и бесполезно.
- Query-обработчик никогда не пишет в базу и не работает в транзакции.
Что почитать дальше
- Команды в CQRS — как устроен write-обработчик с транзакцией.
- Запросы в CQRS — read-обработчик без транзакции и отдельный репозиторий для чтения.
- Read-model — как хранить и обновлять денормализованную проекцию.