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:
- Inline middleware using
app.Use(...) - Convention-based middleware using a middleware class
- 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
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
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();
Here, the lambda supplied to app.Use() becomes part of the request pipeline.
How it works
Suppose the browser requests:
GET /
The middleware executes this code first:
Console.WriteLine($"Request: {context.Request.Method} {context.Request.Path}");
Then:
await next();
passes control to the next component.
After the next component finishes, execution returns to:
Console.WriteLine($"Response Status Code: {context.Response.StatusCode}");
So the execution flow is:
Request
|
v
Log Request
|
v
Next Middleware / Endpoint
|
v
Log Response
|
v
Response
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();
});
Now the response contains:
X-Application: My ASP.NET Core App
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...
});
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
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}");
}
}
The key elements are:
private readonly RequestDelegate _next;
and:
public async Task InvokeAsync(HttpContext context)
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();
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
public static class RequestLoggingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestLogging(
this IApplicationBuilder app)
{
// Register our custom middleware.
return app.UseMiddleware<RequestLoggingMiddleware>();
}
}
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();
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);
}
}
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;
}
}
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);
}
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
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);
}
}
Notice the important difference.
We don't need:
private readonly RequestDelegate _next;
Instead, the next delegate is supplied to:
InvokeAsync(
HttpContext context,
RequestDelegate next)
The interface requires the request-handling method:
Task InvokeAsync(HttpContext context, RequestDelegate next)
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();
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);
}
}
IMiddleware
public class LoggingMiddleware : IMiddleware
{
public async Task InvokeAsync(
HttpContext context,
RequestDelegate next)
{
await next(context);
}
}
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!");
});
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.");
});
Run() does not call the next component because it is terminal.
So:
Use()
|
v
Run()
|
v
Response
rather than:
Use()
|
v
Use()
|
v
Endpoint
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.");
});
});
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
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");
});
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();
});
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);
}
}
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);
}
}
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");
});
The output will be:
Middleware A - Before
Middleware B - Before
Endpoint
Middleware B - After
Middleware A - After
This happens because middleware forms a nested request/response pipeline.
Think of it like this:
A Before
|
+---- B Before
|
+---- Endpoint
|
B After
|
A After
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);
}
}
Register it:
var app = builder.Build();
// Add correlation ID middleware early in the pipeline.
app.UseMiddleware<CorrelationIdMiddleware>();
app.MapControllers();
app.Run();
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
Conceptually:
public async Task InvokeAsync(
HttpContext context,
RequestDelegate next)
{
// 1. Logic before the next middleware
await next(context);
// 2. Logic after the next middleware
}
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.
}
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();
});
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);
}
}
Registered with:
app.UseMiddleware<MyMiddleware>();
Best for reusable custom middleware.
3. IMiddleware
public class MyMiddleware : IMiddleware
{
public async Task InvokeAsync(
HttpContext context,
RequestDelegate next)
{
// Middleware logic
await next(context);
}
}
Registered through dependency injection:
builder.Services.AddTransient<MyMiddleware>();
Then:
app.UseMiddleware<MyMiddleware>();
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(...)
For reusable custom middleware, create a middleware class and use:
app.UseMiddleware<MyMiddleware>()
For DI-driven middleware with per-request activation, implement:
IMiddleware
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)
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
InvokeAsyncmethod versus completely switching over to theIMiddlewareinterface? I usually find myself leaning towardIMiddlewarejust to keep constructor injection consistent across the app.