In CQRS an application splits into two parts: writes (the command side) and reads (the query side). This article is about the first — how commands change state, who does it, and by what rules.
What a command is
Picture a cashier pressing "Confirm order". That is an intent to change the system's state. In code it is expressed as a command — a data object that carries this intent.
public record ConfirmOrderCommand(
Long orderId,
String idempotencyKey
) implements UseCaseCommand<Order> {}
A few important details:
- A command is a record (or a final class): immutable, with equals/hashCode by fields. Convenient to log, pass around, and test.
- There is no logic in the constructor. A command is data, not computation. The controller populates it from the request and passes it on.
- The
<Order>type parameter is what the handler returns. More on that below. idempotencyKeyis the key for money operations, taken from theIdempotency-Keyheader.
One command — one aggregate
This is the key rule of the command side. One transaction changes one aggregate.
Why does it matter? Let's look at a problematic example:
// Common mistake — two aggregates in one transaction
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Customer customer = customerRepository.findById(cmd.customerId(), FOR_UPDATE);
customer.incrementOrderCount(); // change Customer
customerRepository.save(customer);
Order order = orderFactory.createFor(customer, cmd.items());
orderRepository.save(order); // change Order
return order.id();
}
Here the transaction holds locks on two DB rows at once. Under concurrent load this means:
- more waiting (other operations on Customer or Order queue up);
- risk of a deadlock if another transaction takes the locks in a different order;
- on rollback, both changes are lost together.
The right way: one aggregate per transaction, the rest are updated asynchronously through events:
// Correct — change only Order
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Order order = orderFactory.createFor(cmd.customerId(), cmd.items());
orderRepository.save(order);
// The OrderCreated event is registered inside the aggregate;
// the relay publishes it, the Customer service updates its counter itself
return order.id();
}
If the business logic requires changing two aggregates, it is either a saga (a chain of commands with compensations) or a sign that the aggregate boundaries are drawn incorrectly.
The structure of a command handler
A command handler is a class with a single handle method. Its work always consists of the same steps:
@Component
@RequiredArgsConstructor
class ConfirmOrderHandler implements UseCaseHandler<ConfirmOrderCommand, Order> {
private final OrderRepository orderRepository;
@Override
@Transactional
public Order handle(ConfirmOrderCommand cmd) {
// 1. Load the aggregate with a lock
Order order = orderRepository.findById(
new OrderId(cmd.orderId()),
SelectMode.FOR_UPDATE)
.orElseThrow(() -> new OrderNotFoundException(cmd.orderId()));
// 2. Call the domain method — it checks the invariants
order.confirm();
// 3. Save (the OrderConfirmed event is already inside the aggregate)
orderRepository.save(order);
// 4. Return the minimum
return order;
}
}
Let's walk through each step.
Locking on load (FOR UPDATE)
SelectMode.FOR_UPDATE is a pessimistic lock: until the current transaction completes, another transaction cannot load the same aggregate for writing.
Why is this needed? Without a lock a classic race is possible: two transactions read Order.status = NEW at the same time, both decide to confirm the order, both write — one of the changes is lost. FOR UPDATE rules this out.
Invariants — inside the aggregate
The handler does not check state itself. It calls a domain method that knows what is allowed:
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()));
}
}
The rule is simple: the "is this allowed" logic lives in the aggregate; the handler only orchestrates the flow (load → call → save).
The event is registered inside the aggregate
order.confirm() calls registerEvent(new OrderConfirmed(...)). On save the repository publishes this event to the outbox. The relay delivers it to subscribers (for example, the read-model sync service). The handler knows none of this and does not coordinate it by hand.
What a command returns
A command returns the minimum: an identifier, a status, or an empty result. Not a full read DTO.
// Returns the aggregate — the controller maps it to a short JSON itself
public record CreateOrderCommand(...) implements UseCaseCommand<Order> {}
// Returns only the id
public record CreateOrderCommand(...) implements UseCaseCommand<OrderId> {}
// Returns nothing meaningful
public record CancelOrderCommand(...) implements UseCaseCommand<UseCaseEmptyResult> {}
// Common mistake — returns a full read DTO
public record CreateOrderCommand(...) implements UseCaseCommand<OrderSummaryJson> {}
Why can't it return a full read DTO? Because that mixes two responsibilities: writing and reading. A command handler is about writing. If the client needs the full projection, it makes a separate GET request after the write. The contract: POST /orders returns 201 with Location: /orders/{id}, and the client reads via GET /orders/{id} when needed.
Validation: what is checked where
Validation happens in two different places, and they must not be confused.
The request contract — checked at the controller through Jakarta Validation:
public record ConfirmOrderRequest(
@NotNull Long orderId,
@NotBlank @Size(max = 64) String idempotencyKey
) {}
These are technical constraints: the field is not empty, the size is within bounds, the format is correct.
A business invariant — checked in the aggregate method and throws a domain exception:
public void confirm() {
if (this.status != OrderStatus.NEW) {
throw new OrderAlreadyConfirmedException(this.id, this.status);
}
// ...
}
This is a business rule: an order can be confirmed only if it is in the NEW status. Such logic belongs to the domain, not the HTTP layer.
Common mistakes
A separate SELECT to "read and decide"
// Don't do this
@Transactional
public Order handle(ConfirmOrderCommand cmd) {
boolean hasPayment = paymentRepository.existsByOrderId(cmd.orderId()); // separate 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;
}
The problem is twofold: between the first and second query the data may change (a race without a lock), and read logic has leaked into the write handler. If paymentStatus matters for confirming the order, it should be a field of the Order aggregate. Then order.confirm() will check it itself.
Two aggregates in one transaction
Already covered above. Transferring money between two accounts is the classic example where a saga is needed:
// Common mistake
@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);
}
// Correct — a saga of two local commands:
// 1. DebitAccount → AccountDebited
// 2. CreditAccount ← orchestrator, on the event
// On failure — the compensating command CreditAccount (a refund)
In short
- A command is a record with data and the
UseCaseCommand<R>marker. No logic in the constructor. - One command changes one aggregate. Two aggregates mean a saga.
- Loading an aggregate always goes through
FOR UPDATE— otherwise updates can be lost under concurrent requests. - The aggregate checks invariants, not the handler. The handler orchestrates: load → call the method → save.
- The event is registered inside the aggregate; the outbox publishes it on save.
- A command returns the minimum: an id or a status. A full read DTO is the query handler's job.
- Validation: the contract (Jakarta) at the controller; business invariants in aggregate methods.
Further reading
- The query side in CQRS — how the read side works.
- Synchronization through events — how an outbox event from a command handler reaches the read model.
- Aggregate Root —
registerEvent, domain methods, invariants.