This post is part 2 of a four-part series. You might want to read Part 1 first.

In the last post, we talked about what Middleware is, what it's for, and simple ways of including it in our ASP.NET 6 app's pipeline.

Hydro-electricity piplines above Tarraleah Power Station, Tarraleah, Tasmania.
See, now you get why I'm including pictures of pipelines. Photo by Christian Bass / Unsplash

In this post, we're going to expand on these fundamentals to build a few custom Middleware classes.

The Sample Project

Don't forget to check out the sample project over on GitHub:

GitHub - exceptionnotfound/MiddlewareNET6Demo
Contribute to exceptionnotfound/MiddlewareNET6Demo development by creating an account on GitHub.

The Standard Middleware Architecture

Unlike what we did in Part 1, most of the time we want to have our Middleware be separate classes, and not just additional lines in our Program.cs file.

Remember this middleware from Part 1? The one that returned a response consisting of "Hello Dear Readers!"?

//...Rest of Program.cs

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello Dear Readers!");
});

//...Rest of Program.cs

Let's create a custom middleware class that does the same thing.

Basics of a Middleware Class

Here's a basic empty class we'll use for this middleware:

namespace MiddlewareNET6Demo.Middleware
{
    public class SimpleResponseMiddleware
    {
    }
}

A middleware class consists of three parts. First, any middleware class in ASP.NET 6 must include a private instance of RequestDelegate which is populated by the class's constructor. Remember that RequestDelegate represents the next piece of middleware in the pipeline:

namespace MiddlewareNET6Demo.Middleware
{
    private readonly RequestDelegate _next;

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

Second, the class must have an async method InvokeAsync() which takes an instance of HttpContext as its first parameter.

public class SimpleResponseMiddleware
{
    private readonly RequestDelegate _next;

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

    public async Task InvokeAsync(HttpContext context)
    {
        //...Implementation
    }
}

Third, the middleware must have its own unique implementation. For this middleware, all we want to do is return a custom response:

public class SimpleResponseMiddleware
{
    private readonly RequestDelegate _next;

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

    public async Task InvokeAsync(HttpContext context)
    {
        await context.Response.WriteAsync("Hello Dear Readers!");
    }
}

NOTE: within the InvokeAsync() method, most middleware will have a call to await next(context); any middleware that does not do this will be a terminal middleware, because the pipeline beyond that point will not be run. Since our SimpleResponseMiddleware does, in fact, want to stop the pipeline processing, it does not call await next(context).

Adding Middleware to the Pipeline

At this point, it is possible for us to wire up this middleware class to our app in the Program.cs file using the UseMiddleware<T>() method:

//...Rest of Program.cs

app.UseMiddleware<LayoutMiddleware>();

//...Rest of Program.cs

For very simple middleware classes, this is sufficient. However, often we use extension methods instead of UseMiddleware<T>() because they provide us with another layer of abstraction:

namespace MiddlewareNET6Demo.Extensions
{
    public static class MiddlewareExtensions
    {
        public static IApplicationBuilder UseSimpleResponseMiddleware(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<SimpleResponseMiddleware>();
        }
    }
}

We would then use that extension method like so:

//...Rest of Program.cs

app.UseSimpleResponseMiddleware();

//...Rest of Program.cs

Either way of doing this is correct. I personally prefer the clarity and readability of the extension method route, but you can go with whatever you like better.

Building a Logging Middleware

One of the most common scenarios for middleware is logging, specifically logging of things like the request path or headers, culture, or response body.

The LoggingService Class

We're going to build a logging middleware class that does two things:

  1. Log the request path.
  2. Log the distinct response headers.

First, we must create an interfaceILoggingService and class LoggingService.

namespace MiddlewareNET6Demo.Logging
{
    public class LoggingService : ILoggingService
    {

        public void Log(LogLevel logLevel, string message)
        {
            //...Implementation for logging
        }
    }

    public interface ILoggingService
    {
        public void Log(LogLevel level, string message);
    }
}

We then need to add LoggingService to the Services collection of the app in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddTransient<ILoggingService, LoggingService>();

//...Rest of Program.cs

Middleware classes can have services injected into them, just like normal classes.

The LoggingMiddleware Class

Now we need a LoggingMiddleware class which actually does the logging. First, let's create a skeleton for the class LoggingMiddleware, which accepts an instance of ILoggingService as a parameter in the constructor:

namespace MiddlewareNET6Demo.Middleware
{
    public class LoggingMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILoggingService _logger;

        public LoggingMiddleware(RequestDelegate next, ILoggingService logger)
        {
            _next = next;
            _logger = logger;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            //...Implementation
        }
    }
}

Remember that we are recording the request's path, and the response's unique headers. That means we will have code on either side of the call to await next(context).

namespace MiddlewareNET6Demo.Middleware
{
    public class LoggingMiddleware
    {
        //...Rest of implementation

        public async Task InvokeAsync(HttpContext context)
        {
            //Log the incoming request path
            _logger.Log(LogLevel.Information, context.Request.Path);

            //Invoke the next middleware in the pipeline
            await _next(context);

            //Get distinct response headers
            var uniqueResponseHeaders 
                = context.Response.Headers
                                  .Select(x => x.Key)
                                  .Distinct();

            //Log these headers
            _logger.Log(LogLevel.Information, string.Join(", ", uniqueResponseHeaders));
        }
    }
}

Adding LoggingMiddleware to the Pipeline

Since I prefer to use extension methods to add middleware to the pipeline, let's create a new one:

namespace MiddlewareNET6Demo.Extensions
{
    public static class MiddlewareExtensions
    {
        //...Rest of implementation
    
        public static IApplicationBuilder UseLoggingMiddleware(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<LoggingMiddleware>();
        }
    }
}

Finally, we need to call our new extension method in Program.cs to add the LoggingMiddleware class to the pipeline:

//...Rest of Program.cs

app.UseLoggingMiddleware();

//...Rest of Program.cs

When we run the app, we can see (by setting breakpoints) that the code will correctly log both the request path and the response headers.

Coming Up Next!

In the next post in this series, we discuss the order of operations for Middleware, show a new Middleware that logs execution time, and discuss a few of the common pitfalls that might happen when creating your Middleware pipeline. Stick around!

Happy Coding!