All writing

Building Custom Middleware in ASP.NET Core

Inline delegates, convention-based middleware, and IMiddleware, with examples and dependency lifetime notes.

Updated 7 min read
Examples checked with ASP.NET Core 10.0.12
Text size

Search article sections

Search across the full articles and jump to a matching section. Try “composite cursor”, “IMiddleware”, or “round robin”.

C# to ASP.NET Core · Step 2 of 2

Before you start: C# classes, methods, and collections; Basic HTTP requests and responses.

0 of 2 articles completed

  1. C# Boxing and Unboxing
  2. 3 Ways to Build Custom Middleware in ASP.NET Core (you are here)
View this reading path

Introduction

In ASP.NET Core, MiddlewareA component in an HTTP request pipeline that can run work before and after the next component, or return a response without invoking it.Logging middleware can start a timer, await the next component, and then record the elapsed time.Browse glossary processes HTTP requests and responses through a pipeline.

A middleware component can:

  • Inspect the incoming request
  • Modify request headers or body
  • Decide whether to short-circuit the pipeline
  • Invoke the next middleware
  • Modify the outgoing response

Because middleware sits at the lowest level of the request lifecycle, it is commonly used for:

  • Logging and tracing
  • Authentication and authorization
  • Rate limiting
  • Request validation
  • Performance monitoring
  • Response transformation

Middleware runs on every request, so its design affects maintainability and performance.


Understanding Middleware Basics

ASP.NET Core Middleware Pipeline Sequence
ASP.NET Core Middleware Pipeline Sequence

ASP.NET Core Middleware Pipeline Sequence

ASP.NET Core uses a chain of responsibility model. Each middleware receives a HttpContext and a delegate pointing to the next component in the pipeline.

Each middleware can execute logic in two phases:

  • Before calling next
  • After the next middleware completes

Conceptual Pipeline Flow

C#
async Task ProcessRequest(HttpContext context)
{
    // Middleware A (before)
    await MiddlewareA(context, async () =>
    {
        // Middleware B (before)
        await MiddlewareB(context, async () =>
        {
            // Endpoint execution
            await Controller(context);
        });
        // Middleware B (after)
    });
    // Middleware A (after)
}

Key observations:

  • Middleware order matters
  • Short-circuiting stops downstream execution
  • Exceptions bubble upward unless handled

How Middleware Is Registered

Middleware is registered during application startup using Use, Map, or Run.

  • Use allows calling the next middleware
  • Run terminates the pipeline
  • Map branches the pipeline based on path

Execution order is the same as registration order.


Implementation Methods

ASP.NET Core provides three main ways to build custom middleware. Each serves a different purpose and has different trade-offs.


1. Request Delegates (Inline Middleware)

This is the simplest way to write middleware. Logic is defined inline using a lambda.

C#
app.Use(async (context, next) =>
{
    var timer = Stopwatch.StartNew();
    var logger = context.RequestServices.GetService<ILogger<Program>>();
 
    logger?.LogInformation(
        "Processing {Method} {Path}",
        context.Request.Method,
        context.Request.Path
    );
 
    context.Response.OnStarting(() =>
    {
        context.Response.Headers["X-Response-Time"] =
            timer.ElapsedMilliseconds.ToString();
        return Task.CompletedTask;
    });
 
    await next(context);
 
    timer.Stop();
 
    logger?.LogInformation(
        "Completed {Method} {Path} in {ElapsedMs}ms",
        context.Request.Method,
        context.Request.Path,
        timer.ElapsedMilliseconds
    );
});

This approach is useful for:

  • Simple logging
  • Header manipulation
  • Debugging
  • Rapid prototyping

Register OnStarting before calling next: downstream middleware can start writing the response, after which headers become read-only. The header measures elapsed time until headers are sent; the completion log measures the full downstream duration.

Limitations

  • Hard to test
  • No clear separation of concerns
  • Grows messy as logic increases
  • Limited reuse

Inline middleware should remain small and focused.


2. Convention-Based Middleware (Class + Extension Method)

It consists of:

  • A middleware class
  • A constructor receiving RequestDelegate and dependencies
  • An InvokeAsync method
  • An extension method for registration
C#
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;
    private readonly RequestLoggingOptions _options;
 
    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger,
        IOptions<RequestLoggingOptions> options)
    {
        _next = next;
        _logger = logger;
        _options = options.Value;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        var request = await FormatRequest(context.Request);
        _logger.LogInformation("Incoming Request: {Request}", request);
 
        await _next(context);
 
        var response = await FormatResponse(context.Response);
        _logger.LogInformation("Outgoing Response: {Response}", response);
    }
}

Extension Method

C#
public static class RequestLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(
        this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestLoggingMiddleware>();
    }
}

Usage

C#
app.UseRequestLogging();

