DEV Community

Cover image for How Many Ways to Create Middleware in ASP.NET Core?
Ravi Vishwakarma
Ravi Vishwakarma

Posted on

How Many Ways to Create Middleware in ASP.NET Core?

Middleware is one of the most important concepts in ASP.NET Core. Whether you are building authentication, logging, exception handling, request validation, performance monitoring, or custom security rules, middleware gives you a place to execute logic while an HTTP request moves through the application pipeline.

In modern ASP.NET Core, there are three common ways to create custom middleware:

  1. Inline middleware using app.Use(...)
  2. Convention-based middleware using a middleware class
  3. Middleware using IMiddleware

ASP.NET Core also provides methods such as Run() and Map() for terminating or branching the request pipeline. These are useful when designing middleware pipelines, but they are not separate custom middleware implementation patterns.

Let's understand each approach with practical examples.


What Is Middleware?

Middleware is software that is assembled into the ASP.NET Core request pipeline.

A request passes through middleware components one after another. Each middleware can:

  • Inspect the request
  • Modify the request
  • Perform some work before the next middleware
  • Call the next middleware
  • Inspect or modify the response
  • Stop the request from continuing

A simple pipeline looks like this:

Client
   |
   v
Middleware 1
   |
   v
Middleware 2
   |
   v
Middleware 3
   |
   v
Endpoint
   |
   v
HTTP Response
Enter fullscreen mode Exit fullscreen mode

A middleware can execute code both before and after the next middleware.

For example:

Request
  |
  v
[Middleware A]  ---> before logic
  |
  v
[Middleware B]  ---> before logic
  |
  v
[Endpoint]
  |
  v
[Middleware B]  ---> after logic
  |
  v
[Middleware A]  ---> after logic
  |
  v
Response
Enter fullscreen mode Exit fullscreen mode

That behavior is one of the main reasons middleware is so powerful.


1. Inline Middleware Using app.Use()

The simplest way to create middleware is to write it directly in Program.cs using app.Use().

This approach is ideal for:

  • Small pieces of logic
  • Prototypes
  • Simple logging
  • Adding headers
  • Quick request checks
  • Logic that is used only once

Example

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

// Inline middleware
app.Use(async (context, next) =>
{
    // This code executes BEFORE the next middleware.
    Console.WriteLine($"Request: {context.Request.Method} {context.Request.Path}");

    // Call the next middleware in the pipeline.
    await next();

    // This code executes AFTER the next middleware has completed.
    Console.WriteLine($"Response Status Code: {context.Response.StatusCode}");
});

app.MapGet("/", () =>
{
    return "Hello from ASP.NET Core!";
});

app.Run();
Enter fullscreen mode Exit fullscreen mode

Here, the lambda supplied to app.Use() becomes part of the request pipeline.

How it works

Suppose the browser requests:

GET /
Enter fullscreen mode Exit fullscreen mode

The middleware executes this code first:

Console.WriteLine($"Request: {context.Request.Method} {context.Request.Path}");
Enter fullscreen mode Exit fullscreen mode

Then:

await next();
Enter fullscreen mode Exit fullscreen mode

passes control to the next component.

After the next component finishes, execution returns to:

Console.WriteLine($"Response Status Code: {context.Response.StatusCode}");
Enter fullscreen mode Exit fullscreen mode

So the execution flow is:

Request
   |
   v
Log Request
   |
   v
Next Middleware / Endpoint
   |
   v
Log Response
   |
   v
Response
Enter fullscreen mode Exit fullscreen mode

Adding a Custom Response Header

An inline middleware can also modify the response.

app.Use(async (context, next) =>
{
    // Add a custom HTTP response header.
    context.Response.Headers["X-Application"] = "My ASP.NET Core App";

    // Continue processing the request.
    await next();
});
Enter fullscreen mode Exit fullscreen mode

Now the response contains:

X-Application: My ASP.NET Core App
Enter fullscreen mode Exit fullscreen mode

Advantages of Inline Middleware

Inline middleware is:

  • Very easy to write
  • Convenient for small tasks
  • Good for application-specific logic
  • Requires no additional class

Disadvantages

For large applications, too much middleware inside Program.cs can make the file difficult to maintain.

For example, this becomes difficult to manage:

app.Use(async (context, next) =>
{
    // 100 lines of logic...
});

app.Use(async (context, next) =>
{
    // Another 150 lines...
});
Enter fullscreen mode Exit fullscreen mode

When middleware becomes reusable or complex, a separate middleware class is usually a better choice.


2. Convention-Based Middleware Using a Middleware Class

The second and most common approach is to create a dedicated middleware class.

Microsoft's ASP.NET Core documentation describes this as convention-based middleware. Typically, the middleware class receives a RequestDelegate and exposes an Invoke or InvokeAsync method.

This approach is ideal when:

  • Middleware contains significant logic
  • Middleware should be reusable
  • You want a clean Program.cs
  • You want dependency injection for services used during request processing

Step 1: Create the middleware class

Create a file called:

RequestLoggingMiddleware.cs
Enter fullscreen mode Exit fullscreen mode

Then add:

public class RequestLoggingMiddleware
{
    // _next represents the next middleware in the pipeline.
    private readonly RequestDelegate _next;

    // ASP.NET Core passes the next RequestDelegate through the constructor.
    public RequestLoggingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    // InvokeAsync is called for every request that reaches this middleware.
    public async Task InvokeAsync(HttpContext context)
    {
        // Code before _next() runs on the way INTO the pipeline.
        Console.WriteLine(
            $"Incoming Request: {context.Request.Method} {context.Request.Path}");

        // Call the next middleware.
        await _next(context);

        // Code after _next() runs on the way OUT of the pipeline.
        Console.WriteLine(
            $"Response Status: {context.Response.StatusCode}");
    }
}
Enter fullscreen mode Exit fullscreen mode

The key elements are:

private readonly RequestDelegate _next;
Enter fullscreen mode Exit fullscreen mode

and:

public async Task InvokeAsync(HttpContext context)
Enter fullscreen mode Exit fullscreen mode

The _next delegate is what allows your middleware to pass control to the next component.


Registering the Middleware

In modern ASP.NET Core, you can add it using:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

// Add the custom middleware to the request pipeline.
app.UseMiddleware<RequestLoggingMiddleware>();

app.MapGet("/", () => "Hello World!");

app.Run();
Enter fullscreen mode Exit fullscreen mode

UseMiddleware<TMiddleware>() adds the middleware type to the application's request pipeline.


Creating an Extension Method

A very common practice is to create an extension method for your middleware.

Create:

RequestLoggingMiddlewareExtensions.cs
Enter fullscreen mode Exit fullscreen mode
public static class RequestLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(
        this IApplicationBuilder app)
    {
        // Register our custom middleware.
        return app.UseMiddleware<RequestLoggingMiddleware>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now Program.cs becomes cleaner:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

// Register custom middleware through the extension method.
app.UseRequestLogging();

app.MapGet("/", () => "Hello World!");

app.Run();
Enter fullscreen mode Exit fullscreen mode

This style is especially useful in larger applications and reusable libraries.


Injecting Services into Convention-Based Middleware

One of the powerful features of ASP.NET Core middleware is dependency injection.

For example, suppose we want to use ILogger<T>.

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;

    public RequestLoggingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(
        HttpContext context,
        ILogger<RequestLoggingMiddleware> logger)
    {
        // ILogger is injected into InvokeAsync.
        logger.LogInformation(
            "Request started: {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        // Continue to the next middleware.
        await _next(context);

        // Log after the next middleware has completed.
        logger.LogInformation(
            "Request completed with status code {StatusCode}",
            context.Response.StatusCode);
    }
}
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core supports dependency injection into the middleware invocation method for convention-based middleware. This can be useful when you need request-scoped services.


Important: Middleware Constructor Lifetime Considerations

A common mistake is injecting a scoped service directly into the constructor of conventional middleware.

For example:

public class MyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly MyScopedService _service;

    public MyMiddleware(
        RequestDelegate next,
        MyScopedService service) // Be careful with scoped dependencies here.
    {
        _next = next;
        _service = service;
    }
}
Enter fullscreen mode Exit fullscreen mode

Middleware instances activated by the conventional UseMiddleware pattern are not created in the same per-request manner as IMiddleware.

