No matter how well-written your application is, unexpected exceptions will occur in production. Global error handling is the safety net that catches these exceptions, logs them with full context, and returns an appropriate response — a user-friendly error page for browsers, a structured ProblemDetails JSON response for API clients. ASP.NET Core’s built-in error handling infrastructure (UseExceptionHandler, IExceptionHandler, IProblemDetailsService) covers both scenarios with minimal configuration.
UseExceptionHandler for MVC Applications
// ── Program.cs — environment-specific error handling ──────────────────────
if (app.Environment.IsDevelopment())
{
// Full stack trace, request info, source code viewer in browser
app.UseDeveloperExceptionPage();
}
else
{
// Production: redirect to a friendly error controller action
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
// ── HomeController.Error — render the error page ──────────────────────────
[AllowAnonymous]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
var exceptionFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
var requestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
var vm = new ErrorViewModel
{
RequestId = requestId,
// Do NOT expose exception details to users in production
// Log them server-side instead
};
// Log the original exception with full context
if (exceptionFeature?.Error is not null)
{
_logger.LogError(exceptionFeature.Error,
"Unhandled exception for request {RequestId} at path {Path}",
requestId, exceptionFeature.Path);
}
return View(vm);
}
UseExceptionHandler("/Home/Error") middleware catches the exception, clears the response, and makes a new internal request to /Home/Error. The original exception is stored in IExceptionHandlerPathFeature which you can access from the error action. Critically, the Error action runs as a new request — it does NOT have access to the original request’s model state, route values, or action context, only to the stored exception feature. This is why the error action is typically simple, just reading the stored exception and logging it.UseExceptionHandler to return a ProblemDetails JSON response for requests that expect JSON (Accept: application/json): app.UseExceptionHandler(opts => opts.Run(async ctx => { var problem = ctx.RequestServices.GetRequiredService<IProblemDetailsService>(); await problem.WriteAsync(new ProblemDetailsContext { HttpContext = ctx, ProblemDetails = { Status = 500, Title = "An error occurred." } }); })). Alternatively, register an IExceptionHandler implementation for custom mapping logic.UseDeveloperExceptionPage() middleware shows full details — it must be restricted to Development only.Custom Exception Handler with Exception Type Mapping
// ── IExceptionHandler — global custom exception handling ──────────────────
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken ct)
{
// Map known domain exceptions to HTTP status codes
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Resource not found."),
ConflictException => (StatusCodes.Status409Conflict, "Conflict."),
ValidationException => (StatusCodes.Status400BadRequest, "Validation failed."),
ForbiddenException => (StatusCodes.Status403Forbidden, "Access denied."),
_ => (StatusCodes.Status500InternalServerError, "An error occurred.")
};
// Log with appropriate severity
if (statusCode == 500)
logger.LogError(exception, "Unhandled exception for {TraceId}", httpContext.TraceIdentifier);
else
logger.LogWarning(exception, "Domain exception {ExType}", exception.GetType().Name);
httpContext.Response.StatusCode = statusCode;
// Return false to let the next handler run (e.g., UseExceptionHandler redirect)
// Return true to mark as handled (short-circuit)
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = { Status = statusCode, Title = title },
});
return true;
}
}
// ── Register ──────────────────────────────────────────────────────────────
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
Common Mistakes
Mistake 1 — Exposing exception details in production error responses
❌ Wrong — returning exception.Message or StackTrace in the response body.
✅ Correct — return only a correlation ID to the user; log full details server-side.
Mistake 2 — Using UseDeveloperExceptionPage in non-Development environments
❌ Wrong — full stack traces, source code, and environment variables visible to all users.
✅ Correct — always wrap in if (app.Environment.IsDevelopment()).