Thank you to our sponsors who keep this newsletter free to the reader:
Product for Engineers from PostHog is a newsletter helping engineers improve their product skills. Subscribe for free to get curated advice on building great products, lessons (and mistakes) from PostHog, and deep dives on top startups.
IcePanel is a collaborative C4 model modelling & diagramming tool that helps explain complex software systems. With an interactive map, you can align your software engineering & product teams on technical decisions across the business. Check it out here.
Exceptions are for exceptional situations. I even wrote about avoiding exceptions entirely.
But they will inevitably happen in your applications, and you need to handle them.
You can implement a global exception handling mechanism or handle only specific exceptions.
ASP.NET Core gives you a few options on how to implement this. So which one should you choose?
Today, I want to show you an old and new way to handle exceptions in ASP.NET Core 8.
Old Way: Exception Handling Midleware
The standard to implement exception handling in ASP.NET Core is using middleware.
Middleware allows you to introduce logic before or after executing HTTP requests.
You can easily extend this to implement exception handling.
Add a try-catch
statement in the middleware and return an error HTTP response.
There are 3 ways to create middleware in ASP.NET Core:
- Using request delegates
- By convention
IMiddleware
The convention-based approach requires you to define an InvokeAsync
method.
Here's an ExceptionHandlingMiddleware
defined by convention:
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception exception)
{
_logger.LogError(
exception, "Exception occurred: {Message}", exception.Message);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server Error"
};
context.Response.StatusCode =
StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(problemDetails);
}
}
}
The ExceptionHandlingMiddleware
will catch any unhandled exception and return a Problem Details response.
You can decide how much information you want to return to the caller.
In this example, I'm hiding the exception details.
You also need to add this middleware to the ASP.NET Core request pipeline:
app.UseMiddleware<ExceptionHandlingMiddleware>();
New Way: IExceptionHandler
ASP.NET Core 8
introduces a new IExceptionHandler
abstraction for managing exceptions.
The built-in exception handler middleware uses IExceptionHandler
implementations to handle exceptions.
This interface has only one TryHandleAsync
method.
TryHandleAsync
attempts to handle the specified exception within the ASP.NET Core pipeline.
If the exception can be handled, it should return true
.
If the exception can't be handled, it should return false
.
This allows you to implement custom exception-handling logic for different scenarios.
Here's a GlobalExceptionHandler
implementation:
internal sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(
exception, "Exception occurred: {Message}", exception.Message);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server error"
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response
.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Configuring IExceptionHandler Implementations
You need two things to add an IExceptionHandler
implementation to the ASP.NET Core request pipeline:
- Register the
IExceptionHandler
service with dependency injection - Register the
ExceptionHandlerMiddleware
with the request pipeline
You call the AddExceptionHandler
method to register the GlobalExceptionHandler
as a service.
It's registered with a singleton lifetime.
So be careful about injecting services with a different lifetime.
I'm also calling AddProblemDetails
to generate a Problem Details response for common exceptions.
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
You also need to call UseExceptionHandler
to add the ExceptionHandlerMiddleware
to the request pipeline:
app.UseExceptionHandler();
Chaining Exception Handlers
You can add multiple IExceptionHandler
implementations, and they're called in the order they are registered.
A possible use case for this is using exceptions for flow control.
You can define custom exceptions like BadRequestException
and NotFoundException
.
They correspond with the HTTP status code you would return from the API.
Here's a BadRequestExceptionHandler
implementation:
internal sealed class BadRequestExceptionHandler : IExceptionHandler
{
private readonly ILogger<BadRequestExceptionHandler> _logger;
public BadRequestExceptionHandler(ILogger<BadRequestExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not BadRequestException badRequestException)
{
return false;
}
_logger.LogError(
badRequestException,
"Exception occurred: {Message}",
badRequestException.Message);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Bad Request",
Detail = badRequestException.Message
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response
.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
And here's a NotFoundExceptionHandler
implementation:
internal sealed class NotFoundExceptionHandler : IExceptionHandler
{
private readonly ILogger<NotFoundExceptionHandler> _logger;
public NotFoundExceptionHandler(ILogger<NotFoundExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not NotFoundException notFoundException)
{
return false;
}
_logger.LogError(
notFoundException,
"Exception occurred: {Message}",
notFoundException.Message);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Not Found",
Detail = notFoundException.Message
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response
.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
You also need to register both exception handlers by calling AddExceptionHandler
:
builder.Services.AddExceptionHandler<BadRequestExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
The BadRequestExceptionHandler
will execute first and try to handle the exception.
If the exception isn't handled, NotFoundExceptionHandler
will execute next and attempt to handle the exception.
Takeaway
Using middleware for exception handling is an excellent solution in ASP.NET Core.
However, it's great that we have new options using the IExceptionHandler
interface.
I will use the new approach in ASP.NET Core 8 projects.
I'm very much against using exceptions for flow control. Exceptions are a last resort when you can't continue normal application execution. The Result pattern is a better alternative.
Exceptions are also extremely expensive, as David Fowler noted:
If you want to get rid of exceptions in your code, check out this video.
Thanks for reading, and stay awesome!