DEV Community

Cover image for Middleware in ASP.NET Core
Rhuturaj Takle
Rhuturaj Takle

Posted on

Middleware in ASP.NET Core

Middleware in ASP.NET Core

A deep-dive walkthrough of the ASP.NET Core middleware pipeline — covering the request delegate chain model, why registration order determines execution order and short-circuiting, writing custom middleware both the conventional and IMiddleware-interface ways, the specific dependency-injection lifetime trap middleware introduces, branching the pipeline with Map/MapWhen, how the built-in exception-handling, authentication, and routing middleware actually work, and where middleware ends and endpoint routing begins.


Table of Contents

  1. Introduction
  2. The Pipeline Model: RequestDelegate and the Chain
  3. Order Matters: Registration Order Is Execution Order
  4. app.Use, app.Run, and app.Map
  5. Short-Circuiting the Pipeline
  6. Writing Custom Middleware: The Conventional Approach
  7. Writing Custom Middleware: The IMiddleware Interface
  8. The Middleware Dependency Injection Trap
  9. Branching the Pipeline: Map and MapWhen
  10. Exception-Handling Middleware in Depth
  11. Authentication and Authorization Middleware
  12. Where Middleware Ends and Endpoint Routing Begins
  13. Middleware vs. Filters: Two Different Extension Points
  14. Common Pitfalls
  15. Quick Reference Table
  16. Conclusion

Introduction

Every HTTP request an ASP.NET Core application handles passes through a pipeline of middleware components — small, composable pieces of code, each getting a chance to inspect or modify the request, decide whether to pass it further down the chain, and inspect or modify the response as it flows back out. Logging, exception handling, authentication, routing — all of it is middleware, including the pieces built directly into the framework, and the entire pipeline is really just a chain of delegates calling one another, in an order you control explicitly by how you register them. This guide goes deep on that chain model, the single most important and most frequently misunderstood fact about middleware (registration order is execution order, and it matters enormously), how to write your own middleware correctly — including a dependency-injection lifetime trap that's specific to middleware and genuinely easy to fall into — and how the built-in middleware for exception handling, authentication, and routing actually fits into this same model.

Request  →  [Exception Handling]  →  [Logging]  →  [Authentication]  →  [Routing]  →  [Your Endpoint]
                    ↓                      ↓                ↓                ↓               ↓
Response ←  [Exception Handling]  ←  [Logging]  ←  [Authentication]  ←  [Routing]  ←  [Your Endpoint]

Each middleware runs code BEFORE calling the next one (the "down" arrow),
  and can run code AFTER it returns (the "up" arrow) — a request flows IN,
  and a response flows back OUT, through the SAME chain, in reverse.
Enter fullscreen mode Exit fullscreen mode

1. The Pipeline Model: RequestDelegate and the Chain

A RequestDelegate is the fundamental unit everything else is built from

public delegate Task RequestDelegate(HttpContext context);
Enter fullscreen mode Exit fullscreen mode

This series' Delegates guide covers what a delegate fundamentally is — this is just one specific delegate type, but it's the single most important type in this entire guide: every middleware, at its core, is (or produces) a RequestDelegate — something that takes an HttpContext (carrying the request and the eventual response) and asynchronously does something with it.

Middleware as a function that wraps the next middleware's delegate

// Conceptually, EVERY middleware has this shape: given the NEXT delegate in the chain,
// produce a NEW delegate that does its own work, then (usually) calls `next`
RequestDelegate BuildMiddleware(RequestDelegate next)
{
    return async (HttpContext context) =>
    {
        // code here runs BEFORE the rest of the pipeline
        await next(context); // calls the NEXT middleware in the chain
        // code here runs AFTER the rest of the pipeline has finished (on the way back OUT)
    };
}
Enter fullscreen mode Exit fullscreen mode

This is the actual, literal shape of a middleware component — a function taking the "next" delegate and producing a new one that wraps it. Chaining several of these together, each one's next pointing to the next middleware's delegate, is precisely what builds the pipeline diagram in this guide's introduction: a nested sequence of "do something, call next, do something else" delegates, one inside another.

