Global Error Handling in ASP.NET Core 8

Global Error Handling in ASP.NET Core 8

5 min read ·

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:

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:

  1. Register the IExceptionHandler service with dependency injection
  2. 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 GlobalExceptionHandler(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 GlobalExceptionHandler(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!


Whenever you're ready, there are 4 ways I can help you:

  1. Modular Monolith Architecture (NEW): This in-depth course will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario. Join 2,800+ students here.
  2. Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 2,800+ students here.
  3. Patreon Community: Join a community of 1,050+ engineers and gain access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses. Join 1,050+ engineers here.
  4. Promote yourself to 48,000+ subscribers by sponsoring this newsletter.

Become a Better .NET Software Engineer

Join 48,000+ engineers who are improving their skills every Saturday morning.