Strongly Typed Middleware in ASP.NET Core

Most ASP.NET Core developers are familiar with the traditional way to author middleware classes which is based on conventions. The vast majority of examples out there feature the convention-based approach. But there is also a strongly typed approach available which is based on implementing the IMiddleware interface. This seems to be one of ASP.NET Core's best kept secrets, so I thought I'd pull the curtains back and let some light shine on it.

First, let's remind ourselves how conventions-based middleware works. The two conventions that must be applied to a conventions-based middleware class are to declare a constructor that takes a RequestDelegate as a parameter representing the next middleware in the pipeline, and a method named Invoke or InvokeAsync that returns a Task and has at least one parameter, the first being an HttpContext.

The following code illustrates a middleware class that implements these conventions and logs the value of the visitor’s IP address:

public class ConventionalIpAddressMiddleware
{
    private readonly RequestDelegate _next;
    public ConventionalIpAddressMiddleware(RequestDelegate next) => _next = next;
 
    public async Task InvokeAsync(HttpContext context, ILogger<ConventionalIpAddressMiddleware> logger)
    {
        var ipAddress = context.Connection.RemoteIpAddress;
        logger.LogInformation("Visitor is from {ipAddress}", ipAddress);
        await _next(context);
    }
}
  • The constructor takes a RequestDelegate as a parameter
  • The InvokeAsync method returns a Task and has an HttpContext as a first parameter. Any additional services are injected into the Invoke/InvokeAsync method after the HttpContext
  • Processing happens within the InvokeAsync method
  • The RequestDelegate is invoked, passing control to the next middleware in the pipeline

The middleware class is added to the pipeline via the generic UseMiddleware method on the IApplicationBuilder:

app.UseMiddleware<ConventionalIpAddressMiddleware>();

In this particular case, you will probably want to register this middleware after the static files middleware so that it doesn't log the IP address for the same visitor for every file that is requested.

Middleware that follows the conventions-based approach is created as a singleton when the application first starts up, which means that there is only one instance created for the lifetime of the application. This instance is used for every request that reaches it. If your middleware relies on scoped or transient dependencies, you must inject them via the Invoke method so that they are resolved each time the method is called by the framework. If you inject them via the constructor, they will be captured as singletons. The logger in this example is itself a singleton, so it can be provided via the Invoke method or the constructor.

Implementing IMiddleware

The alternative approach to writing middleware classes involves implementing the IMiddleware interface. The IMiddleware interface exposes one method:

Task InvokeAsync(HttpContext context, RequestDelegate next)

Here is the IpAddressMiddleware implemented as IMiddleware:

public class IMiddlewareIpAddressMiddleware : IMiddleware
{
    private readonly ILogger<IMiddlewareIpAddressMiddleware> _logger;
 
    public IMiddlewareIpAddressMiddleware(ILogger<IMiddlewareIpAddressMiddlewarelogger)
    {
        _logger = logger;
    }
    public async Task InvokeAsync(HttpContext contextRequestDelegate next)
    {
        var ipAddress = context.Connection.RemoteIpAddress;
        _logger.LogInformation("Visitor is from {ipAddress}"ipAddress);
        await next(context);
    }
}
  • The middleware class implement IMiddleware the interface
  • Dependencies are injected into the constructor
  • InvokeAsync takes an HttpContext and a RequestDelegate as parameters

The InvokeAsync implementation is very similar to the one that was written using the conventions based approach, except that this time the parameters are an HttpContext and a RequestDelegate. Any services that the class depends on are injected via the middleware class constructor, so fields are required to hold instances of the injected service. This middleware is registered in exactly the same way as the conventions-based example, via the UseMiddleware methods or an extension method:

app.UseMiddleware<IMiddlewareIpAddressMiddleware>();
But an additional step is also required for IMiddleware based components - they must also be registered with the application’s service container. In this example, the middleware is registered with a scoped lifetime.
builder.Services.AddScoped<IMiddlewareIpAddressMiddleware>();

So why are there two different ways to create middleware classes, and which one should you use? Well, the convention-based approach requires that you learn the specific conventions and remember them. There is no compile time checking to ensure that your middleware implements the conventions correctly. This approach is known as weakly-typed. Typically, the first time you discover that you forgot to name your method Invoke or InvokeAsync, or that the first parameter should be an HttpContext will be when you try to run the application and it falls over. If you are anything like me, you often find that you have to refer back to documentation to remind yourself of the convention details, especially if you don't author middleware that often.

The second approach results in strongly typed middleware because you have to implement the members of the IMiddleware interface otherwise the compiler complains and your application won’t even build. So the IMiddleware approach is less error prone and potentially quicker to implement, although you do have to take the extra step of registering the middleware with the service container.

There is another difference between the two approaches. I mentioned earlier that convention-based middleware is always instantiated as a singleton when the pipeline is first built. IMiddleware components are retrieved from the service container and instantiated whenever they are needed by a component that implements the IMiddlewareFactory interface and this difference has ramifications for services that the middleware depends on, based on their registered lifetime. In this example, the middleware has a scoped lifetime, so it can safely accept scoped services via its constructor.

It should be pointed out that the majority of existing framework middleware is authored using the convention-based approach. This is mainly because most of it was written before IMiddleware was introduced in .NET Core 3.0. Having said that, there is no indication that the framework designers feel any need to migrate existing middleware to IMiddleware.