ASP.NET Core Web APIs · Minimal APIs Done Right
Consistent Errors with ProblemDetails and IExceptionHandler
Turn exceptions into ProblemDetails responses with UseExceptionHandler and IExceptionHandler, mapping domain errors to the right status.
What you'll build
An API where services throw domain exceptions (NotFoundException, ConflictException) and an IExceptionHandler maps them to 404 and 409 ProblemDetails; unexpected exceptions become a generic 500 ProblemDetails without internal details.
The idea
AddProblemDetails()plusUseExceptionHandler()andUseStatusCodePages()make every error a ProblemDetails response.- An
IExceptionHandler(registered withAddExceptionHandler<T>()) handles specific exception types; returnfalseto let the default handler deal with others. - Never return stack traces or exception messages of unexpected errors to clients; log them.
The code
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder();
builder.Logging.AddSimpleConsole(o => o.SingleLine = true);
builder.Logging.AddFilter("Microsoft.AspNetCore.Diagnostics", LogLevel.None); // DomainExceptionHandler logs instead
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
var balances = new Dictionary<string, decimal> { ["ana"] = 30m };
app.MapPost("/accounts/{name}/withdraw/{amount:decimal}", (string name, decimal amount) =>
{
if (!balances.TryGetValue(name, out var balance)) throw new NotFoundException($"account {name} not found");
if (amount > balance) throw new ConflictException($"balance {balance} is less than {amount}");
balances[name] = balance - amount;
return Results.Ok(new { balance = balances[name] });
});
app.MapGet("/reports", () => { throw new InvalidOperationException("connection to reports-db.internal refused"); });
app.Urls.Add("http://127.0.0.1:0");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
await Show(await http.PostAsync("/accounts/ana/withdraw/20", null), "withdraw 20");
await Show(await http.PostAsync("/accounts/ana/withdraw/20", null), "withdraw 20 again");
await Show(await http.PostAsync("/accounts/bob/withdraw/5", null), "unknown account");
await Show(await http.GetAsync("/reports"), "unexpected error");
await app.StopAsync();
static async Task Show(HttpResponseMessage r, string label)
{
var body = await r.Content.ReadAsStringAsync();
Console.WriteLine($"{label} -> {(int)r.StatusCode} {body}");
}
class NotFoundException(string message) : Exception(message);
class ConflictException(string message) : Exception(message);
class DomainExceptionHandler(IProblemDetailsService problems, ILogger<DomainExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
{
int? status = exception switch { NotFoundException => 404, ConflictException => 409, _ => null };
if (status is null)
{
logger.LogError("{Method} {Path} failed: {Error}", context.Request.Method, context.Request.Path, exception.Message);
return false; // let the default handler produce a generic 500
}
context.Response.StatusCode = status.Value;
return await problems.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = new ProblemDetails { Status = status, Title = exception.Message },
});
}
}
Output
withdraw 20 -> 200 {"balance":10}
withdraw 20 again -> 409 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"balance 10 is less than 20","status":409,"traceId":"00-d26ea1d8ab7ad827ee77a6cb907eb6ea-d84af76e94341253-00"}
unknown account -> 404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"account bob not found","status":404,"traceId":"00-bf279179fde634985af3fc9abc125903-8b35472065c36f60-00"}
fail: DomainExceptionHandler[0] GET /reports failed: connection to reports-db.internal refused
unexpected error -> 500 {"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-db5357776302361e65321afa30e88400-0d56f892ce748a90-00"}Run this on your computer — see “Get Started” for setup.
Common mistakes
try/catchin every endpoint returning different error shapes.UseDeveloperExceptionPage(or detailed errors) enabled in production.- Mapping every exception to 400, hiding real server errors from monitoring.
Key points
- AddProblemDetails + UseExceptionHandler give one error format.
- IExceptionHandler maps domain exceptions to status codes.
- Unexpected errors: generic 500 for clients, details in logs.
Further reading: Handle errors in ASP.NET Core APIs · IExceptionHandler
Practice questions
What does an IExceptionHandler returning false mean?
Answer
It didn't handle that exception, so the next handler (or the default exception handler) produces the response.
What do AddProblemDetails and UseStatusCodePages contribute?
Answer
They make error responses, including empty-body status codes like 404 from routing, use the ProblemDetails format.
Why not return exception messages for unexpected errors?
Answer
They can reveal internal hostnames, SQL or code paths; clients get a generic 500 while details go to logs.
Open in the app to mark lessons complete, take notes and track your progress.