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.

Create a free account to save this page, take notes and track your progress. Sign in

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() plus UseExceptionHandler() and UseStatusCodePages() make every error a ProblemDetails response.
  • An IExceptionHandler (registered with AddExceptionHandler<T>()) handles specific exception types; return false to let the default handler deal with others.
  • Never return stack traces or exception messages of unexpected errors to clients; log them.

The code

Domain exceptions mapped to ProblemDetails
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/catch in 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.

Was this page helpful?