.NET & C# Development · Lesson 10 of 92

Build Custom Middleware That Logs Every Incoming Request

What Is Middleware

Middleware is a chain of components that each HTTP request passes through. Every component can:

  • Inspect the request before passing it along
  • Short-circuit (return a response without calling the next component)
  • Inspect and modify the response on the way back out
Request  →  [Middleware 1]  →  [Middleware 2]  →  [Middleware 3]  →  Handler
Response ←  [Middleware 1]  ←  [Middleware 2]  ←  [Middleware 3]  ←  Handler

Each middleware calls await _next(context) to pass control to the next one. Code before that call runs on the way in. Code after runs on the way out.


Built-In Middleware — Order Matters

C#
var app = builder.Build();

// Order is critical — wrong order breaks auth, CORS, and routing
app.UseExceptionHandler("/error");   // catch exceptions from everything below
app.UseHttpsRedirection();
app.UseStaticFiles();                // serve wwwroot before routing kicks in
app.UseRouting();
app.UseCors("AllowFrontend");        // after routing, before auth
app.UseAuthentication();             // sets User principal
app.UseAuthorization();              // checks User against policies
app.UseRateLimiter();
app.MapControllers();                // terminal — handles matched routes

The golden rule: exceptions first, static files second, routing, then CORS, then auth.


Use vs Run vs Map

C#
// Use — calls next middleware (non-terminal)
app.Use(async (context, next) =>
{
    Console.WriteLine($"Before: {context.Request.Path}");
    await next(context);                     // pass control forward
    Console.WriteLine($"After: {context.Response.StatusCode}");
});

// Run — terminal, never calls next
app.Run(async context =>
{
    await context.Response.WriteAsync("Hello from terminal middleware");
    // nothing after this runs
});

// Map — branches the pipeline based on path prefix
app.Map("/health", branch =>
{
    branch.Run(async ctx => await ctx.Response.WriteAsync("Healthy"));
});

// MapWhen — branch based on any condition
app.MapWhen(
    ctx => ctx.Request.Headers.ContainsKey("X-Internal"),
    branch => branch.UseMiddleware<InternalRequestMiddleware>());

Convention-Based Middleware Class

The simplest form. No interface needed — the framework finds InvokeAsync by convention.

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

    // Constructor injection — ONLY singleton services here
    // (middleware is constructed once, like a singleton)
    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger)
    {
        _next   = next;
        _logger = logger;
    }

    // InvokeAsync can accept scoped services as parameters (resolved per request)
    public async Task InvokeAsync(HttpContext context)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();

        try
        {
            await _next(context);
        }
        finally
        {
            sw.Stop();
            _logger.LogInformation(
                "{Method} {Path} {QueryString} → {StatusCode} in {Elapsed}ms",
                context.Request.Method,
                context.Request.Path.Value,
                context.Request.QueryString.Value,
                context.Response.StatusCode,
                sw.ElapsedMilliseconds);
        }
    }
}

// Register in Program.cs
app.UseMiddleware<RequestLoggingMiddleware>();

IMiddleware Interface — Scoped-Friendly

Convention-based middleware is effectively a singleton. If you need scoped services, implement IMiddleware:

C#
public class RequestAuditMiddleware : IMiddleware
{
    private readonly IAuditRepository _audit;  // Scoped service — safe here
    private readonly ICurrentUserService _user;

    // Constructor injection of scoped services works because
    // IMiddleware instances are resolved per-request
    public RequestAuditMiddleware(
        IAuditRepository audit,
        ICurrentUserService user)
    {
        _audit = audit;
        _user  = user;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        await next(context);

        // Only audit authenticated requests
        if (context.User.Identity?.IsAuthenticated == true)
        {
            await _audit.RecordAsync(new AuditEntry
            {
                UserId     = _user.UserId,
                Method     = context.Request.Method,
                Path       = context.Request.Path,
                StatusCode = context.Response.StatusCode,
                Timestamp  = DateTimeOffset.UtcNow,
                IpAddress  = context.Connection.RemoteIpAddress?.ToString()
            });
        }
    }
}

// IMiddleware must be registered as a service
builder.Services.AddScoped<RequestAuditMiddleware>();
app.UseMiddleware<RequestAuditMiddleware>();

Full Request/Response Logging Middleware

