Pipeline
The Dispatch pipeline processes messages through a chain of middleware components. Each middleware can inspect, modify, or short-circuit message processing.
Before You Start
- .NET 10.0
- Install the required package:
dotnet add package Excalibur.Dispatch
- Familiarity with handlers and dependency injection
Pipeline Architecture
Messages flow through middleware in a nested pattern:
Each middleware can:
- Execute logic before calling
next() - Execute logic after
next()returns - Modify the message or context
- Short-circuit by returning without calling
next() - Handle exceptions from downstream components
Pipeline Stages
Middleware executes in defined stages. Lower values execute first on request, last on response:
| Stage | Value | Purpose |
|---|---|---|
Start | 0 | Pipeline entry point |
RateLimiting | 50 | Throughput control |
PreProcessing | 100 | Tracing and context setup |
Instrumentation | 150 | Performance metrics |
Authentication | 175 | Identity verification |
Logging | 190 | Audit trails and diagnostics |
Validation | 200 | Input validation |
Serialization | 250 | Message serialization |
Authorization | 300 | Permission checks |
Cache | 400 | Cache lookup and storage |
Optimization | 450 | Batching and bulk operations |
Routing | 500 | Handler resolution |
Processing | 600 | Core handler execution |
PostProcessing | 700 | Cleanup and result transformation |
Error | 800 | Exception handling |
ErrorHandling | 801 | Exception handling (alias) |
End | 1000 | Pipeline exit |
Quick Start
Register Middleware
builder.Services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
// Configure middleware pipeline
dispatch.ConfigurePipeline("Default", pipeline =>
{
pipeline.Use<LoggingMiddleware>();
pipeline.Use<ValidationMiddleware>();
pipeline.Use<AuthorizationMiddleware>();
});
});
Create Custom Middleware
public class TimingMiddleware : IDispatchMiddleware
{
private readonly ILogger<TimingMiddleware> _logger;
public TimingMiddleware(ILogger<TimingMiddleware> logger)
{
_logger = logger;
}
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.Logging;
public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate next,
CancellationToken ct)
{
var sw = Stopwatch.StartNew();
// Call next middleware
var result = await next(message, context, ct);
sw.Stop();
_logger.LogInformation(
"{MessageType} completed in {ElapsedMs}ms",
message.GetType().Name,
sw.ElapsedMilliseconds);
return result;
}
}
Message Kind Filtering
Limit which messages your middleware processes:
public class ValidationMiddleware : IDispatchMiddleware
{
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.Validation;
// Only validate commands and queries, not events
public MessageKinds ApplicableMessageKinds => MessageKinds.Action;
public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate next,
CancellationToken ct)
{
// Validation logic...
return await next(message, context, ct);
}
}
How a message's kind is determined
A message's kind comes from the interface it implements, not from a property or an attribute:
| Interface | Kind |
|---|---|
IDispatchAction / IDispatchAction<TResponse> | MessageKinds.Action |
IDispatchEvent | MessageKinds.Event |
IDispatchDocument | MessageKinds.Document |
MessageKinds is a flags enum, so a type implementing more than one of these carries every
corresponding kind.
Messages with no kind
A type that implements only the bare IDispatchMessage marker declares no kind. Dispatch does not
reject it and does not guess: it treats the message as MessageKinds.All, so every middleware
applies to it rather than the fewest.
That is deliberate and it is not an error. The alternative — applying no middleware to a message whose kind is unknown — would route an unclassified message straight past validation, authorization and auditing. Applying all of them is the fail-closed choice.
To find such a type in a running system, look for the activity event
dispatch.message.unclassified on the current trace. It carries:
| Tag | Meaning |
|---|---|
dispatch.message.type | The full name of the type that declared no kind |
dispatch.message.missing_interface | The interfaces it could implement to declare one |
dispatch.message.applied_kinds | The kinds applied instead — All |
The event is emitted on the trace rather than the log because classification happens on the dispatch
path with no logger in scope. If you did not intend the message to receive every middleware, have it
implement IDispatchAction, IDispatchEvent, or IDispatchDocument.
Short-Circuiting
Return early without calling next() to skip downstream processing:
public class CachingMiddleware : IDispatchMiddleware
{
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.Cache;
public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate next,
CancellationToken ct)
{
var cacheKey = BuildCacheKey(message);
var cached = await _cache.GetAsync<IMessageResult>(cacheKey);
if (cached is not null)
{
return cached; // Short-circuit: don't call next
}
var result = await next(message, context, ct);
if (result.Succeeded)
{
await _cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
}
return result;
}
}
Conditional Middleware
Apply middleware based on runtime conditions:
builder.Services.AddDispatch(dispatch =>
{
dispatch.ConfigurePipeline("Default", pipeline =>
{
// Only in development
pipeline.UseWhen<DebugMiddleware>(
sp => sp.GetRequiredService<IHostEnvironment>().IsDevelopment());
// Only when audit feature is enabled
pipeline.UseWhen<AuditMiddleware>(
sp => sp.GetRequiredService<IConfiguration>()
.GetValue<bool>("Features:AuditEnabled"));
// Based on configuration
pipeline.UseWhen<FeatureMiddleware>(
sp => sp.GetRequiredService<IConfiguration>()
.GetValue<bool>("Features:NewFeature"));
});
});
Automatic Default Pipeline
When you register middleware using UseMiddleware<T>() without explicitly calling ConfigurePipeline(), Dispatch automatically creates a "Default" pipeline:
// This automatically creates a default pipeline
builder.Services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
dispatch.UseMiddleware<LoggingMiddleware>();
dispatch.UseMiddleware<ValidationMiddleware>();
});
Middleware is then ordered by their Stage value.
In This Section
| Topic | Description |
|---|---|
| Pipeline Profiles | Reusable middleware configurations for different scenarios |
See Also
- Middleware - Built-in and custom middleware components
- Handlers - Action and event handlers that the pipeline wraps
- Performance - Optimize pipeline throughput
Continuations, Scopes and Dynamic Selection
Pass the message, context and cancellation token you want downstream middleware to receive to
next. Replacement arguments are honored. When calling a continuation concurrently, supply separate
mutable contexts for independent branches; sharing an application-owned mutable object still requires
application synchronization.
Middleware factories resolve against the dispatch scope. A required factory that returns no middleware fails the dispatch; an optional registration may be skipped. Do not retain scoped services after the scope ends. Routing predicates and profile matchers are evaluated per message, including consecutive messages of the same CLR type with different data or tenant context.
Source-generated call-site forwarding preserves the dispatcher contract; it does not inline the configured middleware chain or guarantee allocation-free dispatch. See Source Generators and the background middleware ownership contract.
Async-only Disposable Middleware
The ordinary Use/profile path probes middleware during synchronous composition to discover its
stage and applicable kinds. That path requires synchronously disposable probe dependencies. For
async-only disposable dependencies, supply metadata explicitly and defer activation:
// Register MyMiddleware as scoped in DI before building the pipeline.
pipeline.UseDeferred(
services => services.GetRequiredService<MyMiddleware>(),
DispatchMiddlewareStage.PreProcessing,
MessageKinds.Action);
UseDeferred<TMiddleware> never invokes the factory during composition. The stock builder supplies
the dispatch scope and awaits disposal when it owns that scope. A caller-provided scope is borrowed.
The factory must resolve a DI-owned instance of exactly the declared concrete type; null and derived
types are rejected before invocation. Explicit stage/kinds override instance metadata. Activation
errors therefore occur on applicable dispatch, not startup. Custom builder implementations remain
responsible for supplying valid request services. Do not block on async disposal from a DI factory.