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

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 — как хранить и обновлять денормализованную проекцию.