ASP.NET Core’s request pipeline is a powerful concept built entirely around middleware. Think of it as an assembly line for your HTTP requests. Each station on the line is a piece of middleware that can inspect, modify, or act upon the request before passing it to the next station.

While ASP.NET Core provides a rich set of built-in middleware for things like routing, authentication, and static files, there will inevitably come a time when you need to create your own. Whether it’s for custom logging, header manipulation, or a unique authentication scheme, writing custom middleware is a fundamental skill.

In this guide, we’ll break down the process into its simplest form, showing you two minimal ways to create and use your own middleware.


Approach 1: The Convention-Based Class

The most common and structured way to create middleware is by defining a class that follows a specific convention. This approach is clean, reusable, and testable.

A middleware class needs two things:

  1. A constructor that accepts a RequestDelegate parameter. This delegate represents the next piece of middleware in the pipeline.
  2. A public method named InvokeAsync (or Invoke) that accepts an HttpContext as its first parameter. This is the method that gets executed.

Let’s create a simple middleware that logs the incoming request path and the outgoing response status code.

Step 1: Create the Middleware Class

Create a new file named SimpleLoggingMiddleware.cs. We’ll use a primary constructor and inject ILogger for proper logging.

SimpleLoggingMiddleware.cs

public sealed class SimpleLoggingMiddleware(RequestDelegate next, ILogger<SimpleLoggingMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        // Code here runs on the "way in" (request)
        logger.LogInformation("Request starting for path: {Path}", context.Request.Path);

        // This is the crucial part: we call the next middleware in the pipeline.
        // If we didn't call this, the request would stop here.
        await next(context);

        // Code here runs on the "way out" (response)
        logger.LogInformation("Request finished with status code: {StatusCode}", context.Response.StatusCode);
    }
}

The logic is straightforward. We log the path, then await next(context) to pass control down the pipeline. Once all subsequent middleware (including your API endpoint) has finished, execution returns, and we log the response status code.

Step 2: Create a Clean Extension Method

While you can register this in Program.cs with app.UseMiddleware<SimpleLoggingMiddleware>(), the idiomatic ASP.NET Core way is to create a simple extension method. This provides a cleaner, more descriptive API.

SimpleLoggingMiddlewareExtensions.cs

public static class SimpleLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseSimpleLogger(this IApplicationBuilder app)
    {
        return app.UseMiddleware<SimpleLoggingMiddleware>();
    }
}

Approach 2: Inline Middleware with app.Use

For very simple, one-off pieces of logic, creating an entire class can feel like overkill. ASP.NET Core allows you to define middleware directly in your Program.cs using a lambda expression with app.Use.

This approach is perfect for quick prototyping or for logic that is tightly coupled to your application’s startup configuration.

Here’s the same logging logic implemented as an inline middleware:

// In Program.cs
app.Use(async (context, next) =>
{
    var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
    
    logger.LogInformation("[Inline] Request starting for path: {Path}", context.Request.Path);

    await next(context);

    logger.LogInformation("[Inline] Request finished with status code: {StatusCode}", context.Response.StatusCode);
});

This code does the exact same thing as our class but lives directly in the pipeline configuration. The lambda receives the HttpContext and the next delegate as parameters. Notice we have to resolve the ILogger from the service provider (context.RequestServices) since we don’t have constructor injection here.

Callout: Middleware Order is Everything

The most common mistake developers make with middleware is getting the order wrong. The pipeline executes in the exact order you add components with app.Use.... For example, app.UseAuthentication() must come before app.UseAuthorization(). Why? Because you have to know who the user is before you can check what they are allowed to do. Always think critically about where your custom middleware should sit in the pipeline to function correctly. A logger like ours should usually go very early to catch as much of the request as possible.


Putting It All Together in Program.cs

Now, let’s see how to wire up our class-based middleware in a minimal API project.

Program.cs

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// It's common to place logging and error handling middleware early.
app.UseSimpleLogger();

app.MapGet("/", () => $"Hello from a minimal API! Check your console logs.");
app.MapGet("/test", () => Results.Ok("This is a test endpoint."));

app.Run();

When you run this application and navigate to / or /test, you will see output in your console similar to this:

info: SimpleLoggingMiddleware[0]
      Request starting for path: /test
info: SimpleLoggingMiddleware[0]
      Request finished with status code: 200

Conclusion

You now have two effective methods for building custom middleware in ASP.NET Core.

  • For reusable, complex, or testable logic, the convention-based class is the best choice. It keeps your code organized and follows standard dependency injection patterns.
  • For simple, localized, or one-off tasks, the inline app.Use lambda is a fantastic, lightweight alternative.

Understanding how to hook into the request pipeline is a key step in mastering ASP.NET Core. With this knowledge, you can now build components to handle almost any cross-cutting concern your application requires.