Are you sure one tiny Use(...)
isn’t silently taxing every request? In most real apps, a single misplaced middleware can add milliseconds to all traffic – and that’s the difference between “snappy” and “why is this slow?”. In this post I’ll show you how to build lean custom middleware for cross‑cutting concerns (logging, localization, multi‑tenancy, correlation IDs), place them correctly, and squeeze more performance out of your pipeline without turning the codebase into spaghetti.
Why middleware first?
Middleware is ASP.NET Core’s conveyor belt. Each request passes through a series of components (the pipeline) until an endpoint handles it. Put a heavy box at the beginning and every item slows down.
Key payoffs of getting middleware right:
- Maintainability: One place for cross‑cutting concerns; controllers/handlers stay focused on domain logic.
- Consistency: The same rule applies to every request (or specific branches).
- Performance: Less duplication, fewer filters/attributes, reduced per‑request overhead when implemented efficiently.
A mental model:

Tip: If a middleware needs routing data (e.g., endpoint metadata), place it after routing and before endpoints.
Two ways to write custom middleware
You can author middleware using either the conventional class pattern or the IMiddleware
(DI‑activated) pattern. Pick one based on lifetime and dependencies.
Conventional class (fast, low ceremony)
public sealed class CorrelationIdMiddleware
{
private static readonly Func<object, string, IDisposable> _scopeFactory =
LoggerMessage.DefineScope<string>("CorrelationId:{CorrelationId}");
private readonly RequestDelegate _next;
private readonly ILogger<CorrelationIdMiddleware> _logger;
public const string HeaderName = "X-Correlation-Id";
public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Read existing or create a new ID
if (!context.Request.Headers.TryGetValue(HeaderName, out var id) || string.IsNullOrWhiteSpace(id))
{
id = Guid.NewGuid().ToString("N");
context.Request.Headers[HeaderName] = id;
}
context.Response.Headers[HeaderName] = id;
using var scope = _scopeFactory(null!, id!);
_logger.LogDebug("Handling request {Method} {Path}", context.Request.Method, context.Request.Path);
await _next(context);
_logger.LogDebug("Finished request {StatusCode}", context.Response.StatusCode);
}
}
public static class CorrelationIdMiddlewareExtensions
{
public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app)
=> app.UseMiddleware<CorrelationIdMiddleware>();
}
When to use: Requires minimal state; you want the absolute simplest, fastest path. Lifetime is implicit (per request) and the class is constructed once with the pipeline.
IMiddleware
(DI‑activated per request)
public sealed class CultureMiddleware : IMiddleware
{
public Task InvokeAsync(HttpContext context, RequestDelegate next)
{
// Priority: query ? header ? default
var culture = context.Request.Query["culture"].FirstOrDefault()
?? context.Request.Headers.AcceptLanguage.ToString().Split(',').FirstOrDefault()
?? "en-US";
try
{
var ci = new System.Globalization.CultureInfo(culture);
System.Globalization.CultureInfo.CurrentCulture = ci;
System.Globalization.CultureInfo.CurrentUICulture = ci;
}
catch { /* ignore invalid culture */ }
return next(context);
}
}
public static class CultureServicesExtensions
{
public static IServiceCollection AddRequestCulture(this IServiceCollection services)
=> services.AddTransient<CultureMiddleware>();
public static IApplicationBuilder UseRequestCulture(this IApplicationBuilder app)
=> app.UseMiddleware<CultureMiddleware>();
}
When to use: You need per-request constructor injection (e.g., scoped services like a tenant store). IMiddleware
instances are resolved from DI on each request.
Building a clean, fast pipeline (minimal hosting)
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
services.AddRequestCulture(); // our IMiddleware
services.AddHttpContextAccessor();
services.AddRouting();
services.AddAuthentication().AddJwtBearer();
services.AddAuthorization();
services.AddResponseCompression();
services.AddResponseCaching();
var app = builder.Build();
// Global error handling (prod)
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseResponseCompression();
app.UseStaticFiles();
app.UseRouting(); // enables endpoint selection data
// Custom cross‑cutting
app.UseCorrelationId(); // conventional middleware
app.UseRequestCulture(); // IMiddleware
app.Use(async (ctx, next) => // tiny inlined guard
{
if (ctx.Request.Headers.ContainsKey("X-Maintenance"))
{
ctx.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await ctx.Response.WriteAsync("Maintenance");
return; // short‑circuit
}
await next();
});
app.UseAuthentication();
app.UseAuthorization();
// Branch by path
app.Map("/health", branch =>
{
branch.Run(async ctx => await ctx.Response.WriteAsync("OK"));
});
app.MapGet("/hello", (HttpContext ctx) =>
{
var culture = System.Globalization.CultureInfo.CurrentCulture;
return Results.Ok(new { Message = $"Hello, {culture.DisplayName}!" });
});
app.Run();
Order cheatsheet:
- Error handling & security headers
- HTTPS redirection, HSTS
- Compression, static files
- Routing
- Your custom middleware that needs route data
- AuthN → AuthZ
- Endpoint mappings (
Map*
) - Response caching (if not handled inside endpoints)
Cross‑cutting recipes you’ll reuse tomorrow
Below are ready‑to‑drop middlewares I’ve used in production. Each is small, predictable, and allocation‑aware.
Request logging (with minimal allocations)
public sealed class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
private static readonly Action<ILogger, string, string, int, Exception?> _after =
LoggerMessage.Define<string, string, int>(
LogLevel.Information,
new EventId(1001, nameof(RequestLoggingMiddleware)),
"{Method} {Path} -> {StatusCode}");
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
_after(_logger, context.Request.Method, context.Request.Path, context.Response.StatusCode, null);
}
}
public static class RequestLoggingExtensions
{
public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
=> app.UseMiddleware<RequestLoggingMiddleware>();
}
Notes:
- Use
LoggerMessage.Define(...)
to avoid boxing/format allocations. - Logging after
next
captures the status code without wrapping the response body.
Correlation ID (propagate through logs & headers)
A reliable correlation ID makes logs and traces stitch together across services. We read an incoming X-Correlation-Id
if present, otherwise generate one, flow it via headers, store it in HttpContext.Items
, and push it into the logging scope and current Activity
.
public interface ICorrelationIdAccessor
{
string? CorrelationId { get; }
}
public sealed class HttpContextCorrelationIdAccessor : ICorrelationIdAccessor
{
private readonly IHttpContextAccessor _http;
public HttpContextCorrelationIdAccessor(IHttpContextAccessor http) => _http = http;
public string? CorrelationId =>
_http.HttpContext?.Items.TryGetValue(CorrelationIdMiddleware.ItemKey, out var v) == true
? v as string
: null;
}
public sealed class CorrelationIdMiddleware
{
public const string HeaderName = "X-Correlation-Id";
public const string ItemKey = "__correlation_id";
private readonly RequestDelegate _next;
private readonly ILogger<CorrelationIdMiddleware> _logger;
private static readonly Func<object, string, IDisposable> _scopeFactory =
LoggerMessage.DefineScope<string>("CorrelationId:{CorrelationId}");
public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{ _next = next; _logger = logger; }
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(HeaderName, out var id) || string.IsNullOrWhiteSpace(id))
{
id = Guid.NewGuid().ToString("N");
context.Request.Headers[HeaderName] = id;
}
// Flow to response & HttpContext
context.Response.Headers[HeaderName] = id;
context.Items[ItemKey] = id.ToString();
// Enrich logs and tracing
using var scope = _scopeFactory(null!, id!);
System.Diagnostics.Activity.Current?.SetTag("correlation_id", id.ToString());
await _next(context);
}
}
public static class CorrelationIdSetup
{
public static IServiceCollection AddCorrelationId(this IServiceCollection services)
=> services.AddSingleton<ICorrelationIdAccessor, HttpContextCorrelationIdAccessor>()
.AddHttpContextAccessor();
public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app)
=> app.UseMiddleware<CorrelationIdMiddleware>();
}
Usage
builder.Services.AddCorrelationId();
app.UseCorrelationId();
You can inject ICorrelationIdAccessor
anywhere (e.g., append to log scopes, cache keys, or outgoing HTTP headers).
Localization via query/header
Keep it simple and deterministic: prefer ?culture=bg-BG
, then Accept-Language
, then a safe default. Cache CultureInfo
instances to avoid repeated allocations.
using System.Globalization;
using System.Collections.Concurrent;
public sealed class CultureMiddleware : IMiddleware
{
private static readonly ConcurrentDictionary<string, CultureInfo> _cache =
new(StringComparer.OrdinalIgnoreCase);
public Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var cultureToken = context.Request.Query["culture"].FirstOrDefault()
?? context.Request.Headers.AcceptLanguage.ToString().Split(',').FirstOrDefault()
?? "en-US";
if (!_cache.TryGetValue(cultureToken, out var culture))
{
try { culture = new CultureInfo(cultureToken); }
catch { culture = CultureInfo.GetCultureInfo("en-US"); }
_cache.TryAdd(cultureToken, culture);
}
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
return next(context);
}
}
public static class CultureSetup
{
public static IServiceCollection AddRequestCulture(this IServiceCollection services)
=> services.AddTransient<CultureMiddleware>();
public static IApplicationBuilder UseRequestCulture(this IApplicationBuilder app)
=> app.UseMiddleware<CultureMiddleware>();
}
Usage
builder.Services.AddRequestCulture();
app.UseRequestCulture();
Requests can force culture via /hello?culture=bg-BG
; otherwise Accept-Language
is used, then the fallback default.
Multi‑tenancy (header/host based)
public interface ITenantAccessor
{
string? TenantId { get; }
}
public sealed class HttpContextTenantAccessor : ITenantAccessor
{
private readonly IHttpContextAccessor _ctx;
public HttpContextTenantAccessor(IHttpContextAccessor ctx) => _ctx = ctx;
public string? TenantId => _ctx.HttpContext?.Items.TryGetValue("tenant", out var v) == true ? v as string : null;
}
public sealed class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next) => _next = next;
public Task InvokeAsync(HttpContext context)
{
// Priority: explicit header > subdomain > path prefix
if (!context.Request.Headers.TryGetValue("X-Tenant", out var tenant))
{
var host = context.Request.Host.Host;
// e.g., acme.app.com -> tenant = acme
var parts = host.Split('.');
if (parts.Length > 2) tenant = parts[0];
}
if (tenant.Count == 0)
{
// try /t/{tenant}/...
var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments?.Length > 1 && string.Equals(segments[0], "t", StringComparison.OrdinalIgnoreCase))
{
tenant = segments[1];
// optionally rewrite path without /t/{tenant}
context.Request.Path = "/" + string.Join('/', segments.Skip(2));
}
}
if (tenant.Count > 0)
context.Items["tenant"] = tenant.ToString();
return _next(context);
}
}
public static class TenantMiddlewareExtensions
{
public static IServiceCollection AddTenantAccessor(this IServiceCollection services)
=> services.AddSingleton<ITenantAccessor, HttpContextTenantAccessor>();
public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
=> app.UseMiddleware<TenantResolutionMiddleware>();
}
Usage: resolve ITenantAccessor
anywhere (db connection factory, cache key prefix, etc.).
Branching: UseWhen
, Map
, MapWhen
Branching prevents “if/else” litter across controllers.
// Map a fixed path segment to a branch
app.Map("/admin", admin =>
{
admin.UseAuthorization(); // extra policy
admin.Run(HandleAdminAsync);
});
// Conditional branch by predicate
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api =>
{
api.UseRateLimiter();
api.Run(HandleApiAsync);
});
// Inline conditional without new branch
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/public"), pub =>
{
pub.UseResponseCaching();
});
Rules of thumb:
Map
matches path prefixes (and trims them for the branch).MapWhen
is arbitrary predicate based.UseWhen
keeps the same path, applies additional middleware when condition is true.
Performance checklist (battle‑tested)
- Do not read the request body unless you must. If you do, enable buffering carefully and reset the stream.
- Avoid sync I/O; await I/O operations. Let Kestrel do its thing.
- Minimize allocations:
- Reuse static
EventId
,LoggerMessage.Define(...)
delegates. - Cache compiled Regex,
CultureInfo
,JsonSerializerOptions
as static/singletons. - Prefer
TryGetValue
over LINQ for hot paths.
- Reuse static
- Short‑circuit early for deny/maintenance/health cases.
- Avoid per-request service lookups when a singleton or options snapshot would do.
- Prefer simple structs or strings for context items (no big objects).
- Return early (don’t wrap streams unless needed).
- Group routes to isolate heavy middleware using
MapGroup
.
Observability: make issues obvious
Add a small diagnostic middleware that surfaces missing headers or tenant.
app.Use(async (ctx, next) =>
{
if (!ctx.Request.Headers.ContainsKey("X-Correlation-Id"))
ctx.Response.Headers.Add("Warning", "199 - Missing X-Correlation-Id");
await next();
});
Pair with structured logging and Application Insights/OpenTelemetry. Once correlation IDs flow, joining traces across services gets much easier.
Testing your middleware
Unit-style test with DefaultHttpContext
[Fact]
public async Task CorrelationId_Is_Added_When_Missing()
{
var logger = new LoggerFactory().CreateLogger<CorrelationIdMiddleware>();
var middleware = new CorrelationIdMiddleware(_ => Task.CompletedTask, logger);
var ctx = new DefaultHttpContext();
await middleware.InvokeAsync(ctx);
Assert.True(ctx.Response.Headers.ContainsKey(CorrelationIdMiddleware.HeaderName));
}
Integration test with WebApplicationFactory
public class PipelineTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public PipelineTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task Culture_Is_Applied()
{
var resp = await _client.GetAsync("/hello?culture=bg-BG");
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Contains("Bulgarian", json.ToString());
}
}
When not to use middleware
- Per-endpoint validation/transformation → use Endpoint Filters (minimal APIs) or MVC Filters.
- Cross-service concerns (retry, circuit breaker) → use HTTP client handlers (e.g.,
HttpClientFactory
policies) rather than server middleware. - Business rules → keep them in the domain/application layer; middleware should remain infrastructure.
Analogy: Middleware is the airport security line – fast checks shared by everyone. It’s not the place to decide where a passenger should vacation.
FAQ: Middleware in the real world
If it needs endpoint metadata/route values, place it after UseRouting
and before mapping endpoints. Otherwise, put it as early as possible to short‑circuit fast.
Use
, Map
, or MapWhen
? Use Map
for path prefix routing, MapWhen
for arbitrary predicates, UseWhen
when you want to stay within the same path and just toggle a few components.
IMiddleware
vs conventional class? Use IMiddleware
when you need scoped DI (e.g., tenant store, user resolver). Conventional is minimal overhead and great for simple, hot‑path logic.
Place it after routing, then read context.GetEndpoint()
and context.Request.RouteValues
.
Many tiny, allocation‑free middlewares are often faster and more maintainable than one monolith. Measure – but don’t fear composition.
Conclusion: Clean pipeline, happier users
A disciplined middleware pipeline turns cross‑cutting chaos into predictable flow: correlation IDs for traceability, culture for a localized UX, tenant resolution for SaaS isolation – all without bloating controllers. Keep components tiny, order them intentionally, and short‑circuit when you can. Your reward? Cleaner code and faster responses.
Which cross‑cutting concern do you plan to move into middleware this week – and where will you place it in the pipeline?