.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] ← HandlerEach 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
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 routesThe golden rule: exceptions first, static files second, routing, then CORS, then auth.
Use vs Run vs Map
// 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.
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:
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.
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.
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.
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
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:
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