Define Commands
A command is an imperative payload requesting a state change. It describes what a caller wants to happen; the aggregate decides from current state whether it is allowed and represents the fact that happened as domain events.
Regular commands let an aggregate Handler produce events from current state; Void commands are acknowledged at the Dispatcher layer.
Command Payloads and Command Messages
A command payload is usually a Kotlin data class or object. When sent, toCommandMessage() wraps the payload with a command ID, request ID, aggregate identity, owner, space, headers, expected version, and creation flags in CommandMessage<C>.
@CreateAggregate
data class CreateOrder(
val items: List<Item>,
val address: ShippingAddress,
val fromCart: Boolean,
)The payload expresses request data; it is not the runtime envelope. To control versions, put @AggregateVersion on a payload property; aggregateVersion on the command message is used for optimistic concurrency checking.
Target Aggregate and Command Metadata
CommandMetadataParser derives a command type's name, target aggregate, aggregate ID, tenant, owner, expected version, and create, allow-create, and void flags. A command can supply its target by implementing NamedAggregate, or by using @AggregateName; @AggregateId selects the target ID, and an unannotated property conventionally named id is used as a fallback.
@TenantId, @OwnerId, and @AggregateVersion provide their respective metadata; @StaticAggregateId and @StaticTenantId provide static values. A value the command carries comes before the one its caller passes. Over HTTP, since 9.3.0, a @TenantId or @OwnerId that contradicts the tenant or owner the route fixes is rejected with 400 (see Request Identity). Constructing a CommandMessage fails if neither the command nor the call arguments can resolve a target aggregate.
Command Handling Functions
A command handling function does only three things: read current state, check business invariants, and return one or more domain events. Database writes, event publication, and projection updates belong to the runtime processing chain, not aggregate decisions.
@AggregateRoot
class Cart(private val state: CartState) {
fun onCommand(command: ChangeQuantity): CartQuantityChanged {
val item = state.items.firstOrNull { it.productId == command.productId }
?: throw IllegalArgumentException("Product does not exist")
return CartQuantityChanged(item.copy(quantity = command.quantity))
}
}The conventional name onCommand is discovered automatically; use @OnCommand(returns = [...]) for another function name or when the return type cannot statically express the event set. The first parameter can be a concrete command, CommandMessage<C>, or ServerCommandExchange<C>; later parameters can be resolved by the IoC container. A handler may return one event, multiple events, or a reactive type; external checks must stay in the reactive chain.
A command is matched to the handler declared for its exact type first. Since 9.3.0, a handler whose first parameter is a superclass or interface of the command also matches when no exact handler exists; the nearest supertype wins, a superclass before an interface at the same distance, and a parameter of type Any never matches. When several supertypes at that distance have handlers (class C : I1, I2 with handlers for both), the first one wins and a warning names the others; declare a handler for the command's own type to choose. Such a command still has to reach the aggregate: the REST command routes and the command facade only accept registered command types (KSP records the handler's parameter type), so a subtype handled this way is sent through CommandGateway, or mounted with @AggregateRoot(commands = [...]) to get a route. After-command functions and @OnError still match the command's exact type: include/exclude and the @OnError parameter name the subtype, not the supertype whose handler ran. When an aggregate declares two handlers for the same command type, the first one found keeps handling it, as before; since 9.3.0 startup logs a warning naming the one that is ignored. A later parameter that nothing resolves is still injected as null; since 9.3.0 the first such call logs a warning when the parameter's type is not nullable. Declare a parameter nullable when the service is optional.
Create, Allow-Create, and Void Commands
@CreateAggregate marks a creation command. Its expected version is the uninitialized version, and it starts from fresh state rather than restoring existing event history.
@AllowCreate permits on-demand creation when the target aggregate does not exist; without it, an ordinary command whose target is absent fails. AddCartItem is an existing allow-create example.
Only these two annotations decide creation. An expected version of 0 (aggregateVersion on the message or the Command-Aggregate-Version header) on any other command is an ordinary optimistic-concurrency check: it does not turn the command into a create command, so a command without @CreateAggregate or @AllowCreate whose target is absent still fails with NotFound. Since 9.2.3; earlier versions treated an expected version of 0 as a create command.
@VoidCommand does not mean “a handler with no return value.” It is still sent to the command bus and becomes an isVoid command, but CommandDispatcher acknowledges and filters it before aggregate dispatch. It therefore does not invoke an aggregate root, emit events, or update state. Mount it on an aggregate through @AggregateRoot(commands = [...]), as ViewCart does.
AfterCommand and OnError
The afterCommand convention or @AfterCommand declares a post-command function after the main command succeeds. Post-command functions are ordered by @Order; include and exclude select command types, and returned events are appended to the same event stream after the main command events.
The onError convention or @OnError declares an error handling function. The runtime first records the original error on the exchange, then invokes the matching error function. The command still fails: with the error the function set on the exchange if it replaced it, otherwise with the original error; an error thrown by the function propagates instead. When the default processor retries a recoverable failure, the error function runs once, after the final failure, on the most recently loaded aggregate (an earlier attempt's when the last one failed before loading the aggregate; not at all when no attempt loaded it); a command that succeeds on a retry never calls it, and what the function does to the error does not decide whether to retry (since 9.2.3; earlier versions called it on every failed attempt). The error function always sees committed state (since 9.3.0): when the failed attempt had already applied its events to the state but did not store them (a sourcing or append failure), it runs on the aggregate loaded again (a create gets a new aggregate from the state factory); when that load fails, it is skipped, the original error is returned, and the skip is logged at ERROR. Use it to observe or perform framework-supported recovery, not as a second write path around business invariants.
Input Validation and Business-Invariant Boundaries
The call boundary owns payload shape and field constraints, such as Jakarta Validation, CommandValidator, and request-ID prechecks. The aggregate owns business invariants that depend on current state, such as cart capacity or order lifecycle.
Do not skip aggregate checks because field validation passed: the same command may be accepted or rejected under different event histories. For every invariant, test Given history, When command, Expect event or error, and the sourced state.
Next: Send Commands
Once the command is defined, use Send Commands to send its CommandMessage, then use Completion Semantics to choose a wait stage that meets the caller's response contract.