Declaring & publishing

A domain event is an immutable record of something that has already happened in the domain. Such events are the primary way other parts of the domain — and other bounded contexts — react to change without coupling to the originating code. In Spring DDD, domain events are plain Kotlin classes annotated with @DomainEvent; the framework publishes them after the originating transaction commits.

Declaring a domain event

Annotate any class with @DomainEvent to mark it as a domain event. The namespace and name attributes give the event a stable type identifier that is independent of the JVM class path — it is this identifier that is written to the event store, not the fully-qualified class name.

@DomainEvent(namespace = "banking", name = "AccountOpened")
data class AccountOpened(
    @AggregateId val accountId: UUID,
    val initialBalance: BigDecimal,
)

@DomainEvent(namespace = "banking", name = "MoneyDeposited")
data class MoneyDeposited(
    @AggregateId val accountId: UUID,
    val amount: BigDecimal,
)

Message key

An event’s message key decides which partition of the capture topic it is stored in. A projection does not depend on it: it applies each event in the partition of the read model the event changes, so every read model receives its events in the order they were published, and different read models may be applied in parallel. See Partitions. The capture topic holds every published event except those an event-sourced aggregate emits: those live in the event store, partitioned by their aggregate. An explicit @MessageKey on one of them must carry the aggregate’s own id; saving refuses any other value. @AggregateId on an event field declares the key, so an event about an aggregate is keyed by the aggregate it belongs to.

An event that belongs to no aggregate declares its key with @MessageKey:

@DomainEvent(namespace = "banking", name = "InterestRateChanged")
data class InterestRateChanged(
    @MessageKey val productId: ProductId,
    val rate: BigDecimal,
)

An explicit @MessageKey takes precedence over @AggregateId, so the event of an aggregate that is not event-sourced can be keyed by another field. A captured event has at most one key; two @MessageKey fields, or two @AggregateId fields and no @MessageKey, fail at startup, as does a key whose type does not reduce to a single string. An event without a key reaches a projection only through a @ReadModelKey on that projection, which names the read model it changes; startup fails otherwise.

@DomainEvent carries the jMolecules DomainEvent stereotype as a meta-annotation, so jMolecules-aware tooling (ArchUnit, Spring Modulith architecture checks) recognises the stereotype without a direct jMolecules compile dependency in your domain module. See Building blocks & stereotypes for the full annotation table.

Publishing

Both event-sourced and JPA aggregates record pending events with registerEvent(…​), inherited from Spring Data’s AbstractAggregateRoot. The repository publishes all buffered events after the aggregate is saved — that is, after the originating transaction commits — then clears the buffer.

// inside BankAccount
fun deposit(amount: BigDecimal) {
    require(amount > BigDecimal.ZERO) { "Deposit amount must be positive" }
    balance += amount
    registerEvent(MoneyDeposited(accountId = id, amount = amount))
}

For the mechanics specific to each aggregate model, see Aggregates (event-sourced) and Domain events & optimistic locking (JPA).

Publishing without an aggregate

Domain events are ordinary Spring application events: the repository emits each one through Spring’s ApplicationEventPublisher. So for an event that is not tied to an aggregate’s state change, you can publish it directly through the publisher — anything that uses Spring’s event machinery works.

@ApplicationService
class AccountService(private val events: ApplicationEventPublisher) {

    fun recordLogin(accountId: AccountId) {
        events.publishEvent(AccountLoggedIn(accountId))
    }
}

A directly published event reaches the same @EventListener and @DomainEventHandler handlers. The difference is durability: it is published in-process only and is not written to the event store, so — unlike an aggregate’s registerEvent — it is neither persisted nor replayable. Use registerEvent for events that record an aggregate’s state change; use the publisher for transient, non-aggregate notifications.

Handling the published events — live Spring listeners, the framework-managed @DomainEventHandler, transaction semantics, and delivery guarantees — is covered in Handling events.