Routing

Routing selects a destination for a message by its type, before a handler is resolved. It is one feature over all three message kinds: a command, a query, or a domain event can be sent to an external system instead of — or, for events, in addition to — the local bus, chosen by message type without changing a single call site. Everything with no declared route continues to dispatch locally.

Routing has two layers, and the split is what makes commands, queries, and events one feature rather than three:

Decision

Which destination a message goes to. A Destination is either the reserved "internal" (the local bus) or a named destination such as "payments". Declarative routes (@Router with @CommandRoute/@QueryRoute/@EventRoutes, config-based routes) and custom RouteResolver beans answer this question — identically for all three message kinds. This whole layer is shared, and the sections up to Routing key apply to every message kind.

Delivery

How the chosen destination is used. This is the one layer that differs by message kind, and only because the kinds themselves differ: a command or query has a single handler, so a route sends it elsewhere and the caller waits (Commands and queries); a domain event is a broadcast, so routes fan it out to additional destinations after the transaction commits (Domain events).

Routing is optional

With no @Router bean, no spring.ddd.routing.commands/queries/events entries, and no custom RouteResolver, every command and query resolves to Destination.INTERNAL and never leaves the local bus, and every domain event is delivered only to its local @DomainEventHandlers. Routing is purely additive: you opt in one message type at a time.

Dependency

Command and query routing ships as part of spring-ddd-cqrs, so it is already available through spring-ddd-starter-cqrs-jdbc, spring-ddd-starter-cqrs-jpa, or either universal bundle (spring-ddd-starter-jdbc, spring-ddd-starter-jpa) — see Choosing your starters. Event routing lives in spring-ddd-domain-events, included through spring-ddd-starter-domain-events and both universal bundles; only durable event delivery adds a dependency (spring-ddd-starter-domain-events-modulith-jdbc — see Durable delivery with Spring Modulith). No separate dependency is required for the decision layer.

Declaring routes with @Router

Group route declarations in a bean annotated @Router("<ruleset>"). The value names the ruleset, which doubles as its config namespace: the bean is active unless explicitly disabled with spring.ddd.routing.rulesets.<ruleset>.enabled=false. A single @Router bean may mix @CommandRoute, @QueryRoute, and @EventRoutes members — all gated together by the same ruleset toggle.

The route annotations are scanned on any Spring bean, not only @Router beans, so a handful of routes can sit on a @Component you already have. What @Router adds is the ruleset toggle: it is the only thing that makes a group of routes switchable from configuration. Routes declared on a plain bean are always active — there is no rulesets entry that reaches them — so reach for @Router whenever a route should be disableable per environment, and prefer it as the default home for routes.

The three route forms

All three annotations share the same three declaration forms, placed on a member of a @Router bean (or any Spring bean, with the ruleset-toggle caveat above). @CommandRoute and @QueryRoute carry a single destination; @EventRoutes carries an array destinations and its dynamic form returns Set<Destination> — the only shape difference, and it follows directly from a command going to one place while an event fans out.

Form Shape

A — dynamic function

A function returning Destination (or Set<Destination> for @EventRoutes), with no destination/destinations attribute set. It is invoked on every dispatch to compute the destination from the message. Its parameters are bound like a handler’s — the payload (a positional parameter or @Payload), plus optional @MetadataValue/@Value parameters — so a route can decide on metadata as well as the payload.

B — static property

A val initialised with messageType<T>(), with the destination attribute set. The property is never invoked; it only names the message type.

C — empty-body marker

A function with the destination attribute set and an unused body. Like form B, the function is never invoked — only its first parameter’s type and the attribute matter.

Setting the destination attribute on a form-A function, or omitting it on the property/marker forms, is a startup error — each member must use exactly one form.

@Command(namespace = "banking", name = "AuthorizePayment")
data class AuthorizePayment(@AggregateId val paymentId: PaymentId, val amount: Money)

@Query(namespace = "banking", name = "FindPaymentStatus")
data class FindPaymentStatus(@AggregateId val paymentId: PaymentId)

@DomainEvent(namespace = "banking", name = "PaymentAuthorized")
data class PaymentAuthorized(val paymentId: PaymentId)

