.NET Programming With Me

Error Handling and Problem Details in ASP.NET Core Controller APIs

Returning a raw 500 with an exception stack trace is not an error handling strategy; it is an absence of one. API consumers need consistent, machine-readable error responses so they can distinguish a "not found" from a "validation failure" from a genuine server fault and react accordingly. ASP.NET Core has supported the Problem Details standard since .NET 5, and in .NET 7 and .NET 8 the built-in support became comprehensive enough that you no longer need a third-party library to get it right.

By the end of this post you will have a working error handling pipeline that maps domain exceptions to Problem Details responses, produces consistent JSON for validation errors and unhandled exceptions, and never leaks internal stack traces to API clients.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK (8.0.100 or later)
  • An existing ASP.NET Core Web API project targeting .NET 8
  • Basic familiarity with middleware and the ASP.NET Core request pipeline
Note: The AddProblemDetails and UseExceptionHandler APIs described here are available from .NET 7 onward. On .NET 6 you will need a slightly different setup; the middleware exists but the unified IProblemDetailsService abstraction does not.

2 What Problem Details actually is

Problem Details is a machine-readable format for HTTP error responses defined in RFC 9457 (which supersedes the earlier RFC 7807). An ASP.NET Core controller decorated with [ApiController] already returns validation errors in this format automatically. The goal of this post is to extend that same format to all other error conditions: domain exceptions, authorization failures, and unhandled exceptions.

A minimal Problem Details response looks like this:

// Typical 404 Problem Details JSON response
// {
//   "type":   "https://tools.ietf.org/html/rfc9110#section-15.5.5",
//   "title":  "Not Found",
//   "status": 404,
//   "detail": "Product with id 42 was not found.",
//   "traceId": "00-a1b2c3d4e5f6a7b8-c9d0e1f2a3b4c5d6-00"
// }

The type field is a URI that identifies the problem type. The title is a short, human-readable label. The detail is specific to this occurrence. The traceId field, added automatically by ASP.NET Core, lets you correlate the response to a specific request in your logging system. You can also add custom extension members alongside these standard fields.


3 Register the built-in Problem Details service

Add AddProblemDetails and configure UseExceptionHandler to point at the built-in Problem Details pipeline. This is the minimum setup that gives you consistent JSON errors for unhandled exceptions.

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddProblemDetails();                    // register IProblemDetailsService

var app = builder.Build();

app.UseExceptionHandler();                               // use built-in exception handler
app.UseStatusCodePages();                                // convert 4xx with no body to Problem Details

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

With just these three lines, any unhandled exception that escapes a controller action is caught by UseExceptionHandler and converted to a Problem Details response with a 500 status code. In development the response includes the exception detail and stack trace; in production it is suppressed automatically.

Important: Call app.UseExceptionHandler() before any other middleware that produces responses. If you call it after app.MapControllers(), it will never intercept exceptions from controller actions.

4 Define domain exceptions and map them to status codes

The built-in handler covers unhandled exceptions with a 500. What it does not do by default is map your domain-specific exceptions, such as a NotFoundException, to the correct HTTP status code. The cleanest way to handle this is a custom IExceptionHandler registered before the built-in fallback.

// Exceptions/NotFoundException.cs
public class NotFoundException : Exception
{
    public NotFoundException(string resourceName, object key)
        : base($"{resourceName} with id {key} was not found.")
    {
        ResourceName = resourceName;
        Key = key;
    }

    public string ResourceName { get; }
    public object Key { get; }
}

// Exceptions/ConflictException.cs
public class ConflictException : Exception
{
    public ConflictException(string message) : base(message) { }
}

// Exceptions/ValidationException.cs — distinct from FluentValidation's own type
public class DomainValidationException : Exception
{
    public DomainValidationException(string message) : base(message) { }
}
// Infrastructure/ExceptionHandlers/DomainExceptionHandler.cs
public class DomainExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetails;

    public DomainExceptionHandler(IProblemDetailsService problemDetails)
        => _problemDetails = problemDetails;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        (int statusCode, string title) = exception switch
        {
            NotFoundException nfe     => (StatusCodes.Status404NotFound,     "Resource not found"),
            ConflictException         => (StatusCodes.Status409Conflict,     "Conflict"),
            DomainValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation error"),
            _                         => (0, string.Empty)   // not handled here
        };

        if (statusCode == 0)
            return false;  // let the next handler take it

        httpContext.Response.StatusCode = statusCode;

        return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception   = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = statusCode,
                Title  = title,
                Detail = exception.Message
            }
        });
    }
}
// Program.cs — register before AddProblemDetails fallback
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddProblemDetails();

ASP.NET Core calls IExceptionHandler implementations in registration order. If DomainExceptionHandler returns false, the next handler in the chain gets the exception. The built-in fallback added by UseExceptionHandler() is always last, so unrecognized exceptions always get a 500 response.


5 Customize the Problem Details output

If you need to add extension fields to every Problem Details response, such as a correlation ID, an error code, or an environment name, configure the AddProblemDetails options delegate.

// Program.cs — custom Problem Details fields
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;

        ctx.ProblemDetails.Extensions["requestId"] =
            ctx.HttpContext.TraceIdentifier;

        if (ctx.Exception is NotFoundException nfe)
        {
            ctx.ProblemDetails.Extensions["resource"] = nfe.ResourceName;
            ctx.ProblemDetails.Extensions["key"]      = nfe.Key;
        }
    };
});

The CustomizeProblemDetails delegate runs for every Problem Details response, including automatic validation error responses from [ApiController]. This is the single place to add cross-cutting fields without modifying any handler.


6 Running and testing the error responses

Call an endpoint that throws a NotFoundException and inspect the response body to confirm the Problem Details format is correct.

// Controllers/ProductsController.cs — throw domain exception
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductResponse>> GetById(int id, CancellationToken ct)
{
    var product = await _productService.GetByIdAsync(id, ct);

    if (product is null)
        throw new NotFoundException(nameof(Product), id);

    return Ok(product);
}
// Expected JSON response for GET /api/products/999 (non-existent)
// HTTP 404
// {
//   "type":      "https://tools.ietf.org/html/rfc9110#section-15.5.5",
//   "title":     "Resource not found",
//   "status":    404,
//   "detail":    "Product with id 999 was not found.",
//   "traceId":   "00-a1b2c3d4e5f6a7b8-c9d0e1f2a3b4c5d6-00",
//   "resource":  "Product",
//   "key":       999
// }

Write an integration test against WebApplicationFactory<T> to ensure the error shape does not regress as the codebase evolves.

// Tests/ErrorHandlingIntegrationTests.cs
[Fact]
public async Task GetById_WithNonExistentId_ReturnsProblemDetails404()
{
    await using var factory = new WebApplicationFactory<Program>();
    var client = factory.CreateClient();

    var response = await client.GetAsync("/api/products/999999");

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

    var body = await response.Content.ReadFromJsonAsync<ProblemDetails>();
    Assert.NotNull(body);
    Assert.Equal(404, body.Status);
    Assert.Equal("Resource not found", body.Title);
}

Wrapping up

A complete error handling pipeline in .NET 8 needs three things: AddProblemDetails to register the service, UseExceptionHandler to catch unhandled exceptions, and one or more IExceptionHandler implementations to map domain exceptions to the right HTTP status codes. The CustomizeProblemDetails delegate handles any cross-cutting fields you need in every response.

The next post in this series covers API versioning: how to support multiple versions of your API simultaneously without breaking existing clients and without duplicating controller code.

Got a question or ran into a problem? Drop a comment below and I will reply.

No comments: