.NET & C# Development · Lesson 6 of 11

Middleware & Filters

Middleware vs Filters

Understanding when to use each:

| | Middleware | Filters | |---|---|---| | Scope | Every request (including static files, non-MVC) | Only MVC/API requests | | Access to | Raw HttpContext | ActionContext, model binding result, IActionResult | | Order | Determined by registration order in Program.cs | Determined by filter pipeline stages | | Best for | CORS, auth, rate limiting, response compression, logging | Validation, model transformation, caching, audit |


Middleware

How the Pipeline Works

Request → Middleware 1 → Middleware 2 → Middleware 3 → Endpoint
Response ←            ←              ←               ←

Each middleware calls await next(context) to pass control to the next component. After next() returns, it handles the response.

Convention-Based Middleware

C#
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();

        // Before the next middleware
        context.Response.OnStarting(() =>
        {
            context.Response.Headers["X-Response-Time"] = $"{sw.ElapsedMilliseconds}ms";
            return Task.CompletedTask;
        });

        await _next(context);

        sw.Stop();

        _logger.LogInformation(
            "{Method} {Path} → {StatusCode} in {Elapsed}ms",
            context.Request.Method,
            context.Request.Path,
            context.Response.StatusCode,
            sw.ElapsedMilliseconds);
    }
}

Registration:

C#
// Program.cs
app.UseMiddleware<RequestTimingMiddleware>();

// Or extension method (preferred):
app.UseRequestTiming();
C#
public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
        => app.UseMiddleware<RequestTimingMiddleware>();
}

IMiddleware (Factory-Activated)

Use IMiddleware when your middleware needs scoped services injected per request (regular convention-based middleware uses the singleton lifetime):

C#
public class TenantResolutionMiddleware : IMiddleware
{
    private readonly ITenantService _tenantService;  // scoped service

    public TenantResolutionMiddleware(ITenantService tenantService)
        => _tenantService = tenantService;

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenantHeader = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();

        if (!string.IsNullOrEmpty(tenantHeader))
        {
            var tenant = await _tenantService.ResolveAsync(tenantHeader);
            context.Items["CurrentTenant"] = tenant;
        }

        await next(context);
    }
}

Registration:

C#
// Must register as transient or scoped (not singleton)
builder.Services.AddTransient<TenantResolutionMiddleware>();
app.UseMiddleware<TenantResolutionMiddleware>();

Global Exception Handling Middleware

The most important middleware — catches all unhandled exceptions and returns a structured error response:

C#
public class GlobalExceptionHandlingMiddleware : IMiddleware
{
    private readonly ILogger<GlobalExceptionHandlingMiddleware> _logger;

    public GlobalExceptionHandlingMiddleware(ILogger<GlobalExceptionHandlingMiddleware> logger)
        => _logger = logger;

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var (statusCode, title, detail) = exception switch
        {
            NotFoundException notFound => (
                StatusCodes.Status404NotFound,
                "Resource Not Found",
                notFound.Message
            ),
            ValidationException validation => (
                StatusCodes.Status422UnprocessableEntity,
                "Validation Failed",
                string.Join("; ", validation.Errors.Select(e => e.ErrorMessage))
            ),
            UnauthorizedAccessException => (
                StatusCodes.Status401Unauthorized,
                "Unauthorized",
                "You are not authorized to access this resource."
            ),
            ForbiddenAccessException => (
                StatusCodes.Status403Forbidden,
                "Forbidden",
                "You do not have permission to perform this action."
            ),
            _ => (
                StatusCodes.Status500InternalServerError,
                "Internal Server Error",
                "An unexpected error occurred."
            )
        };

        // Log server errors with full stack; client errors at warning
        if (statusCode >= 500)
            _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
        else
            _logger.LogWarning(exception, "Handled exception: {Message}", exception.Message);

        var problemDetails = new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Detail = detail,
            Instance = context.Request.Path,
            Extensions =
            {
                ["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier
            }
        };

