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:
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 executionAfter/AfterAsync- Runs after successful handler completionFinally/FinallyAsync- Always runs, even if handler fails; receives exceptionExecuteAsync- 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 Type | Availability | Description |
|---|---|---|
CancellationToken | Before, After, Finally, Execute | The cancellation token passed to the handler |
IServiceProvider | Before, After, Finally, Execute | The service provider for the current scope |
Activity? | Before, After, Finally, Execute | The OpenTelemetry activity (when OpenTelemetry is enabled) |
Exception? | After, Finally only | The exception if the handler failed (always null in Before) |
HandlerExecutionInfo | Before, After, Finally, Execute | Metadata about the executing handler (handler type and method) |
HandlerExecutionDelegate | ExecuteAsync only | Delegate to invoke the wrapped pipeline |
| Handler return type | After, Finally only | The result returned by the handler (e.g., Result<T>, custom types) |
| Before method return type | After, Finally only | Values returned from the Before method |
Example with built-in parameters:
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 inAfterandFinallymethods since exceptions can only occur during or after handler execution.
Note: If a middleware's
Beforemethod is skipped due to short-circuiting by an earlier middleware, that middleware'sFinallymethod will not be called. Only middleware whoseBeforehas executed will haveFinallyinvoked.
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:
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).
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:
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:
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
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
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 = trueso the middleware only applies when the attribute is usedHandlerExecutionInfoto access handler metadata and discover attribute settings
/// <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;
}/// <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:
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 syncExecutemethod - The
nextdelegate returnsobject?- cast as needed for strongly-typed results - Use low
Ordervalues (e.g., 0, 1) to wrap as outermost middleware - Execute middleware can access DI parameters like other middleware methods
- Use
HandlerExecutionInfoto 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).
[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:
public class ValidationMiddleware
{
public HandlerResult Before(object message)
{
if (!IsValid(message))
return HandlerResult.ShortCircuit(Result.Invalid("Validation failed"));
return HandlerResult.Continue();
}
}Short-Circuit Usage
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:
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:
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:
[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):
- Message-specific middleware
- Interface-based middleware
- Object-based middleware
Execution flow:
ExecuteAsync: Lower order values wrap outermost (run first/last)Before: Lower order values run firstAfter/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:
// "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:
// 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 XOrderAfter = [typeof(X)]means "I run after X" — adds an edge from X to this middleware- When no relative constraints exist between two middleware, numeric
Orderis used as a tiebreaker - Middleware with no constraints and no explicit
Orderusesint.MaxValue(runs last) - Unknown types in
OrderBefore/OrderAfterare 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:
// 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
[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:
[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:
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:
// 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:
[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:
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:
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
| Lifetime | Behavior |
|---|---|
| Scoped | Resolved from DI on every invocation |
| Transient | Resolved from DI on every invocation |
| Singleton | Resolved 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:
// 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:
[assembly: MediatorConfiguration(MiddlewareLifetime = MediatorLifetime.Scoped)]Middleware Discovery
Middleware is automatically discovered by the Foundatio.Mediator source generator. To share middleware across projects:
- Create a middleware project with Foundatio.Mediator package referenced
- Reference that project from your handler projects
- 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:
- Naming Convention: Classes ending with
Middleware(e.g.,LoggingMiddleware,ValidationMiddleware) - Attribute: Classes marked with
[Middleware]attribute
Example: Cross-Assembly Middleware
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 automaticallyCommon.Middleware.csproj:
<ItemGroup>
<PackageReference Include="Foundatio.Mediator" />
</ItemGroup>Orders.Handlers.csproj:
<ItemGroup>
<ProjectReference Include="..\Common.Middleware\Common.Middleware.csproj" />
<PackageReference Include="Foundatio.Mediator" />
</ItemGroup>Common.Middleware/LoggingMiddleware.cs:
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.Modulebeing used byProducts.ModuleandOrders.Module.
Setting Middleware Order
Control execution order using the [Middleware(Order = n)] attribute:
[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 firstAfter/Finally: Higher order values run first (reverse order for proper nesting)
Ignoring Middleware
Use [FoundatioIgnore] to exclude middleware classes or methods:
[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:
// ✅ Good - single responsibility
public class ValidationMiddleware { }
public class LoggingMiddleware { }
public class AuthorizationMiddleware { }
// ❌ Avoid - multiple responsibilities
public class EverythingMiddleware { }2. Use Appropriate Lifecycle Methods
// ✅ 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
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
// ✅ 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
- Modular Monolith Sample - Complete working example of cross-assembly middleware
- Handler Conventions - Learn handler discovery rules