Skip to content

Middleware

Middleware in Foundatio Mediator provides a powerful pipeline for implementing cross-cutting concerns like validation, logging, authorization, and error handling. Middleware can run before, after, and finally around handler execution.

Basic Middleware

Create middleware by following naming conventions:

csharp
public class LoggingMiddleware
{
    private readonly ILogger<LoggingMiddleware> _logger;

    public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
    {
        _logger = logger;
    }

    public void Before(object message)
    {
        _logger.LogInformation("Handling {MessageType}", message.GetType().Name);
    }

    public void After(object message)
    {
        _logger.LogInformation("Handled {MessageType}", message.GetType().Name);
    }
}

Middleware Conventions

Class Names

Middleware classes must end with Middleware

Method Names

Valid middleware method names:

  • Before / BeforeAsync - Runs before the handler; can short-circuit execution
  • After / AfterAsync - Runs after successful handler completion
  • Finally / FinallyAsync - Always runs, even if handler fails; receives exception
  • ExecuteAsync - Wraps the entire pipeline (for retry, circuit breaker, etc.)

Method Parameters

  • First parameter: The message (can be object, interface, or concrete type)
  • Remaining parameters: Injected via DI or from built-in context (see below)

Built-in Parameter Types

The following parameter types are automatically available in middleware methods without DI registration:

Parameter TypeAvailabilityDescription
CancellationTokenBefore, After, Finally, ExecuteThe cancellation token passed to the handler
IServiceProviderBefore, After, Finally, ExecuteThe service provider for the current scope
Activity?Before, After, Finally, ExecuteThe OpenTelemetry activity (when OpenTelemetry is enabled)
Exception?After, Finally onlyThe exception if the handler failed (always null in Before)
HandlerExecutionInfoBefore, After, Finally, ExecuteMetadata about the executing handler (handler type and method)
HandlerExecutionDelegateExecuteAsync onlyDelegate to invoke the wrapped pipeline
Handler return typeAfter, Finally onlyThe result returned by the handler (e.g., Result<T>, custom types)
Before method return typeAfter, Finally onlyValues returned from the Before method

Example with built-in parameters:

csharp
public class TracingMiddleware
{
    // Activity is available when OpenTelemetry is enabled
    public void Before(object message, Activity? activity, CancellationToken ct)
    {
        activity?.SetTag("custom.tag", "value");
    }

    // Exception is NOT available in Before methods
    public void After(object message, Activity? activity)
    {
        activity?.SetTag("result.status", "success");
    }

    // Exception IS available in Finally methods
    public void Finally(object message, Activity? activity, Exception? ex)
    {
        if (ex != null)
            activity?.SetTag("error", "true");
    }
}

Note: The Exception? parameter is only available in After and Finally methods since exceptions can only occur during or after handler execution.

Note: If a middleware's Before method is skipped due to short-circuiting by an earlier middleware, that middleware's Finally method will not be called. Only middleware whose Before has executed will have Finally invoked.

Lifecycle Methods

Before

Runs before the handler. Can return values that are passed to After and Finally:

One Method Per Hook

Each middleware class may define only one Before (or BeforeAsync) method — the same applies to After, Finally, and ExecuteAsync. Defining multiple overloads of the same hook (e.g., Before(CreateOrder cmd) and Before(UpdateOrder cmd)) will produce diagnostic error FMED001 and the middleware will not compile.

To handle multiple message types in a single middleware class, accept object and use pattern matching:

csharp
public class ValidationMiddleware
{
    public HandlerResult Before(object message)
    {
        switch (message)
        {
            case CreateOrder create when string.IsNullOrEmpty(create.Name):
                return HandlerResult.ShortCircuit(Result.Invalid("Name is required"));
            case UpdateOrder update when update.OrderId == null:
                return HandlerResult.ShortCircuit(Result.Invalid("OrderId is required"));
            default:
                return HandlerResult.Continue();
        }
    }
}

Alternatively, create separate middleware classes when each message type needs different state (return types from Before that flow to After/Finally).

