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··

api-designaspnetcoredotnet

ASP.NET Core gives you three options for global error handling: custom middleware with a try-catch, the same middleware writing through IProblemDetailsService, and IExceptionHandler. For new projects I reach for IExceptionHandler with AddProblemDetails, because each handler covers one exception type and the framework tries them in registration order.

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 = context,
                Exception = ex,
                ProblemDetails = new ProblemDetails
                {
                    Type = ex.GetType().Name,
                    Title = "An error occured",
                    Detail = ex.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!


Frequently Asked Questions

What is the best way to handle errors globally in ASP.NET Core?

For new projects, use IExceptionHandler together with IProblemDetailsService. You write small, focused handlers for specific exception types, register them with AddExceptionHandler and AddProblemDetails, and enable them with UseExceptionHandler. It is cleaner, easier to test, and scales better than one large middleware.

What is IExceptionHandler in ASP.NET Core?

IExceptionHandler is an interface that ASP.NET Core 8 introduced for global exception handling. Each handler implements TryHandleAsync and returns true when it handled the exception or false to let the next registered handler try, which replaces one massive middleware with focused, single-purpose handlers.

How do you chain multiple exception handlers in ASP.NET Core?

Register each handler with AddExceptionHandler in the order you want them to run. The framework tries them in registration order and uses the first one that returns true from TryHandleAsync. Put specific handlers, like a validation handler, first and the catch-all global handler last.

What is Problem Details (RFC 9457)?

Problem Details is the standard format for HTTP API error responses defined in RFC 9457. In ASP.NET Core, IProblemDetailsService writes it for you via TryWriteAsync, so you return a consistent, standards-based error shape instead of manually crafting JSON responses that API consumers can't predict.

Is middleware still a valid way to handle exceptions in ASP.NET Core?

Yes. A custom middleware that wraps the pipeline in a try-catch, logs the error, and writes a response still works and is simple. The downside is that it gets messy as you add rules for different exception types, and hand-built JSON responses usually ignore the Problem Details standard.

How do you return validation errors from an ASP.NET Core API?

Throw with FluentValidation's ValidateAndThrowAsync, then let a dedicated ValidationExceptionHandler catch ValidationException. It returns a 400 status with a Problem Details body and adds an errors dictionary grouped by property name, so callers see exactly which fields failed and why.

Loading comments...

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

  1. Pragmatic Clean Architecture: Join 5,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.
  2. Modular Monolith Architecture: Join 2,800+ 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.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you 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.
  4. Patreon Community: Join a community of 5,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.

The .NET Weekly

Become a Better .NET Software Engineer

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