Effective error handling plays a crucial role in shaping the behavior of our applications. It involves establishing consistent response formats for our applications, ensuring a standardized approach even when errors occur. .NET 8 comes with the IExceptionHandler abstraction which offers us a new improved approach to handling errors in our applications.
In this article, we’re going to learn how we can use IExceptionHandler to handle exceptions in .NET.
Let’s start.
What Is IExceptionHandler in .NET
IExceptionHandler
is an interface for handling exceptions in ASP.NET Core applications. It defines an interface that we can implement to handle different exceptions. This allows us to write custom logic for handling individual exceptions or groups of exceptions based on their type, in turn providing tailored responses, error messages as well as logging.
Why Use IExceptionHandler
IExceptionHandler
offers a powerful and flexible approach to handling exceptions in .NET APIs, empowering us to build more robust and user-friendly APIs. Also, we can implement IExceptionHandler
in normal C# classes to handle different types of exceptions. This way, we make our applications modular and easy to maintain.
IExceptionHandler
helps us tailor the responses to the specific exceptions, providing more informative error messages. This helps when building user interfaces such that we can redirect users to a dedicated error page whenever exceptions are thrown by our APIs.
In addition to that, when using the IExceptionHandler
interface, we don’t necessarily need to create custom global exception handlers for our applications because .NET 8 already has the middleware implemented for us through the IExceptioHandler
.
Let’s see how we can use IExceptionHandler
in our applications.
Project Setup
Since IExceptionHandler
ships with .NET 8, we can use it out of the box. First, let’s create a Web API project using the dotnet CLI command:
dotnet new web
For tests, we’ll simulate a library service. So let’s create a Book
class:
public class Book { public int Id { get; set; } public string Title { get; set; } public string Author { get; set; } }
Let’s also create an ILibraryService
interface with two methods:
public interface ILibraryService { Book GetById(int id); List<Book> GetAllBooks(); }
Next, let’s add an endpoint to test our implementation:
app.MapGet("/books", async context => { var libraryService = context .RequestServices .GetRequiredService<ILibraryService>(); var allBooks = libraryService.GetAllBooks(); await context.Response.WriteAsJsonAsync(allBooks); });
Now, we can run our application and test the /books
endpoint to get a list of the books:
[ { "id": 1, "title": "The Catcher in the Rye", "author": "J.D. Salinger" }, { "id": 2, "title": "To Kill a Mockingbird", "author": "Harper Lee" }, { "id": 3, "title": "1984", "author": "George Orwell" } ]
This implies that our setup works as expected, let’s dig deeper into exception handling.
Using the IExceptionHandler Middleware in our Applications
Having set up our project, let’s now add the GlobalExceptionHandler
class which implements IExceptionHandler
:
public class GlobalExceptionHandler: IExceptionHandler { }
Then, let’s register our exception handler in the dependency injection container:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); builder.Services.AddProblemDetails();
Whenever there’s an exception in our application, the GlobalExceptionHandler
handler will be automatically invoked. The API will also generate ProblemDetails
standardized responses as per RFC 7807 specification. This way, we have more control over how we intercept, handle, and format error responses.
Finally, let’s add the middleware to the application request pipeline:
app.UseExceptionHandler();
Now, let’s proceed to handle exceptions.
The TryHandleAsync Method
The IExceptionHandler
interface only has the TryHandleAsync()
method that enables us to implement custom exception handling logic for different exceptions. The method tries to handle the specified exception asynchronously within the ASP.NET Core pipeline. It returns a task that represents the asynchronous read operation. The Result
property of the task contains the result of the exception-handling operation. If the exception is handled successfully, the method returns true
, otherwise it returns false
.
Let’s add a custom implementation of TryHandleAsync()
method in our middleware:
public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { logger.LogError( $"An error occurred while processing your request: {exception.Message}"); var problemDetails = new ProblemDetails { Status = (int)HttpStatusCode.InternalServerError, Type = exception.GetType().Name, Title = "An unhandled error occurred", Detail = exception.Message }; await httpContext .Response .WriteAsJsonAsync(problemDetails, cancellationToken); return true; }
First, we log the exception, then use the ProblemDetails
object to populate additional details of the exception. Please note that we have the flexibility to create a custom class for shaping the response, eliminating the need to rely exclusively on ProblemDetails
.
Let’s add an endpoint in the Program
class to test this:
app.MapGet("/books/get-by-author", () => { throw new NotImplementedException(); });
Calling the endpoint, the application throws a NotImplementedException
exception.
Let’s test this out:
{ "type": "NotImplementedException", "title": "An unhandled error occurred", "status": 500, "detail": "The method or operation is not implemented." }
Our exception handler catches the exception, handles it, and returns the expected response format.
Having learned how to set up and handle exceptions using IExceptionHandler
, let’s see how we can handle different types of exceptions in our applications.
Handling Different Exception Types
When handling different types of errors, we implement different instances of IExceptionHandler
, each handling a specific type of exception. Then, we can proceed to register each handler in the dependency injection container by calling the AddExceptionHandler<THandler>()
extension method. It’s worth noting that when registering multiple exception handlers, we should follow the order we’d like to execute them.
Let’s create an exception handler to handle BadHttpRequestException
exceptions:
public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { logger.LogError($"Bad request exception: {exception.Message}"); if (exception is not BadHttpRequestException badRequestException) { return false; } var errorResponse = new ErrorResponse { StatusCode = (int)HttpStatusCode.BadRequest, Title = "Bad Request", Message = badRequestException.Message }; httpContext.Response.StatusCode = errorResponse.StatusCode; await httpContext.Response.WriteAsJsonAsync(errorResponse, cancellationToken); return true; }
Here, the TryHandleAsync()
method uses the errorResponse
object to return the exception details from the API, unlike the first exception handler which we used ProblemDetails
. Having implemented the handler, let’s register it as a service:
builder.Services.AddExceptionHandler<BadRequestExceptionHandler>(); builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
This way, whenever we get a BadHttpRequestException
, our new middleware BadRequestExceptionHandler
will handle it. Any other exceptions will be handled by GlobalExceptionHandler
.
Let’s add an endpoint to test this:
app.MapGet("/books/{id}", async context => { var id = int.Parse(context.Request.RouteValues["id"].ToString()); var libraryService = context.RequestServices.GetRequiredService<ILibraryService>(); var book = libraryService.GetById(id); if (book is null) { throw new BadHttpRequestException($"Book with Id {id} not found.", StatusCodes.Status400BadRequest); } await context.Response.WriteAsJsonAsync(book); });
This endpoint gets a book by its id
passed as a route parameter. If the book exists, we get a 200 success response with the data, otherwise, we get a BadHttpRequestException
.
Now, calling the endpoint with an id
value that exists:
GET https://localhost:7036/books/1
We get a response with the book details:
{ "id": 1, "title": "The Catcher in the Rye", "author": "J.D. Salinger" }
If we are to test with an id
that doesn’t exist:
GET https://localhost:7036/books/0
We get a response with a 400 BadRequest status code:
{ "title": "BadHttpRequestException", "statusCode": 400, "message": "Book with Id 0 not found." }
We could chain more exception handlers to handle the different exceptions by using the same approach.
This far, there’s one potential problem. We could end up with a lot of classes in our applications all handling different exceptions. How about we improve this?
Let’s modify our GlobalExceptionHandler
class:
public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { _logger.LogError( $"An error occurred while processing your request: {exception.Message}"); var errorResponse = new ErrorResponse { Message = exception.Message }; switch (exception) { case BadHttpRequestException: errorResponse.StatusCode = (int)HttpStatusCode.BadRequest; errorResponse.Title = exception.GetType().Name; break; default: errorResponse.StatusCode = (int)HttpStatusCode.InternalServerError; errorResponse.Title = "Internal Server Error"; break; } httpContext.Response.StatusCode = errorResponse.StatusCode; await httpContext .Response .WriteAsJsonAsync(errorResponse, cancellationToken); return true; }
Here, we add a switch statement which we can extend to handle other exception types. In case there’s no matching exception, the application throws the default InternalServerError
exception. This way, we only register one exception handler in the dependency injection container instead of registering multiple handlers.
Conclusion
In this article, we’ve learned how to handle exceptions using the IExceptionHandler interface introduced in .NET 8. We’ve learned how we can set it up in our applications and handle different types of exceptions thrown by our applications. We’ve also learned why we should use it in our applications, one of the reasons being that it helps us centralize exception handling and that we can implement it from normal classes without much boilerplate.