        context.Response.StatusCode = statusCode;
        context.Response.ContentType = "application/problem+json";

        await context.Response.WriteAsJsonAsync(problemDetails);
    }
}
C#
// Program.cs — register FIRST so it catches everything
builder.Services.AddTransient<GlobalExceptionHandlingMiddleware>();
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();

Request/Response Logging Middleware

Logs request bodies and response bodies (for debugging, audit trails):

C#
public class RequestResponseLoggingMiddleware : IMiddleware
{
    private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
    private readonly RequestResponseLoggingOptions _options;

    public RequestResponseLoggingMiddleware(
        ILogger<RequestResponseLoggingMiddleware> logger,
        IOptions<RequestResponseLoggingOptions> options)
    {
        _logger = logger;
        _options = options.Value;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        if (!_options.Enabled)
        {
            await next(context);
            return;
        }

        var requestBody = await ReadRequestBodyAsync(context.Request);

        // Buffer the response so we can read it back
        var originalBody = context.Response.Body;
        using var responseBuffer = new MemoryStream();
        context.Response.Body = responseBuffer;

        await next(context);

        responseBuffer.Seek(0, SeekOrigin.Begin);
        var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync();

        _logger.LogDebug(
            "HTTP {Method} {Path} | Body: {RequestBody} | Response {StatusCode}: {ResponseBody}",
            context.Request.Method,
            context.Request.Path,
            requestBody,
            context.Response.StatusCode,
            responseBody[..Math.Min(responseBody.Length, 500)]  // truncate
        );

        responseBuffer.Seek(0, SeekOrigin.Begin);
        await responseBuffer.CopyToAsync(originalBody);
    }

    private static async Task<string> ReadRequestBodyAsync(HttpRequest request)
    {
        request.EnableBuffering();
        using var reader = new StreamReader(request.Body, leaveOpen: true);
        var body = await reader.ReadToEndAsync();
        request.Body.Position = 0;
        return body;
    }
}

Filters

Filters run inside the MVC pipeline, after routing has matched the endpoint. They have access to model binding results and can short-circuit before the action executes.

Filter Pipeline Order

Authorization Filters
    → Resource Filters (before model binding)
        → Action Filters (before/after action method)
            → Exception Filters (only on exceptions)
            → Result Filters (before/after IActionResult execution)

Action Filter

C#
// Validates the model state before the action executes
public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            var errors = context.ModelState
                .Where(x => x.Value?.Errors.Count > 0)
                .ToDictionary(
                    k => k.Key,
                    v => v.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
                );

            context.Result = new UnprocessableEntityObjectResult(new
            {
                title = "Validation Failed",
                status = 422,
                errors
            });
        }
    }
}
C#
// Apply globally
builder.Services.AddControllers(options =>
    options.Filters.Add<ValidateModelAttribute>()
);

// Or per controller / per action
[ValidateModel]
public class OrdersController : ControllerBase { ... }

Asynchronous Action Filter

C#
public class AuditLogFilter : IAsyncActionFilter
{
    private readonly IAuditService _audit;

    public AuditLogFilter(IAuditService audit) => _audit = audit;

    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        var started = DateTime.UtcNow;

        var executed = await next();  // run the action

        await _audit.LogAsync(new AuditEntry
        {
            UserId = context.HttpContext.User.FindFirst("sub")?.Value,
            Action = context.ActionDescriptor.DisplayName,
            Elapsed = (int)(DateTime.UtcNow - started).TotalMilliseconds,
            StatusCode = context.HttpContext.Response.StatusCode,
            Success = executed.Exception is null,
            Timestamp = started
        });
    }
}

Exception Filter

Handles exceptions thrown by action methods (but not by other middleware):

C#
public class ApiExceptionFilter : IExceptionFilter
{
    private readonly ILogger<ApiExceptionFilter> _logger;

    public ApiExceptionFilter(ILogger<ApiExceptionFilter> logger) => _logger = logger;