@Router("payments")
class PaymentRoutes {

    // Form A (command) — destination depends on the payload
    @CommandRoute
    fun authorize(command: AuthorizePayment): Destination =
        if (command.amount > LARGE_PAYMENT) Destination.named("payments-review") else Destination.named("payments")

    // Form B (query) — a static property
    @QueryRoute("payments")
    val status = messageType<FindPaymentStatus>()

    // Form C (event) — a marker; note 'destinations' (plural)
    @EventRoutes(destinations = ["analytics", "audit"])
    fun onAuthorized(event: PaymentAuthorized) {
        // never invoked — only the parameter type and 'destinations' are read
    }
}

Because a form-A function is bound like a handler, a dynamic route can decide on metadata, not just the payload — for any of the three kinds:

@CommandRoute
fun route(@Payload command: AuthorizePayment, @MetadataValue("tenant") tenant: String): Destination =
    if (tenant == "eu") Destination.named("payments-eu") else Destination.named("payments")

Config-based routes

Static routes can also be declared entirely in configuration, keyed by the message’s name — the <namespace>.<name> of its @Command/@Query/@DomainEvent, falling back to the fully-qualified class name when the message declares no namespaced name. The three maps mirror the three annotations:

spring.ddd.routing:
  commands:
    banking.AuthorizePayment: payments
  queries:
    banking.FindPaymentStatus: payments
  events:
    "[banking.PaymentAuthorized]":
      destinations: [analytics, audit]
      key: "#{paymentId}"

A command or query entry maps a name straight to a destination — equivalent to a form-B annotation and useful when the destination is an operational concern rather than something worth expressing in code. An event entry carries destinations (plural) and an optional key. Map keys containing dots must be bracketed, which is why the event key above is written "[banking.PaymentAuthorized]".

Config keys are matched the same dual way for all three kinds — the message’s canonical <namespace>.<name> first, its fully-qualified class name as a fallback — and take part in polymorphic matching exactly like annotation routes. No class is ever loaded from a config key: the hierarchy is walked on the dispatched instance, so a misspelled or unresolvable key (including a $-nested inner-class name written where the dotted qualified name is expected) is silently inert rather than a startup failure.

Destination bindings

A named destination is bound to a transport with spring.ddd.routing.destinations.<name>:

spring.ddd.routing:
  destinations:
    payments:
      transport: http
      url: https://payments.internal/commands

transport selects the dispatcher bean whose transport id matches; url is transport-specific and interpreted by that dispatcher (event transports use target instead — see Durable delivery). Wiring is validated eagerly at startup: a static route naming an unconfigured destination, or a destination whose transport has no registered dispatcher, fails application startup rather than the first dispatch. A dynamic route (form A) cannot be validated statically and only fails at dispatch time if it resolves to a destination without a transport.

Polymorphic matching

Routes register under their declared type — the parameter type of a dynamic or marker function, or the type argument of messageType<T>(). At dispatch, the message’s runtime type is matched against all declared types, spanning superclasses and interfaces, so one declaration on a shared supertype covers every subtype without repeating itself. This applies uniformly to annotation-declared and config-declared routes, across all three kinds.

Where the kinds differ is only in how multiple matches combine — the same split as delivery:

Commands and queries

resolve to the unique most-specific matching route. An exact match always wins, and a declaration on a subtype overrides one inherited from a supertype. When two unrelated declared supertypes both match and neither is more specific, that dispatch fails loudly (RouteConflictException, naming the competing candidates) rather than picking one arbitrarily — declare a route on the concrete type, or make one supertype extend the other, to break the tie.

Domain events

union every matching declaration’s destinations across the hierarchy, so decoupled modules can each add destinations for the same event without competing (a broadcast has no single winner).

sealed interface PaymentCommand

@Command(namespace = "banking", name = "AuthorizePayment")
data class AuthorizePayment(@AggregateId val paymentId: PaymentId, val amount: Money) : PaymentCommand

@Command(namespace = "banking", name = "RefundPayment")
data class RefundPayment(@AggregateId val paymentId: PaymentId, val amount: Money) : PaymentCommand

@Router("payments")
class PaymentRoutes {

