Writing convention-based ASP.NET Core middleware - part 3

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 HttpContextas a parameter.

All constructor signatures below are valid. The position of the RequestDelegate parameter is not relevant.

public MyMiddleware(RequestDelegate next)
public MyMiddleware(RequestDelegate next, IService service)
public MyMiddleware(ILogger<MyMiddleware> logger, RequestDelegate next, IService service)
Valid constructor signatures

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!

public async Task InvokeAsync(HttpContext context);
public async Task InvokeAsync(HttpContext context, IServiceA serviceA);
public async Task Invoke(HttpContext context);
public async Task Invoke(HttpContext context, IServiceA serviceA);
Valid method signatures

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!

public class Service : IService
{
    private readonly IDependency _dependency; 

    public Service(IDependency dependency) => _dependency = dependency;
}

// Service registration causes captive dependencies
services.AddSingleton<IService, Service>();
services.AddScoped<IDependecy, Dependency>();
Anti-pattern - captive dependency

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).

Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: IService Lifetime: Singleton ImplementationType: Service': Cannot consume scoped service 'IDependency' from singleton 'IService'.)
Exception thrown by the scope validation feature in case of a captive dependency

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.

// Program.cs
services.AddSingleton<IAmSingleton, SingletonService>();
services.AddTransient<IAmTransient, TransientService>();
services.AddScoped<IAmScoped, ScopedService>();

// MyMiddleware.cs
public class MyMiddleware
{
    public readonly RequestDelegate _next;
    public readonly IAmSingleton _singletonService;     
    public readonly IAmTransient _transientService; 

    public MyMiddleware(RequestDelegate next, IAmSingleton service1, IAmTransient service2) 
    {
        _next = next; 
        _singletonService = service1;
        _transientService = service2;
    } 

    public async Task InvokeAsync(HttpContext context, IAmScoped service)
    {
        // ... 
        await _next(context); 
    }
}
Injecting scoped dependencies via the 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.

public static IApplicationBuilder UseMiddleware (IApplicationBuilder app, Type middleware, params object?[] args);

public static IApplicationBuilder UseMiddleware<TMiddleware> (this IApplicationBuilder app, params object?[] args);
Signature UseMiddleware()

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.

// Middleware1.cs
public class Middleware1
{
    private readonly RequestDelegate _next;
    private readonly int _count;
    
    public Middleware1(RequestDelegate next, int count)
    {
        _next = next;
        _count = count;
    } 

    public async Task InvokeAsync(HttpContext context)
    {
        for (var i = 0; i < _count; i++)
            await context.Response.WriteAsync("ASP.NET Core rocks!!\n");
        
        await _next.Invoke(context);
    }
}

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

// Register & configure convention-based middleware
app.UseMiddleware<Middleware1>(3);

// Terminal middleware
app.Run(async context =>
{
    await context.Response.WriteAsync("Terminal middleware\n");
});

app.Run();
Passing configuration parameters

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.

// ApplicationBuilderExtensions.cs 
public static class ApplicationBuilderExtensions
{
    public static IApplicationBuilder UseMyCoolMiddleware(this IApplicationBuilder app, int count)
    {
        return app.UseMiddleware<Middleware1>(count);
    }
}

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

// Register & configure convention-based middleware
app.UseMyCoolMiddleware(count: 3);

// Terminal middleware
app.Run(async context =>
{
    await context.Response.WriteAsync("Terminal middleware\n");
});

app.Run();
Use-Facade

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 or InvokeAsync that takes an HttpContext as the first argument
  • Needs to be thread-safe

Happy hacking! 👨🏻‍💻

Further reading

Introduction to ASP.NET Core middleware - part 1
This article explains the high-level concepts of an ASP.NET Core middleware and is the first in a series of three articles.
Writing inline ASP.NET Core middleware using lambda expressions - part 2
This article is part of a series about ASP.NET Core middleware and explains how inline-middleware can be written using lambda expressions
Writing convention-based ASP.NET Core middleware - part 3
This article is part of a series that explains how to develop convention-based middleware components with ASP.NET Core
Writing factory-based ASP.NET Core middleware - part 4
This article is part of a series that explains how to develop factory-based ASP.NET Core middleware
Testing ASP.NET Core middleware - part 5
Introduction This is the last in a series of posts that explains how ASP.NET Core middleware components can be written. 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