app.Use is how you register one of these into the chain

app.Use(async (context, next) =>
{
    Console.WriteLine("Before");
    await next(context); // pass control to whatever middleware comes NEXT
    Console.WriteLine("After");
});
Enter fullscreen mode Exit fullscreen mode

This is the most direct, inline way to add a middleware step — app.Use takes exactly the shape from the previous example (a context and a "next" delegate) and registers it into the pipeline, in the order you call app.Use, which is the entire subject of Section 2.


2. Order Matters: Registration Order Is Execution Order

The pipeline is built in EXACTLY the order you register middleware in Program.cs

var app = builder.Build();

app.Use(async (context, next) => { Console.WriteLine("Middleware A - before"); await next(context); Console.WriteLine("Middleware A - after"); });
app.Use(async (context, next) => { Console.WriteLine("Middleware B - before"); await next(context); Console.WriteLine("Middleware B - after"); });
app.Run(async context => { Console.WriteLine("Terminal middleware"); await context.Response.WriteAsync("Hello"); });

// Output for every request, in EXACTLY this order:
// Middleware A - before
// Middleware B - before
// Terminal middleware
// Middleware B - after
// Middleware A - after
Enter fullscreen mode Exit fullscreen mode

This is the single most important mechanical fact in this entire guide, worth internalizing precisely: middleware registered first runs its "before" code first, and (because each middleware wraps everything after it) runs its "after" code last, once every subsequent middleware has finished — this is exactly the same nested-wrapping structure as calling several functions each surrounding a call to the next, and it produces the same "first in, last out" ordering you'd expect from that kind of nesting.

Why this makes registration order a genuine, consequential design decision

// ❌ Wrong order: authentication runs AFTER exception handling has ALREADY passed through,
//    but if something in exception handling itself needed to know WHO the user is, it can't
app.UseAuthentication();
app.UseExceptionHandler("/Error");

// ✅ Correct, standard order: exception handling wraps EVERYTHING, catching failures
//    from authentication itself too, not just from what comes after it
app.UseExceptionHandler("/Error");
app.UseAuthentication();
Enter fullscreen mode Exit fullscreen mode

Because each middleware only sees what happens in the middleware registered after it, getting this order wrong has real, concrete consequences — exception-handling middleware registered too late in the chain won't catch exceptions thrown by anything registered before it; authentication middleware registered too late means anything running before it can't rely on the user being identified yet. This is precisely why ASP.NET Core's own project templates and documentation are opinionated and specific about the recommended order for the built-in middleware (Sections 9-10 cover exception handling and authentication specifically).

The recommended, standard ordering for common built-in middleware

app.UseExceptionHandler("/Error");  // FIRST — needs to wrap everything else to catch their exceptions
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();                    // determines WHICH endpoint will handle this request
app.UseAuthentication();             // WHO is the caller?
app.UseAuthorization();              // ARE they allowed to access the endpoint routing selected?
app.MapControllers();                // the terminal middleware — actually invokes the selected endpoint
Enter fullscreen mode Exit fullscreen mode

This ordering isn't arbitrary — UseRouting() needs to run before UseAuthorization() because authorization needs to know which endpoint was matched (to check its specific authorization requirements, like a [Authorize] attribute); UseAuthentication() needs to run before UseAuthorization() for the obvious reason that you need to know who someone is before deciding what they're allowed to do. Section 11 covers exactly how UseRouting() and the later endpoint-invocation step relate.


3. app.Use, app.Run, and app.Map

app.Use: adds a middleware that CAN call the next one

app.Use(async (context, next) =>
{
    // do work
    await next(context); // continues the pipeline
});
Enter fullscreen mode Exit fullscreen mode

