Message interceptors

An interceptor wraps the dispatch of a message to its handler, so cross-cutting behaviour — audit logging, authorisation checks, context enrichment, timing — lives in one place rather than being duplicated across every handler. This is a Spring DDD dispatch feature, not Spring AOP: the framework builds an explicit chain of matching interceptors and calls them in order before (and optionally after) the real handler invocation.

Declaring an interceptor — @MessageHandlerInterceptor

Annotate a method on any Spring @Component with @MessageHandlerInterceptor. The first parameter is the message payload and determines which messages the interceptor applies to, by isInstance check:

  • A concrete type (e.g. WithdrawMoney) matches only that message.

  • A supertype or interface matches every message assignable to it.

  • Any matches every message regardless of type.

Allowed payload types

The payload parameter must be Any or a framework message type: a class annotated with @Command, @Query, or @DomainEvent (or any annotation meta-annotated with @Message). Any other type is rejected at application startup with MessageHandlerInterceptorRegistrationException.

Around vs pre-only

The presence or absence of a second parameter of type InterceptorChain switches between two modes.

Around interceptors

Add an InterceptorChain parameter to receive control of the chain. The method must call chain.proceed() (it returns the downstream result: null / Unit for commands, the handler response for queries) and may wrap the call in try/finally:

@Component
class TimingInterceptor {

    private val log = LoggerFactory.getLogger(javaClass)

    @MessageHandlerInterceptor
    @Order(0) (1)
    fun time(message: Any, chain: InterceptorChain): Any? {
        val start = System.nanoTime()
        try {
            return chain.proceed() (2)
        } finally {
            log.info("Handled {} in {}ms", message::class.simpleName, (System.nanoTime() - start) / 1_000_000)
        }
    }
}
1 A lower @Order value runs earlier (further out); @Order(0) here runs before every unannotated interceptor.
2 chain.proceed() runs the rest of the chain (and finally the handler) and returns its result: null for commands, the handler’s response for queries.

Pre-only interceptors

Omit the InterceptorChain parameter. The framework invokes the method and then proceeds automatically. Throwing an exception aborts dispatch — a veto — without reaching the handler:

@Component
class WithdrawalAuthorization {

    @MessageHandlerInterceptor
    fun authorize(command: WithdrawMoney) {
        val actor = CurrentMessageMetadata.get()["actor"] as? String (1)
        require(actor != null) { "WithdrawMoney requires an authenticated actor" }
    }
}
1 CurrentMessageMetadata (package de.dwittkoetter.ddd.messaging.metadata) is always safe to read inside an interceptor — see Message metadata.

Ordering

Apply @Order to control the position of an interceptor in the chain. Lower value runs first (outermost); an unannotated interceptor sorts last (Ordered.LOWEST_PRECEDENCE). Interceptors run outside the handler’s transaction.

Where interceptors fire

Not all dispatch paths pass through the interceptor chain.

Dispatch path Interceptors fire

Commands (@CommandHandler)

Queries (@QueryHandler)

@DomainEventHandler

@SagaEventHandler

@SagaDeadlineHandler

@ProjectionListener

Deadline handlers are timer-fired and carry no message envelope; projection listeners receive only the event payload and the read-model instance.

Reading metadata from an interceptor

Interceptors are not handler methods and therefore cannot declare @MetadataValue parameters. Read the current MessageMetadata programmatically via CurrentMessageMetadata.get():

@Component
class AuditInterceptor {

    @MessageHandlerInterceptor
    fun audit(message: Any) {
        val actor = CurrentMessageMetadata.get()["actor"] as? String ?: "system" (1)
        // record actor + message type for the audit trail
    }
}
1 Returns MessageMetadata.EMPTY (not null) when called outside a handling scope, so the read is always safe.

The framework binds CurrentMessageMetadata around every supported handler invocation, including inside interceptors. See Message metadata for the full API.