Middlewares in .NET

What is a middleware

Before going head first into code, it's good to understand what middleware is. Within the domain of web applications, middleware operates as an intermediary layer, managing common tasks between distinct endpoints. With that concept in mind, let's explore three reasons why using middleware in your application can be advantageous:

  • Modularity and Reusability:
  • Pipeline Control and Consistency:
  • Flexibility and Integration

Modularity and Reusability:

Middleware enables breaking down complex tasks into reusable components, promoting a modular architecture that leads to code reuse, separation of concerns, and easy adaptability to changes.

Pipeline Control and Consistency:

Middleware manages the flow of operations in a consistent manner, ensuring that tasks like security, authentication, and error handling are uniformly applied to requests.

Flexibility and Integration:

Middleware's flexibility allows for easy addition, modification, or removal of functionalities without disrupting the core application logic. It also facilitates third-party integrations, expanding application capabilities.

Coding time:

Project initialization

I'll start with the ASP.NET Core API template: Project Template Let's create a simple project, with OpenAPI for quick manual testing. Project Settings If you did everything right, you will have a simple WeatherAPI project, and by running it you will see something like this: Swagger UI By executing this endpoint, you will receive random weather data—a nifty feature for initial exploration. In the next section, we'll transform this endpoint to accept user input. Not only that, but we'll also intentionally introduce some bugs along the way. These issues will give us a better perspective on how middleware comes into play.

Changing the endpoint

This is our new endpoint. I'll explain some of the changes later:

[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get(string city)
{
    var cityId = Cities[city];

    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        CityId = cityId,
        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

I've added the query parameter city. Using the city parameter, we get cityId from the internal dictionary Cities.

Added the CityId property on WeatherForecast

Filled the property with the dictionary value Cities; is a simple dictionary with the city name and an id.

And don't worry, this is not my top 5 of the best cities in the world.

csharp
private static readonly Dictionary<string, int> Cities = new()
{
    { "Rio de Janeiro", 1 },
    { "Gothenburg", 2 },
    { "New York", 3 },
    { "Tokyo", 4 },
    { "Venice", 5 }
};

Now, let's quickly test this to see what happens...

If I execute the code and make a request with the city parameter equal to Tokyo, I get a response similar to this:

[
    {
    "date": "2023-08-24",
    "temperatureC": 32,
    "temperatureF": 89,
    "summary": "Cool",
    "cityId": 3
    },
    {
    "date": "2023-08-25",
    "temperatureC": -7,
    "temperatureF": 20,
    "summary": "Hot",
    "cityId": 3
    }
    .
    .
    .
]

Let's ignore this drastic temperature change from one day to another and test it again, but now with a city that does not exist, or even with tokyo all lowercase.

If we do that, we will encounter an exception and receive an Internal Server Error response, complete with the entire stack trace in the body. Something similar to this: Stack Trace Exposing a stack trace can be a bad user experience, and it might also present security risks. Stack traces can inform attackers about your application's inner workings and potential vulnerabilities. To fix that, we can implement our ErrorHandlingMiddleware, which will catch unhandled exceptions and return a more secure and user-friendly message.

Writing a custom Error Handling Middleware

To solve this issue, we can wrap the endpoint invocation in a try-catch block and provide a user-friendly response if any exception occurs. The following ErrorHandlingMiddleware does that. You can put this class anywhere in your project. I usually create a "Middlewares" folder.

namespace CrazyClouds.Middlewares
{
    public class ErrorHandlingMiddleware
    {
        private readonly ILogger<ErrorHandlingMiddleware> _logger;
        private readonly RequestDelegate _next;

        public ErrorHandlingMiddleware(
            ILogger<ErrorHandlingMiddleware> logger,
            RequestDelegate next)
        {
            _logger = logger;
            _next = next;
        }

        public async Task Invoke(HttpContext context)
        {
            try
            {
                // Call the next middleware in the pipeline
                await _next(context);
            }
            catch (Exception ex)
            {
                // Log the exception
                _logger.LogError("unexpected error", ex);

                // Prepare user-friendly response
                context.Response.StatusCode = StatusCodes.Status500InternalServerError;
                context.Response.ContentType = "text";
                await context.Response.WriteAsync("An unexpected error occurred.");
            }
        }
    }
}

Using our ErrorHandlingMiddleware

Now that we have the ErrorHandlingMiddleware class defined, we can inject it into our application by adding the following line in Program.cs:

app.UseMiddleware<ErrorHandlingMiddleware>(); // Our new Middleware
app.UseAuthorization();
app.MapControllers();

app.Run();

If you take a closer look, we have been using middlewares since the start of the project. Both methods, UseAuthorization() and MapControllers() are pre-defined middlewares that execute specific tasks such as authorization/authentication and routing, respectively.

Important! In .NET Core, the order in which you add middleware components to the pipeline determines the sequence in which they are executed when processing an incoming request. This concept is often called "first added, first executed." So if we had the following middlewares in our application:

app.UseMiddleware<Middleware1>();
app.UseMiddleware<Middleware2>();
app.UseMiddleware<Middleware3>();

Middleware1 will be executed first, followed by Middleware2, and then Middleware3. So it's important to add the ErrorHandlingMiddleware at the beginning of the pipeline so that it also catches any error that might occur in one of the other middlewares.

Result

Now if we use our endpoint with an invalid input, we get the following response:

Status: 500 - Internal Server Error An unexpected error occurred.

It's much better than our previous response. Of course, in a real application, we should not return an Unexpected Error when a user provides an invalid input. We could change this middleware to catch a BadHttpRequestException and then return a Bad Request response, for example. But I will leave this improvement for you to implement =)

Conclusion

I hope this blog post was helpful to introduce the key benefits of middlewares to you. By delegating common and contained tasks to middlewares you can design clean, modular and easily maintainable software.

Happy Coding!"