app.Use is the general-purpose registration method — the middleware it registers receives the next delegate and can choose to call it (continuing the pipeline) or not (Section 4 covers exactly what happens if it doesn't).

app.Run: adds a TERMINAL middleware — there's no next at all

app.Run(async context =>
{
    await context.Response.WriteAsync("This is the END of the pipeline — there's no `next` to call");
});
Enter fullscreen mode Exit fullscreen mode

app.Run registers a middleware with no next parameter at all — it's meant to be the final step in the pipeline, always short-circuiting (Section 4) by definition, since there's nothing after it to continue to. Anything registered via app.Use after an app.Run call will never actually execute for requests that reach the app.Run, since the pipeline's chain simply doesn't extend past it.

app.Map: branches the pipeline based on a URL path prefix

app.Map("/admin", adminApp =>
{
    adminApp.Use(async (context, next) => { /* admin-specific middleware */ await next(context); });
    adminApp.Run(async context => await context.Response.WriteAsync("Admin area"));
});

app.Run(async context => await context.Response.WriteAsync("Everything else"));
Enter fullscreen mode Exit fullscreen mode

app.Map creates a genuinely separate branch of the pipeline, active only for requests whose path starts with the given prefix — this is covered in full depth in Section 8, worth knowing here as the third fundamental registration method, alongside Use (continue) and Run (terminate).


4. Short-Circuiting the Pipeline

A middleware that doesn't call next stops the request from going any further

app.Use(async (context, next) =>
{
    if (!context.Request.Headers.ContainsKey("X-Api-Key"))
    {
        context.Response.StatusCode = 401;
        await context.Response.WriteAsync("Missing API key");
        return; // ❌ NOT calling next(context) — the pipeline STOPS here
    }
    await next(context); // only reached if the check passed
});
Enter fullscreen mode Exit fullscreen mode

This is a deliberate, common, and entirely legitimate pattern called short-circuiting: a middleware decides, based on some condition, that the request shouldn't proceed any further — it writes whatever response is appropriate (an error, a redirect, a cached response) and simply doesn't call next, which means every middleware registered after this one, and the eventual endpoint itself, never runs for this specific request at all.

Why this matters for understanding what "the pipeline" actually is

The pipeline isn't a fixed, guaranteed sequence every request marches
  through identically — it's a chain of OPTIONAL continuations, where any
  middleware can choose to stop the chain. Built-in middleware relies on
  this constantly: authentication middleware short-circuits with a 401 if
  no valid credentials are present; a caching middleware might short-
  circuit by returning a cached response directly, never reaching the
  actual endpoint logic at all.
Enter fullscreen mode Exit fullscreen mode

Understanding short-circuiting is what makes sense of why order (Section 2) matters so much — if authentication middleware is going to short-circuit unauthorized requests with a 401, it needs to run early enough in the chain that nothing sensitive (later middleware, the actual endpoint) ever executes for a request that gets short-circuited.

Calling next more than once, or after already writing a response, are both genuine bugs

// ❌ Calling next() twice is a real, if unusual, bug — the DOWNSTREAM pipeline
//    would run TWICE for a single request, which is almost never intended
await next(context);
await next(context); // don't do this

// ❌ Writing to the response and STILL calling next() can cause an
//    "unable to start response, headers already sent" exception if
//    something later in the pipeline also tries to write to the response
context.Response.StatusCode = 404;
await next(context); // risky — later middleware might also try to write
Enter fullscreen mode Exit fullscreen mode

Worth knowing these specific, real anti-patterns — next is meant to be called exactly zero or one times per request, and short-circuiting (not calling it, or calling it and then returning without further writes) should be a clean, deliberate either/or decision, not something ambiguous or double-executed.


5. Writing Custom Middleware: The Conventional Approach

The convention-based pattern: a class with a specific constructor and InvokeAsync shape

public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;

    public RequestTimingMiddleware(RequestDelegate next) => _next = next; // the NEXT delegate, captured ONCE

    public async Task InvokeAsync(HttpContext context) // can ALSO be named "Invoke" — both are recognized
    {
        var stopwatch = Stopwatch.StartNew();
        await _next(context); // continue the pipeline
        stopwatch.Stop();
        Console.WriteLine($"Request took {stopwatch.ElapsedMilliseconds}ms");
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the standard, conventional way to write a reusable custom middleware as its own class rather than an inline lambda — the framework doesn't require implementing any specific interface for this pattern (it works purely by convention: a constructor accepting a RequestDelegate, and a method named Invoke or InvokeAsync accepting an HttpContext) — this is discovered and wired up via reflection when you register it.

Registering a class-based middleware with UseMiddleware<T>

app.UseMiddleware<RequestTimingMiddleware>();
Enter fullscreen mode Exit fullscreen mode

UseMiddleware<T> is what actually instantiates your middleware class and threads it into the pipeline — the framework constructs one instance, passing in the next delegate (and, per Section 7, any other constructor-injectable services), and calls its InvokeAsync for every request that reaches this point in the pipeline.

Additional per-request parameters in InvokeAsync, resolved from DI automatically

public async Task InvokeAsync(HttpContext context, IOrderRepository repository) // injected PER-CALL, not in the constructor
{
    // repository is resolved FRESH for THIS request, from THIS request's scope
    await _next(context);
}
Enter fullscreen mode Exit fullscreen mode

Section 7 explains exactly why this matters, but worth introducing the mechanic here: InvokeAsync (unlike the constructor) can accept additional parameters beyond HttpContext, and the framework resolves these from the current request's DI scope on every single call — this is a deliberate, important design detail this guide's next section covers in full depth.


6. Writing Custom Middleware: The IMiddleware Interface

An alternative, interface-based approach, with explicit DI integration

public class RequestTimingMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next) // `next` is a PARAMETER, not captured in the constructor
    {
        var stopwatch = Stopwatch.StartNew();
        await next(context);
        stopwatch.Stop();
        Console.WriteLine($"Request took {stopwatch.ElapsedMilliseconds}ms");
    }
}

