Опирается на правила: R-CQRS-WHEN-1R-CQRS-WHEN-3 и R-CQRS-WHEN-X1R-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 selectjoin-ы 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 оправдан только при измеренной проблеме одного из четырёх типов:

  1. Read:write ratio ≥ 10:1. Типичный e-commerce: 1 заказ → 10 показов карточки в разных сценариях. При таком соотношении read-нагрузка диктует архитектуру.
  2. Принципиально разная структура. Full-text search по описаниям Product, фильтры по 20 фасетам, ранжирование по relevance — это не реляционная задача. ElasticSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL с pg_trgm.
  3. Read-нагрузка превышает пропускную способность write-DB. PostgreSQL держит 5k write/s, но 50k read/s того же объёма данных требует кеширования или реплики, которая на read-only выдаст 100k+.
  4. Независимое масштабирование. 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-X1lightweight маркеры на старте, эволюция по метрикам
Разделение баз без явной измеренной болиR-CQRS-WHEN-X2read-replica + кеш до явного порога
Маркеры Command<R>/Query<R> пропущены на Уровне 2R-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 по метрикам.