    // every PaymentCommand routes to "payments"...
    @CommandRoute("payments")
    val payments = messageType<PaymentCommand>()

    // ...except refunds, whose more specific declaration wins
    @CommandRoute("payments-review")
    val refunds = messageType<RefundPayment>()
}

AuthorizePayment matches only the PaymentCommand declaration and routes to payments; RefundPayment matches both, and the more specific leaf declaration sends it to payments-review.

Matching is by the type relationship, not by the order in which routes are declared: the result is the same however the annotated members, @Router beans, or config entries are arranged. A command or query resolves to the most-specific type in the hierarchy — and when two unrelated supertypes tie, that is a startup or dispatch RouteConflictException, never an arbitrary "first one declared wins". (You cannot declare two routes for the same command or query type at all — a duplicate is rejected at startup.) Event destinations are unioned into a set, so their order is irrelevant too.

Routing key

All three annotations accept an optional key — a partition/routing key resolved per message and handed to the dispatcher alongside the destination:

@CommandRoute(destination = "payments", key = "#{paymentId}")
val authorize = messageType<AuthorizePayment>()

The value is a Spring Expression: key = "#{paymentId}" is evaluated against the payload, a bare key = "orders" is a literal, and omitting it (the default) means no key. The resolved key reaches the transport, where a dispatcher uses it for partitioning or routing if its transport supports one (for example, a broker partition key). The reserved "internal" dispatcher — the local bus — ignores the key, so with no external transport a key has no observable effect.

What happens when a key cannot be resolved depends on why:

Malformed expression

A #{…} that is not valid SpEL fails at startup — the application does not boot. It is a wiring mistake, caught eagerly, never at dispatch time.

Evaluates to null

A well-formed expression that yields null (for example a nullable property that happens to be null) sends the message with no key — the broker applies its default (round-robin, no partition key, and so on). A null key is a value, not an error.

Cannot be evaluated

An expression that references a property the payload does not have, or fails with a type error, throws at dispatch — it is never silently downgraded to an unkeyed send. For a command or query this surfaces to the caller as CommandExecutionException/QueryExecutionException; for a routed event under the durable Modulith addon the delivery fails and is retried, so the event is never externalized without its key.

Keys resolve polymorphically like routes: a key declared on a supertype applies to its subtypes, with the most-specific declaration winning. For events, an annotation key anywhere on the event’s hierarchy takes precedence over a config key; when none is declared, the most-specific matching config key applies.

Custom route resolvers

For decisions that do not fit a static annotation or config entry — content-based routing, tenant routing, feature-flagged rollout — implement RouteResolver as a Spring bean. It is the one custom escape hatch, shared by all three message kinds:

fun interface RouteResolver {
    /** External destinations for the message; emptySet() means no external route. */
    fun resolve(message: MessageEnvelope): Set<Destination>
}

A resolver returns the external destinations for a message; emptySet() means "no external route" — a command or query is dispatched to its local handler, and an event is delivered only to its local @DomainEventHandlers. RouteResolver beans are ordered by @Order and consulted before the declarative baseline (@Router/@CommandRoute/@QueryRoute/@EventRoutes and config-based routes), so a custom resolver can override — or defer to — the declarative routes. How the resolvers combine follows the same decision-versus-delivery split as everything else:

Commands and queries

the first resolver returning a non-empty set wins (override semantics), and that set must name at most one destination — a command or query has a single handler, so resolving it to more than one destination is a loud CommandExecutionException/QueryExecutionException.

Domain events

every resolver’s destinations are unioned (additive fan-out), on top of the declarative routes and always alongside local handling.

Because resolve receives the whole MessageEnvelope — the payload plus its resolved metadata — one resolver bean can route all three kinds by inspecting message.payload:

@Component
@Order(0)
class TenantRouteResolver : RouteResolver {

    override fun resolve(message: MessageEnvelope): Set<Destination> {
        val tenant = message.metadata["tenantId"] as? String
        return when (message.payload) {
            // a command → route EU tenants to one destination, otherwise stay local
            is AuthorizePayment ->
                if (tenant == "eu") setOf(Destination.named("payments-eu")) else emptySet()
            // an event → contribute an additive fan-out set
            is PaymentAuthorized ->
                setOf(Destination.named("audit"), Destination.named("analytics"))
            // anything else → abstain, leaving the declarative routes to decide
            else -> emptySet()
        }
    }
}

