Pipeline Behaviors

Pipeline behaviors allow you to add cross-cutting concerns to your request handling pipeline.

Open vs Closed Behaviors

Open behavior = an open generic type definition (e.g., LoggingBehavior<,>) that can be applied to any request/response pair. It is resolved by DI for each concrete request at runtime.

Closed behavior = a concrete type (non-generic or fully closed generic) that targets a specific request/response pair.

Example request/response:

public sealed record CreateOrder(string ProductId) : IRequest<OrderResult>;

public sealed record OrderResult(Guid OrderId);

Open behavior example

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async ValueTask<TResponse> HandleAsync(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Handling {RequestType}", typeof(TRequest).Name);

        var stopwatch = Stopwatch.StartNew();
        var response = await next(); // Call the next behavior or handler
        stopwatch.Stop();

        _logger.LogInformation("Handled {RequestType} in {ElapsedMs}ms",
            typeof(TRequest).Name, stopwatch.ElapsedMilliseconds);

        return response;
    }
}

Closed behavior example

public sealed class CreateOrderLoggingBehavior
    : IPipelineBehavior<CreateOrder, OrderResult>
{
    public async ValueTask<OrderResult> HandleAsync(
        CreateOrder request,
        RequestHandlerDelegate<OrderResult> next,
        CancellationToken cancellationToken = default)
    {
        return await next();
    }
}

Registering Behaviors

When using source-generated registration, behaviors are automatically discovered and registered. Do NOT use MediatorOptions.AddOpenBehavior() - it’s not needed.

If your behaviors are in the same project as the source generator, AddGeneratedHandlers() will discover and register them automatically:

using MediatorLite.Generated;

services
    .AddGeneratedHandlers()   // Discovers and registers ALL behaviors automatically
    .AddMediatorLite();       // No need to call options.AddOpenBehavior()

To register only behaviors from the source generator:

services
    .AddGeneratedBehaviors()  // Only pipeline behaviors
    .AddMediatorLite();

Important: The source generator discovers both open generic behaviors (e.g., LoggingBehavior<,>) and closed behaviors (e.g., CreateOrderValidationBehavior). They are registered directly in DI and will be resolved automatically by the mediator.

Manual Registration (Without Source Generation)

When NOT using source-generated registration, you have two options:

Register open generic behaviors through MediatorOptions. This automatically adds them to DI:

services.AddMediatorLite(options =>
{
    options.AddOpenBehavior(typeof(LoggingBehavior<,>));
    options.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

Register closed behaviors:

services.AddMediatorLite(options =>
{
    options.AddBehavior<CreateOrderAuthorizationBehavior>();
});

Option 2: Direct DI Registration

You can also register behaviors directly with the DI container:

// Open generic - applies to all requests
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));

// Closed type - applies to specific request only
services.AddTransient<IPipelineBehavior<CreateOrder, OrderResult>, CreateOrderLoggingBehavior>();

services.AddMediatorLite();

Summary: Source-Gen vs Manual Registration

Method When to Use Behavior Registration
Source-Generated Recommended for all projects AddGeneratedHandlers() - behaviors auto-discovered, DO NOT use options.AddOpenBehavior()
Manual via Options When NOT using source-gen options.AddOpenBehavior() or options.AddBehavior<T>()
Manual via DI When NOT using source-gen services.AddTransient(typeof(IPipelineBehavior<,>), typeof(Behavior<,>))

Key Rule: If you call AddGeneratedHandlers() or AddGeneratedBehaviors(), the source generator handles all behavior registration. Do not mix source-gen with MediatorOptions.AddOpenBehavior() for the same behavior - it will be registered twice.

Execution Order

Behaviors execute in registration order (first registered = first executed):

Request -> LoggingBehavior -> ValidationBehavior -> Handler -> ValidationBehavior -> LoggingBehavior -> Response

Using BehaviorOrderAttribute

[BehaviorOrder(1)] // Executes first
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> { }

[BehaviorOrder(2)] // Executes second
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> { }

Common Behavior Patterns

Validation Behavior

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async ValueTask<TResponse> HandleAsync(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken = default)
    {
        foreach (var validator in _validators)
        {
            var result = await validator.ValidateAsync(request, cancellationToken);
            if (!result.IsValid)
            {
                throw new ValidationException(result.Errors);
            }
        }

        return await next();
    }
}

Transaction Behavior

public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IDbContext _dbContext;

    public TransactionBehavior(IDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async ValueTask<TResponse> HandleAsync(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken = default)
    {
        await using var transaction = await _dbContext.BeginTransactionAsync(cancellationToken);

        try
        {
            var response = await next();
            await transaction.CommitAsync(cancellationToken);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }
}

Short-Circuiting

Behaviors can short-circuit the pipeline by not calling next():

```csharp public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest { private readonly ICache _cache;

public async ValueTask<TResponse> HandleAsync(
    TRequest request,
    RequestHandlerDelegate<TResponse> next,
    CancellationToken cancellationToken = default)
{
    var cacheKey = GenerateCacheKey(request);

    if (_cache.TryGet<TResponse>(cacheKey, out var cached))
    {
        return cached!; // Don't call next(), return cached value
    }

    var response = await next();
    _cache.Set(cacheKey, response);
    return response;
} }