For request-scoped services, a safer pattern is often to inject the service into InvokeAsync():

public async Task InvokeAsync(
    HttpContext context,
    MyScopedService service)
{
    // The scoped service is available during the request.
    service.DoSomething();

    await _next(context);
}
Enter fullscreen mode Exit fullscreen mode

This distinction becomes especially important when middleware depends on scoped services such as a database context. Microsoft's documentation specifically highlights per-request activation as a benefit of IMiddleware.


3. Middleware Using IMiddleware

The third approach is to implement the IMiddleware interface.

This is particularly useful when you want:

  • Strongly typed middleware
  • Middleware activated through dependency injection
  • Per-request middleware activation
  • Constructor injection of scoped services

Microsoft documents IMiddleware as a factory-based middleware activation mechanism. IMiddleware is activated per request, allowing scoped dependencies to be injected into the constructor.


Creating an IMiddleware

Let's create a middleware called:

AuthenticationMiddleware.cs
Enter fullscreen mode Exit fullscreen mode
public class AuthenticationMiddleware : IMiddleware
{
    private readonly IUserService _userService;

    // IUserService can be injected through the constructor.
    public AuthenticationMiddleware(IUserService userService)
    {
        _userService = userService;
    }

    public async Task InvokeAsync(
        HttpContext context,
        RequestDelegate next)
    {
        // Read the user ID from a request header.
        var userId = context.Request.Headers["X-User-Id"]
            .FirstOrDefault();

        // Check whether the user exists.
        var userExists = await _userService.UserExistsAsync(userId);

        if (!userExists)
        {
            // Stop the pipeline when authentication fails.
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;

            await context.Response.WriteAsync("Unauthorized");

            return;
        }

        // User is valid, so continue to the next middleware.
        await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the important difference.

We don't need:

private readonly RequestDelegate _next;
Enter fullscreen mode Exit fullscreen mode

Instead, the next delegate is supplied to:

InvokeAsync(
    HttpContext context,
    RequestDelegate next)
Enter fullscreen mode Exit fullscreen mode

The interface requires the request-handling method:

Task InvokeAsync(HttpContext context, RequestDelegate next)
Enter fullscreen mode Exit fullscreen mode

according to the ASP.NET Core IMiddleware contract.


Registering IMiddleware with Dependency Injection

Unlike a typical convention-based middleware class, an IMiddleware implementation must be registered in the dependency injection container.

For example:

var builder = WebApplication.CreateBuilder(args);

// Register the dependent service.
builder.Services.AddScoped<IUserService, UserService>();

// Register the middleware.
// Scoped or transient registration is appropriate for IMiddleware.
builder.Services.AddTransient<AuthenticationMiddleware>();

var app = builder.Build();

// Add the middleware to the pipeline.
app.UseMiddleware<AuthenticationMiddleware>();

app.MapGet("/", () => "Hello World!");

app.Run();
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core's middleware factory resolves IMiddleware implementations from the dependency injection container and creates them for the request. Microsoft documents Scoped or Transient registration for factory-activated middleware.


IMiddleware vs Convention-Based Middleware

At first glance, these two approaches look very similar.

Convention-based middleware

public class LoggingMiddleware
{
    private readonly RequestDelegate _next;

    public LoggingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        await _next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

IMiddleware

public class LoggingMiddleware : IMiddleware
{
    public async Task InvokeAsync(
        HttpContext context,
        RequestDelegate next)
    {
        await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

The major difference is how the middleware instance is activated.

With IMiddleware, ASP.NET Core uses IMiddlewareFactory and the dependency injection container to create the middleware instance. This provides per-request activation and makes constructor injection of scoped services possible.


4. What About Run()?

You may also see:

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello World!");
});
Enter fullscreen mode Exit fullscreen mode

Is this another way to create middleware?

Technically, Run() adds a terminal request delegate to the pipeline, but it is usually better described as a pipeline termination mechanism, not a separate custom middleware implementation pattern.

For example:

app.Use(async (context, next) =>
{
    Console.WriteLine("Before terminal middleware");

    await next();

    Console.WriteLine("This will not run because Run() is terminal.");
});

app.Run(async context =>
{
    // This is the final handler.
    await context.Response.WriteAsync("Request handled here.");
});
Enter fullscreen mode Exit fullscreen mode

Run() does not call the next component because it is terminal.

So:

Use()
 |
 v
Run()
 |
 v
Response
Enter fullscreen mode Exit fullscreen mode

rather than:

Use()
 |
 v
Use()
 |
 v
Endpoint
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core's middleware documentation describes Run as a terminal delegate in the pipeline.


5. What About Map()?

Another useful method is:

app.Map("/admin", adminApp =>
{
    adminApp.Run(async context =>
    {
        await context.Response.WriteAsync(
            "This is the admin pipeline.");
    });
});
Enter fullscreen mode Exit fullscreen mode

Map() creates a branch in the middleware pipeline based on the request path.

For example:

                    +--> /admin --> Admin Pipeline
                    |
Request --> Main Pipeline
                    |
                    +--> Other Paths --> Main Pipeline
Enter fullscreen mode Exit fullscreen mode

A complete example:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.Use(async (context, next) =>
{
    Console.WriteLine("Main pipeline");

    await next();
});

// Create a separate middleware pipeline for /admin.
app.Map("/admin", adminApp =>
{
    adminApp.Use(async (context, next) =>
    {
        // This middleware runs only for /admin requests.
        Console.WriteLine("Admin pipeline");

        await next();
    });

    adminApp.Run(async context =>
    {
        // Final response for the /admin branch.
        await context.Response.WriteAsync("Admin area");
    });
});

app.Run(async context =>
{
    // Requests that don't use the /admin branch end up here.
    await context.Response.WriteAsync("Main application");
});
Enter fullscreen mode Exit fullscreen mode

Map() is therefore best thought of as a pipeline branching mechanism, rather than a fourth custom middleware class pattern.


Comparison of the Three Main Middleware Approaches

Approach Best For Dependency Injection Reusable Complexity
app.Use() Small/simple logic Yes Low Very Low
Middleware class Reusable business/application middleware Yes High Medium
IMiddleware DI-heavy/request-scoped middleware Excellent High Medium

Which Approach Should You Use?

Use app.Use() for small logic

For example:

app.Use(async (context, next) =>
{
    // Simple request logging.
    Console.WriteLine(context.Request.Path);

    await next();
});
Enter fullscreen mode Exit fullscreen mode

This is perfect when the middleware is short and only needed in one location.


Use a middleware class for reusable middleware

For example:

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Custom exception handling logic.
        await _next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is generally the best default for reusable custom middleware.


Use IMiddleware when constructor injection and request activation matter

For example:

public class TenantMiddleware : IMiddleware
{
    private readonly ITenantService _tenantService;

    public TenantMiddleware(ITenantService tenantService)
    {
        // The tenant service can be injected through DI.
        _tenantService = tenantService;
    }

    public async Task InvokeAsync(
        HttpContext context,
        RequestDelegate next)
    {
        // Resolve the current tenant.
        await _tenantService.ResolveTenantAsync(context);

        // Continue the request pipeline.
        await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is particularly useful when middleware depends on scoped services and you want the middleware itself to be resolved by the DI container per request.


Middleware Execution Order Matters

One of the most important things to understand is that middleware executes in the order in which it is added.

Consider:

app.Use(async (context, next) =>
{
    Console.WriteLine("Middleware A - Before");

    await next();

    Console.WriteLine("Middleware A - After");
});

app.Use(async (context, next) =>
{
    Console.WriteLine("Middleware B - Before");

    await next();

    Console.WriteLine("Middleware B - After");
});

app.Run(async context =>
{
    Console.WriteLine("Endpoint");

    await context.Response.WriteAsync("Hello");
});
Enter fullscreen mode Exit fullscreen mode

The output will be:

Middleware A - Before
Middleware B - Before
Endpoint
Middleware B - After
Middleware A - After
Enter fullscreen mode Exit fullscreen mode

This happens because middleware forms a nested request/response pipeline.

Think of it like this:

A Before
   |
   +---- B Before
            |
            +---- Endpoint
            |
          B After
   |
A After
Enter fullscreen mode Exit fullscreen mode

Therefore, middleware order can affect authentication, authorization, exception handling, routing, CORS, static files, caching, and other parts of the application.


A Real-World Example

Suppose we want to add a correlation ID to every request.

Using a middleware class:

public class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Try to read an existing correlation ID.
        var correlationId =
            context.Request.Headers["X-Correlation-Id"]
                .FirstOrDefault();

        // Generate one if the client did not provide it.
        if (string.IsNullOrWhiteSpace(correlationId))
        {
            correlationId = Guid.NewGuid().ToString();
        }

        // Store the ID in HttpContext so other components can access it.
        context.Items["CorrelationId"] = correlationId;

        // Add the correlation ID to the response.
        context.Response.Headers["X-Correlation-Id"] = correlationId;

        // Continue processing the request.
        await _next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it:

var app = builder.Build();

// Add correlation ID middleware early in the pipeline.
app.UseMiddleware<CorrelationIdMiddleware>();

app.MapControllers();

app.Run();
Enter fullscreen mode Exit fullscreen mode

Now every request can have a consistent correlation ID, which is useful for logging and distributed tracing.


A Simple Mental Model

A good way to remember ASP.NET Core middleware is:

Middleware = Request + Logic + Next + Response
Enter fullscreen mode Exit fullscreen mode

Conceptually:

public async Task InvokeAsync(
    HttpContext context,
    RequestDelegate next)
{
    // 1. Logic before the next middleware

    await next(context);

    // 2. Logic after the next middleware
}
Enter fullscreen mode Exit fullscreen mode

And if you don't call next():

public async Task InvokeAsync(
    HttpContext context,
    RequestDelegate next)
{
    // Stop the pipeline.
    context.Response.StatusCode = 403;

    await context.Response.WriteAsync("Forbidden");

    // No next() call means later middleware won't execute.
}
Enter fullscreen mode Exit fullscreen mode

This is how middleware can act as a gatekeeper.


Final Answer: How Many Ways?

For practical ASP.NET Core development, there are three primary ways to create custom middleware:

1. Inline middleware

app.Use(async (context, next) =>
{
    // Middleware logic
    await next();
});
Enter fullscreen mode Exit fullscreen mode

Best for small, one-off pieces of logic.

2. Convention-based middleware class

public class MyMiddleware
{
    private readonly RequestDelegate _next;

    public MyMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Middleware logic
        await _next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Registered with:

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

Best for reusable custom middleware.

3. IMiddleware

public class MyMiddleware : IMiddleware
{
    public async Task InvokeAsync(
        HttpContext context,
        RequestDelegate next)
    {
        // Middleware logic
        await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Registered through dependency injection:

builder.Services.AddTransient<MyMiddleware>();
Enter fullscreen mode Exit fullscreen mode

Then:

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

Best when middleware needs DI-driven activation and especially when constructor injection of scoped services is important.

Methods such as Run() and Map() are also important when building the pipeline, but they are better understood as terminal and branching pipeline operations, not additional custom middleware implementation styles.

Conclusion

ASP.NET Core middleware is flexible because you can choose the implementation style according to the complexity of your application.

For quick logic, use:

app.Use(...)
Enter fullscreen mode Exit fullscreen mode

For reusable custom middleware, create a middleware class and use:

app.UseMiddleware<MyMiddleware>()
Enter fullscreen mode Exit fullscreen mode

For DI-driven middleware with per-request activation, implement:

IMiddleware
Enter fullscreen mode Exit fullscreen mode

Once you understand HttpContext, RequestDelegate, next(), middleware ordering, and the difference between conventional and factory-based activation, you can build everything from simple logging middleware to sophisticated authentication, exception handling, tenant resolution, request tracing, and security components.

In most real-world applications, a good rule is: start with app.Use() for very small middleware, move to a dedicated middleware class as the logic grows, and consider IMiddleware when dependency-injection lifetime management is an important part of the design.


Happy Coding!

Top comments (1)

Collapse
 
anna_hajare_9520a4c4d15ee profile image
Anna Hajare

Excellent article! You covered the three primary approaches perfectly.

When it comes to Convention-Based Middleware, do you have a personal preference between injecting scoped services into the InvokeAsync method versus completely switching over to the IMiddleware interface? I usually find myself leaning toward IMiddleware just to keep constructor injection consistent across the app.