Опирается на правила: R-CQRS-CMD-1R-CQRS-CMD-5 и R-CQRS-CMD-X1R-CQRS-CMD-X3 из CQRS Style Guide → раздел 2. Command side.

Важно знать

  • Command — это намерение изменить состояние. Record, реализует UseCaseCommand<R>. Без вычислений в конструкторе.
  • Один command — один агрегат. Если меняется два — это либо saga, либо границы агрегатов нарезаны неверно.
  • Command-handler: @Transactional (RW), грузит агрегат через FOR UPDATE, вызывает доменный метод, сохраняет, пишет в outbox.
  • Возвращает минимум: OrderId, Long, UseCaseEmptyResult. Никаких полных read-DTO.
  • Read внутри command — только в рамках load-aggregate. Отдельный SELECT для «прочитать и решить» — антипаттерн.
  • Валидация: контракт DTO через Jakarta, бизнес-инварианты — внутри агрегата (бросает domain exception).
  • Изменение нескольких агрегатов — через saga (см. Distributed → Saga), не через один большой @Transactional.

Command — это намерение пользователя или системы изменить состояние. В CQRS это пишущая половина: всё, что в принципе может что-то поменять, едет через command-handler. Граница чёткая: если handler без readOnly = true, это command, и для него действуют другие правила, чем для query. Раскрытие раздела 2 гайда.

Command — record с маркером UseCaseCommand

R-CQRS-CMD-1: command — это record (или final-класс), который реализует UseCaseCommand<R> из библиотеки usecase-pattern.

public record ConfirmOrderCommand(
    Long orderId,
    String idempotencyKey
) implements UseCaseCommand<Order> {}

Что важно:

  • Record, не class. Иммутабельность из коробки, equals/hashCode по полям, простая сериализация для аудита.
  • Никаких вычислений в конструкторе. Command — данные, не логика. Mapping из DTO происходит в контроллере (см. Use Case Pattern).
  • Параметр типа <R> — возвращаемое значение. Здесь Order — id или агрегат. Подробнее про R ниже.
  • idempotencyKey — обычно поле в command для денежных и других неидемпотентных операций. Контроллер берёт его из header Idempotency-Key.

Command меняет один агрегат

R-CQRS-CMD-2: одна команда — одно изменение одного агрегата. Если в business-логике command трогает два — что-то не так:

  • Либо это saga: распределённая последовательность из нескольких локальных команд с компенсациями (см. Distributed Patterns).
  • Либо границы агрегатов нарезаны неверно: два «агрегата», которые всегда меняются вместе, — это один агрегат (см. Aggregate Root).
// ПЛОХО — два агрегата в одной транзакции
@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();
}

Что не так: транзакция держит локи на двух агрегатах, контеншн растёт, при deadlock — оба commit'а откатываются. Customer и Order живут разными жизнями, у них разный rate изменений.

// ХОРОШО — один агрегат меняется, событие летит дальше
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
    Order order = orderFactory.createFor(cmd.customerId(), cmd.items());
    orderRepository.save(order);
    // OrderCreated событие зарегистрировано в агрегате,
    // outbox-relay опубликует, Customer-сервис увеличит счётчик асинхронно
    return order.id();
}

Customer.orderCount обновляется через event-driven, не через transactional coupling. Eventual consistency между агрегатами — норма DDD.

Структура command-handler-а

R-CQRS-CMD-3: классический command-handler — пять шагов в строгом порядке.

@Component
@RequiredArgsConstructor
class ConfirmOrderHandler implements UseCaseHandler<ConfirmOrderCommand, Order> {

    private final OrderRepository orderRepository;

    @Override
    @Transactional
    public Order handle(ConfirmOrderCommand cmd) {
        // 1. Загрузить агрегат с pessimistic lock
        Order order = orderRepository.findById(
                new OrderId(cmd.orderId()),
                SelectMode.FOR_UPDATE)
            .orElseThrow(() -> new OrderNotFoundException(cmd.orderId()));

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

        // 3. Сохранить (outbox-событие OrderConfirmed зарегистрировано
        //    внутри order.confirm() через registerEvent)
        orderRepository.save(order);

        // 4. Вернуть минимум (см. R-CQRS-CMD-4 ниже)
        return order;
    }
}

Что важно в каждом шаге:

  • @Transactional (без readOnly) — write-режим. См. jOOQ Transactions — RW транзакция начинается на handler-е, не на репозитории.
  • SelectMode.FOR_UPDATE — pessimistic lock на строке агрегата. Конкурирующая команда дождётся commit-а текущей. Без FOR UPDATE возможны lost-updates через классический read-modify-write race.
  • Доменный метод проверяет инварианты. order.confirm() сам бросит OrderAlreadyConfirmedException, если статус не подходит. Handler не делает if (order.status == ...) throw.
  • registerEvent — внутри агрегата. order.confirm() сам регистрирует OrderConfirmed, repository публикует в outbox при save. Это критично для CQRS sync — см. Sync via events.

