Hi There! 👋🏻
In a few past articles, I've talked about pure C# concepts, things like delegates, cancellation tokens, IQueryable
and so on. In this article, we're focusing on something specific, which is Exception Handling, but on a global scale through the use of middleware components that we have in ASP projects. If that sounds interesting, buckle up and let's get the show going!
Exception Handling in General
The use of exceptions in our applications is a general mechanism, and it's used for a lot of different use cases and always handled in a variety of ways. In some codebases, you'll use exceptions to control the flow, as in this case:
public async Task<UserManagerResponse> RegisterUserAsync(RegisterRequest request)
{
var existingUser = await _userManager.FindByEmailAsync(request.Email!);
if (existingUser is not null)
throw new UserCreationFailedException(message: "Email is taken");
// code removed for brevity...
}
In the previous example, a custom exception is thrown to control the flow (a user already registered with the provided email), this approach is sometimes referred to as Exception based control flow. Others may use something else like the Result pattern to limit the use of exceptions across the codebase because throwing and handling exceptions is costly, as they have to propagate up the call stack and be handled somewhere else in the code, in addition to allocation of the Exception
object. If you're interested in a more in depth analysis, check out this article:
Exception Performance Analysis - The C# Players Guide
Why Global Handling?
Assuming we have a Web API project, the code that throws exceptions needs to be handled somewhere, you could do that in the controller, for example the RegisterUser
endpoint that uses the code shown previously:
try
{
var registerResult = await _authRepository
.RegisterUserAsync(request);
if (registerResult.Succeeded)
...
}
catch (UserCreationFailedException ex) // exception handled here and converted into an error response
{
// sign up failed
return BadRequest(new ApiErrorResponse
{
ErrorMessage = ex.Message
});
}
If you're using exceptions to control the flow, which is a pretty common scenario in application development as it's an easy to implement approach, and has great developer experience as it makes the code straightforward to follow (in case it's done right), you are going to have to catch all those exceptions in some layer within your application to convert them into proper error responses that your users or API consumers can make sense of.
You can see where this is going, adding try {} catch {}
blocks in your endpoints, services or whatever it is, is going to reduce the readability of your code, and it's going to add a lot of noise. Not to mention that it's tying more responsibilities to parts of the code that aren't intended to handle exceptions. Like your controller should receive a request, map it to the correct action, which calls the right service or performs the right query and send back the response. It shouldn't be accounting and handling all the exception types a service or repository might be throwing.
At a more abstracted level, if you're using something like MediatR and CQRS, your controller endpoints will mostly always contain the same lines of code, like this:
[HttpPost("login")]
public async Task<IActionResult> LoginUserAsync([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var command = new Login.Command(request);
var response = await _sender.Send(command, cancellationToken);
return Ok(new ApiResponse<LoginResponse>(response)
{
Message = "User login succeeded",
IsSuccess = true
});
}
Build the command/query, send it, return a response.
But in that case, who is handling the thrown exceptions? Enter the Global Error Handling Middleware, that's quite a mouthful
Global Error Handling Middleware
When you first start with ASP, you hear the terms Request pipeline and Middleware a lot, I've talked in a previous article about the request pipeline in ASP and what is a middleware, if you're interested you can check it out here:
The Request Pipeline in ASP NET Explained
In short, every request that comes into our application, will have to go through that pipeline, so we can take advantage of that, and put a single try {} catch {}
block, which can handle all types of exceptions, and builds the appropriate response based on the type of the exception, so that our controllers, services and everything else, do not have to involve themselves in the process of handling the exceptions.
Creating a Middleware
Creating a middleware is actually pretty straightforward, there are two ways to create them, one by convention, and one by implementing the IMiddleware
interface and implementing its InvokeAsync
method, we'll use the first method which is by convention.
I do like to keep my middlewares in a folder named Middlewares, but that's up to you where to create the class.
Here's the code needed for our Middleware:
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
public ErrorHandlingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(_context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
}
}
I'll start off by explaining the flow, and then start implementing the HandleExceptionAsync
method.
Since every request is going to come through this middleware, anywhere in the application, when an exception is thrown, this is where it's going to be caught and handled. This makes the logic of handling exceptions more centralized because now exceptions that are related to the user experience will be handled in this single place. Like this middleware will take the exceptions that interest the user, make a meaningful error response out of them, and send that back in the response.
Implementing the error handling logic
All exceptions inherit from the Exception
base class, so the exception we're catching, can be casted to any custom exception, so in the HandleExceptionAsync
method, we can do the following:
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
HttpStatusCode code = HttpStatusCode.InternalServerError;
string errorMessage = string.Empty;
// Handle the different exception types here
switch(Exception)
{
case AuthenticationFailedException ex:
code = HttpStatusCode.BadRequest;
errorMessage = ex.Message;
break;
case NotAuthorizedException ex:
code = HttpStatusCode.Unauthorized;
errorMessage = ex.Message;
break;
case OperationFailedException ex:
code = HttpStatusCode.BadRequest;
errorMessage = ex.Message;
break;
case Exception ex:
code = HttpStatusCode.InternalServerError;
errorMessage = "Something went wrong. Please try again";
break;
}
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)code;
var errorResponse = new ApiErrorResponse(code, errorMessage);
var serializedErrorResponse = JsonSerializer.Serialize(errorResponse);
await context.Response.WriteAsync(serializedErrorResponse);
}
The type named ApiErrorResponse is a simple class with two properties encapsulating the error message and the HTTP status code and it has the follow declaration:
public class ApiErrorResponse
{
public string? ErrorMessage { get; set; }
public int StatusCode { get; set; }
public ApiErrorResponse(string errorMessage)
{
ErrorMessage = errorMessage;
}
public ApiErrorResponse(string errorMessage, int statusCode)
{
ErrorMessage = errorMessage;
StatusCode = statusCode;
}
}
The code in this method does one thing only, it looks at the type of the exception, and sets the response status code and content to the information extracted from the exception.
When you introduce a new type of exception, you can just add a case
for it in the switch
block, and the middleware will be able to handle that exception type as well, so all you have to do is create your new exception, start throwing it wherever you need, and just add a case for it in the middleware, and it'll be able to take care of transforming it to something you can send back to your users or API consumers.
Additional Benefit
Another benefit to this approach, because it's the central place concerned with handling exceptions, you can go on and inject a logger instance, and also centralize the logging of exceptions in the same middleware:
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ErrorHandlingMiddleware> _logger;
public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(_context);
}
catch (Exception ex)
{
await HandleExceptionAsync();
_logger.LogError(ex);
}
}
...
}
Finally, now that our middleware is in place, we need to go ahead and add it to the request pipeline. In Program.cs, add this line:
app.UseMiddleware<ErrorHandlingMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
💡 NOTE: Registering the error handling middleware first in the pipeline is crucial, because that will allow it to handle exceptions thrown in subsequent middleware components in the chain, and so it'll be able to deal with ultimately any exception our application is going to throw in case that exception occurred during the processing of an incoming request.
Conclusion
Phew! We made it to the end! We've looked, discussed and implemented global error handling mechanism! Take a bow!
The post was quite long but we've looked at quite a few things, from understanding the purpose of exception handling globally, creating the middleware, and seeing how to actually handle the exceptions. Now for any scenario where you want to deal with exceptions on a global, more centralized level, you can use this approach, and tailor it to match your exact needs!
Top comments (0)