Writing convention-based ASP.NET Core middleware - part 3
Introduction
This is part three in a series of articles that explains how middleware for ASP.NET Core can be written. We have already discovered how lambda expressions can be used in conjunction with the Run
, Map
and Use
extension methods to develop middleware.
Articles in this series
Part 1 - Introduction to ASP.NET Core middleware
Part 2 - Writing inline ASP.NET Core middleware using lambda expressions
Part 3 - Writing convention-based ASP.NET Core middleware
Part 4 - Writing factory-based ASP.NET Core middleware
Part 5 - Testing ASP.NET Core middleware
This approach allows us to develop simple components quickly. However, it is limited, and with more elaborated middleware components, you will want to outsource them into separate classes.
Let's now have a closer look at how to do that with convention-based middleware.
Convention-based middleware
Let's start with a simple example that plays Rock, Paper, Scissors with a caller by adding a custom HTTP header X-Rochambeau-Outcome
to every response.
public class RochambeauMiddleware
{
private readonly RequestDelegate _next;
public RochambeauMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var items = new string[] { "rock", "paper", "scissors" };
var result = items[new Random().Next(items.Length)];
context.Response.Headers.Add("X-Rochambeau-Outcome", result);
await _next.Invoke(context);
}
}
The convention
As seen from the example above, a convention-based middleware doesn't have to derive from a base class or implement an interface. Instead, it has to follow a specific convention. This convention dictates a public constructor that expects a RequestDelegate
. Also, there must be a public method that takes an HttpContext
as a parameter.
All constructor signatures below are valid. The position of the RequestDelegate
parameter is not relevant.
Regarding the signature of the public method, the variants below are valid. In contrast to the constructor, the position of the parameters is of relevance and the HttpContext
must come first!
Adhering to this convention is important, as ASP.NET Core uses reflection to instantiate these middleware components, which makes them more flexible and allows for method dependency injection. This wouldn't be possible with an implemented interface or an overwritten method from a base class. Such contracts must be strictly fulfilled, and injecting dependencies via Invoke
resp. InvokeAsync
wouldn't be possible.
☝🏼 Although a convention-based middleware doesn't have to derive from a base class, it still can do so! As long as the class fulfills the convention, ASP.NET Core recognizes it as a middleware component and instantiates it.
Lifetime of a convention-based middleware
It's important to highlight that convention-based middleware components are registered as singletons with the DI container. A short excursion...
The configured lifetime defines whether a DI container returns a new or an existing instance of a type. ASP.NET Core knows three different lifetimes, these are:
- Singleton: For each request, the DI container returns the same instance.
- Scoped: The DI container returns the same instance within a defined scope only
- Transient: For each request, the DI container returns a new instance
The fact that such middleware components are registered as singletons poses a pitfall, as no scoped dependencies should be introduced via the constructor. Doing so would represent an anti-pattern, which Mark Seeman describes as captive dependencies. Services with longer lifetimes capture dependencies with shorter ones and thus artificially extend their lifetime!
However, you are on the safe side if you are using Microsoft's built-in DI container, as it comes with a feature called scope validation. This feature checks for captive dependencies and throws an appropriate exception (assuming the application runs in a development environment).
Transient dependencies introduced via the constructor are less problematic. At least when they are stateless. Nevertheless, it is more consistent to inject them by the public method. Scoped & transient dependencies should only be introduced via the Invoke
or InvokeAsync
method.
Further, it's important to note that convention-based methods must be thread-safe, as concurrent network requests could cause multiple threads to access a middleware instance.
Registration & Configuration
Finally, the middleware component needs to be added to the request pipeline. One of the two UseMiddleware
methods can be used for this task.
The following example demonstrates its usage
// Middleware1.cs
public class Middleware1
{
private readonly RequestDelegate _next;
public Middleware1(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
await context.Response.WriteAsync("Middleware1: Incoming\n");
await _next.Invoke(context);
await context.Response.WriteAsync("Middleware1: Outgoing\n");
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Register convention-based middleware
app.UseMiddleware<Middleware1>();
// Register in-line middleware
app.Use(async (context, next) =>
{
await context.Response.WriteAsync("Middleware2: Incoming\n");
await next.Invoke(context);
await context.Response.WriteAsync("Middleware2: Outgoing\n");
});
// Terminal middleware
app.Run(async context =>
{
await context.Response.WriteAsync("Terminal middleware\n");
});
app.Run();
By looking closer at the UseMiddleware
signature, you'll notice its optional args
parameter. This can be used to pass arguments to the middleware constructor. These can be primitive datatypes, as well as more complex configuration objects.
Suppose you plan to provide your middleware to other developers, e.g., as a NuGet package. In that case, you'll want to include an extension method that follows the established conventions of the existing ASP.NET Core built-in middleware components.
Conclusion
As we have seen, convention-based middleware components help to keep your code organized and clean. In general, these types of middleware components should be used if they don't need any dependencies (or you ensure they have singleton lifetime).
Let me further summarize the most important aspects:
- Lifetime singleton - beware of captive dependencies
- Preferably inject dependencies via method
- Instantiated due reflection, so no manual registration is required
- Requires a public constructor that accepts a
RequestDelegate
- Requires a public method named
Invoke
orInvokeAsync
that takes anHttpContext
as the first argument - Needs to be thread-safe
Happy hacking! 👨🏻💻