csharp
public class TimingMiddleware
{
    public Stopwatch Before(object message)
    {
        return Stopwatch.StartNew();
    }

    public void Finally(object message, Stopwatch stopwatch)
    {
        stopwatch.Stop();
        Console.WriteLine($"Handled {message.GetType().Name} in {stopwatch.ElapsedMilliseconds}ms");
    }
}

After

Runs after successful handler completion. Only called if the handler succeeds:

csharp
public class AuditMiddleware
{
    public void After(object message, IUserContext userContext)
    {
        _auditLog.Record($"User {userContext.UserId} executed {message.GetType().Name}");
    }
}

Finally

Always runs, regardless of success or failure. Receives exception if handler failed:

csharp
public class ErrorHandlingMiddleware
{
    public void Finally(object message, Exception? exception)
    {
        if (exception != null)
        {
            _errorLog.Record(message, exception);
        }
    }
}

Execute Middleware

The ExecuteAsync method wraps the entire middleware pipeline (Before → Handler → After → Finally). This is useful for cross-cutting concerns that need to wrap the full execution, such as:

  • Retry logic - Re-execute the entire pipeline on transient failures
  • Circuit breakers - Fail fast when downstream systems are unavailable
  • Timeout handling - Cancel execution after a deadline

Execution Flow

text
Execute[1]( Execute[2]( Before[1] → Before[2] → Handler → After[2] → After[1] → Finally[2] → Finally[1] ) )

On each retry, the entire inner pipeline re-executes - all Before middlewares run again, the handler runs again, and all After/Finally middlewares run again.

Execute Method Signature

csharp
public async ValueTask<object?> ExecuteAsync(
    TMessage message,                    // The message being handled
    HandlerExecutionDelegate next,       // Delegate to the wrapped pipeline
    // ... additional DI parameters
)

Retry Middleware Example

Here's a complete retry middleware using Foundatio Resilience. This example demonstrates:

  • Custom [Retry] attribute with [UseMiddleware] applied to trigger the middleware
  • ExplicitOnly = true so the middleware only applies when the attribute is used
  • HandlerExecutionInfo to access handler metadata and discover attribute settings
csharp
/// <summary>
/// Custom attribute that triggers RetryMiddleware and configures retry settings.
/// </summary>
[UseMiddleware(typeof(RetryMiddleware))]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class RetryAttribute : Attribute
{
    public int MaxAttempts { get; set; } = 3;
    public int DelayMs { get; set; } = 100;
    public bool UseExponentialBackoff { get; set; } = true;
    public bool UseJitter { get; set; } = true;
}
csharp
/// <summary>
/// Middleware that wraps the entire pipeline with retry logic.
/// Only applies to handlers that use the [Retry] attribute (ExplicitOnly = true).
/// </summary>
[Middleware(Order = 0, ExplicitOnly = true)] // Low order = outermost, ExplicitOnly = only via [Retry]
public static class RetryMiddleware
{
    private static readonly IResiliencePolicy DefaultPolicy = new ResiliencePolicyBuilder()
        .WithMaxAttempts(3)
        .WithExponentialDelay(TimeSpan.FromMilliseconds(100))
        .WithJitter()
        .Build();

    private static readonly ConcurrentDictionary<MethodInfo, IResiliencePolicy> PolicyCache = new();

    public static async ValueTask<object?> ExecuteAsync(
        object message,                      // Takes object - filtering done by [Retry] attribute
        HandlerExecutionDelegate next,
        HandlerExecutionInfo handlerInfo,
        ILogger<IMediator> logger)
    {
        var policy = PolicyCache.GetOrAdd(handlerInfo.HandlerMethod, method =>
        {
            var attr = method.GetCustomAttribute<RetryAttribute>()
                ?? handlerInfo.HandlerType.GetCustomAttribute<RetryAttribute>();

            if (attr == null)
                return DefaultPolicy;

            var builder = new ResiliencePolicyBuilder().WithMaxAttempts(attr.MaxAttempts);

            if (attr.UseExponentialBackoff)
                builder.WithExponentialDelay(TimeSpan.FromMilliseconds(attr.DelayMs));
            else
                builder.WithLinearDelay(TimeSpan.FromMilliseconds(attr.DelayMs));

            if (attr.UseJitter)
                builder.WithJitter();

            return builder.Build();
        });

        return await policy.ExecuteAsync(async ct =>
        {
            logger.LogDebug("RetryMiddleware: Attempt for {MessageType}", message.GetType().Name);
            return await next();
        }, default);
    }
}

