Learn ASP.NET Core global error handling with middleware, Problem Details, and logging. Master exception handling best practices with runnable C# examples today.
ASP.NET Core global error handling is the single most important thing you can add to a production API, yet it is the piece most tutorials skip. When an unhandled exception bubbles up in a poorly configured app, your users see an ugly stack trace, your logs are noisy and inconsistent, and attackers learn far too much about your internals. In this guide you'll learn how to implement global error handling in ASP.NET Core the right way — using centralized exception handling middleware, the RFC 7807 Problem Details standard, and structured logging — with practical, runnable C# examples you can drop straight into your project.
Whether you are a beginner searching "how to handle exceptions in ASP.NET Core", an intermediate developer looking for best practices, or a senior engineer wiring up advanced error handling in C#, this article covers the full picture for .NET 8 and .NET 9.
Why You Need Global Error Handling in ASP.NET Core
Without a centralized strategy, exception handling gets scattered across every controller and service. You end up with try/catch blocks duplicated everywhere, inconsistent error responses, and the constant risk that one forgotten catch leaks a raw 500 Internal Server Error with a full stack trace. Global error handling solves this by catching every unhandled exception in one place, before the response leaves your application.
The benefits are concrete:
- Consistency — every error returns the same predictable JSON shape, which your frontend and API consumers can rely on.
- Security — you never leak stack traces or internal details in production.
- Observability — every exception is logged once, with context, in a structured format your log aggregator can query.
- Maintainability — your controllers stay focused on business logic instead of drowning in try/catch noise.
The Problem Details Standard (RFC 7807)
Before writing any middleware, decide what an error response should look like. The modern answer for ASP.NET Core is Problem Details, defined by RFC 7807 (and its update RFC 9457). It's a machine-readable JSON format for HTTP API errors that every serious .NET API should adopt.
A Problem Details response looks like this:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "The 'email' field is required.",
"instance": "/api/users",
"traceId": "00-abc123..."
}
To enable it globally, register the Problem Details service in Program.cs. This makes ASP.NET Core produce Problem Details automatically for status-code errors and gives you a factory you can reuse in your own middleware.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
{
// Enrich every problem response with useful diagnostic context.
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance =
$"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
context.ProblemDetails.Extensions.TryAdd(
"traceId", context.HttpContext.TraceIdentifier);
};
});
builder.Services.AddControllers();
var app = builder.Build();
The Modern Way: IExceptionHandler (.NET 8+)
Since .NET 8, the recommended approach for global exception handling in ASP.NET Core is the IExceptionHandler interface combined with the built-in exception handler middleware. This is cleaner than writing raw middleware and integrates naturally with dependency injection and Problem Details.
Start by creating a global exception handler that logs the error and writes a Problem Details response:
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger)
{
_problemDetailsService = problemDetailsService;
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
// Log once, with structured context, at the boundary.
_logger.LogError(
exception,
"Unhandled exception for {Method} {Path}",
httpContext.Request.Method,
httpContext.Request.Path);
var (status, title) = MapException(exception);
httpContext.Response.StatusCode = status;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = status,
Title = title,
Type = $"https://httpstatuses.io/{status}"
}
});
}
private static (int Status, string Title) MapException(Exception exception) => exception switch
{
ValidationException => (StatusCodes.Status400BadRequest, "Validation failed"),
KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
}
Now wire it up in Program.cs. Two lines register the handler and enable the middleware pipeline:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// This middleware invokes every registered IExceptionHandler in order.
app.UseExceptionHandler();
app.MapControllers();
app.Run();
Why this design wins: the MapException switch translates domain exceptions into the correct HTTP status codes, so you can throw new KeyNotFoundException() deep in a service and trust the boundary to turn it into a clean 404. Your business logic throws meaningful exceptions; the handler owns the HTTP translation.
Chaining Multiple Exception Handlers
You can register more than one IExceptionHandler. The middleware calls each in order until one returns true. This lets you build focused handlers — for example, one dedicated to validation errors and a catch-all for everything else.
// Order matters: the validation handler runs first,
// and the catch-all handles anything it doesn't claim.
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
A handler that returns false passes the exception along to the next handler; if none handle it, the framework falls back to its default behavior.
The Classic Approach: Custom Exception Handling Middleware
Before .NET 8, most developers wrote a custom exception handling middleware class. It's still worth knowing — you'll see it in countless codebases, and it gives you full control over the pipeline. The pattern wraps the request in a try/catch:
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)
{
_logger.LogError(ex, "Unhandled exception on {Path}", context.Request.Path);
await WriteProblemDetailsAsync(context, ex);
}
}
private static async Task WriteProblemDetailsAsync(HttpContext context, Exception ex)
{
var status = ex switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};
var problem = new ProblemDetails
{
Status = status,
Title = "An error occurred while processing your request.",
Instance = context.Request.Path
};
context.Response.StatusCode = status;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problem);
}
}
Register it as the first middleware in the pipeline so it wraps everything downstream:
// Must be early so it catches exceptions from all later middleware.
app.UseMiddleware<ExceptionHandlingMiddleware>();
For new projects on .NET 8 or 9, prefer IExceptionHandler. Reach for custom middleware only when you need behavior the built-in pipeline doesn't support.
Structured Logging for Exceptions
Logging is half the value of global error handling. The goal is to log each exception exactly once, at the boundary, with enough structured context to diagnose it later. Two rules matter most:
- Pass the exception as the first argument to
LogError— neverex.Messageas a string. The logger captures the full stack trace and inner exceptions only when you pass the exception object itself. - Use message templates, not interpolation. Write
LogError(ex, "Failed for user {UserId}", id)so structured properties reach Serilog, Seq, or Application Insights as queryable fields.
// Good: structured, queryable, full stack trace preserved.
_logger.LogError(ex, "Order {OrderId} failed for {UserId}", orderId, userId);
// Bad: loses the stack trace and produces unqueryable text.
_logger.LogError($"Order {orderId} failed: {ex.Message}");
Correlate logs with responses by including the TraceId in both. When a user reports "error at 3pm", you can search your logs for that exact trace ID and find the full exception instantly. This single practice will save you hours of debugging in production.
Best Practices and Common Pitfalls
Getting ASP.NET Core error handling right is as much about what you avoid as what you add. Here are the practices that separate robust APIs from fragile ones.
- Never expose stack traces in production. Use
app.UseExceptionHandler()in production and only show developer details whenapp.Environment.IsDevelopment(). Leaking internals is a genuine security risk. - Don't catch and swallow exceptions locally unless you can meaningfully recover. Let them bubble up to the global handler. Duplicate try/catch blocks defeat the entire purpose of centralized handling.
- Place exception handling middleware first. If it runs after other middleware, exceptions thrown earlier in the pipeline escape it entirely.
- Don't log the same exception twice. If you log in a service and again at the boundary, you double your log volume and confuse investigations. Log once, at the top.
- Map domain exceptions to status codes. A
404for a missing record and a500for a real fault are very different signals for clients and monitoring alike. - Return
application/problem+jsonas the content type so clients know they're receiving a Problem Details payload. - Add a correlation/trace ID to every error response for support and debugging.
Handling Validation Errors Separately
Validation failures aren't really "errors" in the exceptional sense — they're expected outcomes of bad input. The ValidationProblemDetails type is purpose-built for these, returning a 400 with a per-field error dictionary. Keeping validation distinct from your catch-all handler produces far more useful responses for API consumers building forms against your endpoints.
Conclusion: Key Takeaways
ASP.NET Core global error handling transforms a fragile API into a production-ready one. By centralizing exception handling with IExceptionHandler, standardizing responses with Problem Details, and logging every fault once with structured context, you gain consistency, security, and observability in one coherent design.
Remember these key points:
- Use
IExceptionHandlerwithAddExceptionHandlerandUseExceptionHandlerfor global error handling on .NET 8 and 9. - Adopt Problem Details (RFC 7807/9457) so every error returns a consistent, machine-readable shape.
- Map domain exceptions to the correct HTTP status codes at the boundary, not in your controllers.
- Log exceptions once, with the exception object and structured message templates, and correlate them with a trace ID.
- Never leak stack traces in production, and keep validation errors separate from unexpected faults.
Implement this pattern once and every future endpoint inherits clean, secure, well-logged error handling for free. Start by adding AddProblemDetails() and a single GlobalExceptionHandler to your project today — your future self, and everyone consuming your API, 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