Command возвращает минимум

R-CQRS-CMD-4: возвращаемое значение command — это id новой/изменённой сущности, статус, либо UseCaseEmptyResult. Не полный read-DTO.

// ОК — возвращает агрегат, контроллер сам сделает короткий маппинг в JSON
public record CreateOrderCommand(...) implements UseCaseCommand<Order> {}

// ОК — возвращает только id
public record CreateOrderCommand(...) implements UseCaseCommand<OrderId> {}

// ОК — для idempotent-команд, когда клиент не нуждается в ответе
public record CancelOrderCommand(...) implements UseCaseCommand<UseCaseEmptyResult> {}

// ПЛОХО — возвращает полный read-DTO
public record CreateOrderCommand(...) implements UseCaseCommand<OrderSummaryJson> {}

Почему не полный read-DTO (R-CQRS-CMD-X2):

  • Смешение responsibilities. Command-handler — это write. Если он начинает собирать read-проекцию, в нём появляются join'ы, маппинги, кастомные view — это работа query-handler-а.
  • При eventual consistency данные могут быть рассинхронизированы. Write-handler видит Order.status = CONFIRMED уже сейчас; query-handler через 100ms тоже увидит. Если клиенту реально нужна полная проекция после write — два call'а с понятным контрактом надёжнее, чем один с двумя ответственностями.
  • Контроллер всё равно может сделать второй call. POST /orders → 201 с Location: /orders/{id} + минимум полей в body. Клиент при необходимости делает GET /orders/{id}/summary.

Валидация: контракт vs инвариант

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

// 1. Контракт — на DTO через Jakarta, на контроллере через @Valid
public record ConfirmOrderRequest(
    @NotNull Long orderId,
    @NotBlank @Size(max = 64) String idempotencyKey
) {}

// 2. Бизнес-инвариант — в методе агрегата, бросает domain exception
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()));
    }
}

Подробно — в Validation → Где валидировать и Error Handling → Иерархия исключений.

Что запрещено

Отдельный SELECT «прочитать и решить» в command

R-CQRS-CMD-X1: внутри command-handler нет отдельного read-запроса для «посмотреть и подумать». Всё, что нужно для решения, — это агрегат, загруженный одним findById(id, FOR_UPDATE).

// ПЛОХО — отдельный SELECT в command
@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. Если завтра правило изменится («ещё проверить наличие на складе»), command-handler разрастётся.
  • Race condition: между existsByOrderId и findById payment может появиться/исчезнуть. Без локов проверка не консистентна.

Корректно: либо paymentStatus — поле агрегата Order (тогда order.confirm() сам проверит), либо это разные bounded context'ы, и проверка идёт через explicit query, а не приклеена сбоку.

Command возвращает полный read-DTO

R-CQRS-CMD-X2 — см. выше «Command возвращает минимум». Read-проекция — работа query-handler.

Несколько агрегатов в одном @Transactional

R-CQRS-CMD-X3: если команда меняет два независимых агрегата — это saga, не один command-handler.

// ПЛОХО — две мутации в одной транзакции
@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);
}

// ХОРОШО — orchestration saga
//   1. Command DebitAccount → saga_money_transfer.step = DEBITED
//   2. Event AccountDebited → orchestrator → Command CreditAccount
//   3. Command CreditAccount → saga.step = COMPLETED
//   4. На любой failure → компенсирующий CreditAccount (refund)

Почему: pessimistic locks на два агрегата → contention и deadlock'и (двое одновременно переводят туда-обратно). Saga даёт явную state-machine с компенсациями. Подробно — в Distributed Patterns.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Command-handler делает отдельный SELECT для решенияR-CQRS-CMD-X1Загрузка агрегата one-shot; проверки внутри агрегата
Command возвращает полный read-DTOR-CQRS-CMD-X2id / статус / UseCaseEmptyResult + отдельный query
Несколько агрегатов в одном @Transactional commandR-CQRS-CMD-X3Saga с локальными командами и компенсациями
Загрузка агрегата без FOR UPDATE в write-handlerR-CQRS-CMD-3SelectMode.FOR_UPDATE на load
Manual if status != X в handler вместо метода агрегатаR-CQRS-CMD-5Инвариант в методе агрегата + domain exception

Куда дальше

  • CQRS → раздел 2. Command side — нормативные формулировки R-CQRS-CMD-*.
  • Query side — read-handler-ы с UseCaseQuery и ViewRepository.
  • Sync via events — как outbox-событие из command-handler доходит до read-model.
  • Use Case Pattern — что такое UseCaseCommand и dispatcher.
  • Aggregate Root — registerEvent, доменные методы, инварианты.
  • jOOQ → Transaction boundaries@Transactional на handler, не на repository.
  • Distributed Patterns — saga, когда нужно менять несколько агрегатов.