Custom Middleware Patterns in ASP.NET Core

ASP.NET Core's request pipeline is built on middleware — components that sit between the server and your application logic, processing requests and responses. The framework ships with middleware for authentication, routing, static files, and more, but the real power comes from writing your own.

Convention-Based Middleware

The simplest way to write middleware is the convention-based approach. The class doesn't implement an interface; it follows a naming convention instead:

RequestTimingMiddleware.cs
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

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

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        context.Response.OnStarting(() =>
        {
            context.Response.Headers["X-Response-Time"] = $"{stopwatch.ElapsedMilliseconds}ms";
            return Task.CompletedTask;
        });

        await _next(context);

        stopwatch.Stop();
        _logger.LogInformation("Request {Method} {Path} completed in {Elapsed}ms",
            context.Request.Method, context.Request.Path, stopwatch.ElapsedMilliseconds);
    }
}

Register it in Program.cs:

Example.cs
app.UseMiddleware<RequestTimingMiddleware>();

Convention-based middleware is resolved once at application startup. The constructor receives the RequestDelegate for the next component in the pipeline, and InvokeAsync is called per request.

Factory-Based Middleware with IMiddleware

When your middleware needs scoped dependencies, convention-based middleware falls short — the constructor is only called once. The IMiddleware interface solves this by resolving the middleware from DI on every request:

TenantMiddleware.cs
public class TenantMiddleware : IMiddleware
{
    private readonly TenantDbContext _dbContext;

    public TenantMiddleware(TenantDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();

        if (tenantId is not null)
        {
            var tenant = await _dbContext.Tenants.FindAsync(tenantId);
            context.Items["Tenant"] = tenant;
        }

        await next(context);
    }
}

You must register IMiddleware implementations in DI:

Example.cs
builder.Services.AddScoped<TenantMiddleware>();

// Then in the pipeline:
app.UseMiddleware<TenantMiddleware>();

Branching the Pipeline

Map and MapWhen let you create pipeline branches. Requests matching the condition follow a separate middleware chain:

Example.cs
app.MapWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    apiApp =>
    {
        apiApp.UseMiddleware<ApiKeyValidationMiddleware>();
        apiApp.UseRouting();
        apiApp.UseEndpoints(endpoints => { /* API endpoints */ });
    });

UseWhen is similar but re-joins the main pipeline afterwards — useful for conditionally adding behaviour without creating a dead end:

Example.cs
app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/admin"),
    adminApp => adminApp.UseMiddleware<AuditLogMiddleware>());

Short-Circuiting

Sometimes you want to stop the pipeline early. Simply don't call next:

Example.cs
public async Task InvokeAsync(HttpContext context)
{
    if (context.Request.Headers.ContainsKey("X-Block"))
    {
        context.Response.StatusCode = StatusCodes.Status403Forbidden;
        await context.Response.WriteAsync("Blocked by middleware");
        return; // Pipeline stops here
    }

    await _next(context);
}

Terminal Middleware with Run

app.Run registers terminal middleware — it never calls next, making it the end of the pipeline:

Example.cs
app.Run(async context =>
{
    await context.Response.WriteAsync("Hello from terminal middleware");
});

Extension Methods for Clean Registration

The convention is to provide a Use* extension method for each middleware:

RequestTimingMiddlewareExtensions.cs
public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestTimingMiddleware>();
    }
}

// Usage:
app.UseRequestTiming();

Middleware Ordering Matters

The order you register middleware defines the order requests pass through them — and responses travel back in reverse. The typical ordering is:

  1. Exception handling
  2. HSTS / HTTPS redirection
  3. Static files
  4. Routing
  5. CORS
  6. Authentication
  7. Authorisation
  8. Custom middleware
  9. Endpoint middleware

Getting this wrong causes subtle bugs. For example, placing authentication after routing means route constraints that depend on the user's identity won't work.

Testing Middleware

Convention-based middleware is straightforward to test. Create a DefaultHttpContext, wire up the middleware with a stub RequestDelegate, and assert:

Example.cs
[Fact]
public async Task RequestTimingMiddleware_SetsResponseHeader()
{
    var context = new DefaultHttpContext();
    var middleware = new RequestTimingMiddleware(
        next: _ => Task.CompletedTask,
        logger: NullLogger<RequestTimingMiddleware>.Instance);

    await middleware.InvokeAsync(context);

    Assert.True(context.Response.Headers.ContainsKey("X-Response-Time"));
}

Custom middleware is one of the most practical extension points in ASP.NET Core. Convention-based middleware suits stateless, singleton-safe scenarios. Factory-based middleware handles scoped dependencies cleanly. Between branching, short-circuiting, and pipeline ordering, you have fine-grained control over how every request flows through your application.