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

В 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, доменные методы, инварианты.