Writing Custom Middleware in ASP.NET Core: Clean Pipeline, Better Performance

ASP.NET Core Custom Middleware: Faster, Cleaner Pipelines

Learn to build and place custom middleware for logging, localization, and multi‑tenancy to speed up ASP.NET Core apps and simplify code.

.NET Development·By amarozka · October 1, 2025

ASP.NET Core Custom Middleware: Faster, Cleaner Pipelines

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:

Middleware 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:

  1. Error handling & security headers
  2. HTTPS redirection, HSTS
  3. Compression, static files
  4. Routing
  5. Your custom middleware that needs route data
  6. AuthN → AuthZ
  7. Endpoint mappings (Map*)
  8. 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.
  • 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

Where should I put my custom middleware?

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.

How do I access route data in middleware?

Place it after routing, then read context.GetEndpoint() and context.Request.RouteValues.

Will lots of small middlewares hurt performance?

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?

Leave a Reply

Your email address will not be published. Required fields are marked *