Global Error Handling with Problem Details (RFC 9457) in ASP.NET Core
Every API returns errors. The question is whether those errors are useful to the consumer or just a generic “something went wrong” message that forces them to guess what happened.
If your API returns { “error”: “An error occurred” } or, worse, a raw exception stack trace, your consumers are working harder than they should. There is a standard for this, and ASP.NET Core supports it out of the box.
In this post, I will show you how to implement consistent, machine-readable error responses using Problem Details (RFC 9457) and the `IExceptionHandler` interface.
What Is Problem Details?
Problem Details is an RFC standard (RFC 9457, which replaced RFC 7807) that defines a consistent format for HTTP API error responses. The content type is `application/problem+json`, and every error includes these fields:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Article with ID '3fa85f64' was not found.",
"instance": "/api/articles/3fa85f64"
}type: A URI reference that identifies the problem type
title: A short, human-readable summary
status: The HTTP status code
detail: A human-readable explanation specific to this occurrence
instance: A URI reference identifying this specific occurrence
The format is extensible. You can add custom properties for additional context, like validation errors or trace IDs.
Setting Up in ASP.NET Core
The setup requires just three lines in your Program.cs:
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();AddProblemDetails() configures the framework to emit Problem Details responses from built-in middleware. AddExceptionHandler<T>() registers your custom exception handler. UseExceptionHandler() activates the middleware early in the pipeline.
Writing an Exception Handler
The IExceptionHandler interface has a single method: TryHandleAsync. It receives the HttpContext and the Exception, and returns true if it handled the exception or false to pass it to the next handler.
public 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, “An unhandled exception occurred”);
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, “Not Found”),
ValidationException => (StatusCodes.Status400BadRequest, “Validation Error”),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, “Forbidden”),
_ => (StatusCodes.Status500InternalServerError, “Internal Server Error”)
};
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}Handler Chaining
One of the best features of IExceptionHandler is that you can register multiple handlers. They are called in the order they were registered, and the first handler that returns true wins.
This lets you write focused handlers for specific exception types:
// Handles validation exceptions specifically
public class ValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validationException)
return false;
var problemDetails = new ValidationProblemDetails(
validationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()))
{
Status = StatusCodes.Status400BadRequest,
Title = “Validation Error”,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}Register them in order of specificity:
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // fallbackThe validation handler checks if the exception is a ValidationException. If not, it returns false, and the next handler gets a chance. The global handler at the end catches everything else.
The Evolution of Error Handling in ASP.NET Core
It is worth understanding how we got here, because you will see all of these approaches in existing codebases:
1. Try-Catch in Every Controller (Don’t Do This)
[HttpGet("{id}")]
public async Task<IActionResult> GetArticle(Guid id)
{
try
{
var article = await _repository.GetByIdAsync(id);
return Ok(article);
}
catch (NotFoundException)
{
return NotFound();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting article");
return StatusCode(500);
}
}This duplicates error handling across every action method and produces inconsistent error responses.
2. Exception Handling Middleware (Manual)
A custom middleware that wraps the pipeline in a try-catch. Better than per-controller handling, but you end up writing the plumbing yourself.
3. UseExceptionHandler with Lambda
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(
new ProblemDetails { Title = “Error” });
});
});Functional, but limited. No access to the exception type for differentiated responses.
4. IExceptionHandler (.NET 8+, Recommended)
The current best practice. Structured, chainable, testable, and fully integrated with the Problem Details framework.
.NET 9+: StatusCodeSelector
.NET 9 introduced StatusCodeSelector, which simplifies common exception-to-status-code mappings:
builder.Services.AddProblemDetails();
app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = ex => ex switch
{
ArgumentException => StatusCodes.Status400BadRequest,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
}
});For straightforward mappings where you do not need custom logic, this significantly reduces boilerplate.
Adding Custom Extensions
Problem Details is extensible. Add a trace ID, error code, or any other context your consumers need:
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message
};
problemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? httpContext.TraceIdentifier;
problemDetails.Extensions["errorCode"] = "ARTICLE_NOT_FOUND";This produces:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Article with ID '3fa85f64' was not found.",
"traceId": "00-abc123-def456-01",
"errorCode": "ARTICLE_NOT_FOUND"
}Conclusion
Consistent error responses are not optional for a professional API. Problem Details provides a standard format your consumers can rely on, and IExceptionHandler offers a clean, testable way to produce those responses.
The setup is minimal: register Problem Details, write your exception handlers, and activate the middleware. From that point on, every error your API returns follows the same structure: the correct status code, a useful message, and any additional context the consumer needs.
Stop inventing error formats. Use the standard. Your API consumers will thank you.

