In the API world, working with middleware is a must. Middleware is a piece of code that executes inside the HTTP pipeline for requests and responses. It can inspect, modify, or short-circuit a request before it reaches the endpoint.

The request pipeline can be divided into three stages:

  1. Inbound processing: when the client sends a request, it runs through the middleware in order, and each one performs its own action, such as exception handling, authentication, authorization, and routing.
  2. Endpoint execution: the request hits the endpoint, which generates the response.
  3. Outbound processing: the response travels the same path in reverse and goes back through the same middleware.
Request and response flow through the middleware pipelineA request passes through exception handling, authentication, and authorization middleware on its way to the endpoint, and the response passes back through them in reverse order.EndpointAuthorizationAuthenticationException handlingClientEndpointAuthorizationAuthenticationException handlingClientRequestnext()next()next()ResponseResponseResponseResponse

And order matters, because middleware can depend on each other, and breaking those dependencies leads to unwanted behavior. Put authorization in front of authentication and every request fails, because no user has been established yet. Authorization also has to run after routing, because it reads the [Authorize] metadata of the matched endpoint.

Middleware order in the sample orders APIRequests pass correlation ID and request timing middleware, then health checks branch off, requests under /api need a valid key, and every remaining request must carry a known tenant before reaching the endpoint.

yes

no

yes

no

yes

no

no

yes

Request

Correlation ID (inline)

Request timing (convention-based)

Path is /health?

200 Healthy (terminal branch)

Path starts with /api?

Valid X-Api-Key? (convention-based)

401 Unauthorized

Known X-Tenant-Id? (IMiddleware)

400 Bad Request

GET /api/orders

The health probe sits before the API key and tenant checks so a load balancer can call it without credentials. Move /health below the tenant middleware and every probe gets a 400.

.NET gives you three ways to create middleware.

Request delegate creation

This is the simplest form of middleware in .NET, and it is created directly inside Program.cs.

app.Use(async (context, next) =>
{
    var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
        ?? Guid.NewGuid().ToString("N");

    context.TraceIdentifier = correlationId;
    context.Response.Headers["X-Correlation-Id"] = correlationId;

    await next(context);
});

The two parameters are mandatory: context holds the HttpContext, and next represents the next delegate in the pipeline.

Code before await next(context); runs before downstream processing; code after it runs when that processing completes. That is also why the response header is set before next: once the endpoint starts writing the body, headers can no longer change.

ASP.NET Core provides several methods for configuration:

  • Use adds middleware that can call the next component or short-circuit.
  • Run adds terminal middleware without a next parameter.
  • Map creates a separate branch based on a request path prefix.
  • UseWhen creates a conditional branch that rejoins the main pipeline unless it short-circuits.
  • MapWhen creates a conditional branch that does not rejoin the main pipeline.
app.Map("/health", health => health.Run(async context =>
{
    await context.Response.WriteAsync("Healthy");
}));

They work well for small logic. For larger or reusable implementations, a dedicated middleware class should be considered.

Convention-based creation

This is the most common way of creating middleware. It’s called convention-based because ASP.NET Core recognizes specific constructor and method signatures without requiring an interface.

The logic is encapsulated in its own class. The constructor receives a RequestDelegate parameter, and InvokeAsync (or Invoke) receives the HttpContext as a parameter, holds the logic, and must always return a Task.

If the middleware short-circuits, it should not call await _next(httpContext);. Otherwise it should.

public sealed class ApiKeyMiddleware
{
    private const string HeaderName = "X-Api-Key";
    private readonly RequestDelegate _next;
    private readonly string _apiKey;

    public ApiKeyMiddleware(RequestDelegate next, IOptions<ApiKeyOptions> options)
    {
        _next = next;
        _apiKey = options.Value.Key;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.Request.Headers[HeaderName] != _apiKey)
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsJsonAsync(new { error = "Missing or invalid API key." });
            return;
        }

        await _next(context);
    }
}

