Lessons in this course (9)

6. ASP.NET Core — every way to handle exceptions

Intermediate★ Must knowLesson 6 of 9

1. Developer exception page (development only — shows stack traces):

csharp
if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage();   // on by default in Development with WebApplication

2. Exception handler middleware + IExceptionHandler (.NET 8+) — the recommended global handler:

csharp
builder.Services.AddProblemDetails(o =>
    o.CustomizeProblemDetails = ctx => ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
builder.Services.AddExceptionHandler<AppExceptionHandler>();   // can register several; they run in order until one returns true

var app = builder.Build();
app.UseExceptionHandler();        // MVC apps: app.UseExceptionHandler("/Home/Error") for an HTML page
app.UseStatusCodePages();         // ProblemDetails/HTML bodies for empty 4xx/5xx responses (e.g. 404)

public sealed class AppExceptionHandler(ILogger<AppExceptionHandler> logger, IProblemDetailsService problems) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception ex, CancellationToken ct)
    {
        var (status, title) = ex switch
        {
            NotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
            BusinessRuleException => (StatusCodes.Status422UnprocessableEntity, "Business rule violated"),
            ConflictException or DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Conflict"),
            ValidationException => (StatusCodes.Status400BadRequest, "Validation failed"),
            OperationCanceledException => (499, "Client closed request"),
            _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
        };
        if (status >= 500) logger.LogError(ex, "Unhandled exception for {Method} {Path}", http.Request.Method, http.Request.Path);
        else logger.LogWarning("Handled {Exception}: {Message}", ex.GetType().Name, ex.Message);

        http.Response.StatusCode = status;
        return await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            Exception = ex,
            ProblemDetails = new ProblemDetails
            {
                Status = status,
                Title = title,
                Detail = status < 500 ? ex.Message : null,      // never leak internals for 500s
                Extensions = { ["errorCode"] = (ex as AppException)?.ErrorCode }
            }
        });
    }
}

3. Custom middleware (works on every .NET Core version; same idea):

csharp
public sealed class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        try { await next(context); }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unhandled exception");
            if (context.Response.HasStarted) throw;                   // can't change a response already being sent
            context.Response.StatusCode = 500;
            await context.Response.WriteAsJsonAsync(new { title = "Unexpected error", traceId = context.TraceIdentifier });
        }
    }
}
app.UseMiddleware<ErrorHandlingMiddleware>();                          // register FIRST so it wraps everything

4. MVC exception filters — only for exceptions thrown inside controllers/actions/filters (not middleware, not routing):

csharp
public sealed class DomainExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext ctx)
    {
        if (ctx.Exception is BusinessRuleException br)
        {
            ctx.Result = new UnprocessableEntityObjectResult(new ProblemDetails { Title = br.Message, Status = 422 });
            ctx.ExceptionHandled = true;
        }
    }
}
builder.Services.AddControllers(o => o.Filters.Add<DomainExceptionFilter>());

5. Validation errors — [ApiController] automatically returns 400 ValidationProblemDetails when model binding/validation fails; customise with ConfigureApiBehaviorOptions(o => o.InvalidModelStateResponseFactory = ...).

6. Status code pages for non-exception errors — UseStatusCodePagesWithReExecute("/Error/{0}") renders a friendly page for 404/403 in MVC apps.

MechanismCatchesBest for
UseExceptionHandler + IExceptionHandlerEverything after it in the pipelineGlobal, recommended
Custom middlewareEverything after itOlder versions, special needs
Exception filtersExceptions in MVC actions/filtersMapping domain exceptions per controller
try/catch in actionThat actionExpected, local recovery
UseStatusCodePagesEmpty error status responsesConsistent 404/401/403 bodies
Developer exception pageEverything (dev)Debugging only