Welcome, dear readers, to a brand new series about middleware in .NET 6!

We're going to talk about what middleware is, what it does, why we use it, and demo several implementations of various kinds of middleware. We'll also talk about the pipeline that middleware exists in, how to create it, and why the order of operations in that pipeline matters. Finally, we'll even show two ways to conditionally execute middleware in the pipeline to give you a finer-grain control of what your app does.

Old rusty waterpipe near Melitopol'
Photo by Rodion Kutsaev / Unsplash

Let's get started!

The Sample Project

As with all of my code-focused posts, there's a sample project hosted on GitHub that demonstrates the ideas in this post. You can check it out here:

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

Middleware Basics

At its most fundamental, any given interaction using HTTP is comprised of a request (usually from a browser or API) and a response. Browsers, APIs, or other requestors submit a request and wait for the target (a web server, another API, something else) to return a response.

Middleware sits between the requestor and the target, and can directly modify the response, log things, or generally modify the behavior of the code that generates the response, optionally using the data within the request to do so.

Take a look at this diagram:

Image courtesy of the ASP.NET Docs

ASP.NET 6 implements a pipeline consisting of a series of middleware classes. A request filters down the pipeline until it reaches a point where a middleware class (or something invoked by that middleware) creates a response. The response is then filtered back up through the middleware in reverse order until it reaches the requestor.

Each middleware component consists of a request delegate, a specific kind of object in .NET that can pass execution control onto the next object. Each request delegate chooses whether or not to pass the request on to the next delegate in the pipeline. Depending on the results of its calculations, a middleware may choose not to give execution control to the next item.

What Is Middleware For?

Before we continue, we should probably talk about why we might want to use middleware in an ASP.NET 6 application. To do that, let's talk about common scenarios.

One common scenario that middleware serves well (and one that we'll dive into in a later post) is logging. middleware easily enables logging of requests, including URLs and paths, to a logging system for reporting on later.

Middleware is also a great place to do authorization and authentication, diagnostics, and error logging and handling.

In short, middleware is used for operations that are not domain-specific logic and need to happen on every request, or a majority of requests.

A Simple Program.cs File

Let's take a look at a slightly-modified default Program.cs file generated by Visual Studio when you create a new .NET 6 web application:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
 
var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

//Add various middleware to the app pipeline
app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapRazorPages();

//Finally, run the app
app.Run();

This file creates the pipeline that the ASP.NET 6 web application uses to process requests. It also adds a set of "default" middleware to the pipeline using special methods provided by .NET 6, such as UseStaticFiles() which allows the app to return static files such as .js and .css, and UseRouting() which adds .NET Routing to handle the routing of URLs to server endpoints. By default, if you are using an ASP.NET 6 app, you are already using middleware.

Further, ASP.NET 6 apps can use quite a bit of this "built-in" middleware provided by .NET 6. A full list can be found on the Microsoft docs site.

A Simple Custom Middleware

Let's create a super-simple middleware that does exactly one thing: it returns "Hello Dear Readers!" as its response.

In Program.cs, we add a new middleware using the Run() method, like so:

//Rest of Program.cs

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

app.UseRouting();
app.UseAuthorization();

app.MapRazorPages();

app.Run();

When we run the app, we will see this very simple output:

Ta-da! We've implemented our own custom middleware in ASP.NET 6! However, there's a problem, as we will see shortly.

Run(), Use(), and Map()

When reading the Program.cs file, you can generally identify which parts of the application are considered middleware by looking at the method used to add them to the pipeline. Most commonly this will be done by the methods Run(), Use(), and Map().

Run()

The Run() method invokes a middleware at that point in the pipeline. However, that middleware will always be terminal, e.g. the last middleware executed before the response is returned. Recall this block of code from the previous section:

//Rest of Program.cs

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

//Nothing below this point will be executed
app.UseRouting();
app.UseAuthorization();

app.MapRazorPages();

app.Run();

Because of the invocation of Run(), nothing written after that invocation will be executed.

Use()

The Use() method places a middleware in the pipeline and allows that middleware to pass control to the next item in the pipeline.

app.Use(async (context, next) =>
{
    //Do work that does not write to the Response
    await next.Invoke();
    //Do logging or other work that does not write to the Response.
});

Note the next parameter. That parameter is the request delegate mentioned earlier. It represents the next middleware piece in the pipeline, no matter what that is. By awaiting on next.Invoke(), we are allowing the request to proceed through to the next middleware.

Also, note that it is generally bad practice to modify the response in this kind of middleware unless the pipeline will stop processing here. Modifying a response that has already been generated could cause the response to become corrupted.

All that said, most of the time we will want to add middleware to the pipeline with Use() instead of Run().

Map()

The Map() method is a special case, one that we will talk about more in a later post. It allows us to "branch" the pipeline; we can use it to conditionally invoke middleware based upon the request path.

app.Map("/branch1", HandleBranchOne);

app.Map("/branch2", HandleBranchTwo);

//Rest of Program.cs file

app.Run();

static void HandleBranchOne(IApplicationBuilder app)
{
    app.Run(async context =>
    {
        await context.Response.WriteAsync("You're on Branch 1!");
    });
}

static void HandleBranchTwo(IApplicationBuilder app)
{
    app.Run(async context =>
    {
        await context.Response.WriteAsync("You're on Branch 2!");
    });
}

Exactly why we might want to do this will be covered in Part 4 of this series.

Summary

Middleware is code modules or classes that form a pipeline. That pipeline processes incoming requests and outgoing responses. In the Program.cs file, we can place middleware in a specific order, which will then execute requests in that order and responses in reverse order.

.NET 6 includes a lot of built-in middleware, some of which are used in nearly every web app.

Finally, one way to add middleware to an app pipeline are using the Run(), Use(), and Map() methods. However, this may not be the most common way, as we will see in our next post.

Coming Up Next!

Now that we've covered the basics for middleware, in Part 2 of this series we're going to jump into building some custom classes that can be inserted into an app pipeline. Stick around!

Happy Coding!