Global Error Handling — UseExceptionHandler and ProblemDetails

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);
}
Note: The 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.
Tip: For Web APIs (or mixed MVC/API applications), configure 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.
Warning: Never expose exception details (stack traces, inner exception messages, file paths) to users in production responses. Even “helpful” messages like “Foreign key constraint failed on table ‘Users'” reveal database schema details. Log the full exception server-side with a correlation ID; return only the correlation ID to the user so support can look up the details. The 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()).

🧠 Test Yourself

When UseExceptionHandler("/Home/Error") handles an exception, does the Error action have access to the original route data (e.g., context.Request.Path)?