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 parameternull is injected.

  • Key absent, non-nullable parameterMissingMetadataException is thrown.

  • Key present, value not assignable to parameter typeMetadataTypeMismatchException is thrown.

Supported handlers

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

Handler annotation Supported Notes

@CommandHandler

Yes

@QueryHandler

Yes

@DomainEventHandler

Yes

@SagaEventHandler

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 @MetadataValue resolves on either.

@SagaDeadlineHandler

No

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

@ProjectionListener

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 @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.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 @MessageMetadataProvider values are resolved once per save, on the command thread, and persisted with each event in a typed envelope in the event store. A @ProjectionListener reads 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 @Entity aggregate, or published directly through an ApplicationEventPublisher, 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.