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.
-
Anymatches every message regardless of type.
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 ( |
✅ |
Queries ( |
✅ |
|
✅ |
|
✅ |
|
❌ |
|
❌ |
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.
Related pages
-
Overview — all cross-cutting concerns at a glance.
-
Message metadata — attaching and reading per-message context.