Commands and queries: a single overriding destination

Everything above is the shared decision layer; from here the two message kinds genuinely differ. A command or query has one handler and a caller, so a route to a named destination overrides the local handler. Destination.INTERNAL is the reserved sentinel a message resolves to when no route applies, meaning "handle on the local bus"; once a route resolves to anything else, the message is handed to the matching dispatcher instead and any local @CommandHandler/@QueryHandler for that type is not invoked. The command/query resolver chain reflects this single-winner model: the first custom RouteResolver to return a non-empty set wins, and — because there is one handler — it must resolve to a single destination.

Transport and delivery semantics

A destination’s transport id is served by a CommandDispatcher or QueryDispatcher bean:

@Component
class HttpCommandDispatcher(/* ... */) : CommandDispatcher {

    override val transport = "http"

    override fun dispatch(destination: Destination, message: CommandMessage, key: String?) {
        // resolve the destination's url from spring.ddd.routing.destinations and deliver 'message';
        // 'key' is the resolved routing key (may be null)
    }
}

QueryDispatcher follows the same shape, except dispatch returns the query’s result to the caller. Both SPIs live alongside the routing core (de.dwittkoetter.ddd.cqrs.command.routing, de.dwittkoetter.ddd.cqrs.query.routing); the local bus is itself just the reserved "internal" dispatcher, so a custom transport is registered like any other Spring bean.

CommandGateway.send resolves the route and calls dispatch synchronously — it blocks until that call returns, not necessarily until the command is handled. For the local bus these are the same, because dispatch runs the handler inline; for an asynchronous transport (a broker producer, an HTTP call awaiting a 202-style acknowledgement), dispatch may return once the transport has accepted the command. CommandGateway.sendAsync adds a layer: it resolves the route and dispatcher synchronously (so validation, metadata, and route-resolution failures still surface to the caller), then submits the dispatch itself to a background executor. Failures during routing or dispatch — including a resolved-but-unwired destination — are wrapped in CommandExecutionException for commands and QueryExecutionException for queries, matching Commands and Queries.

Domain events: additive fan-out

An event route adds external destinations rather than replacing anything:

Additive, never overriding

Local @DomainEventHandler dispatch always happens — a route fans the event out to external destinations on top of local handling. The reserved internal destination is therefore meaningless in an event route and is dropped with a WARN wherever it appears.

Union across declarations

Multiple declarations for one event type — including declarations on its supertypes — union their destinations (see Polymorphic matching). A dynamic route returning an empty set simply contributes nothing.

sealed interface AccountEvent

@DomainEvent(namespace = "banking", name = "AccountOpened")
data class AccountOpened(val accountId: AccountId, val owner: String) : AccountEvent

@DomainEvent(namespace = "banking", name = "MoneyTransferred")
data class MoneyTransferred(val accountId: AccountId, val amount: Money) : AccountEvent

@Router("banking")
class BankingEventRoutes {

    // every AccountEvent goes to "audit"
    @EventRoutes(destinations = ["audit"])
    val accountEvents = messageType<AccountEvent>()

    // union across the hierarchy — AccountOpened goes to "analytics" AND "audit"
    @EventRoutes(destinations = ["analytics"])
    val opened = messageType<AccountOpened>()

    // dynamic — destinations computed per event; an empty set adds nothing
    // (assumes a Comparable Money for illustration)
    @EventRoutes
    fun transferred(event: MoneyTransferred): Set<Destination> =
        if (event.amount > REPORTING_THRESHOLD) setOf(Destination.named("fraud")) else emptySet()
}

AccountOpened fans out to analytics and audit; MoneyTransferred always goes to audit, and large transfers additionally to fraud — and every one is still dispatched to its local `@DomainEventHandler`s.

Custom RouteResolver beans contribute event destinations the same additive way: their sets are unioned in on top of the declarative routes, never overriding them.

Delivery semantics

