Commands

A command expresses the caller’s intent to change system state. Spring DDD dispatches commands through a type-safe command bus to a single handler that owns the transaction; the bus itself is a pure dispatcher. Commands are part of the CQRS feature provided by spring-ddd-starter-cqrs-jdbc (or a full bundle such as spring-ddd-starter-jdbc) — see Choosing your starters.

Defining a command

Annotate any class with @Command to mark it as a command message. The optional namespace and name attributes give the command a stable type identifier that is independent of the JVM class path. Mark exactly one field with @AggregateId to identify the target aggregate instance; the framework uses this field to locate the aggregate on the non-creational handler path.

import de.dwittkoetter.ddd.annotation.AggregateId
import de.dwittkoetter.ddd.annotation.Command

@Command(namespace = "banking", name = "OpenAccount")
data class OpenAccount(
    @AggregateId val accountId: AccountId,
    val initialBalance: Money,
)

@Command(namespace = "banking", name = "DepositMoney")
data class DepositMoney(
    @AggregateId val accountId: AccountId,
    val amount: Money,
)
@Command carries the jMolecules Command stereotype as a meta-annotation, so jMolecules-aware tooling recognises the stereotype automatically. See Building blocks & stereotypes for the full annotation table.

Command handlers on a bean

Any Spring-managed @Component can host a @CommandHandler method. The method must accept the command as its first parameter and return Unit (plain bean handlers; the aggregate-loading variant below returns the aggregate instead). Inject collaborators through the constructor — method-parameter bean injection is not supported.

import de.dwittkoetter.ddd.annotation.ApplicationService
import de.dwittkoetter.ddd.cqrs.CommandHandler

@ApplicationService
class AccountCommandHandlers(
    private val repository: BankAccountRepository,
) {

    @CommandHandler
    fun handle(command: OpenAccount) {
        val account = BankAccount(command.accountId, command.initialBalance)
        repository.save(account)
    }

    @CommandHandler
    fun handle(command: DepositMoney) {
        val account = repository.findById(command.accountId).orElseThrow()
        account.deposit(command.amount)
        repository.save(account)
    }
}

By default each @CommandHandler method runs in its own REQUIRES_NEW transaction. Override the propagation level with the propagation attribute:

import org.springframework.transaction.annotation.Propagation

@CommandHandler(propagation = Propagation.REQUIRED)
fun handle(command: DepositMoney) { ... }

For finer control — isolation level, timeout, rollback rules — annotate the method with a full @Transactional in addition to @CommandHandler.

The transaction model

The command bus owns no transaction. Each handler unit opens its own transaction independently:

  • A bean handler (a @Component @CommandHandler method) runs in REQUIRES_NEW by default, as governed by the @Transactional meta-annotation on @CommandHandler.

  • An auto-managed aggregate handler (see below) runs in a framework-owned REQUIRES_NEW that wraps the load → handle → save sequence atomically.

  • A query handler runs in a read-only transaction — see Queries.

Because the bus is a pure dispatcher, interceptor invocations happen outside any transaction boundary.

Dispatching commands

Inject CommandGateway and call send (synchronous) or sendAsync (fire-and-forget):

import de.dwittkoetter.ddd.annotation.ApplicationService
import de.dwittkoetter.ddd.cqrs.command.CommandGateway

@ApplicationService
class AccountApplicationService(private val gateway: CommandGateway) {

    fun openAccount(accountId: AccountId, initialBalance: Money) {
        gateway.send(OpenAccount(accountId, initialBalance))
    }

    fun depositAsync(accountId: AccountId, amount: Money) {
        gateway.sendAsync(DepositMoney(accountId, amount))
    }
}

send blocks the calling thread until the handler completes and propagates any handler exception wrapped in a CommandExecutionException. sendAsync submits the command to a background executor and returns immediately; exceptions inside the handler are logged but do not propagate to the caller. Both methods validate the command via Jakarta Bean Validation before dispatching.

Auto-managed aggregate handling

Instead of writing repository calls by hand, place @CommandHandler directly on the aggregate class (shown here as an event-sourced aggregate):

  • A secondary constructor annotated with @CommandHandler is a creational handler — the framework calls the constructor and saves the resulting aggregate instance.

  • A method annotated with @CommandHandler is a non-creational handler — the framework loads the aggregate by the command’s @AggregateId, calls the method, and then saves the aggregate.

import de.dwittkoetter.ddd.cqrs.CommandHandler

class BankAccount : EventSourcingAggregateRoot<BankAccount, AccountId>() {

    // Creational handler — framework instantiates and saves
    @CommandHandler
    constructor(command: OpenAccount) {
        registerEvent(AccountOpened(command.accountId, command.initialBalance))
    }

    // Non-creational handler — framework loads by @AggregateId, calls, saves
    @CommandHandler
    fun handle(command: DepositMoney) {
        require(command.amount.isPositive()) { "Deposit amount must be positive" }
        registerEvent(MoneyDeposited(accountId = id, amount = command.amount))
    }
}

The load-handle-save sequence runs inside a single framework-owned REQUIRES_NEW transaction. No separate handler bean or repository call is required.

The propagation attribute of @CommandHandler has no effect on aggregate handlers — both auto-managed roots and component handlers that declare a managed aggregate parameter always run inside a framework-owned REQUIRES_NEW transaction.

This pattern works for both event-sourced and JPA aggregates. For the persistence details specific to each model, see Aggregates (event-sourced) and Repositories & command handling (JPA).

Component handlers with aggregate loading

A @Component @CommandHandler method may also declare a managed aggregate as a parameter. The framework loads the aggregate before invoking the method and saves it after, with no repository code required in the handler body.

import de.dwittkoetter.ddd.annotation.ApplicationService
import de.dwittkoetter.ddd.cqrs.CommandHandler
import de.dwittkoetter.ddd.cqrs.messaging.exception.AggregateNotFoundException

@ApplicationService
class AccountCommandHandlers {

    // Nullable param — aggregate loaded if it exists; null passed if it does not
    @CommandHandler
    fun handle(command: OpenAccount, account: BankAccount?): BankAccount {
        check(account == null) { "Account ${command.accountId} already exists" }
        return BankAccount(command.accountId, command.initialBalance)
        // return type is BankAccount → framework saves the return value
    }

    // Non-null param — AggregateNotFoundException if the aggregate does not exist
    @CommandHandler
    fun handle(command: DepositMoney, account: BankAccount) {
        account.deposit(command.amount)
        // return Unit → framework saves the (mutated) parameter
    }
}

Nullable aggregate parameter — the framework attempts to load the aggregate by the command’s @AggregateId; if the aggregate does not exist, null is passed to the handler.

Non-null aggregate parameter — the aggregate must exist; if it does not, an AggregateNotFoundException is thrown before the handler is invoked.

Save-target rules:

  • Return the aggregate type → the framework saves the return value.

  • Return Unit → the framework saves the (mutated) parameter.

Inject collaborators through the constructor; method-parameter bean injection is not supported. Handler parameters annotated with @MetadataValue are also supported — see Message metadata and Message interceptors.