Why This Works Well

  • Clear responsibility boundaries
  • Full dependency injection support
  • Configurable via options
  • Easy to unit test
  • Reusable across applications

Use convention-based middleware as the default for most custom middleware.


3. Factory-Based Middleware (IMiddleware)

This approach uses the IMiddleware interface. Each request receives a fresh instance.

C#
public class PerformanceMiddleware : IMiddleware
{
    private readonly ILogger<PerformanceMiddleware> _logger;
    private readonly IMetricsService _metrics;
 
    public PerformanceMiddleware(
        ILogger<PerformanceMiddleware> logger,
        IMetricsService metrics)
    {
        _logger = logger;
        _metrics = metrics;
    }
 
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var timer = Stopwatch.StartNew();
        var path = context.Request.Path;
 
        try
        {
            await next(context);
        }
        finally
        {
            timer.Stop();
 
            await _metrics.RecordMetricAsync(new RequestMetric
            {
                Path = path,
                Method = context.Request.Method,
                Duration = timer.ElapsedMilliseconds,
                StatusCode = context.Response.StatusCode
            });
        }
    }
}

Registration

C#
services.AddTransient<PerformanceMiddleware>();
app.UseMiddleware<PerformanceMiddleware>();

Characteristics

  • Full constructor injection
  • New instance per request
  • Easier unit testing
  • Slightly higher allocation cost

IMiddleware suits components with many dependencies or strict test-isolation requirements.


Middleware Lifetime and DI Behavior

  • Convention-based middleware is created once
  • Dependencies follow their registered lifetimes
  • IMiddleware instances are created per request

Avoid injecting scoped services into singleton middleware unless the middleware itself is scoped via IMiddleware.


Best Practices

Error Handling

Centralized exception handling middleware is common.

C#
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    try
    {
        await next(context);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unhandled exception");
        context.Response.StatusCode = 500;
        await context.Response.WriteAsync("Internal Server Error");
    }
}

This should be placed early in the pipeline.


Performance Considerations

  • Avoid buffering request bodies unless necessary
  • Use async APIs exclusively
  • Minimize allocations
  • Avoid synchronous I/O
  • Be careful with large response interception

Middleware runs on every request. Even small inefficiencies multiply quickly.


Configuration Support

Middleware should be configurable via options.

C#
public class MiddlewareOptions
{
    public bool EnableLogging { get; set; }
    public string[] ExcludedPaths { get; set; }
    public int TimeoutSeconds { get; set; }
}

C#
services.Configure<MiddlewareOptions>(
    configuration.GetSection("Middleware"));

Avoid hardcoded behavior.


Advanced Scenarios

Conditional Execution

C#
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    if (!_options.ExcludedPaths.Contains(context.Request.Path))
    {
        await ProcessRequest(context);
    }
 
    await next(context);
}

This is useful for excluding health checks or static files.


Branching Pipelines

C#
app.Map("/api", apiApp =>
{
    apiApp.UseMiddleware<ApiVersionMiddleware>();
    apiApp.UseMiddleware<ApiKeyMiddleware>();
});

Branching avoids unnecessary middleware execution for unrelated routes.


Ordering Pitfalls

Incorrect ordering can break applications.

Examples:

  • Authentication must run before authorization
  • Exception handling must wrap downstream components
  • Response compression must run before response writing

Pipeline order should be intentional and documented.


Middleware Approach Comparison

ApproachComplexityDI SupportBest Use Case
Request DelegatesLowLimitedSmall logic, quick checks
Convention-BasedMediumGoodReusable, configurable middleware
Factory-BasedHighExcellentMany dependencies or strict test-isolation needs

Summary

Key takeaways:

  • Middleware order defines behavior
  • Keep middleware focused and small
  • Prefer convention-based middleware by default
  • Use IMiddleware for complex, test-heavy scenarios
  • Treat middleware as infrastructure, not business logic

Shared request logic belongs in one place. Middleware can keep that logic out of controllers and apply it consistently across requests.

Check Your Understanding

Try it yourself · optional

Trace the response path

Middleware A is registered before B. Both log before and after awaiting next. The endpoint logs E and completes normally. What is the log order?

Verified Examples

Checked on September 16, 2026 with ASP.NET Core 10.0.12 and .NET 10.0.12, using SDK 10.0.112. A local Kestrel server handled two real HTTP requests to verify delegate ordering, one-time construction of conventional middleware, per-request resolution of transient IMiddleware, and a response timing header registered through OnStarting.

The verification harness is scripts/verify-blog/dotnet/Program.cs in the portfolio repository. The logging and metrics helpers in the article remain application-specific examples.

Keep track of what you’ve read.

C# to ASP.NET Core

0 of 2 articles completed

You’ve reached the end of this path. Explore another.
ShareTwitterLinkedInReddit