Repositories & command handling

A JPA aggregate is persisted through a plain Spring Data JpaRepository — there is nothing spring-ddd-specific about the repository interface itself. The optional cqrs-jpa bridge adds auto-managed @CommandHandler support: it loads the aggregate before a command method and saves it afterwards, matching the model used for event-sourced aggregates.

Repository

Declare a standard Spring Data JPA repository:

interface BankAccountRepository : JpaRepository<BankAccount, AccountId>

Calling save also publishes the aggregate’s buffered domain events — see Domain events & optimistic locking.

JpaRepository is a Spring Data abstraction — Spring DDD adds no wrapper, custom base class, or factory for it. Consult the Spring Data JPA reference for query methods, projections, and pagination.

Auto-managed command handling

The spring-ddd-cqrs-jpa module (activated by spring-ddd-starter-cqrs-jpa, or the full bundle spring-ddd-starter-jpa) provides a cqrs-jpa bridge that manages the aggregate lifecycle for you when @CommandHandler is declared directly on an @Entity @Aggregate class.

How it works

  • Creational handlers — a constructor annotated with @CommandHandler creates a new aggregate instance. The bridge calls the constructor and then saves the new aggregate via the repository.

  • Non-creational handlers — a method annotated with @CommandHandler updates an existing aggregate. The bridge loads the aggregate by the id extracted from the command (via @AggregateId), invokes the method, and then saves the aggregate.

In both cases the load and save are performed transparently — you do not call the repository yourself.

Example

@Entity
@Table(name = "bank_accounts")
@Aggregate(namespace = "banking")
class BankAccount(
    @EmbeddedId
    @AttributeOverride(name = "value", column = Column(name = "id", nullable = false, updatable = false))
    @AggregateId
    override var id: AccountId = AccountId(),
) : AbstractAggregateRootEntity<BankAccount, AccountId>() {

    var balance: BigDecimal = BigDecimal.ZERO

    /** Creational handler — opens a new account. The bridge creates and saves the aggregate. */
    @CommandHandler
    constructor(cmd: OpenAccount) : this(cmd.accountId) {
        balance = cmd.initialBalance.value
        registerEvent(AccountOpened(id, cmd.initialBalance))
    }

    /** Non-creational handler — deposits money. The bridge loads, invokes, and saves. */
    @CommandHandler
    fun deposit(cmd: DepositMoney) {
        balance = balance.add(cmd.amount.value)
        registerEvent(MoneyDeposited(id, cmd.amount))
    }
}

The command class for a non-creational handler must carry an @AggregateId field so the bridge can locate the aggregate:

@Command
data class DepositMoney(
    @AggregateId val accountId: AccountId,
    val amount: Money,
)

@Aggregate is required for the auto-managed @CommandHandler bridge. It is not required for plain JPA persistence or event publication on its own.

Commands reach these handlers through the command bus — see CQRS for dispatch, gateways, and the full command rules.