builder.Services.AddTransient<RequestTimingMiddleware>(); // MUST be explicitly registered in the DI container
app.UseMiddleware<RequestTimingMiddleware>();
Enter fullscreen mode Exit fullscreen mode

IMiddleware is a formal interface-based alternative to Section 5's convention-based approach — the key structural difference is that next is passed as a parameter to InvokeAsync directly, rather than captured once in the constructor, and the middleware class itself must be explicitly registered with the DI container (AddTransient, typically) before UseMiddleware<T> can resolve it.

Why IMiddleware exists: it makes the middleware's own dependency-injection lifetime explicit and controllable

Because IMiddleware-based middleware is resolved from the DI container
  EVERY TIME (following whatever lifetime you registered it with —
  Transient, Scoped, or Singleton, per this series' ASP.NET Core
  Dependency Injection guide), you have EXPLICIT control over its
  lifetime — unlike Section 5's convention-based middleware, which is
  ALWAYS constructed ONCE and reused for every request, regardless of
  what you do.
Enter fullscreen mode Exit fullscreen mode

This is the genuine, practical trade-off between the two approaches, and it's directly related to Section 7's DI trap: convention-based middleware (Section 5) is always effectively a singleton (constructed once, at application startup, and reused for every subsequent request) — IMiddleware gives you the choice to register it as Scoped or Transient instead, if that's genuinely what a specific middleware needs.


7. The Middleware Dependency Injection Trap

The critical fact: convention-based middleware is constructed ONCE, like a singleton, regardless of how it's registered

public class MyMiddleware
{
    private readonly IOrderRepository _repository; // ❌ SCOPED service, injected via the CONSTRUCTOR

