Routing
Routing decides which handler runs for a given message, and it does so ahead of dispatch: during registry creation in reflection mode, or at compile time in source-generation mode.
Polymorphic fallback
Routes are polymorphic and precomputed, and the same resolution runs for every message kind. A concrete message uses its exact handler when one exists; otherwise Dispatcher selects the most-specific compatible base class or interface handler.
For a query or command, the selected handler is the one that runs. Given
ArchiveDocumentCommand : DocumentCommand, dispatching an ArchiveDocumentCommand reaches
ICommandHandler<DocumentCommand> when no handler for the exact type exists, as long as
ArchiveDocumentCommand is a known route target. With nothing compatible at all,
dispatch throws HandlerNotFoundException.
Notifications resolve the same way, and only the outcome differs. Given
UserCreatedEvent : DomainEvent, publishing a UserCreatedEvent reaches the
INotificationHandler<DomainEvent> handlers under the same conditions, and publishing with nothing
compatible does nothing instead of throwing.
flowchart TD
A[Dispatch a message] --> B{Handler for the<br/>exact type?}
B -- yes --> C[Run it]
B -- no --> D{Is the message a<br/>known route target?}
D -- no --> F
D -- yes --> E{Most-specific<br/>compatible base handler?}
E -- yes --> G[Run that handler]
E -- no --> F["Query or command: HandlerNotFoundException<br/>Notification: nothing runs"]
A message type is selected this way, and then:
- Query and command dispatch invokes the single handler for the selected type, including that type's pipeline behaviors.
- Notification dispatch invokes every registered handler for that one selected type, sequentially in registration order. It does not broadcast across the inheritance hierarchy, so a handler for the derived type and a handler for its base type never both run. Compatible open generic handlers still run afterwards, as described in Messages and handlers.
- Unrelated equally specific candidates make the route ambiguous. The reflection implementation
throws
AmbiguousHandlerExceptionduring registry creation, and source generation reports a compiler diagnostic. Neither defers the failure to dispatch.
Note
Ambiguity and duplication are always startup-time or build-time failures. Dispatch never has to decide between two candidate handlers.
Route targets
A fallback route is precomputed only for concrete message types Dispatcher knows about, its route targets. How a type becomes one depends on the mode:
| Mode | Route targets come from |
|---|---|
| Reflection | Handler assemblies, plus the assemblies that declare their handled message types |
| Source generation | The same assemblies, plus concrete messages declared by the generated host |
This supports shared contracts assemblies without scanning every application and framework reference.
When you must register a route target explicitly
The reflection implementation cannot discover a derived message declared in an otherwise unrelated assembly. When handlers are registered only through the typed registration methods, or when a derived type lives outside the discovered assemblies, explicitly register each concrete type that needs a precomputed fallback route:
builder.Services
.AddQueryHandler<BaseQuery, Result, BaseQueryHandler>()
.AddDispatcherMessage<DerivedQuery>();
Warning
Without that route target the derived type has no fallback route, and the base handler is never
reached: dispatching DerivedQuery throws HandlerNotFoundException, and publishing a derived
notification finds no handler and does nothing.
AddDispatcherMessage applies to the reflection-based implementation only. Source-generated
routes must be known at build time from the generated host, a generated handler module, or an
assembly that declares one of the handled message types.
Common surprises
| Symptom | Likely cause |
|---|---|
HandlerNotFoundException on a derived type that has a base handler |
The derived type is not a route target; add AddDispatcherMessage<T>() |
| A published notification does nothing | Same cause, in the kind that fails silently |
| Base and derived handlers both expected to run | Notification dispatch selects one type; it does not broadcast up the hierarchy |
DuplicateHandlerException at startup |
Two handlers registered for the same handled message type, often a factory registration on top of scanning |
AmbiguousHandlerException at startup, or a build error |
Two unrelated equally specific candidate handlers |
The factory-registration case is explained under current limitations.