    public void OnException(ExceptionContext context)
    {
        if (context.Exception is NotFoundException notFound)
        {
            context.Result = new ObjectResult(new ProblemDetails
            {
                Status = 404,
                Title = "Not Found",
                Detail = notFound.Message
            }) { StatusCode = 404 };

            context.ExceptionHandled = true;
        }
    }
}

Prefer global exception middleware over exception filters for most cases — middleware catches more (including exceptions from middleware and filters themselves).

Result Filter — Response Envelope

Wraps all API responses in a standard envelope:

C#
public class ResponseEnvelopeFilter : IResultFilter
{
    public void OnResultExecuting(ResultExecutingContext context)
    {
        if (context.Result is ObjectResult objectResult
            && context.HttpContext.Response.StatusCode < 400)
        {
            context.Result = new ObjectResult(new
            {
                success = true,
                data = objectResult.Value,
                timestamp = DateTime.UtcNow
            })
            { StatusCode = objectResult.StatusCode };
        }
    }

    public void OnResultExecuted(ResultExecutedContext context) { }
}

Resource Filter — Response Caching

Resource filters run before model binding — ideal for short-circuiting with a cached response:

C#
public class CacheResponseAttribute : Attribute, IResourceFilter
{
    private readonly int _seconds;
    private static readonly Dictionary<string, (DateTime Expiry, IActionResult Result)> _cache = new();

    public CacheResponseAttribute(int seconds = 60) => _seconds = seconds;

    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        var key = context.HttpContext.Request.Path + context.HttpContext.Request.QueryString;

        if (_cache.TryGetValue(key, out var cached) && cached.Expiry > DateTime.UtcNow)
        {
            context.Result = cached.Result;  // short-circuit — skip action
        }
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
        if (context.Result is ObjectResult result)
        {
            var key = context.HttpContext.Request.Path + context.HttpContext.Request.QueryString;
            _cache[key] = (DateTime.UtcNow.AddSeconds(_seconds), result);
        }
    }
}
C#
[HttpGet("{id}")]
[CacheResponse(seconds: 30)]
public async Task<IActionResult> GetProduct(int id) { ... }

Authorization Filter (Custom Policy)

C#
public class RequireAdminFilter : IAuthorizationFilter
{
    public void OnAuthorization(AuthorizationFilterContext context)
    {
        var user = context.HttpContext.User;

        if (!user.Identity?.IsAuthenticated ?? true)
        {
            context.Result = new UnauthorizedResult();
            return;
        }

        if (!user.IsInRole("Admin"))
        {
            context.Result = new ForbidResult();
        }
    }
}

Putting It Together: Correct Order in Program.cs

Middleware order matters — requests flow through top to bottom, responses bottom to top:

C#
var app = builder.Build();

// 1. Exception handling — must be first to catch all errors
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();

// 2. Security headers
app.UseHsts();
app.UseHttpsRedirection();

// 3. Static files — no auth required
app.UseStaticFiles();

// 4. Routing
app.UseRouting();

// 5. CORS — before auth
app.UseCors("AllowFrontend");

// 6. Auth
app.UseAuthentication();
app.UseAuthorization();

// 7. Custom middleware that needs auth context
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseRequestTiming();

// 8. Endpoints
app.MapControllers();
app.MapHealthChecks("/health");

Key Takeaways

  • Middleware runs for every request; filters run only inside the MVC pipeline
  • Use IMiddleware (factory-activated) when you need scoped services — regular convention middleware is a singleton
  • Global exception handling middleware belongs first in the pipeline
  • ActionFilterAttribute is easiest for synchronous filters; IAsyncActionFilter for async work
  • Exception filters only catch exceptions from action methods — exception middleware catches everything
  • Set context.ExceptionHandled = true in exception filters to suppress re-throwing
  • Resource filters run before model binding — the right place for response caching
  • Middleware order in Program.cs is the most common source of auth bugs — routing before CORS before auth is the correct sequence