    public MyMiddleware(RequestDelegate next, IOrderRepository repository) // constructor injection
    {
        _next = next;
        _repository = repository; // this is the EXACT captive dependency problem from this series'
                                    //  ASP.NET Core Dependency Injection guide's Section 7 — just less obvious here
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the single most important, and most easily missed, gotcha specific to middleware: app.UseMiddleware<T>() constructs your middleware class exactly once, at application startup — not once per request. Any dependency injected via the constructor (as opposed to InvokeAsync's parameters, per Section 5's closing example) is therefore resolved exactly once too, at startup — which means injecting a Scoped service via the constructor is precisely this series' ASP.NET Core Dependency Injection guide's captive dependency problem, just occurring implicitly through middleware's own single-construction lifetime rather than an explicit Singleton registration.

Why InvokeAsync's per-call parameters exist specifically to solve this

public class MyMiddleware
{
    private readonly RequestDelegate _next; // safe — RequestDelegate itself has no per-request state issue

    public MyMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context, IOrderRepository repository) // ✅ injected HERE instead
    {
        // `repository` is resolved FRESH, from THIS specific request's scope, on EVERY call
        await _next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is exactly why Section 5 introduced InvokeAsync's additional-parameter capability — parameters on InvokeAsync (beyond HttpContext) are resolved fresh, from the current request's DI scope, on every single invocation, which is the correct, safe way to consume a Scoped (or Transient) service inside convention-based middleware. The rule this produces is simple and worth memorizing: inject Singleton services via the constructor; inject Scoped or Transient services via InvokeAsync's parameters, never the constructor.

The same trap, restated for IMiddleware-based middleware

public class MyMiddleware : IMiddleware
{
    private readonly IOrderRepository _repository; // depends on HOW this class itself was registered

    public MyMiddleware(IOrderRepository repository) => _repository = repository; // constructor injection here is FINE

    public async Task InvokeAsync(HttpContext context, RequestDelegate next) { await next(context); }
}

builder.Services.AddScoped<MyMiddleware>(); // AS LONG AS this is registered Scoped (or Transient), constructor injection is safe
Enter fullscreen mode Exit fullscreen mode

IMiddleware doesn't automatically fix this — it just gives you the tool to fix it correctly: because IMiddleware-based middleware is resolved from DI on every request (rather than constructed once at startup), constructor injection of a Scoped service is safe here, provided you registered the middleware class itself with a matching (Scoped or Transient) lifetime — registering an IMiddleware class as Singleton would reintroduce the exact same trap this section describes.


8. Branching the Pipeline: Map and MapWhen

Map: branches based on a URL path prefix, and the branch's pipeline REPLACES the main one for matching requests

app.Map("/api", apiApp =>
{
    apiApp.UseMiddleware<ApiKeyAuthMiddleware>(); // ONLY runs for requests under /api
    apiApp.Run(async context => await context.Response.WriteAsync("API response"));
});

app.Run(async context => await context.Response.WriteAsync("Non-API response"));
Enter fullscreen mode Exit fullscreen mode

Any request whose path starts with /api is diverted entirely into the branch's own pipeline — it does not also continue through whatever was registered after the app.Map(...) call in the main pipeline; the branch is a genuinely separate, self-contained sub-pipeline. This is useful for applying entirely different middleware sets to different parts of an application (a distinct authentication scheme for an API area versus a cookie-based scheme for a web UI area, for instance).

MapWhen: branches based on an arbitrary predicate, not just a path prefix

app.MapWhen(context => context.Request.Headers.ContainsKey("X-Beta-Feature"), betaApp =>
{
    betaApp.Use(async (context, next) => { /* beta-specific middleware */ await next(context); });
});
Enter fullscreen mode Exit fullscreen mode

MapWhen generalizes Map's path-based branching to any condition you can express as a Func<HttpContext, bool> — a header check, a query string value, anything derivable from the request — making it the more flexible, if slightly more verbose, branching tool when the condition genuinely isn't just "does the path start with X."

Why branches rejoin (or don't) matters for understanding the pipeline's actual shape

Per this guide's Introduction diagram: the overall pipeline is a TREE, not
  strictly a single linear chain, once Map/MapWhen are involved — the main
  pipeline can branch into several separate sub-pipelines, each with its
  own middleware, and a request only ever flows through ONE branch
  (whichever one its path/condition matched), never through multiple
  branches or back into the "trunk" pipeline after matching one.
Enter fullscreen mode Exit fullscreen mode

Worth updating the mental model from this guide's opening diagram slightly: while the common case really is a single linear chain, Map/MapWhen genuinely turns it into a tree structure for applications that need meaningfully different pipelines for different areas — still built from exactly the same RequestDelegate-wrapping mechanism (Section 1), just organized into branches rather than one single sequence.


9. Exception-Handling Middleware in Depth

UseExceptionHandler: catches exceptions from everything registered AFTER it, and re-executes the pipeline against an error path

app.UseExceptionHandler("/Error"); // must be registered EARLY — it needs to wrap everything else (Section 2)

// A minimal API or controller action mapped to "/Error":
app.Map("/Error", errorApp => errorApp.Run(async context =>
{
    var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
    var exception = exceptionFeature?.Error; // the ORIGINAL exception that was caught
    context.Response.StatusCode = 500;
    await context.Response.WriteAsync("An error occurred.");
}));
Enter fullscreen mode Exit fullscreen mode

UseExceptionHandler wraps everything registered after it in a try/catch (conceptually — the real implementation is somewhat more involved, but this is the correct mental model) — on catching an unhandled exception from downstream, it re-executes the request against the configured error path, with the original exception made available via IExceptionHandlerFeature, letting your error-handling logic build an appropriate response without needing to catch exceptions manually in every individual endpoint.

The lambda-based alternative: UseExceptionHandler with inline configuration

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred." });
    });
});
Enter fullscreen mode Exit fullscreen mode

