В 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, а когда нет.