Global Error Handling in ASP.NET Core: Exception Middleware and ProblemDetails
Starting from the real response of an unhandled exception on .NET 6, we move to UseExceptionHandler with an error controller, then to our own middleware that maps domain exceptions to 404/409/500 ProblemDetails.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
Let's add an endpoint to the project that throws an exception when it can't reach the stock service:
[ApiController]
[Route("api/[controller]")]
public class DiagnosticsController : ControllerBase
{
[HttpGet("boom")]
public IActionResult Boom()
{
throw new InvalidOperationException("Stock service is unreachable.");
}
}
There is not a single line about error handling in Program.cs. The app runs in the Production environment and a client calls GET /api/diagnostics/boom. What does the client see: the exception message, a JSON error object, the stack trace, or none of these? The answer is in the next section.
What Happens When Nobody Catches the Exception?
On .NET 6, in the Production environment, the real response is this:
HTTP/1.1 500 Internal Server Error
Content-Length: 0
Server: Kestrel
None of them. Status code 500, empty body. Kestrel caught the exception, logged it and sent the client a bare 500. The developer on the client side only knows that "something blew up"; not what, not even whether it's a bug or a transient problem.
Send the same request in the Development environment and the picture changes:
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
System.InvalidOperationException: Stock service is unreachable.
at ProductCatalog.Controllers.DiagnosticsController.Boom() in .../Controllers/DiagnosticsController.cs:line 12
This is the developer exception page. In .NET 6, WebApplication adds it to the very beginning of the pipeline on its own in the Development environment. Look at it from a browser and you get an HTML page; from a client like curl you get plain text. It's a great tool during development; in Production it must never be on, because it tells the outside world your source paths, method names and internals.
What we want sits between those two extremes: an error response that is consistent in every environment, machine-readable, and leaks nothing sensitive. In the API world, the standard for that is the RFC 7807 ProblemDetails format.
Not Writing try/catch in Every Action
The first reflex is usually to wrap every action in try/catch. In an API with fifty actions that means fifty copies, and one of them will inevitably start returning a different format. Also remember that an exception can be thrown not only in an action but in model binding, in a filter or in a middleware. Error handling belongs at the edge of the pipeline; it's like installing a central fire alarm system instead of putting a separate extinguisher in every room of the building.
ASP.NET Core's built-in answer in .NET 6 is the UseExceptionHandler middleware.
UseExceptionHandler and an Error Controller
UseExceptionHandler catches an exception thrown later in the pipeline, re-routes the request to the path you give it and runs the pipeline one more time. At that path we put a small controller that produces the error response:
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class ErrorController : ControllerBase
{
[Route("/error")]
public IActionResult HandleError() => Problem();
[Route("/error-development")]
public IActionResult HandleErrorDevelopment([FromServices] IHostEnvironment hostEnvironment)
{
if (!hostEnvironment.IsDevelopment())
{
return NotFound();
}
IExceptionHandlerFeature exceptionHandlerFeature =
HttpContext.Features.Get<IExceptionHandlerFeature>()!;
return Problem(
detail: exceptionHandlerFeature.Error.StackTrace,
title: exceptionHandlerFeature.Error.Message);
}
}
if (app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error-development");
}
else
{
app.UseExceptionHandler("/error");
}
The actions deliberately have no [HttpGet]: an error can happen on a GET or a POST, and the re-executed request keeps its original HTTP method. [ApiExplorerSettings(IgnoreApi = true)] keeps this controller out of the Swagger document. We reach the original exception through IExceptionHandlerFeature. In Production the response now looks like this:
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc7231#section-6.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-0c05739c205588c9d1100a611d64182a-f0155360ce0f59cc-00"}
The Problem() method built the ProblemDetails for us, traceId included. You can search for that value in the logs and land on exactly that request's entry. In newer versions the type field becomes a link to RFC 9110; the shape stays the same.
But there is a problem. In the previous posts we carried "not found" out of the service as null. Once the service starts reporting it with an exception, UseExceptionHandler turns that into the same 500 too. For a missing product we would see a 500 saying "An error occurred" instead of a 404. We need something that decides based on the type of the exception.
Domain Exceptions
We define our own exceptions that describe a broken business rule:
public abstract class DomainException : Exception
{
protected DomainException(string message) : base(message)
{
}
}
public class NotFoundException : DomainException
{
public NotFoundException(string resource, object key)
: base($"{resource} with id {key} was not found.")
{
}
}
public class ConflictException : DomainException
{
public ConflictException(string message) : base(message)
{
}
}
Instead of returning null, the service now says exactly what happened:
public async Task<ProductDto> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
ProductDto? product = await _db.Products
.AsNoTracking()
.Where(p => p.Id == id)
.Select(ToDto)
.FirstOrDefaultAsync(cancellationToken);
return product ?? throw new NotFoundException("Product", id);
}
public async Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken = default)
{
bool nameTaken = await _db.Products.AnyAsync(p => p.Name == request.Name, cancellationToken);
if (nameTaken)
{
throw new ConflictException($"A product named '{request.Name}' already exists.");
}
Product product = new()
{
Name = request.Name,
Price = request.Price,
CategoryId = request.CategoryId
};
_db.Products.Add(product);
await _db.SaveChangesAsync(cancellationToken);
return await GetByIdAsync(product.Id, cancellationToken);
}
The question mark in the return type is gone: Task<ProductDto> instead of Task<ProductDto?>. The controller got simpler too; no more null checks:
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
{
return await _productService.GetByIdAsync(id, cancellationToken);
}
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken cancellationToken)
{
ProductDto created = await _productService.CreateAsync(request, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
Our Own Exception Middleware
Now we write the middleware that turns an exception into the right status code based on its type:
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Exceptions;
namespace ProductCatalog.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _environment;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger,
IHostEnvironment environment)
{
_next = next;
_logger = logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// the client gave up; this is not an error
_logger.LogInformation("Request {Path} was cancelled by the client", context.Request.Path);
if (!context.Response.HasStarted)
{
context.Response.StatusCode = 499;
}
}
catch (Exception exception) when (!context.Response.HasStarted)
{
await WriteProblemAsync(context, exception);
}
}
private async Task WriteProblemAsync(HttpContext context, Exception exception)
{
(int statusCode, string title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
ConflictException => (StatusCodes.Status409Conflict, "Conflict"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
if (statusCode == StatusCodes.Status500InternalServerError)
{
_logger.LogError(exception, "Unhandled exception for {Method} {Path}",
context.Request.Method, context.Request.Path);
}
else
{
_logger.LogWarning("{ExceptionType}: {Message}", exception.GetType().Name, exception.Message);
}
// we wrote the domain messages, so they are safe; an unexpected error's message only in Development
bool showMessage = exception is DomainException || _environment.IsDevelopment();
ProblemDetails problem = new()
{
Status = statusCode,
Title = title,
Detail = showMessage ? exception.Message : null,
Instance = context.Request.Path
};
problem.Extensions["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier;
context.Response.StatusCode = statusCode;
await context.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json");
}
}
There are a few decisions here; let's take them one at a time. The switch expression keeps the whole mapping in one place; when you add a new domain exception, you add one line here. The alternative is an abstract StatusCode property on DomainException, so each exception carries its own code. That moves the mapping next to the exception, but it makes the domain layer aware of HTTP status codes; in this post we chose to keep the mapping at the HTTP edge.
The second decision is the log level. A missing product is not a system failure, it's the outcome of the client's request; Warning is enough. An unexpected exception is logged as Error, with its stack trace. That way your alert rules fire on things that actually need a look.
The third decision is Detail. We wrote the domain exception messages ourselves, so showing them to the client is safe. An unexpected exception's message can contain anything from a connection string to a file path; that's why it only shows up in Development.
The when (!context.Response.HasStarted) filter matters too. Once the response headers have started going out to the client, you can no longer change the status code. In that case we don't catch the exception; it bubbles up and the server closes the connection. That beats appending a second JSON to the end of a half-written one.
We register it in Program.cs at the very beginning of the pipeline, because a middleware can only catch exceptions from what comes after it:
var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapControllers();
app.Run();
Results and Cancelled Requests
We sent the same requests again in Production; the POST request uses the name "Mechanical Keyboard", which already exists:
GET /api/products/99
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"title":"Resource not found","status":404,"detail":"Product with id 99 was not found.","instance":"/api/products/99","traceId":"00-2ee0d6bfcc5d2bf03e9225288a823f23-8e0c3b928482ece0-00"}
POST /api/products
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"title":"Conflict","status":409,"detail":"A product named 'Mechanical Keyboard' already exists.","instance":"/api/products","traceId":"00-410e0e54f0d33a7523dde8354667a3d2-8b0a778f835d488e-00"}
GET /api/diagnostics/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{"title":"An unexpected error occurred","status":500,"instance":"/api/diagnostics/boom","traceId":"00-1c200c6a27ea704c07adf66843fc92b0-19b6e41919015f08-00"}
The 500 has no detail field; in Development the same request includes "detail":"Stock service is unreachable.". We also saw that these outputs are identical on .NET 6 and .NET 10, because we build the ProblemDetails ourselves.
The first catch block in the middleware pays off the debt from the previous post. The OperationCanceledException thrown when the client drops the connection no longer shows up as fail, but as this line:
info: ProductCatalog.Middleware.ExceptionHandlingMiddleware[0]
Request /api/diagnostics/slow was cancelled by the client
499 is an unofficial code nginx uses for "client closed request"; nobody sees it because the client is already gone, but it lets you separate cancellations from real errors in logs and metrics. The StatusCodes class in .NET 6 has no constant for it, so we wrote the number directly.
Later versions enrich this picture: .NET 7 standardizes producing ProblemDetails with AddProblemDetails and IProblemDetailsService, and .NET 8 turns what this middleware does into an official extension point with the IExceptionHandler interface. We will see them in the version posts; on .NET 6, the middleware above does the job fully.
Common Pitfalls
Putting [HttpGet] on the error action. We tried it: when UseExceptionHandler re-routed the error from a POST request to the error endpoint, the request was still a POST, so the client got 405 Method Not Allowed instead of a 500. Error actions should have no HTTP method attribute.
Putting the middleware in the wrong order. The UseMiddleware<ExceptionHandlingMiddleware>() line can't see exceptions from middleware registered before it. It belongs at the very top.
Returning stack traces in Production. Writing exception.ToString() or StackTrace to the body hands an attacker a map of your application. Details go to the logs, the traceId goes to the client.
Assuming [ApiController]'s 400s go through here too. A model validation failure is not an exception; [ApiController] produces its own ValidationProblemDetails response before the action runs. If you want its format to match ours exactly, you need to customize it with ApiBehaviorOptions.InvalidModelStateResponseFactory.
Swallowing exceptions. A block like catch (Exception) { return Ok(); } doesn't make the error go away, it only makes it invisible. Whatever you catch, either log it or rethrow it.
Modeling everything with exceptions. Exceptions are designed for genuinely exceptional situations and are relatively expensive. For cases that are part of the API's natural flow, such as "product not found", you have two options: domain exceptions (calling code stays clean and the mapping lives in one place, but the flow travels an invisible path) or an explicit result type (you must check every return, but the flow is visible in the signature). In this series we chose domain exceptions for readability; if you see frequently thrown exceptions in a profiler on a high-traffic endpoint, consider switching to a result type.
A Small Experiment
★☆☆ Run the app in Production without ExceptionHandlingMiddleware, then with it, and compare the GET /api/diagnostics/boom responses. Match the traceId in the log with the one in the response.
★★☆ Add a "a product cannot be created if its category does not exist" rule to ProductService.CreateAsync. Define a new domain exception that returns 400 for it and add it to the mapping in the middleware.
★★★ Use InvalidModelStateResponseFactory to add an instance field to [ApiController]'s validation response as well, and make sure the traceId is produced the same way as in the middleware.
The middleware caught exceptions at the outermost layer of the pipeline. Sometimes, though, you want to apply a behavior only to certain controllers or actions, with access to the action's parameters and result. In the next post we will talk about MVC's second pipeline inside the first, the filters, and how to choose between a middleware and a filter.