Learn ASP.NET Core global error handling with middleware, IExceptionHandler, Problem Details & logging. Copy runnable C# examples and ship safer APIs today.
Unhandled exceptions are inevitable — a database goes down, a third-party API times out, a null sneaks past your validation. What is not inevitable is leaking stack traces to clients, returning inconsistent error payloads, or losing the log entry that would have told you what went wrong. Solid ASP.NET Core exception handling solves all three problems in one place: a single global pipeline that catches every unhandled exception, logs it with full context, and returns a clean, standards-compliant response. In this guide you'll build global error handling for ASP.NET Core step by step — from classic custom middleware, to the modern IExceptionHandler interface in .NET 8+, to RFC 9457 Problem Details and structured logging.
Why Global Exception Handling in ASP.NET Core Matters
Wrapping every controller action in try/catch is the most common mistake developers make with error handling. It fails for three reasons:
- Repetition breeds inconsistency. Fifty scattered catch blocks means fifty chances to format the error response differently, forget a log call, or return the wrong status code. Your API's error contract becomes whatever each developer felt like that day.
- Security leaks. The moment one endpoint forgets to catch, ASP.NET Core's developer exception page (or a raw 500) can expose stack traces, connection strings, and internal type names — a gift to attackers.
- Cross-cutting concerns belong in the pipeline. Exception handling, like authentication and logging, applies to every request. ASP.NET Core's middleware pipeline exists precisely so you write that logic once.
A global handler gives you one place to enforce the rules: log every failure, map known exception types to correct HTTP status codes, and return a consistent body for everything else. Let's build it.
Option 1: Global Exception Handling Middleware (Works on All Versions)
Custom middleware is the classic approach and still the right choice if you're on .NET 6 or 7, or you want full control. The middleware sits early in the pipeline, wraps the rest of the request in a try/catch, and converts exceptions into responses.
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next,
ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unhandled exception for {Method} {Path}",
context.Request.Method, context.Request.Path);
await WriteProblemResponseAsync(context, ex);
}
}
private static async Task WriteProblemResponseAsync(
HttpContext context, Exception ex)
{
var (statusCode, title) = ex switch
{
KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
ArgumentException => (StatusCodes.Status400BadRequest, "Invalid request"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
Instance = context.Request.Path,
Extensions = { ["traceId"] = context.TraceIdentifier }
});
}
}
Register it first in Program.cs so it wraps everything downstream:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Why the pattern-matching switch? Mapping exception types to status codes centrally means your domain code can throw meaningful exceptions (KeyNotFoundException when an entity doesn't exist) without knowing anything about HTTP. Your business layer stays clean; the translation happens once, at the edge.
Why include a trace ID? When a customer reports "I got an error," the traceId in the response body is the key that lets your support team find the exact log entry. Never return the exception message itself for 500s — return the correlation ID and keep the details server-side.
Option 2: IExceptionHandler — the Modern .NET 8+ Approach
.NET 8 introduced IExceptionHandler, and it's now the recommended way to do global error handling in ASP.NET Core. Instead of hand-rolling middleware, you implement a small interface and plug it into the built-in exception handler middleware. The framework handles the plumbing; you handle the policy.
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
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 for {Method} {Path}",
httpContext.Request.Method, httpContext.Request.Path);
var (statusCode, title) = exception switch
{
KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
ArgumentException => (StatusCodes.Status400BadRequest, "Invalid request"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
Instance = httpContext.Request.Path,
Extensions = { ["traceId"] = httpContext.TraceIdentifier }
}, cancellationToken);
return true; // true = handled; false = pass to the next IExceptionHandler
}
}
Wire it up with two lines:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
Why prefer this over custom middleware? Three reasons. First, the return value of TryHandleAsync lets you chain handlers: register a ValidationExceptionHandler first, a NotFoundExceptionHandler second, and a catch-all last — each returns false to pass exceptions it doesn't recognize. Second, AddProblemDetails() gives you a framework-managed fallback, so even an exception thrown inside your handler still produces a sane response. Third, it composes with the rest of the framework (status code pages, developer exception page in development) instead of fighting it.
Chaining Multiple Handlers
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // fallback, registered last
builder.Services.AddProblemDetails();
Handlers run in registration order. This keeps each handler small and single-purpose — a genuine improvement over one giant switch statement as your application grows.
Problem Details: The Standard Error Format Your API Should Speak
Both examples above return ProblemDetails, and that's deliberate. RFC 9457 (which supersedes RFC 7807) defines a machine-readable error format that API clients across every language already understand:
{
"type": "https://csharp-coder.com/errors/not-found",
"title": "Resource not found",
"status": 404,
"detail": "Order 42 does not exist.",
"instance": "/api/orders/42",
"traceId": "0HMVFE0A284AM:00000001"
}
Why use a standard instead of your own { "error": "..." } shape? Because clients — SPAs, mobile apps, other services — can write one error-handling path for your entire API surface, and tooling (OpenAPI generators, HTTP client libraries, API gateways) recognizes application/problem+json out of the box. ASP.NET Core's AddProblemDetails() also lets you enrich every response globally:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["machine"] = Environment.MachineName;
};
});
Logging Exceptions the Right Way
Catching exceptions without logging them properly just converts crashes into silent failures. Three rules matter most:
1. Pass the exception as the first argument
// ❌ Wrong — stack trace is lost, message is a flat string
_logger.LogError("Error: " + ex.Message);
// ✅ Right — full exception with stack trace, structured template
_logger.LogError(ex, "Failed to process order {OrderId}", orderId);
Why: the first overload throws away the stack trace and inner exceptions — the two things you actually need at 2 a.m. Structured templates ({OrderId} rather than string interpolation) let log platforms like Seq, Application Insights, or Elasticsearch index the value, so you can query "all failures for order 12345."
2. Log once, at the boundary
If every layer catches, logs, and rethrows, one failure produces five log entries and your error rate metrics become fiction. Let exceptions bubble up to the global handler and log there — with request path, method, and trace ID for context. Lower layers should only catch when they can genuinely recover.
3. Choose severity deliberately
A 404 caused by a mistyped URL is not an error — logging it as one buries real failures in noise. A reasonable policy: LogWarning for client-caused 4xx exceptions, LogError for unexpected 5xx failures, and LogCritical only for conditions that threaten the whole process.
Common Pitfalls in ASP.NET Core Error Handling
- Registering the middleware too late. Exception middleware only catches exceptions from middleware after it. Register it first (or call
app.UseExceptionHandler()before routing/auth) or exceptions thrown in earlier components slip past. - Swallowing exceptions with
catch (Exception) { }. An empty catch block is a bug you've scheduled for later. If you can't handle it, don't catch it. throw ex;instead ofthrow;. Rethrowing with the variable resets the stack trace to the current line, destroying the evidence. Use the barethrow;statement.- Using exceptions for flow control. Exceptions are expensive to throw and make hot paths slow. For expected outcomes ("user not found"), consider result objects; reserve exceptions for genuinely exceptional states.
- Leaking internals in production. Never put
ex.Messageorex.StackTracein a 500 response. Gate detailed errors behindapp.Environment.IsDevelopment()and the built-inUseDeveloperExceptionPage. - Forgetting that middleware can't catch everything. Exceptions thrown after the response has started streaming, or from background
Tasks not awaited within the request, won't reach your handler. For fire-and-forget work, useIHostedService/background queues with their own error handling.
Quick Reference: Which Approach Should You Use?
- .NET 8 or newer:
IExceptionHandler+AddProblemDetails(). It's the framework-endorsed pattern, chains cleanly, and requires the least code. - .NET 6/7 or need full pipeline control: custom global exception middleware, registered first.
- Minimal APIs and MVC alike: both approaches work identically — the pipeline doesn't care how your endpoints are defined.
- Avoid: MVC exception filters as your global strategy — they only cover MVC actions, missing exceptions from other middleware, minimal APIs, and routing.
Conclusion
Great ASP.NET Core exception handling isn't about catching more exceptions — it's about handling all of them in exactly one place, consistently. Key takeaways:
- Centralize error handling globally: use
IExceptionHandleron .NET 8+, or custom middleware on earlier versions — never scatteredtry/catchblocks in controllers. - Return RFC 9457 Problem Details (
application/problem+json) so every client gets a predictable, standards-based error contract. - Map domain exceptions to HTTP status codes at the edge, keeping your business logic HTTP-agnostic.
- Log once, at the boundary, with the exception object and structured templates — and always include a trace ID in the response so users can be matched to logs.
- Never leak stack traces or internal details in production responses.
Drop the GlobalExceptionHandler from this guide into your next project and you'll ship an API that fails gracefully, logs usefully, and keeps its secrets. Your future on-call self will thank you.
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