Learn ASP.NET Core global exception handling with middleware, IExceptionHandler and Problem Details. Copy runnable C# examples and ship safer APIs today.
If your API still wraps every action in a try/catch, you are paying a tax on every endpoint you write. ASP.NET Core global exception handling lets you catch, log, and format every unhandled exception in exactly one place — so your controllers stay focused on business logic and your clients always receive a predictable error payload. In this guide you will learn the three approaches that ship with .NET (exception handler middleware, custom middleware, and the modern IExceptionHandler interface), how to return standards-compliant Problem Details responses, and how to wire in structured logging with a correlation ID your support team can actually search.
Everything here targets .NET 8 and .NET 9/10, and every snippet is runnable in a minimal API or MVC project.
Why Global Exception Handling Matters
Three concrete problems get solved the moment you centralise error handling:
- Information disclosure. An unhandled exception in production can leak stack traces, SQL fragments, connection strings, and internal type names. That is a genuine security finding in most penetration tests.
- Inconsistent contracts. Without a central handler, one endpoint returns a plain string, another returns JSON, and a third returns an HTML error page. Front-end and mobile teams then write defensive parsing code for every call.
- Unsearchable logs. If each
catchblock logs differently, you cannot correlate a user's complaint ("the app said something went wrong at 2pm") with a specific log entry.
A single handler fixes all three at once, and it keeps working for exceptions you never anticipated — the null reference in a library you do not own, the timeout from a dependency that was healthy yesterday.
Approach 1: UseExceptionHandler — the Built-In Middleware
ASP.NET Core ships with UseExceptionHandler. It sits near the top of the pipeline, catches anything thrown further down, and re-executes the request against a handler you supply.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
app.Map("/error", (HttpContext context) =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var exception = feature?.Error;
return Results.Problem(
title: "An unexpected error occurred.",
statusCode: StatusCodes.Status500InternalServerError,
extensions: new Dictionary<string, object?>
{
["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier
});
});
app.Run();
Two details are easy to miss. First, UseExceptionHandler must be registered before the middleware it is meant to protect — the pipeline is ordered, and it cannot catch what runs above it. Second, UseDeveloperExceptionPage is for local debugging only; never let it reach production, because it renders the full stack trace and environment variables to the browser.
The lambda overload
If you do not want a dedicated route, use the inline overload. This avoids re-executing routing and is slightly cheaper:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
var problem = new ProblemDetails
{
Title = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError,
Instance = context.Request.Path
};
await context.Response.WriteAsJsonAsync(problem);
});
});
Approach 2: Custom Middleware (the Classic Pattern)
Before .NET 8, most teams wrote their own middleware. It is still perfectly valid, and it is the clearest way to map specific exception types to specific HTTP status codes.
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleAsync(context, ex);
}
}
private async Task HandleAsync(HttpContext context, Exception ex)
{
var (status, title) = ex switch
{
ValidationException => (StatusCodes.Status400BadRequest, "Validation failed."),
NotFoundException => (StatusCodes.Status404NotFound, "Resource not found."),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Access denied."),
OperationCanceledException => (499, "Client closed request."),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
};
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
if (status >= 500)
_logger.LogError(ex, "Unhandled exception. TraceId: {TraceId}", traceId);
else
_logger.LogWarning(ex, "Handled exception. TraceId: {TraceId}", traceId);
if (context.Response.HasStarted)
return; // Too late to change the response.
context.Response.Clear();
context.Response.StatusCode = status;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = title,
Status = status,
Instance = $"{context.Request.Method} {context.Request.Path}",
Extensions = { ["traceId"] = traceId }
});
}
}
Register it first in the pipeline:
app.UseMiddleware<ExceptionHandlingMiddleware>();
The Response.HasStarted check is the single most important line in that class. Once the first byte of the response body has been flushed, headers and status code are immutable — attempting to change them throws, and you end up with a truncated response and a second exception in your logs.
Approach 3: IExceptionHandler — the Modern .NET 8+ Way
.NET 8 introduced IExceptionHandler, and it is now the recommended approach for ASP.NET Core global exception handling. You register one or more handlers; the framework calls them in registration order until one returns true. This gives you a clean chain of responsibility instead of one enormous switch.
public sealed class ValidationExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
public ValidationExceptionHandler(IProblemDetailsService problemDetailsService)
=> _problemDetailsService = problemDetailsService;
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validation)
return false; // Pass to the next handler.
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Title = "One or more validation errors occurred.",
Status = StatusCodes.Status400BadRequest,
Detail = validation.Message,
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1"
}
});
}
}
And a catch-all fallback that logs at error level:
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
private readonly IProblemDetailsService _problemDetailsService;
public GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IProblemDetailsService problemDetailsService)
{
_logger = logger;
_problemDetailsService = problemDetailsService;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(
exception,
"Unhandled exception on {Method} {Path}",
httpContext.Request.Method,
httpContext.Request.Path);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Title = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError
}
});
}
}
Wire everything up in Program.cs. Order matters — specific handlers first, catch-all last:
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Instance =
$"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["requestId"] = ctx.HttpContext.TraceIdentifier;
};
});
var app = builder.Build();
app.UseExceptionHandler(); // Required — activates the handlers above.
app.UseStatusCodePages(); // Problem Details for bare 404s and 401s too.
Note the bare app.UseExceptionHandler() with no arguments. Registering IExceptionHandler services alone does nothing; the middleware is what invokes them. Forgetting this line is the number one reason developers report that "IExceptionHandler is not being called".
Problem Details: Why RFC 9457 Beats a Custom Error Class
Problem Details is a standardised JSON error format (RFC 9457, which obsoletes RFC 7807). A response looks like this:
// HTTP/1.1 400 Bad Request
// Content-Type: application/problem+json
//
// {
// "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
// "title": "One or more validation errors occurred.",
// "status": 400,
// "detail": "Email address is not valid.",
// "instance": "POST /api/customers",
// "traceId": "00-8f2a1c9e4b7d3a56-1a2b3c4d5e6f7a8b-01"
// }
Why use it instead of your own ApiError class? Because clients already understand it. Refit, NSwag, the Azure SDKs, and most OpenAPI code generators deserialise application/problem+json out of the box. Publishing a standard shape means one less integration document for your consumers, and it makes your OpenAPI spec honest about what failures look like.
The traceId extension is the piece that pays for itself. Surface it in your UI ("Error reference: 00-8f2a…") and a user's screenshot becomes a direct query into Application Insights, Seq, or Datadog.
Structured Logging That You Can Actually Query
Log the exception object itself, not ex.Message — passing the exception to the ILogger overload preserves the stack trace and inner exceptions:
// Wrong — stack trace is lost, and the message is interpolated into
// the template, so you cannot group by it.
_logger.LogError($"Something failed: {ex.Message}");
// Right — structured properties, full exception, searchable template.
_logger.LogError(ex,
"Order {OrderId} failed for tenant {TenantId}",
orderId, tenantId);
Add a scope so every log line inside a request carries the same identifiers:
app.Use(async (context, next) =>
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["TraceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
["UserId"] = context.User.FindFirst("sub")?.Value ?? "anonymous"
}))
{
await next();
}
});
With Serilog or OpenTelemetry writing to a structured sink, you can now filter by TraceId and see the full story of one request across every service it touched.
ASP.NET Core Error Handling Best Practices
- Register the handler first. Exception middleware must be at or near the top of the pipeline, before routing, authentication, and endpoints.
- Never return exception details in production. Gate
ex.ToString()behindIHostEnvironment.IsDevelopment(), and prefer never returning it at all — thetraceIdis enough for support. - Do not use exceptions for expected flow. A missing record is a 404, not a thrown exception. Exceptions are expensive (roughly microseconds each, plus stack capture) and they pollute your error dashboards. Consider a Result type for predictable failures.
- Treat
OperationCanceledExceptionseparately. When a user navigates away, the request is cancelled — logging that as an error creates phantom 500s. Checkcontext.RequestAborted.IsCancellationRequestedand log at information level. - Map status codes deliberately. 400 for bad input, 401 for missing credentials, 403 for insufficient permissions, 404 for missing resources, 409 for concurrency conflicts, 422 for semantic validation failures, 500 only for genuine bugs.
- Test your handler. A
WebApplicationFactoryintegration test that hits an endpoint which throws, then asserts on the status code and theproblem+jsoncontent type, costs ten minutes and catches pipeline-ordering regressions forever. - Watch out for
Response.HasStarted. Streaming endpoints and Server-Sent Events can begin writing before the exception occurs; your handler must bail out gracefully. - Remember middleware cannot catch everything. Exceptions thrown during host startup, inside
IHostedService.StartAsync, or on background threads never enter the request pipeline. Handle those separately withAppDomain.CurrentDomain.UnhandledExceptionand try/catch inside your hosted services.
Common Pitfalls
Double handling. If you register both a custom middleware and UseExceptionHandler, whichever is outermost wins and the inner one may never fire. Pick one strategy.
Swallowing exceptions. An empty catch { } anywhere in your code means the global handler never sees the problem and you get a silent 200 with garbage data. If you must catch locally, log and rethrow with throw; — never throw ex;, which resets the stack trace.
Forgetting AddProblemDetails(). Without it, IProblemDetailsService.TryWriteAsync returns false and your handler silently falls through to a blank 500.
Which Approach Should You Choose?
For any new project on .NET 8 or later, use IExceptionHandler plus AddProblemDetails. You get dependency injection, testable units, a clean chain for exception-specific handling, and full framework support. Reach for custom middleware only when you need to do something the interface cannot express — such as inspecting the response body or wrapping the pipeline in a transaction scope. Use the route-based UseExceptionHandler("/error") form when you are maintaining an older codebase or rendering HTML error pages in an MVC app.
Conclusion and Key Takeaways
Done properly, ASP.NET Core global exception handling is about fifty lines of code that you write once and never think about again — while every endpoint you add afterwards inherits safe, consistent, well-logged error behaviour for free.
- Prefer
IExceptionHandleron .NET 8+; register specific handlers before the catch-all and always callapp.UseExceptionHandler(). - Return RFC 9457 Problem Details so clients get a predictable, tool-friendly error contract.
- Attach a
traceIdto every response and every log line so support tickets map to log entries in one search. - Log the exception object with structured properties — never interpolate
ex.Messageinto the template. - Never leak stack traces in production, and never use exceptions for expected control flow.
- Guard against
Response.HasStarted, and handle background-thread and startup exceptions outside the pipeline.
Add the handler to your next project before you write your first endpoint. It is the cheapest reliability improvement available in ASP.NET Core.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment