Опирается на правила: R-CQRS-QRY-1R-CQRS-QRY-4 и R-CQRS-QRY-X1R-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 / DELETER-CQRS-QRY-X1Перенести в отдельный command
Query грузит агрегат через <X>Repository с relations / lock ради маппингаR-CQRS-QRY-X2<X>ViewRepository с raw select нужных полей
Query возвращает агрегат или Entity наружуR-CQRS-QRY-X3Read-DTO как readonly-интерфейс или frozen object
Query вызывает доменный метод (order.archive())R-CQRS-QRY-4Отдельный scheduled command-handler
Handler инжектирует ORDER_REPOSITORY вместо ORDER_VIEW_REPOSITORYR-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.