В CQRS приложение делится на две части: запись (command side) и чтение (query side). Эта статья про первую — как команды меняют состояние, кто это делает и по каким правилам.
Что такое команда
Представьте кассира, который нажимает «Подтвердить заказ». Это намерение изменить состояние системы. В коде оно оформляется как команда — объект с данными, который несёт это намерение.
public record ConfirmOrderCommand(
Long orderId,
String idempotencyKey
) implements UseCaseCommand<Order> {}
Несколько важных деталей:
- Команда — это record (или final-класс): иммутабельный, с equals/hashCode по полям. Удобно логировать, передавать, тестировать.
- В конструкторе нет логики. Команда — это данные, не вычисления. Контроллер заполняет её из запроса и передаёт дальше.
- Параметр
<Order>— что вернёт обработчик. Об этом ниже. idempotencyKey— ключ для денежных операций, берётся из заголовкаIdempotency-Key.
Одна команда — один агрегат
Это ключевое правило command side. Одна транзакция меняет один агрегат.
Почему это важно? Посмотрим на проблемный пример:
// Частая ошибка — два агрегата в одной транзакции
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Customer customer = customerRepository.findById(cmd.customerId(), FOR_UPDATE);
customer.incrementOrderCount(); // меняем Customer
customerRepository.save(customer);
Order order = orderFactory.createFor(customer, cmd.items());
orderRepository.save(order); // меняем Order
return order.id();
}
Здесь транзакция держит блокировки сразу на двух строках БД. При конкурентной нагрузке это означает:
- больше ожидания (другие операции с Customer или Order стоят в очереди);
- риск взаимной блокировки (deadlock), если другая транзакция возьмёт блокировки в другом порядке;
- при откате — оба изменения теряются вместе.
Правильно: один агрегат в транзакции, остальные обновляются асинхронно через события:
// Правильно — меняем только Order
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Order order = orderFactory.createFor(cmd.customerId(), cmd.items());
orderRepository.save(order);
// OrderCreated-событие зарегистрировано внутри агрегата;
// relay опубликует его, Customer-сервис обновит счётчик сам
return order.id();
}
Если бизнес-логика требует менять два агрегата — это либо saga (цепочка команд с компенсациями), либо сигнал, что границы агрегатов нарезаны неверно.
Структура command-handler-а
Обработчик команды (command-handler) — это класс с одним методом handle. Его работа всегда состоит из одних и тех же шагов:
@Component
@RequiredArgsConstructor
class ConfirmOrderHandler implements UseCaseHandler<ConfirmOrderCommand, Order> {
private final OrderRepository orderRepository;
@Override
@Transactional
public Order handle(ConfirmOrderCommand cmd) {
// 1. Загрузить агрегат с блокировкой
Order order = orderRepository.findById(
new OrderId(cmd.orderId()),
SelectMode.FOR_UPDATE)
.orElseThrow(() -> new OrderNotFoundException(cmd.orderId()));
// 2. Вызвать доменный метод — он проверяет инварианты
order.confirm();
// 3. Сохранить (событие OrderConfirmed уже внутри агрегата)
orderRepository.save(order);
// 4. Вернуть минимум
return order;
}
}
Разберём каждый шаг.
Блокировка при загрузке (FOR UPDATE)
SelectMode.FOR_UPDATE — это pessimistic lock: пока текущая транзакция не завершится, другая транзакция не сможет загрузить этот же агрегат для записи.
Зачем это нужно? Без блокировки возможна классическая гонка: две транзакции одновременно читают Order.status = NEW, обе решают подтвердить заказ, обе записывают — одно из изменений теряется. FOR UPDATE исключает эту ситуацию.
Инварианты — внутри агрегата
Обработчик не проверяет состояние сам. Он вызывает доменный метод, который сам знает, что допустимо:
public final class Order extends AggregateRoot<OrderId> {
public void confirm() {
if (this.status != OrderStatus.NEW) {
throw new OrderAlreadyConfirmedException(this.id, this.status);
}
if (this.items.isEmpty()) {
throw new EmptyOrderException(this.id);
}
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmed(this.id, Instant.now()));
}
}
Правило простое: логика «можно ли это сделать» живёт в агрегате, обработчик только оркестрирует поток (загрузить → вызвать → сохранить).
Событие регистрируется внутри агрегата
order.confirm() вызывает registerEvent(new OrderConfirmed(...)). При save репозиторий публикует это событие в outbox. Relay доставит его подписчикам (например, сервису синхронизации read-модели). Обработчик всё это не знает и не координирует вручную.
Что возвращает команда
Команда возвращает минимум: идентификатор, статус или пустой результат. Не полный read-DTO.
// Возвращает агрегат — контроллер сам сделает маппинг в краткий JSON
public record CreateOrderCommand(...) implements UseCaseCommand<Order> {}
// Возвращает только id
public record CreateOrderCommand(...) implements UseCaseCommand<OrderId> {}
// Не возвращает ничего значимого
public record CancelOrderCommand(...) implements UseCaseCommand<UseCaseEmptyResult> {}
// Частая ошибка — возвращает полный read-DTO
public record CreateOrderCommand(...) implements UseCaseCommand<OrderSummaryJson> {}
Почему нельзя возвращать полный read-DTO? Потому что это смешивает две обязанности: запись и чтение. Command-handler занимается записью. Если клиенту нужна полная проекция — он делает отдельный GET-запрос после записи. Контракт: POST /orders возвращает 201 с Location: /orders/{id}, а клиент при необходимости читает через GET /orders/{id}.
Валидация: где что проверяется
Валидация происходит в двух разных местах, и их нельзя путать.
Контракт запроса — проверяется на контроллере через Jakarta Validation:
public record ConfirmOrderRequest(
@NotNull Long orderId,
@NotBlank @Size(max = 64) String idempotencyKey
) {}
Это технические ограничения: поле не пустое, размер в пределах, формат правильный.
Бизнес-инвариант — проверяется в методе агрегата и бросает доменное исключение:
public void confirm() {
if (this.status != OrderStatus.NEW) {
throw new OrderAlreadyConfirmedException(this.id, this.status);
}
// ...
}
Это бизнес-правило: заказ можно подтвердить только если он в статусе NEW. Такая логика принадлежит домену, не HTTP-слою.
Частые ошибки
Отдельный SELECT «прочитать и решить»
// Так делать не нужно
@Transactional
public Order handle(ConfirmOrderCommand cmd) {
boolean hasPayment = paymentRepository.existsByOrderId(cmd.orderId()); // отдельный read
if (!hasPayment) throw new PaymentRequiredException(cmd.orderId());
Order order = orderRepository.findById(new OrderId(cmd.orderId()), FOR_UPDATE)
.orElseThrow(...);
order.confirm();
orderRepository.save(order);
return order;
}
Проблема двойная: между первым и вторым запросом данные могут измениться (гонка без блокировки), и read-логика просочилась в write-handler. Если paymentStatus важен для подтверждения заказа — он должен быть полем агрегата Order. Тогда order.confirm() сам проверит его.
Два агрегата в одной транзакции
Уже разобрали выше. Перевод денег между двумя счетами — классический пример, где нужна saga:
// Частая ошибка
@Transactional
public void handle(TransferMoneyCommand cmd) {
Account from = accountRepository.findById(cmd.fromId(), FOR_UPDATE);
Account to = accountRepository.findById(cmd.toId(), FOR_UPDATE);
from.debit(cmd.amount());
to.credit(cmd.amount());
accountRepository.save(from);
accountRepository.save(to);
}
// Правильно — saga из двух локальных команд:
// 1. DebitAccount → AccountDebited
// 2. CreditAccount ← orchestrator по событию
// При ошибке — компенсирующая команда CreditAccount (возврат)
Коротко
- Команда — record с данными и маркером
UseCaseCommand<R>. Без логики в конструкторе. - Одна команда меняет один агрегат. Два агрегата — это saga.
- Загрузка агрегата всегда через
FOR UPDATE— иначе возможна потеря обновлений при конкурентных запросах. - Инварианты проверяет агрегат, не обработчик. Handler оркеструет: загрузить → вызвать метод → сохранить.
- Событие регистрируется внутри агрегата; outbox публикует его при save.
- Команда возвращает минимум: id или статус. Полный read-DTO — работа query-handler-а.
- Валидация: контракт (Jakarta) — на контроллере; бизнес-инварианты — в методах агрегата.
Что почитать дальше
- Query side в CQRS — как работает сторона чтения.
- Синхронизация через события — как outbox-событие из command-handler доходит до read-модели.
- Aggregate Root —
registerEvent, доменные методы, инварианты.