Message metadata
Contextual data such as tenant identifier, acting user, or trace correlation often needs to travel
with a command or event without becoming part of the domain payload.
Embedding it in every command and event class creates brittle coupling; keeping it in a dedicated
side-channel leaves those signatures clean.
Spring DDD carries this context in a MessageMetadata envelope that is attached to each dispatched
message and made available to handlers.
The MessageMetadata API
MessageMetadata is an immutable, typed envelope of key-value pairs.
Read a value by key, check whether a key is present, or obtain an unmodifiable view of the map:
val tenantId = metadata["tenant-id"] (1)
val present = metadata.containsKey("tenant-id") (2)
val view = metadata.asMap() (3)
| 1 | Returns the stored value, or null when the key is absent. |
| 2 | Distinguishes an absent key from a key explicitly mapped to null. |
| 3 | An unmodifiable Map<String, Any?> view; callers cannot mutate the backing map. |
The sentinel MessageMetadata.EMPTY represents metadata with no entries.
It is returned by CurrentMessageMetadata.get() when called outside a handling scope (see
Programmatic access — CurrentMessageMetadata).
Attaching metadata — @MessageMetadataProvider
Annotate a parameterless method on any Spring @Component with @MessageMetadataProvider("key")
to supply the value for that key.
The framework invokes all registered providers once per dispatch, on the request thread, outside
the handler transaction:
@Component
class TenantMetadataProvider(private val tenants: TenantContext) {
@MessageMetadataProvider("tenant-id") (1)
fun tenantId(): String? = tenants.currentTenantOrNull() (2)
}
| 1 | "tenant-id" is the key under which the value is stored in the MessageMetadata envelope.
Providers run on the request thread as a command or query is sent through its gateway
(CommandGateway or QueryGateway). |
| 2 | Returning null omits the key entirely. |
Provider methods must follow these rules — violations fail fast at application startup:
-
The method must be parameterless and must not return
Unit. -
The key must not be blank.
-
Keys must be globally unique across all providers in the application context.
Reading metadata in a handler — @MetadataValue
Declare a parameter annotated with @MetadataValue("key") after the payload parameter on any
supported handler method:
@CommandHandler
fun handle(command: WithdrawMoney, @MetadataValue("tenant-id") tenantId: String?) { (1)
// tenantId holds the value attached when the command was sent
val ledger = tenantId ?: error("No tenant in context")
// …
}
| 1 | The payload parameter (WithdrawMoney) must not carry @MetadataValue. |
Resolution semantics:
-
Key absent, nullable parameter —
nullis injected. -
Key absent, non-nullable parameter —
MissingMetadataExceptionis thrown. -
Key present, value not assignable to parameter type —
MetadataTypeMismatchExceptionis thrown.
Supported handlers
@MetadataValue is supported on handler types that receive a full message envelope.
| Handler annotation | Supported | Notes |
|---|---|---|
|
Yes |
|
|
Yes |
|
|
Yes |
|
|
Yes |
On both delivery paths — the publish-captured metadata on the live event path, and the persisted
metadata on the ordered event-sourced inbound; a required |
|
No |
A deadline is a timer, not a message; no envelope is passed to the handler. |
|
Yes |
Metadata is the event’s persisted (event-sourced) or publish-captured (live) metadata; identical on live delivery and rebuild for event-sourced events. |
Whole-metadata injection
A parameter typed MessageMetadata (instead of @MetadataValue-annotated) receives the entire
metadata map rather than a single key:
@CommandHandler
fun handle(command: WithdrawMoney, metadata: MessageMetadata) { (1)
val tenantId = metadata["tenant-id"] as? String
// …
}
| 1 | Works on every handler type listed in Supported handlers — including @ProjectionListener.
Unlike @MetadataValue, a MessageMetadata parameter always binds; it is never absent, and at
worst receives MessageMetadata.EMPTY. |
Marking the payload — @Payload
By default the payload — the command, query, event, or the object a @ProjectionListener receives —
binds to the first handler parameter.
Annotating a parameter with @Payload binds the payload there instead, so it can appear at any
position: useful when a metadata value reads more naturally first, or simply to make the binding
explicit.
It is entirely optional; handlers without it keep the positional default unchanged.
@CommandHandler
fun handle(@MetadataValue("tenant-id") tenantId: String?, @Payload command: WithdrawMoney) {
// …
}
Fail-loud rules, enforced at startup:
-
At most one parameter per method may carry
@Payload. -
A
@Payloadparameter must not also carry@Valueor@MetadataValue. -
Once
@Payloadmarks the payload elsewhere, the first parameter is free to be a@MetadataValue/MessageMetadataparameter, as in the example above.
@Payload (package de.dwittkoetter.ddd.messaging.metadata, alongside @MetadataValue) is
recognized by every handler family: command, query, domain-event, saga event/deadline, and
projection listener.
Metadata on projections
Metadata travels with framework messages — anything (meta-)annotated @Command, @Query, or
@DomainEvent (that is, anything @Message-meta-annotated).
Publishing or appending anything else captures no metadata, and a @ProjectionListener for it sees
MessageMetadata.EMPTY.
How the metadata a projection listener sees relates to replay depends on how its event is delivered:
- Event-sourced delivery
-
The producing side’s
@MessageMetadataProvidervalues are resolved once per save, on the command thread, and persisted with each event in a typed envelope in the event store. A@ProjectionListenerreads that persisted value both on live consumer delivery and on every rebuild — the metadata is deterministic and identical across replay. - Live-only delivery
-
Events raised by a JPA
@Entityaggregate, or published directly through anApplicationEventPublisher, have no event log — metadata is captured at publish time and bound to the event instance (this requires the domain-events module on the classpath). These events do not replay at all, which is a pre-existing restriction of live-only delivery; the metadata they carry simply inherits it.
Append-time persistence and publish-time capture are two separate resolutions of the same providers: the event store column is resolved once when the aggregate saves, while the live-path binder captures at publish time. A provider that returns different values on repeated calls therefore yields different metadata on the two paths — by design. For event-sourced delivery this is invisible to projections, which only ever read the persisted value.
@JsonProjection(name = "account-summary")
class AccountSummaryProjection {
private val log = LoggerFactory.getLogger(javaClass)
@ProjectionListener
fun on(
event: MoneyDeposited,
summary: AccountSummary,
@MetadataValue("tenant-id") tenantId: String?, (1)
) {
summary.balance += event.amount.value
log.debug("tenant {} deposited into {}", tenantId, summary.id) (2)
}
}
| 1 | For an event-sourced MoneyDeposited, tenantId is the value persisted with the event —
identical whether this listener runs on live delivery or during a rebuild. |
| 2 | Incidental side effects such as logging can read metadata inside a state-managing listener
without changing its shape. A dedicated side-effect listener — one without a read-model
parameter — binds metadata the same way; its shape is fun on(event, …metadata params). |
Resolution semantics for @MetadataValue/MessageMetadata parameters on a @ProjectionListener
are identical to those described in Reading metadata in a handler.
On the projection consumer path, a resulting MissingMetadataException or
MetadataTypeMismatchException is an apply error and routes through the projection’s
onError policy (HALT/SKIP) like any other apply failure — see
Projection delivery.
On the live-only path, the failure is logged and swallowed, matching existing live-listener
error semantics.
Programmatic access — CurrentMessageMetadata
A collaborator that is not itself a handler method can read the current metadata via
CurrentMessageMetadata.get():
@Service
class AuditTrail {
fun record(action: String) {
val actor = CurrentMessageMetadata.get()["actor"] as? String ?: "system" (1)
// …
}
}
| 1 | Returns MessageMetadata.EMPTY when called outside a handling scope, so reads are always safe. |
CurrentMessageMetadata (package de.dwittkoetter.ddd.messaging.metadata) is a thread-local
holder.
The framework binds it, including inside interceptors, around:
-
command handlers
-
query handlers
-
@DomainEventHandler -
@SagaEventHandler -
@ProjectionListener -
plain Spring event listeners
The one exception is @SagaDeadlineHandler: a deadline is a timer, not a message, so no metadata
is bound around it.
See Message interceptors for how interceptors observe this
ambient scope.
How metadata flows
Metadata resolved for a command is bound to the envelope that travels with that dispatch.
When the command handler publishes domain events, those events carry the same metadata, so a
@DomainEventHandler reading @MetadataValue("tenant-id") sees the originating tenant context
without any explicit propagation in application code.
For durable async handlers, Spring DDD persists the MessageMetadata alongside the event
publication so that it is fully restored when a publication is resubmitted after a restart.
Spring Modulith Integration covers the durable-resubmit mechanism.
Related pages
-
Overview — all cross-cutting concerns at a glance.
-
Message interceptors — intercept and augment handler invocations.