Global Error Handling in ASP.NET Core: From Middleware to Modern Handlers

Global Error Handling in ASP.NET Core: From Middleware to Modern Handlers

5 min read ·

Dev Productivity is Down. Here's Why. 68% of developers save 10+ hours weekly with AI, but half still lose just as much to broken processes. Atlassian's 2025 Developer Experience Report reveals rising time waste, disconnects with leadership, and the gap between feeling productive and being productive. Think your tools are smarter than your org chart? You're not alone. Download the full report.

ReSharper is coming to VS Code. JetBrains is bringing the power of ReSharper and AI assistant to Visual Studio Code! Here's your chance to influence their future - join the public preview to get early access, test powerful new tools, and share your feedback directly with the development team.

Let's talk about something we all deal with but often put off until the last minute - error handling in our ASP.NET Core apps.

When something breaks in production, the last thing you want is a cryptic 500 error with zero context. Proper error handling isn't just about logging exceptions. It's about making sure your app fails gracefully and gives useful info to the caller (and you).

In this article, I'll walk through the main options for global error handling in ASP.NET Core.

We'll look at how I used to do it, what ASP.NET Core 9 offers now, and where each approach makes sense.

Middleware-Based Error Handling

The classic way to catch unhandled exceptions is with custom middleware. This is where most of us start, and honestly, it still works great for most scenarios.

internal sealed class GlobalExceptionHandlerMiddleware(
    RequestDelegate next,
    ILogger<GlobalExceptionHandlerMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unhandled exception occurred");

            // Make sure to set the status code before writing to the response body
            context.Response.StatusCode = ex switch
            {
                ApplicationException => StatusCodes.Status400BadRequest,
                _ => StatusCodes.Status500InternalServerError
            };

            await context.Response.WriteAsJsonAsync(
                new ProblemDetails
                {
                    Type = ex.GetType().Name,
                    Title = "An error occured",
                    Detail = ex.Message
                });
        }
    }
}

Don't forget to add the middleware to the request pipeline:

app.UseMiddleware<GlobalExceptionHandlerMiddleware>();

This approach is solid and works everywhere in your pipeline. The beauty is its simplicity: wrap everything in a try-catch, log the error, and return a consistent response.

But once you start adding specific rules for different exception types (e.g. ValidationException, NotFoundException), this becomes a mess. You end up with long if / else chains or more abstractions to handle each exception type.

Plus, you're manually crafting JSON responses, which means you're probably not following RFC 9457 (Problem Details) standards.

Enter IProblemDetailsService

Microsoft recognized this pain point and gave us IProblemDetailsService to standardize error responses. Instead of manually serializing our own error objects, we can use the built-in Problem Details format.

internal sealed class GlobalExceptionHandlerMiddleware(
    RequestDelegate next,
    IProblemDetailsService problemDetailsService,
    ILogger<GlobalExceptionHandlerMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unhandled exception occurred");

            // Make sure to set the status code before writing to the response body
            context.Response.StatusCode = ex switch
            {
                ApplicationException => StatusCodes.Status400BadRequest,
                _ => StatusCodes.Status500InternalServerError
            };

            await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
            {
                HttpContext = httpContext,
                Exception = exception,
                ProblemDetails = new ProblemDetails
                {
                    Type = exception.GetType().Name,
                    Title = "An error occured",
                    Detail = exception.Message
                }
            });
        }
    }
}

This is much cleaner. We're now using a standard format that API consumers expect, and we're not manually fiddling with JSON serialization. But we're still stuck with that growing switch statement problem. You can learn more about using Problem Details in .NET here.

The Modern Way: IExceptionHandler

ASP.NET Core 8 introduced IExceptionHandler, and it's a game-changer. Instead of one massive middleware handling everything, we can create focused handlers for specific exception types.

Here's how it works:

internal sealed class GlobalExceptionHandler(
    IProblemDetailsService problemDetailsService,
    ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        logger.LogError(exception, "Unhandled exception occurred");

        httpContext.Response.StatusCode = exception switch
        {
            ApplicationException => StatusCodes.Status400BadRequest,
            _ => StatusCodes.Status500InternalServerError
        };

        return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Type = exception.GetType().Name,
                Title = "An error occured",
                Detail = exception.Message
            }
        });
    }
}

The key here is the return value. If your handler can deal with the exception, return true. If not, return false and let the next handler try.

Don't forget to register it with DI and the request pipeline:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

// And in your pipeline
app.UseExceptionHandler();

This approach is so much cleaner. Each handler has one job, and the code is easy to test and maintain.

Chaining Exception Handlers

You can chain multiple exception handlers together, and they'll run in the order you register them. ASP.NET Core will use the first one that returns true from TryHandleAsync.

Example: One for validation errors, one global fallback.

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

Let's say you're using FluentValidation (and you should be). Here's a complete setup:

internal sealed class ValidationExceptionHandler(
    IProblemDetailsService problemDetailsService,
    ILogger<ValidationExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not ValidationException validationException)
        {
            return false;
        }

        logger.LogError(exception, "Unhandled exception occurred");

        httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        var context = new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Detail = "One or more validation errors occurred",
                Status = StatusCodes.Status400BadRequest
            }
        };

        var errors = validationException.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key.ToLowerInvariant(),
                g => g.Select(e => e.ErrorMessage).ToArray()
            );
        context.ProblemDetails.Extensions.Add("errors", errors);

        return await problemDetailsService.TryWriteAsync(context);
    }
}

And in your app, just throw like this:

// In your controller or service - IValidator<CreateUserRequest>
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
    await _validator.ValidateAndThrowAsync(request);

    // Your business logic here
}

The execution order is important. The framework will try each handler in the order you registered them. So put your most specific handlers first, and your catch-all handler last.

Summary

We've come a long way from the days of manually crafting error responses in middleware. The evolution looks like this:

For new projects, I'd go straight to IExceptionHandler. It's cleaner, more maintainable, and gives you the flexibility to handle different exception types exactly how you want.

The key takeaway? Don't let error handling be an afterthought. Set it up early, make it consistent, and your users (and your future self) will thank you when things inevitably go wrong.

Thanks for reading.

And stay awesome!


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

  1. (NEW) Pragmatic REST APIs: You will learn how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  2. Pragmatic Clean Architecture: Join 4,000+ students in this comprehensive course that 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.
  3. Modular Monolith Architecture: Join 2,000+ engineers in this in-depth course that 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.
  4. Patreon Community: Join a community of 1,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

Become a Better .NET Software Engineer

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