Pipeline Behaviors
Pipeline behaviors wrap command and query handlers with cross-cutting concerns like validation, logging, exception handling, and metrics. They form a nested middleware pipeline that executes around every request dispatched through IMediator.Send() and IMediator.Query().
How the Pipeline Works
IPipelineBehavior Interface
Every pipeline behavior implements IPipelineBehavior<TRequest, TResponse>:
public interface IPipelineBehavior<in TRequest, TResponse>
where TRequest : notnull
{
Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken);
}request-- The command or query being dispatched.next-- A delegate that invokes the next behavior in the pipeline (or the handler itself if this is the innermost behavior).cancellationToken-- The cancellation token propagated from the caller.
RequestHandlerDelegate
public delegate Task<TResponse> RequestHandlerDelegate<TResponse>(CancellationToken cancellationToken = default);This delegate represents the next step in the pipeline. Call await next(cancellationToken) to continue execution, flowing the token this behavior received unchanged. Do not call it to short-circuit.
BREAKING in 4.0 — the delegate now takes a token
Before 4.0, RequestHandlerDelegate<TResponse> was parameterless, so a behavior could never substitute its own token (a timeout, a linked token, ...) for the handler. The parameter is optional (= default), so await next() still compiles — but the meaning of an omitted argument changed: it now means "flow the token I was given", not "pass CancellationToken.None". Concretely:
await next(cancellationToken)(recommended) explicitly flows the token this behavior received.await next()flows the exact same token — the mediator's pipeline wiring treats adefaultargument as "keep mine", not as an explicit reset. This is what keeps every existingawait next()call site correct without modification.await next(someOtherToken)substitutessomeOtherTokenfor every inner behavior and the handler — see the timeout example below.
Caveat: because of this convention, a behavior that explicitly passes default is indistinguishable from one that omits the argument — both flow the received token. There is no way to force CancellationToken.None through next(); a behavior that needs that must thread its own token through some other mechanism.
Pipeline Execution Model
Behaviors wrap the handler in a nested fashion. The first registered behavior is the outermost wrapper. The last registered behavior is closest to the handler.
Registration order is reversed during execution
Behaviors are reversed during execution, meaning the first behavior you register wraps everything else. Think of it like layers of an onion -- the first registered behavior is the outer shell.
Given this registration order:
services.AddPipelineBehavior(typeof(UnhandledExceptionBehavior<,>)); // 1st registered
services.AddPipelineBehavior(typeof(LoggingBehavior<,>)); // 2nd registered
services.AddPipelineBehavior(typeof(ValidationBehavior<,>)); // 3rd registered
services.AddPipelineBehavior(typeof(MetricsBehavior<,>)); // 4th registeredThe execution flows like this:
In detail:
- UnhandledExceptionBehavior receives the request, wraps everything in a try/catch, calls
next(). - LoggingBehavior logs the request start, calls
next(), logs the duration and outcome. - ValidationBehavior runs all FluentValidation validators. If validation fails, it short-circuits by returning a
ValidationResultwithout callingnext(). If validation passes, it callsnext(). - MetricsBehavior starts a timer, calls
next()(the handler), records the histogram metric. - Handler executes the business logic and returns a
ResultorResult<T>.
Short-Circuiting
A behavior can short-circuit the pipeline by returning a result without calling next(). This prevents downstream behaviors and the handler from executing. The ValidationBehavior uses this pattern to reject invalid requests before they reach the handler.
Built-in Behaviors
Modulus provides four built-in pipeline behaviors. Register them in the order shown below for the recommended pipeline structure.
1. UnhandledExceptionBehavior
Catches any unhandled exception thrown by downstream behaviors or the handler and converts it into a Result.Failure.
services.AddPipelineBehavior(typeof(UnhandledExceptionBehavior<,>));What it does:
- Wraps the call to
next()in a try/catch - On exception, logs the error via
ILogger - Returns
Result.Failure(Error.Failure("UnhandledException", "An unexpected error occurred."))-- the original exception message is logged but never exposed to callers
When it short-circuits: Never intentionally -- it only catches exceptions from inner layers.
Always register outermost
Register UnhandledExceptionBehavior first so it wraps everything else. This ensures no unhandled exceptions escape the mediator pipeline.
2. LoggingBehavior
Logs the start, duration, and outcome of every command and query.
services.AddPipelineBehavior(typeof(LoggingBehavior<,>));What it does:
- Logs request type name at the start (Information level)
- Starts a
Stopwatchbefore callingnext() - On success, logs the elapsed time (Information level)
- On failure, logs the error codes from the
Result(Warning level)
When it short-circuits: Never -- it always calls next() and reports the result.
Example log output:
info: LoggingBehavior[0]
Handling CreateProductCommand
info: LoggingBehavior[0]
Handled CreateProductCommand in 42msOr on failure:
warn: LoggingBehavior[0]
CreateProductCommand failed with errors: Product.DuplicateSku3. ValidationBehavior
Runs all registered FluentValidation validators for the request type before the handler executes. If any validator reports errors, the behavior short-circuits with a ValidationResult.
services.AddPipelineBehavior(typeof(ValidationBehavior<,>));What it does:
- Resolves all
IValidator<TRequest>instances from the DI container - Runs all validators in parallel via
Task.WhenAll - Collects all validation failures
- If there are failures, returns a
ValidationResultwithError.Validation(...)for each failure - If there are no failures, calls
next()to proceed to the handler
When it short-circuits: When any FluentValidation validator reports one or more failures.
Example validator:
public sealed class CreateProductCommandValidator
: AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.Price)
.GreaterThan(0)
.WithMessage("Price must be greater than zero.");
RuleFor(x => x.Sku)
.NotEmpty()
.Matches("^[A-Z0-9-]+$")
.WithMessage("SKU must contain only uppercase letters, digits, and hyphens.");
}
}Validators are auto-discovered
The source-generated AddModulusHandlers() extension registers all AbstractValidator<T> implementations in the module automatically. You do not need to register them manually.
4. MetricsBehavior
Records handler execution duration as an OpenTelemetry histogram metric.
services.AddPipelineBehavior(typeof(MetricsBehavior<,>));What it does:
- Creates a
modulus.mediator.handler.durationhistogram viaIMeterFactory - Starts a timer before calling
next() - Records the elapsed duration with tags:
handler.name-- The request type name (e.g.,CreateProductCommand)outcome--successorfailure
When it short-circuits: Never -- it always calls next() and records the metric.
5. TracingBehavior
Opt-in distributed tracing: wraps each request in an Activity from the Modulus.Mediator ActivitySource.
services.AddPipelineBehavior(typeof(TracingBehavior<,>));What it does:
- Starts an activity named after the request type
- Tags
modulus.request_typewith the request's full name - On completion tags
modulus.outcome(success/failure/exception), and for failures addsmodulus.error_count,modulus.error_code, and sets the activity status toError
Subscribe in your OpenTelemetry configuration with .AddSource("Modulus.Mediator") (exposed as the ActivitySourceName constant on the behavior) -- see the OpenTelemetry recipe.
When it short-circuits: Never -- it always calls next().
Recommended Registration Order
// Program.cs or module registration
services.AddPipelineBehavior(typeof(UnhandledExceptionBehavior<,>)); // Outermost: catch all exceptions
services.AddPipelineBehavior(typeof(LoggingBehavior<,>)); // Log start/duration/outcome
services.AddPipelineBehavior(typeof(ValidationBehavior<,>)); // Validate before handler
services.AddPipelineBehavior(typeof(MetricsBehavior<,>)); // Measure handler durationThis ensures:
- Exceptions are always caught (even from logging or validation)
- Logging captures the full duration including validation time
- Validation runs before the handler, so invalid requests never hit business logic
- Metrics measure only the handler execution time (not validation or logging overhead)
Writing Custom Behaviors
You can create your own pipeline behaviors for concerns like caching, authorization, unit-of-work, or rate limiting.
Example: Unit of Work Behavior
The library ships a UnitOfWorkBehavior (in Modulus.Mediator.Behaviors) that calls IUnitOfWork.SaveChangesAsync() after a successful command — the library's IUnitOfWork deliberately exposes only that one member, and EF Core's SaveChanges is already transactional per call. Register the shipped behavior and implement IUnitOfWork over your DbContext:
public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options);
public sealed class CatalogUnitOfWork(CatalogDbContext dbContext) : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> dbContext.SaveChangesAsync(cancellationToken);
}If you need an explicit multi-SaveChanges transaction (e.g. several DbContexts in one command), that is a richer contract than the library's — define your own interface and behavior:
// User-owned: a wider transactional contract than the library IUnitOfWork.
public interface ITransactionalUnitOfWork
{
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitAsync(CancellationToken cancellationToken = default);
Task RollbackAsync(CancellationToken cancellationToken = default);
}
public sealed class TransactionBehavior<TRequest, TResponse>(ITransactionalUnitOfWork unitOfWork)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand
where TResponse : Result
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
await unitOfWork.BeginTransactionAsync(cancellationToken);
var result = await next(cancellationToken);
if (result.IsSuccess)
{
await unitOfWork.CommitAsync(cancellationToken);
}
else
{
await unitOfWork.RollbackAsync(cancellationToken);
}
return result;
}
}Constrain to commands only
Notice the where TRequest : ICommand constraint. This ensures the behavior only applies to commands (which mutate state), not queries (which are read-only). Without this constraint, the behavior would wrap queries in unnecessary transactions. (The shipped UnitOfWorkBehavior does the equivalent check at runtime so it can also cover ICommand<T>.)
Example: Caching Behavior (Queries Only)
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
public interface ICacheable
{
string CacheKey { get; }
TimeSpan? CacheDuration { get; }
}
public sealed class CachingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IQuery<TResponse>, ICacheable
{
private readonly IDistributedCache _cache;
public CachingBehavior(IDistributedCache cache)
{
_cache = cache;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var cached = await _cache.GetStringAsync(request.CacheKey, cancellationToken);
if (cached is not null)
{
return JsonSerializer.Deserialize<TResponse>(cached)!;
}
var result = await next(cancellationToken);
var duration = request.CacheDuration ?? TimeSpan.FromMinutes(5);
await _cache.SetStringAsync(
request.CacheKey,
JsonSerializer.Serialize(result),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = duration },
cancellationToken);
return result;
}
}Example: Timeout Behavior (Substituting a Token)
Because next now takes a token, a behavior can hand the rest of the pipeline a different token than the one it was given — the pattern a timeout needs. Link the caller's token with a fresh timeout-bound one and pass the linked token to next:
public sealed class TimeoutBehavior<TRequest, TResponse>(TimeSpan timeout)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
where TResponse : Result
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
using var timeoutCts = new CancellationTokenSource(timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
// Every inner behavior and the handler receive linkedCts.Token, not cancellationToken --
// this substitution was impossible before 4.0, when `next` took no parameters.
return await next(linkedCts.Token);
}
}If the handler observes linkedCts.Token (for example by passing it to Task.Delay, an HttpClient call, or a database query) and the timeout elapses first, the handler sees cancellation even though the caller's own token was never cancelled. If the caller's token is cancelled first, linkedCts.Token reflects that too -- CreateLinkedTokenSource cancels its token when either source does.
Registering Custom Behaviors
Register custom behaviors using the same AddPipelineBehavior extension method:
services.AddPipelineBehavior(typeof(UnitOfWorkBehavior<,>));
services.AddPipelineBehavior(typeof(CachingBehavior<,>));Place them in the appropriate position relative to the built-in behaviors:
services.AddPipelineBehavior(typeof(UnhandledExceptionBehavior<,>));
services.AddPipelineBehavior(typeof(LoggingBehavior<,>));
services.AddPipelineBehavior(typeof(ValidationBehavior<,>));
services.AddPipelineBehavior(typeof(UnitOfWorkBehavior<,>)); // After validation, before metrics
services.AddPipelineBehavior(typeof(MetricsBehavior<,>));What Is NOT Wrapped by the Pipeline
WARNING
Pipeline behaviors only apply to commands and queries dispatched via mediator.Send() and mediator.Query(). The following are not wrapped by pipeline behaviors:
- Domain events (
mediator.Publish()) -- Domain event handlers are invoked directly without any pipeline wrapping. - Streaming queries (
mediator.Stream()) -- Streaming handlers returnIAsyncEnumerable<T>, which is incompatible with theTask<TResponse>signature used by pipeline behaviors.
See Also
- Commands & Queries -- The requests that flow through the pipeline
- Result Pattern -- The Result and Error types returned by behaviors
- Domain Events -- Event publishing (not part of the pipeline)
- Streaming Queries -- Streaming queries (not part of the pipeline)