Wrapping every single Controller action in a try { ... } catch { return StatusCode(500); } block is an egregious anti-pattern. Not only does it bloat your codebase with redundant code, but it also risks missing errors thrown outside controllers. The architect's solution is a Global Exception Handling Middleware.
Middleware is a pipeline. Every HTTP request enters the pipeline, passes through various middlewares (like Authentication), hits the Controller, and the Response bubbles back out through the identical pipeline. If we put a massive try-catch at the very entrance of the pipeline, it will perfectly catch any exception thrown anywhere in the entire application.
We create a class that intercepts the HTTP context, invokes the rest of the application (await _next(context)), and catches any resulting explosions.
using System.Net;
using System.Text.Json;
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
// _next represents the rest of the application pipeline
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
// PROCEED: Let the request traverse the rest of the API
await _next(context);
}
catch (Exception ex)
{
// BOOM! An unhandled exception was thrown somewhere!
_logger.LogError(ex, "An unhandled exception occurred.");
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// Standardize the API response to always be JSON
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
// Custom Error Object (If in Production, NEVER show the stack trace)
var response = new
{
StatusCode = context.Response.StatusCode,
Message = _env.IsDevelopment() ? exception.Message : "An unexpected internal server error occurred.",
Details = _env.IsDevelopment() ? exception.StackTrace : null
};
var json = JsonSerializer.Serialize(response);
await context.Response.WriteAsync(json);
}
}
Location in the pipeline is critical. The exception handler MUST be the very first middleware registered so it completely wraps everything below it.
var app = builder.Build();
// 1. MUST BE FIRST IN THE PIPELINE
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
In newer .NET versions, Microsoft introduced an interface-based approach that is slightly cleaner than writing raw middleware.
public class CustomExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
{
// Log it, modify the response, and return true to indicate the exception was handled.
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { Error = "Fatal Server Error." }, cancellationToken);
return true;
}
}
// In Program.cs
builder.Services.AddExceptionHandler<CustomExceptionHandler>();
app.UseExceptionHandler(_ => { }); // Activates it in the pipeline
Q: "If an unhandled exception occurs in a background task (IHostedService), will the Global Exception Handling Middleware catch it?"
Architect Answer: "No. Middleware only exists within the context of an executing HTTP Request pipeline. An `IHostedService` (like a background timer running every 5 minutes) executes entirely outside of the HTTP pipeline. If an unhandled exception occurs inside a background service, the Middleware will never see it. Instead, you must wrap your background task execution logic in its own `try/catch` block. Furthermore, starting in .NET 6, unhandled exceptions in background services will violently crash the entire host process by default (terminating the web server completely) unless the `HostOptions.BackgroundServiceExceptionBehavior` is explicitly configured to Ignore them."