Global Exception Handling in .NET
Reading Time: 8 minutes
The Problem
Exception handling often starts with scattered try/catch blocks and quickly becomes inconsistent:
- different endpoints return different error shapes
- internal exception details are accidentally exposed
- logs are noisy but hard to correlate
- clients cannot reliably parse failures
In production, this creates two major issues: poor client experience and weak operational visibility. If every team member handles exceptions differently, your API behavior becomes unpredictable and difficult to support.
The Solution
Use a single, centralized global exception handling strategy in ASP.NET Core:
- Catch unhandled exceptions in one place with
UseExceptionHandler - Return standardized RFC 7807 responses (
application/problem+json) viaProblemDetails - Log exceptions once, consistently, with structured logging
- Control how much detail is exposed based on environment
This gives clients a stable contract while preserving diagnostic detail for developers and operators.
Description
1. Configure global exception handling in Program.cs
ASP.NET Core provides built-in middleware for unhandled exceptions. Pair it with ProblemDetails to return consistent responses.
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
};
});
var app = builder.Build();
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async httpContext =>
{
var exceptionFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionFeature?.Error;
var logger = httpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("GlobalExceptionHandler");
if (exception is not null)
{
logger.LogError(exception,
"Unhandled exception for {Method} {Path}. TraceId: {TraceId}",
httpContext.Request.Method,
httpContext.Request.Path,
httpContext.TraceIdentifier);
}
var statusCode = exception switch
{
ArgumentException => StatusCodes.Status400BadRequest,
KeyNotFoundException => StatusCodes.Status404NotFound,
_ => StatusCodes.Status500InternalServerError
};
httpContext.Response.StatusCode = statusCode;
var problem = new ProblemDetails
{
Status = statusCode,
Title = statusCode == 500 ? "An unexpected error occurred." : "Request failed.",
Detail = app.Environment.IsDevelopment() ? exception?.Message : null,
Type = $"https://httpstatuses.com/{statusCode}",
Instance = httpContext.Request.Path
};
problem.Extensions["traceId"] = httpContext.TraceIdentifier;
await httpContext.Response.WriteAsJsonAsync(problem);
});
});
app.MapGet("/orders/{id:int}", (int id) =>
{
if (id <= 0) throw new ArgumentException("Order id must be greater than zero.");
if (id == 404) throw new KeyNotFoundException("Order was not found.");
if (id == 500) throw new InvalidOperationException("Simulated failure.");
return Results.Ok(new { id, status = "Processed" });
});
app.Run();
2. Use explicit exception-to-status mapping
Map known business exceptions to the right HTTP status code. This avoids returning 500 for expected failures.
Example mapping policy:
ArgumentException->400 Bad RequestKeyNotFoundException->404 Not FoundUnauthorizedAccessException->403 Forbidden- unknown exceptions ->
500 Internal Server Error
Keep this mapping centralized so behavior stays consistent across the whole API.
3. Keep responses safe for production
Never leak stack traces, connection strings, or internal class names in production responses. Use environment checks to include technical details only in Development.
A safe default is:
- in Development: include exception message
- outside Development: return a generic message + trace identifier
This still lets support teams correlate issues by traceId without exposing internals.
4. Add structured logging for observability
Built-in logging is enough to start, but many teams add Serilog for richer structured logs and sink flexibility.
Serilog.AspNetCoreversion10.0.0Serilog.Sinks.Consoleversion6.1.1
dotnet add package Serilog.AspNetCore --version 10.0.0
dotnet add package Serilog.Sinks.Console --version 6.1.1
Minimal setup:
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console());
5. Validate behavior with integration tests
A reliable global handler should be testable end to end:
- unhandled exception returns RFC 7807 JSON
- mapped exceptions return expected status code
- response includes
traceId - production mode does not return sensitive details
This prevents regressions when middleware order or custom mappings evolve.
Summary
Global exception handling in .NET should be centralized, consistent, and safe:
- one middleware path for unhandled exceptions
- standardized
ProblemDetailsresponses - centralized exception-to-status mapping
- structured logging with trace correlation
The result is better API ergonomics for clients and faster incident diagnosis for teams.
References
- Handle errors in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/error-handling
- ProblemDetails in ASP.NET Core Web API: https://learn.microsoft.com/aspnet/core/web-api/handle-errors
- ILogger in .NET and ASP.NET Core: https://learn.microsoft.com/dotnet/core/extensions/logging
- Serilog ASP.NET Core README: https://github.com/serilog/serilog-aspnetcore