Register it in Program.cs, respecting its order in the pipeline. Here it is combined with UseWhen, so only /api requests need a key:

app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    api => api.UseMiddleware<ApiKeyMiddleware>());

An instance is created when the pipeline is built and reused across requests. IOptions<T> is a singleton, so reading it in the constructor is safe. How this affects scoped services is covered later.

Factory-based creation

This is my preferred way to create middleware in ASP.NET Core, because it provides an explicit interface contract and supports constructor injection of scoped dependencies.

The class must implement the IMiddleware interface, which defines a single method, InvokeAsync, receiving both the HttpContext and the RequestDelegate for the next middleware. Because it’s strongly typed, there’s no risk of a misspelled method name that only fails at runtime.

public sealed class TenantContext
{
    public string? TenantId { get; set; }
}

public sealed class TenantResolutionMiddleware : IMiddleware
{
    private const string HeaderName = "X-Tenant-Id";
    private readonly TenantContext _tenant;
    private readonly ITenantStore _store;

    public TenantResolutionMiddleware(TenantContext tenant, ITenantStore store)
    {
        _tenant = tenant;
        _store = store;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenantId = context.Request.Headers[HeaderName].FirstOrDefault();

        if (string.IsNullOrWhiteSpace(tenantId) || !_store.Exists(tenantId))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new { error = $"Unknown or missing {HeaderName}." });
            return;
        }

        _tenant.TenantId = tenantId;
        await next(context);
    }
}

Registration requires two steps:

  • DI registration: scoped or transient. Scoped matches the lifetime of the TenantContext it depends on.
  • Pipeline registration: using the built-in UseMiddleware<T>().
builder.Services.AddSingleton<ITenantStore, InMemoryTenantStore>();
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<TenantResolutionMiddleware>();

var app = builder.Build();

app.UseMiddleware<TenantResolutionMiddleware>();

app.MapGet("/api/orders", (TenantContext tenant) => new
{
    tenant = tenant.TenantId,
    orders = new[] { "ORD-1001", "ORD-1002" }
});

The endpoint receives the same TenantContext instance the middleware filled in, because both are resolved from the same request scope:

curl -H "X-Api-Key: dev-secret-key" -H "X-Tenant-Id: acme" http://localhost:5199/api/orders
# {"tenant":"acme","orders":["ORD-1001","ORD-1002"]}

When UseMiddleware<T>() detects that the class implements IMiddleware, it uses the registered IMiddlewareFactory to resolve an instance for each request.

There are two things to keep in mind:

  1. If you forget the DI registration, the error isn’t thrown at startup. The app starts, /health still answers, and the first request that reaches the middleware fails with a 500: InvalidOperationException: No service for type 'TenantResolutionMiddleware' has been registered.
  2. You can’t pass extra arguments through UseMiddleware<T>(). Configuration has to come through DI, for example with IOptions<T>.

DI lifetimes and the scoped-service pitfall

The main lifetime difference between convention-based and factory-based middleware is when their instances and dependencies are resolved.

When middleware instances and their dependencies are resolvedConvention-based middleware is created once from the root provider when the pipeline is built, while IMiddleware and scoped services are resolved from the request scope on every request.

Every request: request scope

Scoped TenantContext

IMiddleware instance

InvokeAsync parameters

Pipeline build: once, root provider

Convention-based instance

Constructor dependencies

For scoped dependencies, convention-based middleware should use method injection through InvokeAsync. Constructor injection would resolve the service from the root provider, causing a lifetime mismatch. Here is the incorrect version, a timing middleware that wants to log the tenant:

// Wrong: TenantContext is scoped, but this constructor runs once at startup.
public sealed class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly TenantContext _tenant;

    public RequestTimingMiddleware(RequestDelegate next, TenantContext tenant)
    {
        _next = next;
        _tenant = tenant;
    }

    // ...
}

With scope validation enabled, which is the default in the Development environment, the host refuses to start:

