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 — null is injected.

  • Key absent, non-nullable parameter — MissingMetadataException is thrown.

  • Key present, value not assignable to parameter type — MetadataTypeMismatchException is thrown.

Supported handlers

@MetadataValue is supported on handler types that receive a full message envelope.

Handler annotation Supported Notes

@CommandHandler

✅

@QueryHandler

✅

@DomainEventHandler

✅

@SagaEventHandler

✅

The persisted metadata of the message; a required @MetadataValue resolves from it.

@ProjectionListener

✅

On a domain event, the event’s metadata. On another projection’s read model, the metadata of the message whose apply wrote the read model’s latest state; empty when that message carried none, or when a rebuild deleted the read model.

@SagaDeadlineHandler

❌

A deadline is a timer, not a message; no envelope is passed to the handler.

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. 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, or event — 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 @Payload parameter must not also carry @Value or @MetadataValue.

  • Once @Payload marks the payload elsewhere, the first parameter is free to be a @MetadataValue/MessageMetadata parameter, as in the example above.

@Payload (package de.dwittkoetter.ddd.messaging.annotation, alongside @MetadataValue) is recognized by every handler family: command, query, domain-event, and saga event/deadline.

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

  • 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.