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

> Learn How to handle errors globally in ASP.NET Core using middleware, IProblemDetailsService, and the new IExceptionHandler in .NET 8. This week's issue walks you through pragmatic approaches to error handling, with real-world tips for validation, chaining, and observability.

Published: 2025-07-12. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-from-middleware-to-modern-handlers

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.

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**](https://milanjovanovic.tech/blog/3-ways-to-create-middleware-in-asp-net-core).
This is where most of us start, and honestly, it still works great for most scenarios.

```csharp
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:

```csharp
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)](https://www.rfc-editor.org/rfc/rfc9457) 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.

```csharp {3,23-33}
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**](https://milanjovanovic.tech/blog/problem-details-for-aspnetcore-apis).

## 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:

```csharp
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:

```csharp
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**](https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-8) 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.

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

Let's say you're using [FluentValidation](https://fluentvalidation.net/) (and you should be).
Here's a complete setup:

```csharp
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:

```csharp
// 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:

- [**Middleware**](https://milanjovanovic.tech/blog/3-ways-to-create-middleware-in-asp-net-core): Simple, works everywhere, but gets complex fast
- [**IProblemDetailsService**](https://milanjovanovic.tech/blog/problem-details-for-aspnetcore-apis): Standardizes response format, still manageable
- [**IExceptionHandler**](https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-8): Modern, testable, and scales beautifully

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.