Persisting business state and sending an event to an external system are two systems with no shared transaction, so the delivery contract is chosen to be the least harmful:

  • Dispatch happens after the publishing transaction commits — never inside it. An event is never sent for state that subsequently rolls back (no phantom events), and broker availability never gates the business transaction.

  • Dispatch runs once per resolved destination, with failures isolated per destination: all destinations are attempted, and one dead transport never starves the others.

  • Delivery is the transport’s job. The SPI is EventDispatcher — the event analog of CommandDispatcher, selected by the same spring.ddd.routing.destinations.<name>.transport binding — but the core ships no delivery itself, including no after-commit trigger: a dispatcher bean alone does not deliver. A transport integration supplies its own trigger and durability, the way the Spring Modulith addon (next section) does for the modulith transport. A custom integration that triggers only after commit is best-effort at-most-once without an outbox — a crash between commit and send loses the delivery, though it never produces a phantom.

  • No silent lossiness: a statically declared event route whose destination is unconfigured, or whose transport has no EventDispatcher bean, fails application startup. A dynamic route fails at dispatch time. An application never ends up with at-most-once — or no delivery at all — by accident.

For durable, at-least-once delivery, use the Spring Modulith addon described next.

Durable delivery with Spring Modulith

spring-ddd-starter-domain-events-modulith-jdbc makes routed events durable by riding Spring Modulith’s Event Publication Registry. For every configured destination, the addon registers a durable transactional listener with its own listener id, so Modulith persists one EVENT_PUBLICATION row per (event, destination) — carrying the concrete event type, not a wrapper. Each delivery is an independent, durable, retried unit: resubmission redelivers only the destinations that failed, never siblings that already completed.

Retry is registry-driven — not a continuous poller. A failed or interrupted delivery leaves its publication row incomplete and is resubmitted on application restart (spring.modulith.events.republish-outstanding-events-on-restart=true) or by an explicit or scheduled IncompleteEventPublications resubmission; a crash mid-delivery is retried on the next restart or sweep, not seconds later.

The addon supplies the modulith transport (an EventDispatcher), but the last hop to the broker is the application’s: it must provide an EventExternalizationTransport bean wrapping its broker client — Modulith’s broker starters do not expose one as a bean. An illustrative Kafka-style example:

@Bean
fun eventExternalizationTransport(kafka: KafkaOperations<String, Any>) =
    object : EventExternalizationTransport {
        override fun externalize(payload: Any, target: RoutingTarget): CompletableFuture<*> =
            kafka.send(target.target, target.key, payload)
    }

Destinations bind to the addon with transport: modulith; target names the broker target (topic, exchange, and so on) handed to the transport — when omitted, the destination name itself is used:

spring.ddd.routing:
  destinations:
    analytics:
      transport: modulith
      target: account-events
    audit:
      transport: modulith
      target: audit-log
  headers: [correlationId, causationId]

spring.ddd.routing.headers is the shared metadata-to-header allow-list (default empty): listed metadata entries travel with the event as transport headers. When the allow-list selects anything, the payload handed to the transport is a Spring Message whose headers carry the metadata; otherwise it is the raw event, leaving default broker serialization untouched.

Two characteristics worth knowing when operating this:

Latency

Delivery runs synchronously on the publishing thread, immediately after the transaction commits — an event routed to N destinations makes N sequential sends before the publishing call returns. The registry makes slow deliveries durable, not invisible.

Operator warnings

Two situations surface as WARNs rather than failures. Orphaned publications: when a destination is removed across a deploy, incomplete EVENT_PUBLICATION rows keyed to its now-absent listener have no retry path; a startup check warns so the operator can re-add the destination or purge the rows (a removed destination must not block boot). Route drift: when the route decision no longer includes a destination by delivery or resubmission time, that delivery warns and leaves the publication incomplete instead of silently completing it — re-enable the route or purge the publication.

What is not included yet

Routing provides the decision layer (@Router, @CommandRoute/@QueryRoute/@EventRoutes, config-based routes, custom RouteResolver beans for all three kinds) and the transport SPIs (CommandDispatcher/QueryDispatcher/EventDispatcher), with Spring Modulith as the shipped event transport. It does not ship a concrete HTTP transport or any other non-Modulith transport, and it does not include inbound support for a remote system to deliver messages back into this application — both are separate work.