# Handling Global Exceptions in ASP.NET Core

AddProblemDetails and UseExceptionHandler catch every exception an action doesn't, log it, and answer with a tidy 500 problem details response that never shows the stack trace. An IExceptionHandler can answer chosen exceptions with a better status, such as 409 Conflict.

Freelancer · ASP.NET Core · REST API · Updated November 3, 2026

## After this lesson you can

- Turn unhandled exceptions into problem details responses with AddProblemDetails and UseExceptionHandler
- Write an IExceptionHandler that answers one kind of exception with its own status code

## Key takeaways

- `AddProblemDetails()` and `UseExceptionHandler()` catch every exception an action doesn't, log it, and answer with a `500` problem details response.
- The stack trace goes to the app's log, never to the client, because it shows strangers how your code is built.
- Put `app.UseExceptionHandler()` right after `builder.Build()`, so it's in front of everything else.
- An `IExceptionHandler`, registered with `AddExceptionHandler`, answers the exceptions it knows, such as SQL error 547 with `409 Conflict`, and returns `false` for the rest.
- Handle the expected in the action, such as `return NotFound();`, and the unexpected in one place for the whole API.

In *DELETE*, deleting a category that still has products sent the client a `500` with the whole `SqlException` and its stack trace. That's two problems:

1. **It shows too much.** A stack trace tells strangers how your code is built: class names, file paths, SQL. That's useful to an attacker and useless to a client.
2. **It says too little.** Every other error from this API is problem details JSON that the client knows how to read. This one is a wall of text.

You could put a `try` and `catch` in every action. But exceptions you don't expect can happen anywhere, so the answer belongs in **one place** for the whole API.

## One line each

```csharp
builder.Services.AddProblemDetails();
```

```csharp
var app = builder.Build();

app.UseExceptionHandler();
```

- **`AddProblemDetails()`** makes ASP.NET Core write its error responses as problem details.
- **`UseExceptionHandler()`** adds a step to the start of the request pipeline that catches any exception from anything after it, such as your controllers. It **logs** the exception, with its stack trace, and answers the client with a `500` problem details response. Put it right after `builder.Build()`, so it's in front of everything else.

Now the same request:

```http
DELETE {{HostAddress}}/api/categories/1
```

```http
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500
}
```

The client gets a clean, standard answer. The details haven't vanished: they're in the app's **log**, which you can see in the console window where the API is running, starting with *An unhandled exception has occurred while executing the request*. That's where you, not your clients, should look.

## Choosing a better answer: IExceptionHandler

A foreign-key conflict isn't really the server's fault: the client asked to delete something that other data still uses. `409 Conflict` would say that properly.

An **`IExceptionHandler`** gets the first look at each unhandled exception. It answers the ones it knows about, and leaves the rest for the standard `500`:

```csharp
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;

namespace NorthwindApi.Errors;

// Turns a foreign key conflict from SQL Server (error 547) into 409 Conflict.
public class DatabaseConflictHandler : IExceptionHandler
{
    private readonly IProblemDetailsService problemDetailsService;

    public DatabaseConflictHandler(IProblemDetailsService problemDetailsService)
    {
        this.problemDetailsService = problemDetailsService;
    }

    public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        if (exception is not SqlException sqlException || sqlException.Number != 547)
        {
            return false;
        }

        httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
        return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            ProblemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "Conflict",
                Detail = "Other data still uses this item, so it can't be changed or deleted."
            }
        });
    }
}
```

- **`IExceptionHandler`** is an interface with one method, `TryHandleAsync`, which gets the exception.
- **It returns `false`** for exceptions it doesn't handle. `exception is not SqlException sqlException || ...` reads: *if it isn't a `SqlException`, or it's a different SQL error, leave it.* SQL Server's error number **547** means a constraint, such as a foreign key, stopped the statement.
- **It returns `true`** once it has written the response: the status code, then a problem details body written by **`IProblemDetailsService`**, which it asked for in its constructor.
- **`ValueTask<bool>`** is a lighter cousin of `Task<bool>`. Treat it the same way: `async`, and `return` a `bool`.

Register it in `Program.cs`, next to `AddProblemDetails()`:

```csharp
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DatabaseConflictHandler>();
```

```http
DELETE {{HostAddress}}/api/categories/1
```

```http
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  "title": "Conflict",
  "status": 409,
  "detail": "Other data still uses this item, so it can't be changed or deleted."
}
```

Every action in every controller now answers a foreign-key conflict with `409`, and you didn't touch a single one. Any other exception still gets the standard `500`. You can register several handlers; they're asked in the order they were added.

## Where each error belongs

| Situation | Handled by |
|---|---|
| The body breaks a validation rule | `[ApiController]`: `400`, automatically |
| No item with that id | The action: `return NotFound();` |
| A rule the action checks, such as a name already taken | The action: `return Conflict(...);` |
| An exception you can turn into a meaningful status, anywhere in the API | An `IExceptionHandler`: such as `409` for SQL error 547 |
| Anything else | `UseExceptionHandler()`: logged, and a `500` with no details |

## Check yourself

2 questions to try on the lesson page.

## So what

Handle the expected in the action, and the unexpected in one place. With `AddProblemDetails` and `UseExceptionHandler`, every error your API sends is the same tidy shape, the details stay in your log, and an `IExceptionHandler` can turn a known exception into the right status everywhere at once.

## Key terms

- Exception Handling Middleware
- Problem Details in ASP.NET Core
- IExceptionHandler

## Cheat sheets

- [Handling Global Exceptions in ASP.NET Core](https://www.ask-codey.com/cheat-sheets/handling-global-exceptions-in-aspnet-core-cheat-sheet)

Source: https://www.ask-codey.com/lessons/handling-global-exceptions-in-aspnet-core
