Skip to main content

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​

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:

StageValuePurpose
Start0Pipeline entry point
RateLimiting50Throughput control
PreProcessing100Tracing and context setup
Instrumentation150Performance metrics
Authentication175Identity verification
Logging190Audit trails and diagnostics
Validation200Input validation
Serialization250Message serialization
Authorization300Permission checks
Cache400Cache lookup and storage
Optimization450Batching and bulk operations
Routing500Handler resolution
Processing600Core handler execution
PostProcessing700Cleanup and result transformation
Error800Exception handling
ErrorHandling801Exception handling (alias)
End1000Pipeline 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:

InterfaceKind
IDispatchAction / IDispatchAction<TResponse>MessageKinds.Action
IDispatchEventMessageKinds.Event
IDispatchDocumentMessageKinds.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:

TagMeaning
dispatch.message.typeThe full name of the type that declared no kind
dispatch.message.missing_interfaceThe interfaces it could implement to declare one
dispatch.message.applied_kindsThe 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​

TopicDescription
Pipeline ProfilesReusable 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.