Apply to handlers - the [Retry] attribute both triggers the middleware AND configures it:

csharp
public class OrderHandler
{
    // [Retry] triggers RetryMiddleware with custom settings
    [Retry(MaxAttempts = 5, DelayMs = 200)]
    public async Task<Result<Order>> HandleAsync(CreateOrder command)
    {
        // ... create order with up to 5 retries
    }

    // No attribute - RetryMiddleware does NOT run (ExplicitOnly = true)
    public Result<Order> Handle(GetOrder query)
    {
        // ... no retry behavior
    }
}

Key Points

  • Execute middleware only supports async (ExecuteAsync) - no sync Execute method
  • The next delegate returns object? - cast as needed for strongly-typed results
  • Use low Order values (e.g., 0, 1) to wrap as outermost middleware
  • Execute middleware can access DI parameters like other middleware methods
  • Use HandlerExecutionInfo to access handler type and method for reflection
  • Cache policies per handler to avoid rebuilding on each invocation

Complete Example: See the Clean Architecture Sample RetryMiddleware, RetryAttribute, and PaymentHandler (which randomly throws transient errors to demonstrate retry behavior).

Caching Middleware

The same [UseMiddleware] + ExplicitOnly pattern works for caching. A [Cached] attribute triggers a CachingMiddleware that memoizes handler results keyed by message value (C# records have value equality, so identical queries are automatic cache hits).

csharp
[Cached(DurationSeconds = 60)]
public async Task<Result<ProductCatalogSummary>> HandleAsync(GetProductCatalog query, ...)
{
    await Task.Delay(500, cancellationToken); // Simulate expensive aggregation
    // First call is slow; subsequent calls return instantly from cache
}

Complete Example: See the CachingMiddleware, CachedAttribute, and ProductHandler in the Clean Architecture Sample.

Short-Circuiting with HandlerResult

Middleware can short-circuit handler execution by returning a HandlerResult from the Before method:

Real-World Validation Example

Let's look at the validation middleware from the sample:

csharp
public class ValidationMiddleware
{
    public HandlerResult Before(object message)
    {
        if (!IsValid(message))
            return HandlerResult.ShortCircuit(Result.Invalid("Validation failed"));

        return HandlerResult.Continue();
    }
}

Short-Circuit Usage

csharp
public class AuthorizationMiddleware
{
    public HandlerResult Before(object message, IUserContext userContext)
    {
        if (!IsAuthorized(userContext, message))
        {
            // Short-circuit with forbidden result
            return HandlerResult.ShortCircuit(Result.Forbidden("Access denied"));
        }

        // Continue to handler
        return HandlerResult.Continue();
    }

    private bool IsAuthorized(IUserContext user, object message)
    {
        // Authorization logic
        return true;
    }
}

State Passing Between Lifecycle Methods

Values returned from Before are automatically injected into After and Finally by type:

csharp
public class TransactionMiddleware
{
    public IDbTransaction Before(object message, IDbContext context)
    {
        return context.BeginTransaction();
    }

    public async Task After(object message, IDbTransaction transaction)
    {
        await transaction.CommitAsync();
    }

    public async Task Finally(object message, IDbTransaction transaction, Exception? ex)
    {
        if (ex != null)
            await transaction.RollbackAsync();

        await transaction.DisposeAsync();
    }
}

Tuple State Returns

You can return multiple values using tuples:

csharp
public class ComplexMiddleware
{
    public (Stopwatch timer, string correlationId) Before(object message)
    {
        return (Stopwatch.StartNew(), Guid.NewGuid().ToString());
    }

    public void Finally(object message, Stopwatch timer, string correlationId, Exception? ex)
    {
        timer.Stop();
        _logger.LogInformation("Correlation {CorrelationId} completed in {Ms}ms",
            correlationId, timer.ElapsedMilliseconds);
    }
}

Middleware Ordering

Use the [Middleware] attribute to control execution order:

csharp
[Middleware(10)]
public class ValidationMiddleware
{
    // Runs early in Before, late in After/Finally
}

[Middleware(50)]
public class LoggingMiddleware
{
    // Runs later in Before, earlier in After/Finally
}

Default ordering (without explicit order):

  1. Message-specific middleware
  2. Interface-based middleware
  3. Object-based middleware

Execution flow:

  • ExecuteAsync: Lower order values wrap outermost (run first/last)
  • Before: Lower order values run first
  • After/Finally: Higher order values run first (reverse order for proper nesting)

Relative Ordering

Instead of managing numeric order values, you can express ordering relationships between middleware using OrderBefore and OrderAfter:

csharp
// "I must run before LoggingMiddleware"
[Middleware(OrderBefore = [typeof(LoggingMiddleware)])]
public class AuthMiddleware
{
    public HandlerResult Before(object message)
    {
        // Auth check runs before logging
        return HandlerResult.Continue();
    }
}

// "I must run after AuthMiddleware"
[Middleware(OrderAfter = [typeof(AuthMiddleware)])]
public class AuditMiddleware
{
    public void Before(object message)
    {
        // Audit runs after auth
    }
}

public class LoggingMiddleware
{
    public void Before(object message) { }
}

The resulting execution order for Before is: AuthMiddleware → LoggingMiddleware → AuditMiddleware

You can specify multiple types in a single declaration:

csharp
// This middleware must run before both Validation and Logging
[Middleware(OrderBefore = [typeof(ValidationMiddleware), typeof(LoggingMiddleware)])]
public class SecurityMiddleware
{
    public void Before(object message) { }
}

How relative ordering works:

  • OrderBefore = [typeof(X)] means "I run before X" — adds an edge from this middleware to X
  • OrderAfter = [typeof(X)] means "I run after X" — adds an edge from X to this middleware
  • When no relative constraints exist between two middleware, numeric Order is used as a tiebreaker
  • Middleware with no constraints and no explicit Order uses int.MaxValue (runs last)
  • Unknown types in OrderBefore/OrderAfter are silently ignored (they may not apply to the current handler)

Circular Dependencies

If middleware form a circular dependency (e.g., A says OrderBefore B, and B says OrderBefore A), a compiler warning FMED012 is emitted and the cycle participants fall back to numeric Order sorting.

Message-Specific Middleware

Target specific message types or interfaces:

csharp
// Only runs for ICommand messages
public class CommandMiddleware
{
    public void Before(ICommand command)
    {
        _commandLogger.Log($"Executing command: {command.GetType().Name}");
    }
}

// Only runs for CreateOrder messages
public class OrderCreationMiddleware
{
    public HandlerResult Before(CreateOrder command)
    {
        if (_orderService.IsDuplicate(command))
            return HandlerResult.ShortCircuit(Result.Conflict("Duplicate order"));

        return HandlerResult.Continue();
    }
}

Handler-Specific Middleware

Use the [UseMiddleware] attribute to apply middleware to specific handlers rather than globally by message type.

Basic Usage

csharp
[UseMiddleware(typeof(StopwatchMiddleware))]
[UseMiddleware(typeof(AuditMiddleware), Order = 10)]
public class OrderHandler
{
    public Result<Order> Handle(CreateOrder command) { /* ... */ }
}

Custom Middleware Attributes

Create reusable attributes by applying [UseMiddleware] to your attribute class:

csharp
[UseMiddleware(typeof(RetryMiddleware))]
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class RetryAttribute : Attribute
{
    public int MaxAttempts { get; set; } = 3;
}

[UseMiddleware(typeof(CachingMiddleware))]
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class CachedAttribute : Attribute
{
    public int DurationSeconds { get; set; } = 300;
}

Usage is clean and expressive:

csharp
public class OrderHandler
{
    [Retry(MaxAttempts = 5)]
    public async Task<Result<Order>> HandleAsync(CreateOrder command) { /* ... */ }

    [Cached(DurationSeconds = 60)]
    public Result<Order> Handle(GetOrder query) { /* ... */ }
}

ExplicitOnly Middleware

Mark middleware with ExplicitOnly = true to prevent automatic global application. The middleware will only run when explicitly referenced via [UseMiddleware] or a custom attribute:

csharp
// This middleware only runs when a handler uses [Retry] or [UseMiddleware(typeof(RetryMiddleware))]
[Middleware(Order = 0, ExplicitOnly = true)]
public class RetryMiddleware
{
    public async ValueTask<object?> ExecuteAsync(object message, HandlerExecutionDelegate next)
    {
        // Retry logic...
    }
}

Without ExplicitOnly = true, middleware that takes object as the message type would run for ALL handlers.

Method vs Class Level

Attributes can be applied at method or class level:

csharp
[Retry] // Applies to all handler methods in this class
public class OrderHandler
{
    public Result<Order> Handle(CreateOrder command) { /* ... */ }

    [Cached(DurationSeconds = 120)] // Method-level adds caching for this method only
    public Result<Order> Handle(GetOrder query) { /* ... */ }
}

Real-World Examples

Comprehensive Logging Middleware

Here's the logging middleware from the sample project:

csharp
public class LoggingMiddleware
{
    private readonly ILogger<LoggingMiddleware> _logger;

    public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
    {
        _logger = logger;
    }

    public static void Before(object message, ILogger<LoggingMiddleware> logger)
    {
        logger.LogInformation("Handling {MessageType}", message.GetType().Name);
    }
}

Async Middleware

All lifecycle methods support async versions:

csharp
public class AsyncMiddleware
{
    public async Task<Stopwatch> BeforeAsync(object message, CancellationToken ct)
    {
        await SomeAsyncSetup(ct);
        return Stopwatch.StartNew();
    }

    public async Task AfterAsync(object message, Stopwatch sw, CancellationToken ct)
    {
        await SomeAsyncCleanup(ct);
    }

    public async Task FinallyAsync(object message, Stopwatch sw, Exception? ex, CancellationToken ct)
    {
        sw.Stop();
        await LogTiming(message, sw.ElapsedMilliseconds, ex, ct);
    }
}

Middleware Lifetime

Lifetime Behavior

LifetimeBehavior
ScopedResolved from DI on every invocation
TransientResolved from DI on every invocation
SingletonResolved from DI on every invocation (DI handles caching)
None/Default (no constructor deps)Created once with new() and cached
None/Default (with constructor deps)Created once with ActivatorUtilities.CreateInstance and cached

Controlling Lifetime with [Middleware] Attribute

Use the [Middleware] attribute to explicitly control lifetime:

csharp
// Resolved from DI - DI handles singleton caching
[Middleware(Lifetime = MediatorLifetime.Singleton)]
public class LoggingMiddleware { /* ... */ }

// Resolved from DI on every invocation
[Middleware(Lifetime = MediatorLifetime.Scoped)]
public class ValidationMiddleware { /* ... */ }

// No explicit lifetime - cached in static field since no constructor deps
public class SimpleMiddleware
{
    public void Before(object message) { /* ... */ }
}

Project-Level Default

Set a default lifetime for all middleware:

csharp
[assembly: MediatorConfiguration(MiddlewareLifetime = MediatorLifetime.Scoped)]

Middleware Discovery

Middleware is automatically discovered by the Foundatio.Mediator source generator. To share middleware across projects:

  1. Create a middleware project with Foundatio.Mediator package referenced
  2. Reference that project from your handler projects
  3. Ensure the handler project also references Foundatio.Mediator

The source generator will discover middleware in referenced assemblies that have the Foundatio.Mediator source generator.

Discovery Rules

Middleware classes are found using:

  1. Naming Convention: Classes ending with Middleware (e.g., LoggingMiddleware, ValidationMiddleware)
  2. Attribute: Classes marked with [Middleware] attribute

Example: Cross-Assembly Middleware

text
Solution/
├── Common.Middleware/              # Shared middleware project
│   ├── Common.Middleware.csproj    # References Foundatio.Mediator
│   └── LoggingMiddleware.cs        # Discovered by convention
└── Orders.Handlers/                # Handler project
    ├── Orders.Handlers.csproj      # References Common.Middleware AND Foundatio.Mediator
    └── OrderHandler.cs             # Uses LoggingMiddleware automatically

Common.Middleware.csproj:

xml
<ItemGroup>
  <PackageReference Include="Foundatio.Mediator" />
</ItemGroup>

Orders.Handlers.csproj:

xml
<ItemGroup>
  <ProjectReference Include="..\Common.Middleware\Common.Middleware.csproj" />
  <PackageReference Include="Foundatio.Mediator" />
</ItemGroup>

Common.Middleware/LoggingMiddleware.cs:

csharp
namespace Common.Middleware;

// Discovered by naming convention (ends with Middleware)
public class LoggingMiddleware
{
    public void Before(object message, ILogger logger)
    {
        logger.LogInformation("Handling {MessageType}", message.GetType().Name);
    }
}

The middleware will automatically be applied to all handlers in Orders.Handlers project.

💡 Complete Example: See the Modular Monolith Sample for a working demonstration of cross-assembly middleware in a multi-module application with shared middleware in Common.Module being used by Products.Module and Orders.Module.

Setting Middleware Order

Control execution order using the [Middleware(Order = n)] attribute:

csharp
[Middleware(Order = 1)]  // Runs first in Before, last in After/Finally
public class LoggingMiddleware { }

[Middleware(Order = 10)] // Runs later in Before, earlier in After/Finally
public class PerformanceMiddleware { }

Execution flow:

  • Before: Lower order values run first
  • After/Finally: Higher order values run first (reverse order for proper nesting)

Ignoring Middleware

Use [FoundatioIgnore] to exclude middleware classes or methods:

csharp
[FoundatioIgnore] // Entire class ignored
public class DisabledMiddleware
{
    public void Before(object message) { }
}

public class PartialMiddleware
{
    public void Before(object message) { }

    [FoundatioIgnore] // Only this method ignored
    public void After(object message) { }
}

Best Practices

1. Keep Middleware Focused

Each middleware should handle one concern:

csharp
// ✅ Good - single responsibility
public class ValidationMiddleware { }
public class LoggingMiddleware { }
public class AuthorizationMiddleware { }

// ❌ Avoid - multiple responsibilities
public class EverythingMiddleware { }

2. Use Appropriate Lifecycle Methods

csharp
// ✅ Validation in Before (can short-circuit)
public HandlerResult Before(object message) => HandlerResult.ShortCircuit(ValidateMessage(message));

// ✅ Logging in Finally (always runs)
public void Finally(object message, Exception? ex) => LogResult(message, ex);

// ❌ Don't validate in After (handler already ran)

3. Handle Exceptions Gracefully

csharp
public void Finally(object message, Exception? exception)
{
    if (exception != null)
    {
        // Log, notify, cleanup, etc.
        _logger.LogError(exception, "Handler failed for {MessageType}", message.GetType().Name);
    }
}

4. Use Strongly-Typed Message Parameters

csharp
// ✅ Specific to commands
public void Before(ICommand command) { }

// ✅ Specific to queries
public void Before(IQuery query) { }

// ⚠️ Generic (runs for everything)
public void Before(object message) { }

Next Steps

Released under the MIT License.