From The River To The Sea

Day 0: We still remember Gaza

Mohammed's avatar
Mohamed Mostafa

Software Engineer

Building Custom Middleware in ASP.NET Core

Introduction

In ASP.NET Core, middleware is the system that processes incoming HTTP requests and outgoing responses. Each middleware component can:

  • Inspect the request
  • Modify the request
  • Call the next middleware
  • Modify the response

Middleware runs in sequence, forming a structured pipeline. Understanding how to build custom middleware is important for tasks such as logging, authentication, performance tracking, and request transformation.

Understanding Middleware Basics

The request pipeline is made up of multiple middleware components. Each component can run:

  • Before calling next()
  • After the next component finishes

Here is the conceptual flow:

async Task ProcessRequest(HttpContext context)
{
    // Middleware 1 - Before
    await LoggingMiddleware(context, async () => {
        // Middleware 2 - Before  
        await AuthenticationMiddleware(context, async () => {
            // Middleware 3 - Before
            await YourController(context);
            // Middleware 3 - After
        });
        // Middleware 2 - After
    });
    // Middleware 1 - After
}

Implementation Methods

1. Request Delegates (Inline Middleware)

This method is simple and ideal for small features that do not require a separate class.

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        var app = builder.Build();

        app.Use(async (context, next) =>
        {
            var timer = Stopwatch.StartNew();
            var logger = context.RequestServices.GetService<ILogger<Program>>();
            
            logger?.LogInformation("Processing {Method} {Path}", 
                context.Request.Method, context.Request.Path);
            
            await next(context);
            
            timer.Stop();
            var elapsed = timer.ElapsedMilliseconds;

            context.Response.Headers.Add("X-Response-Time", elapsed.ToString());
            logger?.LogInformation("Completed {Method} {Path} in {ElapsedMs}ms", 
                context.Request.Method, context.Request.Path, elapsed);
        });

        app.Use(async (context, next) =>
        {
            if (!context.Request.Headers.ContainsKey("API-Version") &&
                !context.Request.Query.ContainsKey("api-version"))
            {
                context.Request.Headers.Add("API-Version", "1.0");
            }
            
            var version = context.Request.Headers["API-Version"].FirstOrDefault() ??
                         context.Request.Query["api-version"].FirstOrDefault() ?? "1.0";
                         
            context.Items["API-Version"] = version;
            
            await next(context);
        });

        app.Run();
    }
}

When to use:

  • Small features
  • Quick checks
  • Simple logic without many dependencies

2. Convention-Based Middleware (Class + Extension Method)

This method provides structure and reusable code. It supports dependency injection and is easy to test.

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;
    private readonly RequestLoggingOptions _options;

    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger,
        IOptions<RequestLoggingOptions> options)
    {
        _next = next;
        _logger = logger;
        _options = options.Value;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var request = await FormatRequest(context.Request);
        _logger.LogInformation($"Incoming Request: {request}");

        await _next(context);

        var response = await FormatResponse(context.Response);
        _logger.LogInformation($"Outgoing Response: {response}");
    }

    private async Task<string> FormatRequest(HttpRequest request)
    {
        request.EnableBuffering();
        var body = await new StreamReader(request.Body).ReadToEndAsync();
        request.Body.Position = 0;

        return $"{request.Method} {request.Path}{request.QueryString} {body}";
    }

    private async Task<string> FormatResponse(HttpResponse response)
    {
        response.Body.Seek(0, SeekOrigin.Begin);
        var text = await new StreamReader(response.Body).ReadToEndAsync();
        response.Body.Seek(0, SeekOrigin.Begin);

        return $"{response.StatusCode}: {text}";
    }
}

Extension Method

public static class RequestLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(
        this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestLoggingMiddleware>();
    }
}

Usage

app.UseRequestLogging();

When to use:

  • Shared middleware
  • Structured applications
  • Need configuration support

3. Factory-Based Middleware (IMiddleware)

This is the most flexible and testable option. Each request gets its own instance.

public class PerformanceMiddleware : IMiddleware
{
    private readonly ILogger<PerformanceMiddleware> _logger;
    private readonly IMetricsService _metrics;

    public PerformanceMiddleware(
        ILogger<PerformanceMiddleware> logger,
        IMetricsService metrics)
    {
        _logger = logger;
        _metrics = metrics;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var timer = Stopwatch.StartNew();
        var path = context.Request.Path;

        try
        {
            await next(context);
            
            timer.Stop();
            
            await _metrics.RecordMetricAsync(new RequestMetric
            {
                Path = path,
                Method = context.Request.Method,
                Duration = timer.ElapsedMilliseconds,
                StatusCode = context.Response.StatusCode
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Request failed for {Path}", path);
            throw;
        }
    }
}

Registration

services.AddTransient<PerformanceMiddleware>();
app.UseMiddleware<PerformanceMiddleware>();

When to use:

  • Complex logic
  • Full dependency injection
  • Enterprise-scale apps

Best Practices

Error Handling

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    try
    {
        await next(context);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unhandled exception");
        throw;
    }
}

Performance

  • Avoid unnecessary buffering
  • Use async operations
  • Minimize allocations

Configuration Example

public class MiddlewareOptions
{
    public bool EnableLogging { get; set; }
    public string[] ExcludedPaths { get; set; }
    public int TimeoutSeconds { get; set; }
}

services.Configure<MiddlewareOptions>(configuration.GetSection("Middleware"));

Advanced Scenarios

Conditional Middleware

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    if (!_options.ExcludedPaths.Contains(context.Request.Path))
    {
        await ProcessRequest(context);
    }
    
    await next(context);
}

Chaining Multiple Middleware

app.UseMiddleware<AuthenticationMiddleware>()
   .UseMiddleware<LoggingMiddleware>()
   .UseMiddleware<CompressionMiddleware>();

Branching

app.Map("/api", apiApp =>
{
    apiApp.UseMiddleware<ApiVersionMiddleware>();
    apiApp.UseMiddleware<ApiKeyMiddleware>();
});

Middleware Approach Comparison

Approach             Complexity   DI Support     Best Use Case
---------------------------------------------------------------------------
Request Delegates    Low          Limited        Small features, prototypes
Convention-Based     Medium       Good           Reusable components
Factory-Based        High         Excellent      Complex logic, enterprise apps