1. Where Middleware Fits in the Architecture

Middleware components form the HTTP Request Pipeline in ASP.NET Core. They sit directly between the web server (like Kestrel) and your application’s endpoints (like Minimal APIs or MVC Controllers).

flowchart TD
    Req[HTTP Request] --> Kestrel[Kestrel Web Server]
    
    subgraph Pipeline [Middleware Pipeline]
        Kestrel --> MW1[Middleware 1: Exception Handling]
        MW1 --> MW2[Middleware 2: Authentication]
        MW2 --> CustomMW[Custom Middleware: Timing]
        
        CustomMW --> MapBranch{app.Map /admin}
        MapBranch -->|Matches /admin| AdminEP[Admin Middleware / Endpoint]
        MapBranch -->|Standard Path| Endpoint[Endpoint Routing / Standard API]
        
        AdminEP --> Join[Pipeline Join]
        Endpoint --> Join
    end

    Pipeline --> Resp[HTTP Response]

    %% Outgoing feedback paths
    MW1 -.->|On the way out| Resp
    MW2 -.->|On the way out| Resp
    CustomMW -.->|On the way out| Resp

When an HTTP request arrives, it flows through each registered middleware component sequentially on its way to your endpoint. On the way back out, the response flows back through those same components in reverse order.


2. Core Concepts: RequestDelegate, Use, Run, and Map

RequestDelegate

Under the hood, every middleware is powered by a RequestDelegate, which is defined as:

public delegate Task RequestDelegate(HttpContext context);
 

It takes an HttpContext and returns a Task.

app.Use(), app.Run(), and app.Map()

  • app.Use(): Takes a factory function with the signature Func<RequestDelegate, RequestDelegate>. It receives the next delegate in the pipeline, wraps it, and returns a new delegate. This allows requests to pass through and responses to return.
  • app.Run(): A terminal middleware delegate. It accepts only a RequestDelegate with no next parameter, short-circuiting the pipeline so nothing after it runs.
  • app.Map(): Branches the request pipeline based on matching specific URL paths. If a request path matches the segment, it forks into a separate sub-pipeline containing its own independent configuration.

3. The Mystery: Why Build Backwards at Startup, but Run Forwards at Runtime?

  • At Startup Time (Building the chain): .NET stitches your middleware together backwards (from the bottom up). This is because of the wrapper pattern: Middleware A cannot wrap around Middleware B unless Middleware B already exists and is passed to it as the next parameter.
  • At Runtime (Handling requests): The request flows forwards (top-to-bottom) because each middleware invokes await next(context) when it is ready to pass control down the line.

4. How to Construct a Custom Middleware

There are two main ways to build custom middleware:

  1. Inline Lambda Middleware (Great for quick logic using app.Use)
  2. Class-Based Middleware with an Extension Method (Production-standard best practice)

Let’s use the production-standard class-based approach, which measures request execution time.


5. Working Code Example (REST API + Custom Middleware + app.Map)

Here is a complete, runnable Program.cs file containing a custom performance-timing middleware, an explicit branch via app.Map, and a sample REST API endpoint.

using System.Diagnostics;
 
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
// ==========================================
// 1. REGISTER GLOBAL CUSTOM MIDDLEWARE
// ==========================================
app.UseRequestTiming();
 
// ==========================================
// 2. CONDITIONAL BRANCHING USING app.Map
// ==========================================
app.Map("/admin", adminApp =>
{
    // This sub-pipeline runs ONLY for requests starting with /admin
    adminApp.Run(async context =>
    {
        await context.Response.WriteAsync("Hello from the Admin Dashboard Branch!");
    });
});
 
// ==========================================
// 3. CONFIGURE STANDARD REST API ENDPOINT
// ==========================================
app.MapGet("/api/hello", () =>
{
    // Simulate some work
    Thread.Sleep(100); 
    return Results.Ok(new { message = "Hello from the ASP.NET Core REST API!", timestamp = DateTime.UtcNow });
});
 
app.Run();
 
 
// ==========================================
// 4. CUSTOM MIDDLEWARE IMPLEMENTATION
// ==========================================
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;
 
    // The 'next' parameter represents the next middleware in the pipeline
    public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        // --- CODE ON THE WAY IN ---
        var stopwatch = Stopwatch.StartNew();
        _logger.LogInformation("--> Incoming request: {Path}", context.Request.Path);
 
        // Pass control to the next middleware in the pipeline
        await _next(context);
 
        // --- CODE ON THE WAY OUT ---
        stopwatch.Stop();
        _logger.LogInformation("<-- Outgoing response for {Path} completed in {ElapsedMilliseconds}ms with status {StatusCode}",
            context.Request.Path,
            stopwatch.ElapsedMilliseconds,
            context.Response.StatusCode);
    }
}
 
// ==========================================
// 5. EXTENSION METHOD FOR CLEAN REGISTRATION
// ==========================================
public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestTimingMiddleware>();
    }
}
 

6. How to Test It

  1. Create a new ASP.NET Core Web API project:
dotnet new web -n MiddlewareDemo
cd MiddlewareDemo
 
  1. Replace the contents of Program.cs with the code snippet above.
  2. Run the application:
dotnet run
 
  1. Test the standard REST API endpoint:
curl http://localhost:5000/api/hello
 
  1. Test the branched app.Map endpoint:
curl http://localhost:5000/admin
 
  1. Observe your console logs to see the global timing middleware capture both requests while the /admin path routes into its own isolated branch!

InvokeAsync

InvokeAsync (or just Invoke) is the strict convention required by ASP.NET Core convention-based middleware.

The framework looks for a method named either Invoke or InvokeAsync using reflection when you call app.UseMiddleware<T>(). If it doesn’t find one, it will throw an exception at runtime.

A Quick Note on the Naming:

  • InvokeAsync is preferred because middleware operations are inherently asynchronous (calling await _next(context)).

  • You can technically name it Invoke, but InvokeAsync makes the asynchronous pattern clear and matches standard .NET conventions.


Running Code


1. Why is /admin not showing in Swagger?

Swagger (OpenAPI) relies on Endpoint Routing metadata to discover what APIs exist in your application. It scans for things registered via MapGet, MapPost, or Controllers that expose metadata (like return types, parameters, and routes).

Your /admin route is not showing up because:

  • It uses a raw terminal delegate (app.Run): Instead of registering a standard endpoint, app.Map creates a physical branch in the pipeline that directly processes the HttpContext via a raw RequestDelegate.
  • No Metadata: Because it bypasses the endpoint routing system and directly writes text to the response, Swagger has no metadata to inspect or document. It’s treated as a raw middleware branch rather than a documented API endpoint.

2. Do all middleware create custom extension methods for Use and Map?

No, custom extension methods are entirely optional. They are just “syntactic sugar” to make your Program.cs cleaner.

How it works without an extension method:

Instead of writing a custom extension method like app.UseRequestTiming(), you can always use the built-in generic method provided by ASP.NET Core:

app.UseMiddleware<RequestTimingMiddleware>();
 

Both lines do the exact same thing under the hood. Developers write extension methods like UseRequestTiming() simply because it looks cleaner and hides the generic syntax.

What about Map?

  • You never write custom Map extension methods for your own middleware.
  • Map is a built-in routing feature provided directly by the framework (IApplicationBuilder.Map) used specifically for branching the pipeline based on URL paths.