Routing & Endpoints¶
By default, every message is processed inline — synchronously in the caller's context, within the caller's DI scope. No endpoints, no background workers.
Routing lets you override this default. You declare which messages should be dispatched to endpoints — background workers that process messages asynchronously in their own DI scope. The caller enqueues the message and returns immediately.
graph LR
Bus["bus.send(cmd)"] -->|no route| Inline[Inline Handler]
Bus -->|routed| EP[Endpoint]
EP --> Worker[Background Worker]
Worker --> Handler[Handler in fresh scope]
Default Behavior¶
Without routing configuration, all three dispatch methods execute inline:
This is equivalent to calling MessagingModule.register() with no arguments. If your application
only needs synchronous, in-process message handling, you can skip the rest of this page.
Local Queue Endpoints¶
A local queue is a buffered, in-process endpoint backed by an anyio memory stream. Messages sent to a local queue are enqueued and return immediately. A background worker task drains the queue and processes each message in a fresh DI scope.
local_queue() creates an endpoint descriptor that MessagingModule uses to construct a live
LocalQueueEndpoint during application initialization:
The string argument is the endpoint URI — a logical name you reference in route declarations.
When to use local queues
Local queues are useful when you want fire-and-forget semantics without leaving the process. Common cases: sending emails, updating projections, recording analytics. They decouple the handler's execution time from the caller's response time.
Per-Type Routing¶
Use route(MessageType).to('endpoint-uri') to route a specific message type to an endpoint:
When bus.publish(OrderPlaced(...)) is called, handlers for OrderPlaced are dispatched to the
domain-events endpoint. Handlers not covered by the route still run inline (see
Additive Routing).
Module-Level Routing¶
Use route_module(Module).events_to('endpoint-uri') to route all events registered in a
module to an endpoint. This is more maintainable than per-type routing when a module owns many
event types:
Every event type bound via MessagingExtension().bind_event(...) inside PaymentModule is
routed to the domain-events endpoint.
Additive Routing¶
When an event has handlers in multiple modules and only some are routed, publish() does both:
- Runs non-routed handlers inline (synchronously in the caller's scope).
- Dispatches to endpoints for routed handlers (asynchronously).
This means routing is additive — it never silences inline handlers that were not routed.
graph TD
Publish["bus.publish(OrderPlaced)"] --> Check{Routed?}
Check -->|Module A handler routed| EP[Endpoint]
EP --> WorkerA[Worker: Module A handler]
Check -->|Module B handler not routed| InlineB[Inline: Module B handler]
Consider an example where OrderPlaced has handlers in two modules:
- Module A — handler is routed to a local queue endpoint.
- Module B — handler has no route, so it runs inline.
When you call bus.publish(OrderPlaced(...)), Module B's handler executes immediately in the
caller's scope, while Module A's handler is enqueued for background processing.
Routing Precedence¶
Routes are evaluated in this order:
| Source | Example |
|---|---|
| Per-type route | route(OrderPlaced).to('events') |
| Module-level route | route_module(OrdersModule).events_to('events') |
| Inline (default) | No route configured |
Routes are additive — if both a per-type and a module-level route match, the message is dispatched to all matching endpoints. When no route matches, the message runs inline.
Endpoint Lifecycle¶
Endpoints are managed automatically by EndpointLifecycleExtension, which is registered
internally by MessagingModule. You do not need to start or stop endpoints manually.
| Phase | Action |
|---|---|
| After app init | All endpoints are started (background workers spawn) |
| On app shutdown | All endpoints are stopped (queues drain and close) |
Endpoints start after all modules have been initialized and stop in reverse order during shutdown.
Method Semantics with Routing¶
Each dispatch method interacts with routing differently:
| Method | Routable | Behavior when routed |
|---|---|---|
invoke() |
No | Always inline. Returns a typed response. |
send() |
Yes | No route = inline. Route = endpoint dispatch (fire-and-forget). |
publish() |
Yes | Additive: inline handlers run + routed handlers go to endpoints. |
invoke() is never routed
invoke() always executes inline because it returns a typed response to the caller.
Routing is inherently asynchronous — there is no way to return a response from a background
worker. Use send() if you want a routable fire-and-forget command.
Further reading¶
- Message Bus — setup, interfaces, and dispatch methods
- Message Context — correlation tracking across message chains
- Pipeline Behaviors — cross-cutting middleware for request handling
- Events — event definitions, handlers, and publishers