ASP.NET Core cookbook · Intermediate

Map exceptions to ProblemDetails with IExceptionHandler

Unhandled exceptions leak stack traces, and domain errors all become 500s.

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

Approach

Register AddProblemDetails() and an IExceptionHandler that maps domain exceptions to status codes, and let the default handler produce a generic 500 for everything else. Add the trace ID to every problem response.

Solution

using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
    options.CustomizeProblemDetails = ctx =>
        ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapGet("/orders/{id}", (string id) => id == "o1" ? Results.Ok() : throw new NotFoundException($"Order {id} not found"));
app.Run();

public class NotFoundException(string message) : Exception(message);
public class ConflictException(string message) : Exception(message);

public class DomainExceptionHandler(IProblemDetailsService problems, ILogger<DomainExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
    {
        int? status = exception switch
        {
            NotFoundException => StatusCodes.Status404NotFound,
            ConflictException => StatusCodes.Status409Conflict,
            _ => null,
        };
        if (status is null)
        {
            logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
            return false;                                     // default handler writes a generic 500
        }
        context.Response.StatusCode = status.Value;
        return await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            Exception = exception,
            ProblemDetails = new ProblemDetails { Status = status, Title = exception.Message },
        });
    }
}

Pitfalls

  • UseDeveloperExceptionPage in production.
  • Exceptions for expected flow in hot paths; consider result types for very frequent "not found" cases.
  • Logging the same exception in the handler and again in middleware.

Variations

  • Register several handlers; they run in registration order until one returns true.
  • Return TypedResults.Problem(...) directly from handlers for expected errors.

Further reading: Handle errors in APIs

Reference: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling-api

More ASP.NET Core recipes

Was this page helpful?