Опирается на правила:
R-CQRS-CMD-1…R-CQRS-CMD-5иR-CQRS-CMD-X1…R-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 для денежных и других неидемпотентных операций. Контроллер берёт его из headerIdempotency-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иfindByIdpayment может появиться/исчезнуть. Без локов проверка не консистентна.
Корректно: либо 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-DTO | R-CQRS-CMD-X2 | id / статус / UseCaseEmptyResult + отдельный query |
Несколько агрегатов в одном @Transactional command | R-CQRS-CMD-X3 | Saga с локальными командами и компенсациями |
Загрузка агрегата без FOR UPDATE в write-handler | R-CQRS-CMD-3 | SelectMode.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, когда нужно менять несколько агрегатов.