Error Handling in ASP.NET Core Web API: Problem Details and Global Handlers
ASP.NET Core error handling done well means your API clients never receive a mystery 500 response with no actionable information. Every error -- whether a validation failure, a domain rule violation, or an unexpected crash -- ideally comes back as a structured, consistent response that developers can actually parse and act on. This matters more than most teams realize until they're debugging a production incident and the API just returns Internal Server Error with an empty body.
This guide covers the full picture for .NET 10: the Problem Details standard, built-in middleware, the modern IExceptionHandler interface, and how to layer everything into a production-ready asp.net core error handling strategy that scales with your application.
The Problem Details Standard (RFC 9457)
The HTTP ecosystem has long suffered from inconsistent error formats. Every API did its own thing -- some returned { "error": "..." }, others { "message": "..." }, and plenty returned HTML error pages to JSON clients. RFC 9457 (which superseded RFC 7807) standardizes this once and for all. It defines the application/problem+json content type and a set of well-known fields that error responses commonly include.
The core fields are worth understanding before you write a line of code. The type field is a URI that identifies the error type -- it can be a real URL pointing to documentation, or the placeholder about:blank. The title gives a human-readable summary of the problem type, not the specific occurrence. The status mirrors the HTTP status code. The detail field provides specific information about this particular occurrence, meant to help the developer debug without exposing sensitive internals. The instance is a URI that identifies the specific occurrence -- usually the request path.
In .NET 10, enabling Problem Details support is a single method call. The framework provides AddProblemDetails(), and the middleware hooks in automatically when you call UseExceptionHandler() without arguments. Here is the minimal setup:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails(); // Enables RFC 9457 compliant responses
var app = builder.Build();
// UseExceptionHandler() with no path uses ProblemDetails when AddProblemDetails is registered
app.UseExceptionHandler();
// Converts bare status codes (404 from routing, 405 Method Not Allowed, etc.) to ProblemDetails
app.UseStatusCodePages();
app.MapControllers();
app.Run();
When an unhandled exception occurs, this combination produces a Problem Details response by default -- the exact shape can still vary based on environment settings and any customization you apply:
{
"type": "https://tools.ietf.org/html/rfc9457",
"title": "An error occurred while processing your request.",
"status": 500,
"detail": null,
"instance": "/api/orders/42",
"traceId": "00-a1b2c3d4e5f6-b7c8d9e0f1a2-00"
}
Note on
typeURIs: In production, consider using your own domain for type URIs (e.g.,"https://api.yourdomain.com/errors/internal-error") rather than pointing directly at the RFC URI. This lets you host human-readable documentation at that URL and gives clients a stable, API-specific reference. Thetools.ietf.orgURIs shown in examples follow the RFC 9457 pattern (https://tools.ietf.org/html/rfc9457).
The traceId is automatically added by ASP.NET Core in .NET 10. It is invaluable for correlating errors across distributed systems, and you get it for free.
UseExceptionHandler: The Built-in Global Middleware
UseExceptionHandler is the workhorse of asp.net core error handling. It wraps the entire downstream pipeline in a try/catch, catches any unhandled exceptions, and gives you a controlled opportunity to produce a proper response. The simplest form passes a path -- ASP.NET Core re-executes the request at that path to generate the error response. The more powerful form, and the one preferred in .NET 10, uses a lambda that runs inline.
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
var status = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
UnauthorizedAccessException => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status500InternalServerError
};
var problemDetails = new ProblemDetails
{
Status = status,
Title = status == 500 ? "An unexpected error occurred" : exception?.Message,
Detail = status == 500 ? null : exception?.Message,
Instance = context.Request.Path
};
problemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.TraceIdentifier;
context.Response.StatusCode = status;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
This approach works well for small applications. But as your domain grows, the exception type switching inside the lambda becomes unwieldy. A wall of if/else or a switch with dozens of cases is hard to maintain and impossible to test in isolation. That is exactly the problem IExceptionHandler solves.
IExceptionHandler: The DI-Friendly Approach (.NET 8+)
IExceptionHandler was introduced in .NET 8 and is a modern, convenient option for asp.net core error handling in .NET 10. You implement the interface in a class, register it with DI, and ASP.NET Core calls your handlers in registration order. The first handler that returns true wins -- all others are skipped. Multiple handlers chain together, each responsible for a specific exception type.
This is structurally identical to the Chain of Responsibility pattern -- each handler in the chain decides whether to handle the request or pass it along. The pattern keeps each handler focused, independently testable, and easy to add or remove.
Here is a complete example with a domain-specific handler and a generic fallback:
// Handles domain-specific exceptions
public sealed class DomainExceptionHandler : IExceptionHandler
{
private readonly ILogger<DomainExceptionHandler> _logger;
public DomainExceptionHandler(ILogger<DomainExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not DomainException domainException)
{
return false; // Not our concern -- let the next handler try
}
_logger.LogWarning(
domainException,
"Domain rule violated: {ErrorCode} -- {Message}",
domainException.ErrorCode,
domainException.Message);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Business rule violation",
Detail = domainException.Message,
Type = "https://api.example.com/errors/domain"
};
problemDetails.Extensions["errorCode"] = domainException.ErrorCode;
problemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? httpContext.TraceIdentifier;
httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // Handled
}
}
// Catches everything else
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(
exception,
"Unhandled exception: {ExceptionType} on {Method} {Path}",
exception.GetType().Name,
httpContext.Request.Method,
httpContext.Request.Path);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred",
Type = "https://tools.ietf.org/html/rfc9457"
};
problemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? httpContext.TraceIdentifier;
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Registering both in Program.cs:
// Register in the order they should be tried
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
// In the pipeline -- no path argument needed
app.UseExceptionHandler();
The registration order is meaningful. DomainExceptionHandler is evaluated first because it was registered first. Only if it returns false does GlobalExceptionHandler get a chance. This is clean, predictable, and mirrors how the Mediator pattern dispatches requests to specific handlers -- each handler knows exactly what it is responsible for.
Customizing Problem Details Globally
Sometimes you want to add custom fields to every problem details response -- not just exception responses, but also 404s, 401s, and validation errors from [ApiController]. The CustomizeProblemDetails callback runs for all of them.
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
// Add traceId to every problem response
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
// Add server node info (helpful in multi-node deployments)
context.ProblemDetails.Extensions["nodeId"] = Environment.MachineName;
// ISO 8601 timestamp for client-side correlation
context.ProblemDetails.Extensions["timestamp"] =
DateTimeOffset.UtcNow.ToString("o");
};
});
This approach gives you a uniform response shape across your entire API. Every error -- whether produced by routing, authentication, validation, or your own exception handlers -- will include these fields. Client applications can write a single error-handling routine that works everywhere.
In .NET 10, ProblemDetailsContext gained a SuppressDiagnosticsCallback property, giving you finer control over when diagnostics events are suppressed for handled exceptions. This is useful when you want to suppress diagnostic telemetry for expected exceptions (such as validation errors or not-found responses) while still recording them for unexpected errors.
This technique is conceptually similar to what the Decorator pattern describes in object-oriented terms: you are wrapping the base behavior (producing a problem details response) with additional cross-cutting concerns (adding trace IDs, timestamps) without modifying the core logic.
Validation Error Responses with [ApiController]
The [ApiController] attribute automates a critical part of asp.net core error handling for you. When model validation fails, it short-circuits execution before your action method runs and returns a 400 Bad Request with a ValidationProblemDetails body. You never need to write if (!ModelState.IsValid) checks in API controller actions.
The automatic validation response looks like this:
{
"type": "https://tools.ietf.org/html/rfc9457",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["The Email field is not a valid e-mail address."],
"Name": ["The Name field is required."]
},
"traceId": "00-a1b2c3..."
}
To customize this response -- for example to return 422 instead of 400 for semantic validation failures, or to add your custom extension fields -- replace InvalidModelStateResponseFactory:
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Type = "https://api.example.com/errors/validation",
Title = "Validation failed",
Status = StatusCodes.Status422UnprocessableEntity
};
problemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
return new UnprocessableEntityObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" }
};
};
});
The choice between 400 and 422 depends on how you classify the error and what semantics you want clients to rely on. Both are valid; the key is choosing deliberately and applying consistently. Use 400 for requests with bad syntax or structure, 422 for semantically invalid requests. Either way, document your convention in your API spec so consumers know what to expect.
Exception Filters vs IExceptionHandler
These two mechanisms both catch exceptions, but they operate at different levels. Understanding the distinction helps you choose the right tool for each scenario.
Exception filters (IExceptionFilter, IAsyncExceptionFilter) are MVC-scoped. They only catch exceptions thrown inside MVC action methods -- not in middleware, not in minimal API handlers, not in background services. You can apply them globally, per-controller, or per-action via attributes, which gives you fine-grained control. They are a reasonable fit when specific exceptions should map to specific responses only within certain controllers.
IExceptionHandler is pipeline-wide. It catches anything that bubbles up through the entire middleware stack -- controllers, minimal APIs, and even exceptions in other middleware. In .NET 10, IExceptionHandler is the commonly preferred approach unless you have a very specific reason to scope error handling to individual actions. For pure Web APIs, there is rarely such a reason.
If you are familiar with the Proxy pattern, you can think of IExceptionHandler as a pipeline-level proxy that intercepts every request and translates exception language into HTTP language -- a clean separation of concerns.
Logging Errors Properly
ASP.NET Core error handling and structured logging are inseparable. Every unhandled exception ideally produces a structured log entry with enough context to diagnose the problem later. The How to Set Up Serilog in ASP.NET Core guide covers Serilog setup in depth, and the Serilog in .NET complete guide covers structured logging patterns -- but a few error-handling-specific practices are worth emphasizing here.
Log the full exception object, not just exception.Message. The message omits the stack trace, inner exceptions, and contextual data that makes the difference between a 5-minute fix and a 2-hour investigation. Use the overload that accepts an Exception as the first parameter:
_logger.LogError(
exception,
"Order processing failed for OrderId {OrderId} -- {ExceptionType}",
orderId,
exception.GetType().Name);
Avoid logging and re-throwing the same exception at multiple layers. Log once, at the outermost catch point -- your global exception handler. Multiple log entries for a single exception create noise and make correlation harder. Let the structured log entry carry the full context, including the trace ID.
Development vs Production Errors
One of the most practical aspects of asp.net core error handling is environment-based configuration. In development, you want full exception details -- stack traces, inner exceptions, query strings. In production, you want the opposite: structured Problem Details with no sensitive internals exposed.
if (app.Environment.IsDevelopment())
{
// Full stack traces and exception details -- never enable in production
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler(); // Clean ProblemDetails via your IExceptionHandler chain
app.UseHsts();
}
UseDeveloperExceptionPage returns a rich HTML page (or detailed JSON for API requests that send Accept: application/json) with the full exception details. It is invaluable during development. But in production, stack traces and internal file paths can reveal exploitable information about your system. Ensure ASPNETCORE_ENVIRONMENT is set to Production in your deployment environment, and never conditionally enable developer pages based on anything other than IsDevelopment().
Frequently Asked Questions
What is the difference between UseExceptionHandler and IExceptionHandler in ASP.NET Core?
UseExceptionHandler is the middleware component you register in the request pipeline. It wraps the downstream pipeline in a try/catch and catches unhandled exceptions. When registered with no arguments and AddProblemDetails has been called, it produces a Problem Details response by default -- the exact shape can still vary based on environment settings and any customization you apply.
IExceptionHandler is an interface you implement in your own classes. These implementations are called by UseExceptionHandler when an exception occurs. Multiple handlers can be registered, and they are tried in order -- the first one to return true handles the exception and subsequent handlers are skipped. Think of UseExceptionHandler as the outer shell and IExceptionHandler implementations as the pluggable logic running inside it.
In .NET 10, the commonly preferred approach is to use both together: UseExceptionHandler() in the pipeline and one or more IExceptionHandler implementations registered with AddExceptionHandler<T>().
Should validation errors return HTTP 400 or HTTP 422?
Both status codes are defensible, and you will find respected APIs using each. RFC 9110 defines 400 Bad Request as appropriate when the server cannot process the request due to a client error -- which includes invalid syntax, missing required fields, and values outside allowed ranges. RFC 9110 defines 422 Unprocessable Content as appropriate when the request is syntactically correct but cannot be processed due to semantic errors.
The practical difference: use 400 for structural problems (missing required field, wrong data type) and 422 for business rule violations (a date range where the end is before the start, an order quantity that exceeds inventory). ASP.NET Core returns 400 by default for model validation failures. You can override this with InvalidModelStateResponseFactory if your team prefers 422. The most important thing is consistency -- pick a convention and document it in your API spec.
How do I add custom fields like errorCode to every Problem Details response?
Use the CustomizeProblemDetails callback in AddProblemDetails(). This callback receives every problem details response before it is written -- including automatic validation errors, routing 404s, and responses from your IExceptionHandler implementations. You can add any key-value pair to ProblemDetails.Extensions and it will be serialized as an additional field in the JSON response.
For exception-specific data like an errorCode that only makes sense when a domain exception is thrown, add the extension in your IExceptionHandler implementation instead. That way the field only appears when the relevant exception type is caught, rather than appearing (with a null or default value) on every response.
How do I handle NotFoundException to return 404 instead of 500?
Create a custom exception class (e.g., NotFoundException : Exception) and implement an IExceptionHandler that checks for it. When the handler receives a NotFoundException, it sets the HTTP status code to 404, writes a problem details body, and returns true.
A cleaner pattern is to add a StatusCode property to a base exception class and have a single IExceptionHandler read it. Your NotFoundException sets StatusCode = 404, your ConflictException sets StatusCode = 409, and so on. The handler maps exception status codes to HTTP status codes without knowing anything about specific exception types. This makes adding new exception types purely additive -- no handler changes needed.
Can I test IExceptionHandler implementations in isolation?
Yes, and this is one of the main reasons to prefer IExceptionHandler over inline lambda handlers. Construct your handler, create a DefaultHttpContext with a mock response stream, call TryHandleAsync with a test exception, and assert the response status code and body.
For integration tests, use WebApplicationFactory<Program> to start a real test server and configure endpoints that deliberately throw specific exceptions. Then call those endpoints with an HttpClient and assert the full response -- status code, content type, and parsed JSON body. Integration tests are especially valuable for verifying that your AddProblemDetails customizations apply consistently across all error paths.
What happens if an IExceptionHandler implementation throws an exception itself?
If your IExceptionHandler throws while trying to handle an exception, ASP.NET Core falls back to its built-in behavior. If AddProblemDetails is registered, the framework will attempt to produce a generic 500 problem details response. If it cannot do that either, it produces a bare 500 with no body.
Your exception handlers need to be defensive. Avoid calling services that could fail -- no database calls, no external HTTP calls, nothing that might throw. If you absolutely need external data in your error handler, wrap it in a try/catch. The error handling layer is not the place for complex orchestration.
How does asp.net core error handling interact with middleware short-circuiting?
UseExceptionHandler only catches exceptions that propagate through the pipeline. Middleware that short-circuits by writing a response and not calling next() does not trigger UseExceptionHandler -- it bypasses it entirely. This is intentional. Health check endpoints, rate limiters, and cached response middleware all need to short-circuit without triggering exception handling.
The implication is that middleware registered before UseExceptionHandler in the pipeline is outside the error handling safety net. Keep middleware that might produce errors after UseExceptionHandler in the registration order, which is why the recommended pipeline ordering usually puts UseExceptionHandler first.

