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

В CQRS всё, что меняет данные, идёт через команды. Это «пишущая» половина: создать заказ, подтвердить платёж, отменить бронь. Отдельно от чтения, отдельные классы, другие правила. Статья объясняет, как это выглядит в NestJS на TypeScript.

Что такое Command и зачем он отдельным классом

Раньше в типичном контроллере можно было написать прямо: принять JSON, достать из базы объект, поменять поле, сохранить. Проблема — логика рассыпана по контроллерам, их невозможно тестировать изолированно, и непонятно, где вообще живут бизнес-правила.

Command в CQRS — это простой класс с данными о намерении. «Подтвердить заказ», «Списать деньги», «Создать продукт». Контроллер превращает HTTP-запрос в command-объект и передаёт его handler-у. Вся логика — в handler-е и агрегате, не в контроллере.

// core/order/command/confirm-order.command.ts
import { Command } from '@core/usecase';

export class ConfirmOrder implements Command<OrderId> {
  constructor(
    readonly orderId: OrderId,
    readonly idempotencyKey: string,
  ) {}
}

Несколько вещей, на которые стоит обратить внимание:

  • Все поля — readonly. Command — это данные, не логика. Никаких методов, никаких вычислений в конструкторе.
  • Command<R> — интерфейс-маркер из core/usecase.ts. Параметр R — тип возвращаемого значения handler-а. Здесь OrderId.
  • idempotencyKey — поле для операций, которые нельзя выполнить дважды (платежи, подтверждения). Контроллер берёт его из заголовка Idempotency-Key.
  • Никаких @IsNotEmpty и декораторов валидации. Command — внутренний объект; валидация входных данных происходит раньше, на уровне request-DTO.

Контроллер выглядит так:

// adapters/in/http/order.controller.ts
@Post(':id/confirm')
async confirm(
  @Param('id', ParseUUIDPipe) id: string,
  @Headers('idempotency-key') key: string,
): Promise<{ id: string }> {
  const result = await this.handler.execute(
    new ConfirmOrder(OrderId.of(id), key),
  );
  return { id: result.value };
}

Контроллер только маппит параметры запроса в command и обратно. Никакой бизнес-логики.

Как устроен command-handler

Handler — это тот, кто выполняет команду. Четыре шага в строгом порядке:

// core/order/command/confirm-order.handler.ts
@Injectable()
export class ConfirmOrderHandler implements Handler<ConfirmOrder, OrderId> {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
    @Inject(TX_RUNNER)        private readonly tx: TransactionRunner,
    @Inject(CLOCK)            private readonly clock: Clock,
  ) {}

  async execute(cmd: ConfirmOrder): Promise<OrderId> {
    return this.tx.run(async () => {
      // 1. Загрузить агрегат
      const order = await this.orders.byId(cmd.orderId);
      if (!order) throw new OrderNotFoundError(cmd.orderId);

      // 2. Вызвать доменный метод — он проверяет инварианты
      order.confirm(this.clock.now());

      // 3. Сохранить
      await this.orders.save(order);

      // 4. Вернуть результат
      return order.id;
    });
  }
}

Разберём по шагам:

Транзакция открывается в handler-е, через TransactionRunner, а не внутри репозитория. Всё внутри tx.run(...) выполняется в одном соединении с базой. Если что-то пошло не так — автоматический откат.

Загрузка агрегата через byId. Реализация репозитория при открытой транзакции использует SELECT ... FOR UPDATE, защищая от одновременного изменения двумя запросами.

Доменный метод проверяет бизнес-правила. order.confirm() сам бросит OrderAlreadyConfirmedError, если заказ уже подтверждён. Handler не делает проверок статуса сам — это работа агрегата.

Возвращаем минимум — обычно только id. Не весь объект, не read-проекцию.

Один агрегат на команду

Частая ошибка начинающих — попытаться изменить два объекта в одной транзакции:

// Проблема — два агрегата в одной транзакции
async execute(cmd: CreateOrder): Promise<OrderId> {
  return this.tx.run(async () => {
    const customer = await this.customers.byId(cmd.customerId);
    customer.incrementOrderCount();
    await this.customers.save(customer);

    const order = Order.create(cmd.customerId, cmd.items);
    await this.orders.save(order);
    return order.id;
  });
}

Проблема: транзакция держит блокировки на двух таблицах одновременно. При параллельных запросах это быстро приводит к взаимным блокировкам. Customer и Order живут разным темпом и меняются по разным причинам.

Правило простое: одна команда — один агрегат. Если нужно затронуть второй — это либо saga (цепочка отдельных команд с компенсациями), либо границы агрегатов нарезаны неправильно.

Правильно: Order.create() внутри регистрирует доменное событие OrderCreated. Репозиторий при save записывает его в таблицу outbox. Отдельный процесс публикует событие, и сервис Customer обновляет счётчик асинхронно.

// Правильно — один агрегат, событие летит дальше
async execute(cmd: CreateOrder): Promise<OrderId> {
  return this.tx.run(async () => {
    const order = Order.create(cmd.customerId, cmd.items);
    await this.orders.save(order);
    return order.id;
  });
}

Что возвращает command-handler

Command-handler возвращает минимум: id созданной или изменённой сущности, статус-объект или void. Не полный объект, не read-проекцию.

// Создать заказ — возвращает id
export class CreateOrder implements Command<OrderId> {
  constructor(readonly customerId: CustomerId, readonly items: OrderItem[]) {}
}

// Подтвердить — тоже id
export class ConfirmOrder implements Command<OrderId> {
  constructor(readonly orderId: OrderId, readonly idempotencyKey: string) {}
}

// Отменить — без ответных данных достаточно
export class CancelOrder implements Command<void> {
  constructor(readonly orderId: OrderId, readonly reason: string) {}
}

Почему не возвращать полный объект? Write-handler начинает собирать read-проекцию — join-ы, маппинги, кастомные поля — это работа query-handler-а. Если клиенту нужна полная проекция после создания, он делает отдельный GET-запрос. Это честнее и надёжнее.

Валидация: входные данные и бизнес-правила

Валидация в command-side происходит в двух местах, и их нельзя путать.

На входе — проверяем формат данных. Это делает class-validator на request-DTO ещё до создания command-объекта:

export class ConfirmOrderRequest {
  @IsUUID()
  orderId: string;

  @IsString()
  @MaxLength(64)
  idempotencyKey: string;
}

Если данные невалидны по формату — ответ 400 клиенту, до всякой логики.

Внутри агрегата — проверяем бизнес-инварианты:

export class Order {
  confirm(now: Date): void {
    if (this.status !== OrderStatus.NEW) {
      throw new OrderAlreadyConfirmedError(this.id, this.status);
    }
    if (this.items.length === 0) {
      throw new EmptyOrderError(this.id);
    }
    this.status = OrderStatus.CONFIRMED;
    this.confirmedAt = now;
    this.registerEvent(new OrderConfirmed(this.id, now));
  }
}

Агрегат бросает типизированное доменное исключение. Error-handler преобразует его в 409 или 422. Handler не делает проверок статуса сам — это ответственность агрегата.

Пример: создание продукта

Структура handler-а одна и та же для любого домена:

// core/product/command/create-product.command.ts
export class CreateProduct implements Command<ProductId> {
  constructor(
    readonly name: string,
    readonly sku: string,
    readonly price: Money,
    readonly categoryId: CategoryId,
  ) {}
}

@Injectable()
export class CreateProductHandler implements Handler<CreateProduct, ProductId> {
  constructor(
    @Inject(PRODUCT_REPOSITORY) private readonly products: ProductRepository,
    @Inject(TX_RUNNER)          private readonly tx: TransactionRunner,
  ) {}

  async execute(cmd: CreateProduct): Promise<ProductId> {
    return this.tx.run(async () => {
      const existing = await this.products.bySku(cmd.sku);
      if (existing) throw new DuplicateSkuError(cmd.sku);

      const product = Product.create(cmd.name, cmd.sku, cmd.price, cmd.categoryId);
      await this.products.save(product);
      return product.id;
    });
  }
}

bySku — проверка уникальности выполняется внутри той же транзакции, что и сохранение. Между проверкой и вставкой нет «окна», где мог бы вклиниться другой запрос с тем же SKU.

Пример: идемпотентная команда

Для операций, которые нельзя выполнить дважды — платежи, списания — handler проверяет ключ идемпотентности внутри транзакции:

@Injectable()
export class ChargeAccountHandler implements Handler<ChargeAccount, void> {
  async execute(cmd: ChargeAccount): Promise<void> {
    return this.tx.run(async () => {
      const account = await this.accounts.byId(cmd.accountId);
      if (!account) throw new AccountNotFoundError(cmd.accountId);

      const alreadyProcessed = await this.accounts.hasProcessed(cmd.idempotencyKey);
      if (alreadyProcessed) return;

      account.charge(cmd.amount, cmd.idempotencyKey);
      await this.accounts.save(account);
    });
  }
}

ChargeAccount возвращает void — UI не нуждается в ответных данных, достаточно HTTP 204. Если запрос уже был обработан — молча возвращаем без ошибки.

Частые ошибки

Отдельный SELECT «посмотреть и решить» в handler-е. Всё, что нужно для принятия решения, должно жить в агрегате. Отдельный read-запрос в command-handler-е — это race condition: между проверкой и сохранением состояние могло измениться.

// Ошибка
async execute(cmd: ConfirmOrder): Promise<OrderId> {
  return this.tx.run(async () => {
    const hasPayment = await this.payments.existsByOrderId(cmd.orderId); // отдельный read
    if (!hasPayment) throw new PaymentRequiredError(cmd.orderId);
    // ...
  });
}

Если paymentStatus нужен для принятия решения — это поле агрегата Order, и order.confirm() сам проверит. Либо это разные bounded context-ы, и проверка идёт через явный query до вызова команды.

Несколько агрегатов в одном tx.run. Перевод денег между двумя счетами — это не одна транзакция с двумя агрегатами, это saga: списать с одного счёта, опубликовать событие, зачислить на другой, при ошибке — компенсировать.

Command возвращает полную read-проекцию. Это работа query-handler-а. После POST клиент делает GET, если нужны детали.

Коротко

  • Command — класс с readonly-полями и маркером Command<R>. Только данные, никакой логики.
  • Handler делает четыре шага: открыть транзакцию → загрузить агрегат → вызвать доменный метод → сохранить.
  • Транзакция открывается в handler-е через TransactionRunner, не внутри репозитория.
  • Одна команда — один агрегат. Два агрегата — это saga.
  • Возвращать минимум: id или void. Read-проекция — отдельный query.
  • Валидация формата — на request-DTO. Бизнес-инварианты — внутри метода агрегата.
  • Доменный метод агрегата сам проверяет состояние и бросает типизированное исключение.

Что почитать дальше

  • Query side — read-handler-ы с Query<R> и ViewRepository.
  • Sync через события — как событие из command-handler-а доходит до read-model.
  • Read-model — структура и восстановимость read-model.
  • Когда CQRS оправдан — когда вводить CQRS, а когда нет.