Logs method, path, status code, elapsed time, and client IP. Safe for production.

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

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

    public async Task InvokeAsync(HttpContext context)
    {
        var request   = context.Request;
        var startTime = DateTimeOffset.UtcNow;
        var sw        = System.Diagnostics.Stopwatch.StartNew();

        _logger.LogDebug(
            "HTTP {Method} {Path} started from {ClientIp}",
            request.Method,
            request.Path,
            GetClientIp(context));

        try
        {
            await _next(context);
        }
        finally
        {
            sw.Stop();

            var statusCode = context.Response.StatusCode;
            var level = statusCode >= 500 ? LogLevel.Error
                      : statusCode >= 400 ? LogLevel.Warning
                      : LogLevel.Information;

            _logger.Log(level,
                "HTTP {Method} {Path} → {StatusCode} in {ElapsedMs}ms | " +
                "Client: {ClientIp} | User: {UserId}",
                request.Method,
                request.Path.Value,
                statusCode,
                sw.ElapsedMilliseconds,
                GetClientIp(context),
                context.User.Identity?.Name ?? "anonymous");
        }
    }

    private static string GetClientIp(HttpContext context)
    {
        // Respect forwarded headers (reverse proxy / load balancer)
        return context.Request.Headers["X-Forwarded-For"].FirstOrDefault()
            ?? context.Connection.RemoteIpAddress?.ToString()
            ?? "unknown";
    }
}

Short-Circuiting the Pipeline

Return a response without calling the next middleware. Useful for health checks, feature flags, maintenance mode.

C#
public class MaintenanceModeMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration _config;

    public MaintenanceModeMiddleware(RequestDelegate next, IConfiguration config)
    {
        _next   = next;
        _config = config;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var maintenanceEnabled = _config.GetValue<bool>("App:MaintenanceMode");

        if (maintenanceEnabled)
        {
            // Short-circuit — don't call _next
            context.Response.StatusCode  = 503;
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsJsonAsync(new
            {
                title   = "Service Unavailable",
                message = "The service is temporarily down for maintenance.",
                status  = 503
            });
            return;  // pipeline stops here
        }

        await _next(context);  // normal flow
    }
}

Correlation ID Middleware

Attach a unique ID to every request so you can trace it across logs.

C#
public class CorrelationIdMiddleware
{
    private const string HeaderName = "X-Correlation-Id";

    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        // Use incoming correlation ID or generate a new one
        var correlationId = context.Request.Headers[HeaderName].FirstOrDefault()
            ?? Guid.NewGuid().ToString("N");

        // Make it available to the rest of the pipeline
        context.Items["CorrelationId"] = correlationId;

        // Echo it back in the response
        context.Response.OnStarting(() =>
        {
            context.Response.Headers[HeaderName] = correlationId;
            return Task.CompletedTask;
        });

        await _next(context);
    }
}

// Read the correlation ID anywhere in the pipeline
public class OrderService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public OrderService(IHttpContextAccessor httpContextAccessor)
        => _httpContextAccessor = httpContextAccessor;

    public void DoWork()
    {
        var correlationId = _httpContextAccessor.HttpContext?.Items["CorrelationId"] as string;
        // Use in logs, downstream HTTP calls, etc.
    }
}

Complete Middleware Registration Order

C#
var app = builder.Build();

// 1. Catch exceptions from all middleware below
app.UseMiddleware<ExceptionHandlerMiddleware>();

// 2. Attach correlation ID early so all logs include it
app.UseMiddleware<CorrelationIdMiddleware>();

// 3. Log every request
app.UseMiddleware<RequestLoggingMiddleware>();

// 4. Maintenance mode gate
app.UseMiddleware<MaintenanceModeMiddleware>();

// 5. Built-in pipeline
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

// 6. Audit authenticated requests (IMiddleware, registered as scoped)
app.UseMiddleware<RequestAuditMiddleware>();

// 7. Route to controllers
app.MapControllers();
app.MapHealthChecks("/health");

app.Run();

Extension Methods for Clean Registration

Wrap your middleware registration in extension methods to keep Program.cs clean:

C#
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
        => app.UseMiddleware<RequestLoggingMiddleware>();

    public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app)
        => app.UseMiddleware<CorrelationIdMiddleware>();

    public static IApplicationBuilder UseMaintenanceMode(this IApplicationBuilder app)
        => app.UseMiddleware<MaintenanceModeMiddleware>();
}

// Program.cs — clean and readable
app.UseRequestLogging();
app.UseCorrelationId();
app.UseMaintenanceMode();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

What to Learn Next

  • Serilog: Replace basic ILogger with structured logging that powers your middleware logs
  • Global Error Handling: Build a production-grade exception middleware with ProblemDetails
  • Rate Limiting: Add the built-in .NET 7+ rate limiter to your pipeline