This is functionally equivalent to the path-based form above, just configured inline rather than requiring a separate mapped route — a common, convenient choice for APIs specifically, where the error response is typically a structured JSON payload rather than an HTML error page.

Why this belongs at the very top of the middleware chain, restated with the mechanism now explained

Per Section 2's general ordering guidance, now with the FULL reasoning:
  because UseExceptionHandler only catches exceptions from middleware
  registered AFTER it (per the chain model, Section 1), registering it
  ANYWHERE other than first means exceptions from earlier middleware
  (logging, HTTPS redirection, or a custom middleware registered before
  it) would go entirely UNCAUGHT by this handler.
Enter fullscreen mode Exit fullscreen mode

This closes the loop on Section 2's ordering guidance with the actual mechanical reason behind it — exception-handling middleware's usefulness is directly, structurally limited to whatever comes after it in the chain, which is precisely why the standard convention places it first.


10. Authentication and Authorization Middleware

UseAuthentication: determines WHO is making the request, populating HttpContext.User

app.UseAuthentication(); // examines the request (a cookie, a bearer token, etc.) and sets HttpContext.User accordingly
Enter fullscreen mode Exit fullscreen mode

Authentication middleware doesn't reject anything by itself — its job is purely to identify the caller, if possible, based on whatever credentials the request carries, populating HttpContext.User with a ClaimsPrincipal representing that identity (or an unauthenticated, anonymous principal if no valid credentials were present).

UseAuthorization: determines whether the IDENTIFIED caller is allowed to access the specific endpoint being requested

app.UseAuthorization(); // checks the MATCHED ENDPOINT's [Authorize] requirements against HttpContext.User
Enter fullscreen mode Exit fullscreen mode

