ProblemDetails & Error Handling
Key Points
- RFC 9457 ProblemDetails is the standard error response format. Always use it for errors.
AddProblemDetails()registers the producer;UseExceptionHandler()(with no args, .NET 8+) catches exceptions and produces ProblemDetails.IExceptionHandler(.NET 8+) is the modern handler interface — register multiple to handle different exception types.- Request decompression middleware accepts gzip/brotli/deflate request bodies; pair with response compression.
- Don't leak stack traces to clients in production. Log them server-side.
Concepts (deep dive)
Setup (modern, .NET 8+)
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<MyExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler(); // converts exceptions to ProblemDetails
app.UseStatusCodePages(); // adds ProblemDetails for 4xx/5xx without bodies
IExceptionHandler implementations
public class NotFoundExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is not NotFoundException nf) return false;
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Type = "https://example.com/probs/not-found",
Title = "Not found",
Status = 404,
Detail = nf.Message,
Instance = ctx.Request.Path
}, cancellationToken: ct);
return true; // handled
}
}
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
Multiple handlers run in registration order. First to return true wins. If none handle, the framework's default ProblemDetails producer takes over (returns 500).
Custom problem types
Define a stable URI for each problem class — clients can match on type:
public class ValidationFailedException(IDictionary<string, string[]> errors)
: Exception("Validation failed")
{
public IDictionary<string, string[]> Errors { get; } = errors;
}
public class ValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is not ValidationFailedException v) return false;
var problem = new ValidationProblemDetails(v.Errors)
{
Type = "https://example.com/probs/validation",
Title = "Validation failed",
Status = 422,
Instance = ctx.Request.Path
};
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsJsonAsync(problem, cancellationToken: ct);
return true;
}
}
ValidationProblemDetails extends ProblemDetails with an errors dictionary.
Adding traceId for correlation
builder.Services.AddProblemDetails(o =>
{
o.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["instance"] = ctx.HttpContext.Request.Path.Value;
};
});
Now every ProblemDetails response includes a traceId clients can quote when reporting issues — and you correlate to logs in OpenTelemetry / Application Insights.
Returning ProblemDetails manually
// Minimal API
return Results.Problem(detail: "Order not found", statusCode: 404);
return Results.ValidationProblem(errorsDict);
// MVC controller
return Problem(detail: "...", statusCode: 422);
return ValidationProblem(ModelState);
UseStatusCodePages for "naked" non-success
If a handler returns 404 with no body, UseStatusCodePages injects a ProblemDetails body:
For richer customization:
app.UseStatusCodePages(async ctx =>
{
var problem = new ProblemDetails
{
Status = ctx.HttpContext.Response.StatusCode,
Type = $"https://example.com/probs/{ctx.HttpContext.Response.StatusCode}",
Title = ReasonPhrases.GetReasonPhrase(ctx.HttpContext.Response.StatusCode)
};
await ctx.HttpContext.Response.WriteAsJsonAsync(problem);
});
Request decompression
Now the server accepts Content-Encoding: gzip|br|deflate request bodies and decompresses transparently. Useful for bandwidth-constrained clients (mobile, IoT).
Pair with response compression:
builder.Services.AddResponseCompression(o =>
{
o.EnableForHttps = true;
o.Providers.Add<BrotliCompressionProvider>();
o.Providers.Add<GzipCompressionProvider>();
});
app.UseResponseCompression();
Don't leak in production
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); // detailed HTML with stack
else
app.UseExceptionHandler(); // ProblemDetails with no internals
UseDeveloperExceptionPage shows full stack traces — only enable in Dev.
Result-pattern integration
If you're using a Result type, convert to ProblemDetails at the boundary:
public static IResult ToHttp<T>(this Result<T> r) => r switch
{
{ IsSuccess: true } ok => TypedResults.Ok(ok.Value),
{ Error.Code: "NotFound" } => TypedResults.NotFound(),
{ Error.Code: "Validation" } e => TypedResults.ValidationProblem(e.Error.Errors!),
var f => TypedResults.Problem(detail: f.Error.Message, statusCode: 500)
};
app.MapPost("/orders", async (Req req, IService s) => (await s.PlaceAsync(req)).ToHttp());
See Result Pattern vs Exceptions.
Code: correct vs wrong
❌ Wrong: returning raw exception text
✅ Correct: ProblemDetails + log
catch (Exception ex)
{
Logger.LogError(ex, "operation failed");
return Results.Problem(statusCode: 500); // generic; details in logs
}
❌ Wrong: hand-rolled JSON error
✅ Correct: ProblemDetails
Design patterns for this topic
Pattern 1 — "Single ProblemDetails shape across all errors"
- Intent: clients have one error parser.
Pattern 2 — "Custom IExceptionHandler per exception class"
- Intent: translate domain exceptions to HTTP semantics.
Pattern 3 — "traceId extension for correlation"
- Intent: clients can report the trace; you find the request in logs.
Pattern 4 — "DeveloperExceptionPage in Dev; ProblemDetails in Prod"
- Intent: rich diagnostics locally; safe responses in prod.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| ProblemDetails | Standard | Verbose schema for simple errors |
IExceptionHandler | Per-type handling | Multiple registrations |
| Result pattern | Explicit | API-conversion code |
| Exception filters | MVC-specific | Not first-class |
When to use / when to avoid
- Use ProblemDetails for every error response.
- Use
IExceptionHandlerfor app-wide unhandled exceptions. - Avoid raw exception text in responses.
- Avoid leaking server internals to clients.
Interview Q&A
Q1. What's RFC 9457 ProblemDetails? Standard error response shape: type, title, status, detail, instance, plus extensions. ASP.NET Core has built-in support.
Q2. What's IExceptionHandler? Modern (.NET 8+) interface for converting exceptions to HTTP responses. Register one or more; first to handle wins.
Q3. Difference between UseExceptionHandler and UseDeveloperExceptionPage? The former produces ProblemDetails (production-safe). The latter shows full stack traces (Dev only).
Q4. How do you add traceId to all ProblemDetails? AddProblemDetails(o => o.CustomizeProblemDetails = ctx => { ... }) — append Extensions["traceId"].
Q5. What's ValidationProblemDetails? Extends ProblemDetails with an errors dictionary mapping field name → array of error messages.
Q6. How does UseStatusCodePages differ from UseExceptionHandler? UseStatusCodePages adds bodies to existing 4xx/5xx responses without bodies. UseExceptionHandler catches exceptions and produces a response.
Q7. Should you return 200 OK with { error: ... } body? No — lies about success. Use the proper status code + ProblemDetails.
Q8. What does request decompression middleware do? Decompresses incoming Content-Encoding: gzip|br|deflate request bodies transparently.
Q9. Where do you put auth-failure responses? UseAuthorization returns 401/403; UseStatusCodePages adds ProblemDetails body.
Q10. How do you map a domain exception to an HTTP response without coupling controllers to HTTP? IExceptionHandler per exception type. Domain throws NotFoundException; handler returns 404 + ProblemDetails. Controllers stay HTTP-naive.
Gotchas / common mistakes
- ⚠️ Stack traces in production — info disclosure.
- ⚠️
Results.Ok(new { error })— wrong status code. - ⚠️ Forgetting
AddProblemDetails—UseExceptionHandlermay not produce ProblemDetails. - ⚠️ Multiple
IExceptionHandlerorder — first matching wins; document order intent. - ⚠️ Throwing in
IExceptionHandler— surfaces as 500.