System.InvalidOperationException: Cannot resolve scoped service 'MiddlewareDemo.TenantContext' from root provider.

Without scope validation, which is the Production default, nothing fails at all. The middleware keeps one TenantContext created outside any request, so it never sees the tenant that TenantResolutionMiddleware sets: requests for acme and globex both return 200, and both log tenant none.

The corrected version moves the scoped dependency into InvokeAsync, where ASP.NET Core resolves it from the request scope on every call:

public sealed 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, TenantContext tenant)
    {
        var start = Stopwatch.GetTimestamp();

        await _next(context);

        _logger.LogInformation(
            "{Method} {Path} for tenant {Tenant} returned {StatusCode} in {Elapsed:0.0} ms",
            context.Request.Method,
            context.Request.Path,
            tenant.TenantId ?? "none",
            context.Response.StatusCode,
            Stopwatch.GetElapsedTime(start).TotalMilliseconds);
    }
}

ILogger<T> is a singleton, so it can stay in the constructor. The logged tenant is correct even though timing runs before tenant resolution, because it is read after await _next(context), when TenantResolutionMiddleware has already filled in the same scoped instance.

Factory-based middleware supports constructor injection of scoped services because it is resolved within the request scope, which is why TenantResolutionMiddleware can take TenantContext in its constructor.

Testing middleware

Both class-based styles can be tested directly, without a test server: build a DefaultHttpContext, supply a next delegate, and assert on the response.

[Fact]
public async Task Missing_key_short_circuits_with_401()
{
    var nextCalled = false;
    var middleware = new ApiKeyMiddleware(
        _ => { nextCalled = true; return Task.CompletedTask; },
        Options.Create(new ApiKeyOptions { Key = "test-key" }));
    var context = new DefaultHttpContext();

    await middleware.InvokeAsync(context);

    Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
    Assert.False(nextCalled);
}

[Fact]
public async Task Known_tenant_is_stored_in_scoped_context()
{
    var tenant = new TenantContext();
    var middleware = new TenantResolutionMiddleware(tenant, new InMemoryTenantStore());
    var context = new DefaultHttpContext();
    context.Request.Headers["X-Tenant-Id"] = "acme";

    await middleware.InvokeAsync(context, _ => Task.CompletedTask);

    Assert.Equal("acme", tenant.TenantId);
}

With IMiddleware, the dependencies arrive through the constructor, so the test passes them in like any other class. The inline correlation ID delegate has no class to construct, so it is usually tested through the pipeline instead.

When to use each approach

AspectInline delegatesConvention-basedFactory-based (IMiddleware)
SetupDefine a delegate with app.UseCreate a class and call UseMiddleware<T>()Implement IMiddleware, register in DI, and call UseMiddleware<T>()
ActivationDelegate configured when building the pipelineInstance created when building the pipelineResolved through DI for each request
Scoped dependenciesResolve through context.RequestServicesInject into InvokeAsyncInject through the constructor
Compile-time safetyDelegate signature checked by the compilerInvoke/InvokeAsync found by convention, so mistakes fail at runtimeInterface contract checked by the compiler
TestingUsually tested through the pipelineClass can be tested directly with a supplied next delegateClass can be tested directly with dependencies and a next delegate
Main advantageMinimal setupDedicated, reusable classExplicit contract and request-scoped activation
Main tradeoffLarger implementations clutter Program.csRequires awareness of constructor dependency lifetimesRequires DI registration and resolution per request
Suitable use casesSmall, focused behavior: correlation IDs, health probesReusable middleware with application-lifetime dependencies or method injection: API keys, timingMiddleware that benefits from scoped constructor dependencies: tenant resolution, auditing

Use inline delegates when the behavior is short and easy to understand in the pipeline configuration.

Choose convention-based middleware when you want a dedicated class and are comfortable using method injection for scoped dependencies.

My preference for more substantial implementations is factory-based middleware. The explicit interface and constructor injection make dependencies straightforward to express and test.