.NET & C# Development · Lesson 12 of 92
Never Return a Raw 500 Again — ProblemDetails & Global Errors
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.