Skip to content

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>:

csharp
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

csharp
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 a default argument as "keep mine", not as an explicit reset. This is what keeps every existing await next() call site correct without modification.
  • await next(someOtherToken) substitutes someOtherToken for 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:

csharp
services.AddPipelineBehavior(typeof(UnhandledExceptionBehavior<,>));  // 1st registered
services.AddPipelineBehavior(typeof(LoggingBehavior<,>));             // 2nd registered
services.AddPipelineBehavior(typeof(ValidationBehavior<,>));          // 3rd registered
services.AddPipelineBehavior(typeof(MetricsBehavior<,>));             // 4th registered

The execution flows like this:

In detail:

  1. UnhandledExceptionBehavior receives the request, wraps everything in a try/catch, calls next().
  2. LoggingBehavior logs the request start, calls next(), logs the duration and outcome.
  3. ValidationBehavior runs all FluentValidation validators. If validation fails, it short-circuits by returning a ValidationResult without calling next(). If validation passes, it calls next().
  4. MetricsBehavior starts a timer, calls next() (the handler), records the histogram metric.
  5. Handler executes the business logic and returns a Result or Result<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.

csharp
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.

csharp
services.AddPipelineBehavior(typeof(LoggingBehavior<,>));

What it does:

  • Logs request type name at the start (Information level)
  • Starts a Stopwatch before calling next()
  • 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 42ms

Or on failure:

warn: LoggingBehavior[0]
      CreateProductCommand failed with errors: Product.DuplicateSku

3. 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.

csharp
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 ValidationResult with Error.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:

csharp
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.

csharp
services.AddPipelineBehavior(typeof(MetricsBehavior<,>));

What it does:

  • Creates a modulus.mediator.handler.duration histogram via IMeterFactory
  • Starts a timer before calling next()
  • Records the elapsed duration with tags:
    • handler.name -- The request type name (e.g., CreateProductCommand)
    • outcome -- success or failure

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.

csharp
services.AddPipelineBehavior(typeof(TracingBehavior<,>));

What it does:

  • Starts an activity named after the request type
  • Tags modulus.request_type with the request's full name
  • On completion tags modulus.outcome (success / failure / exception), and for failures adds modulus.error_count, modulus.error_code, and sets the activity status to Error

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().

csharp
// 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 duration

This 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:

csharp
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:

csharp
// 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)

csharp
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:

csharp
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:

csharp
services.AddPipelineBehavior(typeof(UnitOfWorkBehavior<,>));
services.AddPipelineBehavior(typeof(CachingBehavior<,>));

Place them in the appropriate position relative to the built-in behaviors:

csharp
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 return IAsyncEnumerable<T>, which is incompatible with the Task<TResponse> signature used by pipeline behaviors.

See Also

Released under the MIT License.