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 Route 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, each delivered by a route consumer that reads the event from the topic it is stored in (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-jpa or the full-stack bundle spring-ddd-starter-jpa — see Starters.
Event routing lives in spring-ddd-domain-events, included through spring-ddd-starter-domain-events and spring-ddd-starter-jpa.
Delivering a routed event needs the topic consumer runtime of spring-ddd-starter-messaging-consumer-jdbc, which spring-ddd-starter-cqrs-jpa and spring-ddd-starter-eventsourcing-jdbc bring in, and an EventDispatcher the application supplies for the destination’s transport (see Route consumers).
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 at dispatch time: the hierarchy is walked on the dispatched instance.
Keys are checked once at startup, as far as they can be.
A key that resolves to a real class carrying the wrong one of @Command/@Query/@DomainEvent — an event type named under spring.ddd.routing.commands, say — fails the context: there is no reading of that configuration under which it works.
A class carrying none of them is left alone, since a map key has never had to be annotated.
A key that resolves to no class and matches no message type the application declares is logged at WARN: it is most likely a misspelling (including a $-nested inner-class name written where the dotted qualified name is expected), but it may also be the canonical name of a type from a library outside the scanned packages, so it does not fail startup.
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 (an event dispatcher reads target, the broker target, instead).
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: a command or query fails at dispatch time if it resolves to a destination without a transport, and an event sends nothing to such a destination (see Route consumers).
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.
Route key
Every route has a route key: the key a destination’s messages are ordered by, and the key the dispatcher hands the transport alongside the destination, for example a broker’s message key. The events with the same route key reach a destination in the order they were stored; commands and queries are sent when they are issued.
Where an event’s route key comes from, first match wins:
-
The
keydeclared for its type: an@EventRouteskeyanywhere on the event’s hierarchy, else the most specific matchingspring.ddd.routing.events.<type>.key. -
Otherwise, the event’s message key: its
@MessageKeyproperty, else its@AggregateId. A value object such as anAccountIdis sent in its scalar form. -
Otherwise, no key.
A declared key is the expression’s value as text, whereas the default message key is sent in its scalar form.
To key by a value object’s id, name its scalar, as in #{customerId.value}.
So most event routes need no key: the events of one BankAccount are already keyed by the account.
Declare one only to order by something else:
@DomainEvent
data class MoneyDeposited(@AggregateId val accountId: AccountId, val amount: Money)
@DomainEvent
data class CardPaymentAuthorized(@AggregateId val cardId: CardId, val customerId: CustomerId, val amount: Money)
@Router
class BankingEventRoutes {
// keyed by the account: the event's message key
@EventRoutes(destinations = ["ledger"])
val deposits = messageType<MoneyDeposited>()
// keyed by the customer: every card payment of one customer arrives in order, across their cards
@EventRoutes(destinations = ["fraud-check"], key = "#{customerId.value}")
val cardPayments = messageType<CardPaymentAuthorized>()
}
A route key belongs to the event type, not to a destination: every destination CardPaymentAuthorized routes to receives it keyed by the customer.
Two @EventRoutes declarations with different keys for one type fail startup; a key in the configuration applies only where no annotation declares one (see the precedence below).
Commands and queries take a key the same way (@CommandRoute, @QueryRoute, or their configuration entries), but they have no message key to fall back on: without a key, they are sent without one.
@CommandRoute(destination = "payments", key = "#{paymentId}")
val authorize = messageType<AuthorizePayment>()
The value is a Spring Expression: key = "#{paymentId}" is evaluated against the payload, and a bare key = "transfers" is a literal.
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, and for an event it does not fall back to the message key. - 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 an event route the route consumer’s dispatch fails and is retried, so the event never leaves without its key. Since the event’s place is then unknown, it holds every partition of that destination until fixed.
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 route 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.
Route consumers
Every domain event an external system may need is already stored durably: a captured event in the capture topic, an event-sourced event in the event store.
Event routing is one more reader of those topics.
Each configured destination whose transport has an EventDispatcher gets a route consumer: it reads both topics, resolves each event’s destinations, and dispatches the events that route to it.
Delivery is the route consumer’s job; a transport only sends.
The SPI is EventDispatcher — the event analog of CommandDispatcher, selected by the same spring.ddd.routing.destinations.<name>.transport binding.
It receives the shaped OutboundMessage (body, allow-listed metadata, route key) and must throw when the send fails: a dispatcher that returns before the broker has the message loses the event on a failure.
A dispatch must also return or throw in the end: a dispatcher that never returns stalls its destination, so it bounds its send with its client’s own timeouts.
When it cannot reach the broker at all it throws DestinationUnavailableException (see below), and in the example the Kafka client’s org.apache.kafka.common.errors.TimeoutException is wrapped in one.
The framework ships no transport for event routes: the application supplies an EventDispatcher bean for each transport id its destinations use.
The dispatcher looks up the destination’s binding in DestinationRegistry and sends to its target, the destination name when unset; OutboundMessage.key is the route key and metadata holds the allow-listed entries.
A dispatcher over a Kafka client, for example:
@Component
class KafkaEventDispatcher(
private val kafka: KafkaOperations<String, Any>,
private val destinations: DestinationRegistry,
) : EventDispatcher {
override val transport = "kafka"
override fun dispatch(destination: Destination, message: OutboundMessage) {
require(destination is Destination.Named) { "Event routes only go to named destinations, got $destination" }
val name = destination.name
val binding = checkNotNull(destinations.binding(name)) { "No destination named '$name'" }
val record = ProducerRecord<String, Any>(binding.target ?: binding.name, message.key, message.body)
message.metadata.asMap().forEach { (header, value) ->
record.headers().add(header, value?.toString()?.toByteArray())
}
// blocks until the broker has the record, throws when the send fails;
// the client's delivery.timeout.ms bounds the wait, and a broker that is away is reported as such
try {
kafka.send(record).get()
} catch (e: ExecutionException) {
val cause = e.cause ?: e
if (cause is TimeoutException) throw DestinationUnavailableException("Kafka is unreachable", cause)
throw cause
}
}
}
With the binding below, an AccountOpened routed to audit is sent to the banking.audit topic, keyed by its route key, with the allow-listed metadata as headers.
A route consumer only ever dispatches to named destinations, so a dispatcher never receives the internal one.
spring.ddd.routing.headers is the shared metadata-to-header allow-list (default empty): the listed metadata entries stored with the event travel with it, and the transport decides how they become headers.
spring.ddd.routing:
destinations:
audit:
transport: kafka
target: banking.audit
headers: [correlationId, causationId]
consumer:
start:
events: latest
polling:
interval: 1s
error-handler: de.dwittkoetter.ddd.messaging.consumer.HaltErrorHandler
consumers:
audit:
start:
events: earliest
error-handler: de.dwittkoetter.ddd.messaging.consumer.SkipErrorHandler
What a route consumer guarantees:
- At-least-once
-
The dispatch comes first and the checkpoint is saved after it. A crash in between sends up to one page of events again, so an external system can see duplicates and should deduplicate them, for example by a business key. See Delivery guarantees.
- Order per key
-
The events of one route key reach a destination in the order they were stored. By default the route key is the message key, so the events of one
BankAccountarrive in order; a declaredkeyorders by its value instead (see Route key). Events that have no route key at all, no declared key and no message key, reach a destination in the order they were stored. The capture topic and the event store are drained separately, so there is no order across them. A route key longer than 255 characters is held back, retried and decided like any other, but its order is not kept when one of its events fails. - Only committed events
-
A route consumer reads only what the topics hold: an event of a rolled-back transaction is never sent, and broker availability never gates the business transaction.
- Latency
-
An event leaves within the polling interval (
spring.ddd.routing.consumer.polling.interval, 1 s by default) plus one polling interval of the high-water mark (spring.ddd.messaging.high-water-mark.polling.interval), up to two on PostgreSQL, after its transaction commits; see How quickly the mark moves. - A failure holds back one destination
-
A failing dispatch is retried with the consumer’s delivery retry. The failing event is retried while other route keys go on, and whatever the error handler, its route key’s later events wait behind it until the budget is spent. Other destinations keep sending, and a warning that it makes no progress repeats every
spring.ddd.messaging.consumer.liveness-warn-interval(default5m, see Configuration properties) while it stays stuck. Once the delivery budget is spent, the destination’s error handler decides; see Errors. - Start position
-
A destination with no checkpoint on a topic starts at
spring.ddd.routing.consumer.start.events, or its ownspring.ddd.routing.consumers.<destination>.start.events:latest, the default, sends only the events stored after its first start;earlieststarts at the oldest event the topic still holds. It is read once per destination and topic; a later change never moves an existing checkpoint. Withlatest, events published between the application accepting work and the destination’s first read may be skipped on the destination’s first start, as with a broker consumer starting at its latest offset. An application’s own tests of its routes should setspring.ddd.routing.consumer.start.events=earliest, so an event published right after the context starts is not skipped. On a read-model topic it isspring.ddd.routing.consumer.start.read-models(or the destination’sconsumers.<destination>.start.read-models),earliestby default: a new read-model route sends every read model’s latest state, then every change. - Retention
-
A route checkpoint holds back capture topic retention like any consumer’s. A destination removed from the configuration keeps holding it until an operator unsubscribes it from the capture topic,
POST /actuator/consumers/route/<destination>/unsubscribe/capture-topicorConsumerLifecycle.unsubscribe(ConsumerKey("route", "<destination>"), "capture-topic")(the endpoint needsmanagement.endpoint.consumers.access=unrestricted, see theconsumersendpoint), which is refused while the destination is still configured. See A consumer that is gone. - Multiple instances
-
With
spring.ddd.routing.consumer.distribution: partitioned, the default, the instances share a destination’s partitions, so each event is dispatched by one instance;single-ownerdispatches a destination from one instance at a time, one event at a time, in order per topic: the capture topic and the event store are drained separately, so there is no single order across both. A destination’s ownspring.ddd.routing.consumers.<destination>.distributionoverrides the default; see Running on multiple instances. - Changing a route key
-
Change a type’s
keyonly while every destination it routes to is caught up. A destination held back on an event partitions the events after it by the new key, so one of them may be sent twice or not at all. A rolling deploy whose instances run different keys for a type has the same effect while both versions run, as does adding or removing a@MessageKeyon a routed type, or changing which routes match a type.
Errors
An event that cannot be read and an event that cannot be sent are handled in opposite ways:
-
An event whose type the application can no longer read — its class removed, say — is passed over: it is skipped, never sent, and counts as consumed, as it is for every consumer of the topic.
-
An event that cannot be sent is retried under the delivery budget while the destination’s other route keys keep going out; its route key’s later events wait behind it and go out in order once it is sent. When the budget is spent, the destination’s error handler decides; see Errors.
-
A destination that cannot be reached is waited out, never halted or skipped over: when the broker is down, unreachable, or times out before taking the event, the
EventDispatcherthrowsDestinationUnavailableException, or wraps its transport’s error in one, and the route consumer records nothing and delivers the event again on every poll, however long the outage lasts. Any other exception a dispatcher throws counts toward the delivery budget and the error handler, except a refused connection or one closed mid-read, which is waited out like an unreachable destination; see What is not a poison message. A dispatcher that does not tell an outage apart from a failed send halts its destination once an outage outlasts the budget.
A destination’s error handler is spring.ddd.routing.consumers.<destination>.error-handler, else spring.ddd.routing.consumer.error-handler, HaltErrorHandler by default.
Each names an ErrorHandler class by its fully qualified name, a built-in or one of your own, and resolves to the one bean of that class: with none or several, the application does not start, naming the destination and the class.
It decides what becomes of an event the destination could not take within its delivery budget; see Error handlers for the retries before it and for a handler of your own.
spring.ddd.routing:
consumer:
error-handler: de.dwittkoetter.ddd.messaging.consumer.HaltErrorHandler # the default for every destination
consumers:
audit:
error-handler: de.dwittkoetter.ddd.messaging.consumer.DeadLetterKeyErrorHandler
For an event that leaves the application, the outcomes mean:
HALT-
The destination’s route consumer stops on every instance, and nothing more is sent there until it is resumed,
POST /actuator/consumers/route/audit/resume(withmanagement.endpoint.consumers.access=unrestricted, see theconsumersendpoint) orConsumerLifecycle.resume(ConsumerKey("route", "audit")), or the application restarts. Other destinations keep sending. SKIP-
The event is never sent to that destination; the key’s next event goes out.
DEAD_LETTER_KEY-
The event stays a dead letter, and so does every later event of its route key at that destination: the account whose events the destination cannot take is held back, every other account’s events keep going out, and the destination never stops. Once an operator retries or deletes the first dead event, the account’s later events go out in order behind it.
DEAD_LETTER-
Once its budget is spent the event is set aside: the account’s events that waited behind it go out in order, and its later events pass it, so the destination receives that event out of order if an operator retries it.
Whatever the error handler, a destination never receives an older state of a read model after a newer one: an undelivered state that a newer state of the same read model has followed is dropped instead of sent.
An operator retries or deletes a dead event, DEAD_LETTER_KEY or DEAD_LETTER:
POST /actuator/consumers/route/audit/retry-dead-letters once an outage that outlasted the budget is over, or one event at a time; see Undelivered messages.
With spring.ddd.messaging.consumer.retry-dead-letters-on-start, every start retries them.
A route consumer’s dispatch never commits with its checkpoint, so delivery is at least once: a destination can still receive an event twice when an operator deletes it while it is being sent, or when a failed poll delivers it again.
A failing route key or route resolver records the event in every partition of that destination, since its key cannot be known; events with a key go on meanwhile, and under DEAD_LETTER_KEY or DEAD_LETTER the event becomes a DEAD_LETTER dead letter once per partition, each partition going on past it; one retry or delete of it acts on every partition, as does retry-dead-letters.
Startup checks
The application fails to start when a route cannot be delivered:
-
an event route is declared but no topic consumer runtime exists:
spring-ddd-starter-messaging-consumer-jdbcis missing,spring.ddd.messaging.enabled=false, or the application has several DataSources and none is primary; -
an
@EventRoutesdeclaration, or aspring.ddd.routing.eventsentry whose key resolves to a class, routes a type no topic carries: one that is neither annotated@DomainEventnor the read model of a persistent projection, the read model of an in-memory projection, one excluded by@NotCapturedorspring.ddd.messaging.capture.event.exclude-event-types, a captured type while capture is disabled, or an event-sourced type while the event store is not the framework’s JDBC event store; -
a
spring.ddd.routing.consumers.<destination>entry names no destination with a route consumer; -
a route declared with
@EventRoutesor underspring.ddd.routing.eventsnames a destination that is not configured, or whose transport has noEventDispatcher.
The check has limits:
-
A routed sealed type is checked for each of its concrete subtypes. A routed interface or abstract class that is not sealed is not checked, since its subtypes cannot be listed. A routed base class that is neither sealed nor abstract is checked as itself, so one that is neither annotated
@DomainEventnor a persistent projection’s read model fails startup even when its subtypes are: make such a base sealed or abstract. -
A config route is checked once its
spring.ddd.routing.eventskey resolves to a class: a message type in the application’s scanned packages, named by its canonical<namespace>.<name>or its fully-qualified class name, or any class on the classpath named by its fully-qualified class name. A key that resolves to no class, such as a library’s event outside the scanned packages named by its canonical name, is not checked. -
A dynamic route — a dynamic
@EventRoutesfunction or a customRouteResolver— that returns a destination with no route consumer sends nothing there, and nothing reports it. -
Only an
@EventRoutesdeclaration or aspring.ddd.routing.eventsentry makes the topic consumer runtime required. An application that routes events only through customRouteResolverbeans and has no topic consumer runtime starts, and delivers no event. Addspring-ddd-starter-messaging-consumer-jdbc, as for a declared route.
Routing read models
A persistent projection’s read models can be routed like domain events.
A route is declared on the read-model type, with the same forms: messageType<T>(), a typed dynamic @EventRoutes function, or a spring.ddd.routing.events.<class name> entry.
A read model has no canonical message name, so the entry’s key is the fully qualified class name.
Nothing leaves the application unless a route names the type.
@Router("analytics")
class AnalyticsRoutes {
@EventRoutes(destinations = ["analytics"])
val summaries = messageType<AccountSummary>()
}
spring.ddd.routing.events:
"[com.example.bank.AccountSummary]":
destinations: [analytics]
The destination’s route consumer reads the projection’s read-model topic and sends each change through the destination’s EventDispatcher, with the guarantees of an event route: at least once, in order per route key, resumed from its checkpoint, a failing destination holding back only itself.
A destination that falls behind keeps the topic from discarding the deletions it has not sent yet, as a downstream projection does.
- Current state
-
Each message carries the read model’s state after the change, not a delta. Changes to one read model are collapsed while it is read, and only each read model’s newest state is kept, so an external system may receive fewer messages than there were changes. Outside a rebuild of the projection, it never receives an older state after a newer one of the same read model; during a rebuild it receives the re-derived states again, from the oldest.
- Deletion
-
A deleted read model is sent as its last state; the deletion itself is not conveyed. A deletion the external system must see belongs on the read model, as a flag its deleting listener sets.
- Route key
-
Without a declared
key, the route key is the read model’s id, so the broker orders messages per read model as the projection does. A declaredkeyis evaluated on the read model. - Metadata
-
A read-model message carries the metadata stored with the event whose change wrote it, as the metadata providers resolved it when that event was published; through a chain of projections it stays that event’s metadata. It is the same metadata a downstream projection receives with the read model, so a route resolver sees what a
@MetadataValueparameter would. The header allow-list applies as for events. - Start position
-
earliestby default: a destination new on the topic first sends every read model’s latest state. - Which topics are read
-
A destination reads a read-model topic only when a typed declaration routes the read-model type, a supertype or a subtype of it to the destination; a typed dynamic function counts for every destination. A read model that only a custom
RouteResolverroutes is not sent. A route on a supertype that events and read models share, a common interface say, also sends those read models; a route onAnysends none. - Removed route
-
A destination whose read-model route is removed while the destination stays configured keeps its checkpoint on that topic, so it keeps holding back the topic’s discarding of deletions.
- Two projections of one type
-
When two projections produce the same read-model type, both are routed, with no order between them.
- In-memory projections
-
An in-memory projection has no read-model topic. A route on its read-model type fails startup.
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).
It ships no transport of its own — no HTTP transport for commands and queries, no broker transport for events.
It does not include inbound support for a remote system to deliver messages back into this application either; both are separate work.