Never Return a Raw 500 Again — ProblemDetails & Global Errors
Implement RFC 9457 ProblemDetails across your entire ASP.NET Core API. Global exception handling with IExceptionHandler, custom exception-to-status-code mapping, and extended error fields — all in one place.
Why Raw Errors Are Unacceptable
A raw 500 response with an empty body (or worse, a stack trace) tells the client nothing useful and exposes internals. RFC 9457 defines a standard JSON format that every API client can parse consistently.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Order 42 was not found.",
"traceId": "00-abc123-def456-00"
}Every error from every endpoint should look like this.
Step 1 — Add ProblemDetails Middleware
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails(); // registers IProblemDetailsService
var app = builder.Build();
app.UseExceptionHandler(); // catches unhandled exceptions
app.UseStatusCodePages(); // turns bare 404/405 into ProblemDetails
app.MapControllers();
app.Run();With UseExceptionHandler() (no path argument) + AddProblemDetails(), ASP.NET Core automatically formats unhandled exceptions as ProblemDetails. That gets you 80% of the way there. The remaining 20% is mapping your own exceptions.
Step 2 — Define Custom Exceptions
public abstract class AppException : Exception
{
protected AppException(string message) : base(message) { }
}
public class NotFoundException : AppException
{
public NotFoundException(string resource, object id)
: base($"{resource} with id '{id}' was not found.") { }
}
public class ValidationException : AppException
{
public IReadOnlyDictionary<string, string[]> Errors { get; }
public ValidationException(IDictionary<string, string[]> errors)
: base("One or more validation errors occurred.")
{
Errors = new Dictionary<string, string[]>(errors);
}
}
public class ConflictException : AppException
{
public ConflictException(string message) : base(message) { }
}Throw these from your services. The handler (below) maps them to status codes.
Step 3 — IExceptionHandler (.NET 8+)
IExceptionHandler is the clean way to handle exceptions globally. You can register multiple handlers; they run in registration order until one returns true.
public class AppExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
private readonly ILogger<AppExceptionHandler> _logger;
public AppExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<AppExceptionHandler> logger)
{
_problemDetailsService = problemDetailsService;
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var (statusCode, title) = exception switch
{
NotFoundException => (404, "Not Found"),
ValidationException => (400, "Validation Failed"),
ConflictException => (409, "Conflict"),
UnauthorizedAccessException => (401, "Unauthorized"),
_ => (0, null) // let default handler take it
};
if (statusCode == 0) return false; // not handled
_logger.LogWarning(exception, "Handled exception: {Title}", title);
httpContext.Response.StatusCode = statusCode;
var context = new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails =
{
Status = statusCode,
Title = title,
Detail = exception.Message
}
};
// Add validation errors if present
if (exception is ValidationException validationEx)
{
context.ProblemDetails.Extensions["errors"] = validationEx.Errors;
}
await _problemDetailsService.WriteAsync(context);
return true;
}
}Register it:
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails();Step 4 — Add traceId to Every Response
The traceId field makes it trivial to correlate a client error with your logs.
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["instance"] =
$"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
};
});Now every ProblemDetails response — whether from your handler, a 404, or a 405 — contains traceId automatically.
Step 5 — Extended Validation Error Format
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Validation Failed",
"status": 400,
"detail": "One or more validation errors occurred.",
"traceId": "00-abc123-def456-00",
"errors": {
"Name": ["Name is required", "Name must be less than 100 characters"],
"Price": ["Price must be greater than 0"]
}
}The errors dictionary maps field names to arrays of messages — the same format ASP.NET Core uses for ModelState errors. If you use FluentValidation, convert ValidationResult to Dictionary<string, string[]> before throwing:
public static class ValidationResultExtensions
{
public static ValidationException ToException(this ValidationResult result)
{
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
return new ValidationException(errors);
}
}Putting It Together — Full Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
};
});
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run();Testing It
// In a service — just throw:
public async Task<Order> GetOrderAsync(int id, CancellationToken ct)
{
var order = await _db.Orders.FindAsync([id], ct);
if (order is null)
throw new NotFoundException(nameof(Order), id);
return order;
}The controller stays clean:
[HttpGet("{id}")]
public async Task<Order> GetOrder(int id, CancellationToken ct)
{
return await _orderService.GetOrderAsync(id, ct);
// NotFoundException bubbles up → handler maps it → 404 ProblemDetails
}Summary
| Component | Role |
|---|---|
| AddProblemDetails() | Registers the formatter |
| UseExceptionHandler() | Catches unhandled exceptions |
| UseStatusCodePages() | Formats bare status codes |
| IExceptionHandler | Maps your exceptions to status codes |
| CustomizeProblemDetails | Adds traceId and custom fields globally |
One handler, one format, every endpoint. Clients get consistent, parseable errors and you get correlation IDs in your logs.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.