Authorization middleware runs after both authentication (it needs to know who the caller is) and routing (Section 11 — it needs to know which specific endpoint was matched, since that's where [Authorize] attributes and their specific requirements, like role or policy requirements, actually live) — if the identified user doesn't satisfy the matched endpoint's requirements, authorization middleware short-circuits (Section 4) the request with a 401 or 403 response.

Why authentication and authorization are genuinely separate middleware, not one combined step

Authentication answers: "who is this?"
Authorization answers: "is THIS specific person allowed to do THIS specific thing?"
Enter fullscreen mode Exit fullscreen mode

This separation mirrors a real, meaningful distinction — a request can be successfully authenticated (the framework knows exactly who's asking) and still be unauthorized (that specific, known person doesn't have permission for this specific action) — keeping these as two distinct middleware steps, each doing one job, is a direct application of the single-responsibility thinking this series' Threading and other guides apply elsewhere, here specifically to the request pipeline's own composition.


11. Where Middleware Ends and Endpoint Routing Begins

UseRouting: matches the request to a specific endpoint, without invoking it yet

app.UseRouting(); // examines the request's path/method, finds the MATCHING endpoint, stores it on HttpContext
Enter fullscreen mode Exit fullscreen mode

UseRouting is itself just another middleware in the chain — its specific job is to look at the incoming request and determine which registered endpoint (a controller action, a minimal API route, a Razor Page) it corresponds to, storing that match on the HttpContext for later middleware (specifically UseAuthorization, per Section 10) to consult, without actually invoking that endpoint yet.

MapControllers/MapGet/etc.: registers the endpoints themselves, and the terminal middleware that actually invokes the matched one

app.MapControllers(); // registers controller-based endpoints
app.MapGet("/hello", () => "Hello, world!"); // registers a minimal API endpoint
Enter fullscreen mode Exit fullscreen mode

These Map* calls (distinct from Section 8's app.Map path-branching method, despite the similar name) do two things: they register the available endpoints for UseRouting to match against, and they collectively serve as the pipeline's terminal middleware — once a request reaches this point having been matched and authorized, it's here that the actual controller action or minimal API delegate is finally invoked.

The genuine distinction: middleware is about the PIPELINE; endpoints are what the pipeline ultimately routes TO

Middleware: cross-cutting concerns applied to EVERY request passing
  through a given point in the chain (logging, auth, exception handling) —
  doesn't know or care about application-specific business logic.
Endpoints (controllers, minimal APIs): the actual, request-specific
  business logic — "handle a GET to /orders/5," specifically.
Enter fullscreen mode Exit fullscreen mode

This is worth stating as the clean conceptual boundary this whole guide has been building toward: middleware handles the concerns that apply broadly, uniformly, across many or all requests, regardless of which specific business operation they're ultimately for; endpoint routing and the endpoints themselves handle the concern that's inherently specific to this request — "what does a GET to /orders/5 actually mean, and what should happen." Everything before UseRouting/MapControllers in the pipeline is cross-cutting; everything the matched endpoint itself does is request-specific.


12. Middleware vs. Filters: Two Different Extension Points

MVC filters: a SIMILAR cross-cutting concept, but scoped specifically to controller/action execution, not the whole pipeline

public class LogActionFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context) => Console.WriteLine("Before action");
    public void OnActionExecuted(ActionExecutedContext context) => Console.WriteLine("After action");
}
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core MVC has its own, separate extensibility mechanism — filters (IActionFilter, IExceptionFilter, IAuthorizationFilter, and others) — which look conceptually similar to middleware (before/after hooks around something) but operate at a narrower scope: specifically around the execution of a controller action, with direct access to MVC-specific context (model binding results, the action's arguments) that raw middleware, operating purely on HttpContext, doesn't have.

When to reach for middleware versus a filter

Middleware: applies to EVERY request reaching this point in the pipeline
  — including ones that never reach an MVC controller at all (a static
  file request, a minimal API endpoint, a request that gets short-
  circuited earlier). The right choice for genuinely cross-cutting,
  framework-level concerns.
Filters: apply SPECIFICALLY to MVC controller actions, with access to
  MVC-specific context (action arguments, the controller instance,
  model-binding/validation results) that middleware simply doesn't have
  visibility into. The right choice when the cross-cutting logic
  genuinely needs that MVC-specific context.
Enter fullscreen mode Exit fullscreen mode

This is a genuine, practical decision point worth understanding rather than treating the two as interchangeable: if the logic needs to run for literally every request regardless of whether it's headed to an MVC controller, or needs to run at a point in the pipeline before routing has even determined an endpoint, middleware is the right tool; if the logic specifically needs to inspect or modify a controller action's bound arguments or its result, a filter is the right, more specifically-scoped tool.


13. Common Pitfalls

Pitfall Why it hurts Better approach
Registering middleware in the wrong order Later middleware may depend on something earlier middleware was supposed to set up (identity, the matched endpoint); exception handling won't catch what came before it Follow the standard, documented ordering (Section 2), and understand why it's ordered that way, not just that it should be
Injecting a Scoped service via a convention-based middleware's CONSTRUCTOR The middleware class is only constructed once, at startup — this is the captive dependency problem occurring implicitly Inject Scoped/Transient dependencies via InvokeAsync's parameters instead, which are resolved fresh per request (Section 7)
Registering an IMiddleware class as Singleton when it needs Scoped dependencies via its constructor Reintroduces the exact same captive dependency trap, just through a different registration path Match the IMiddleware class's own DI registration lifetime to what its constructor dependencies actually need (Section 7)
Forgetting to call next(context) unintentionally The pipeline silently stops — later middleware and the eventual endpoint never run, often producing a confusing, empty or incomplete response with no obvious error Be deliberate about short-circuiting (Section 4) — it should be an intentional decision, never an accidental omission
Writing to the response and then still calling next(context) Risks an "headers already sent" exception if something later in the pipeline also attempts to write Treat writing a response and calling next as mutually exclusive within a single middleware invocation
Assuming UseExceptionHandler catches exceptions from middleware registered before it It only wraps what comes AFTER it in the chain — exceptions from earlier middleware go uncaught Register exception-handling middleware first, or as close to first as genuinely possible (Section 9)
Confusing app.Map (pipeline path-branching) with app.MapGet/MapControllers (endpoint registration) Despite similar names, they do genuinely different things — one branches the middleware pipeline, the others register endpoints for routing to match against Keep the distinction clear: Map/MapWhen branch the middleware chain (Section 8); Map{Verb}/MapControllers register endpoints (Section 11)
Using a filter for logic that needs to apply to non-MVC requests too Filters only run for matched MVC controller actions — a request to a minimal API endpoint or a static file never triggers them Use middleware for genuinely cross-cutting concerns spanning the whole pipeline; reserve filters for MVC-action-specific needs (Section 12)

Quick Reference Table

Concept C# Syntax Purpose
Inline middleware app.Use(async (context, next) => { ... }); Ad hoc middleware registered directly in Program.cs
Terminal middleware app.Run(async context => { ... }); Ends the pipeline; no next to call
Class-based, convention style app.UseMiddleware<MyMiddleware>(); Reusable middleware; constructed ONCE at startup (Section 5, Section 7)
Class-based, IMiddleware style builder.Services.AddScoped<MyMiddleware>(); app.UseMiddleware<MyMiddleware>(); Reusable middleware with explicit, controllable DI lifetime (Section 6)
Path-based branching app.Map("/api", branch => { ... }); Diverts matching requests into a genuinely separate sub-pipeline (Section 8)
Predicate-based branching app.MapWhen(ctx => ..., branch => { ... }); Branches on any condition, not just a path prefix
Exception handling app.UseExceptionHandler("/Error"); Catches unhandled exceptions from everything registered after it (Section 9)
Identify the caller app.UseAuthentication(); Populates HttpContext.User, without itself rejecting anything
Enforce access rules app.UseAuthorization(); Checks the matched endpoint's requirements against the identified caller
Match the request to an endpoint app.UseRouting(); + app.MapControllers(); Determines, then later invokes, the specific business logic this request maps to

Conclusion

Middleware's entire model boils down to one simple, mechanical idea — a chain of delegates, each wrapping the next, executed in exactly the order you register them — but that simplicity is precisely what makes registration order, short-circuiting, and the middleware-specific dependency-injection trap so consequential once you're building anything beyond a trivial pipeline. Understanding that convention-based middleware is constructed exactly once, effectively behaving like a singleton regardless of what you inject into its constructor, is the single most important, most specific piece of knowledge this guide covers — it's the same captive dependency problem this series' ASP.NET Core Dependency Injection guide details, just showing up implicitly through middleware's own construction lifetime rather than an explicit AddSingleton call, which is exactly what makes it easy to introduce without realizing it.

Everything else — Map/MapWhen branching the pipeline into a tree, exception handling needing to sit first to wrap everything else, authentication and authorization as two deliberately separate concerns, and the clean boundary between middleware's cross-cutting role and an endpoint's request-specific business logic — builds on that same chain-of-delegates foundation. Knowing exactly what a RequestDelegate is, and that "the pipeline" is really just nested function calls with an explicit next, is what turns Program.cs's sequence of app.Use... calls from configuration you copy from a template into something you can genuinely reason about and extend correctly.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the middleware-constructor-captured-a-stale-DbContext-at-startup debugging session that made the "middleware is basically a singleton" rule click far better than any documentation note ever could.

Top comments (0)