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
Destinationis either the reserved"internal"(the local bus) or a named destination such as"payments". Declarative routes (@Routerwith@CommandRoute/@QueryRoute/@EventRoutes, config-based routes) and customRouteResolverbeans 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 |
B — static property |
A |
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). Anullkey 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
@DomainEventHandlerdispatch always happens — a route fans the event out to external destinations on top of local handling. The reservedinternaldestination 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 ofCommandDispatcher, selected by the samespring.ddd.routing.destinations.<name>.transportbinding — 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 themodulithtransport. 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
EventDispatcherbean, 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_PUBLICATIONrows 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.