Framework Cookbooks

ASP.NET Core Error Handling Guide

Complete guide to ASP.NET Core error handling: exception middleware, RFC 9457 ProblemDetails, model validation, custom exception filters, and health checks.

Exception Handling Middleware

ASP.NET Core's middleware pipeline handles exceptions at the framework level. The two built-in options are UseExceptionHandler (production) and UseDeveloperExceptionPage (development):

// Program.cs
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/error");  // Redirect to error endpoint
    // or use the lambda overload:
    app.UseExceptionHandler(exApp =>
        exApp.Run(async ctx =>
        {
            ctx.Response.StatusCode = 500;
            ctx.Response.ContentType = "application/problem+json";
            await ctx.Response.WriteAsync("{\"title\":\"Server error\"}");
        })
    );
}

For Minimal APIs, use IExceptionHandler (introduced in .NET 8):

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx, Exception exception, CancellationToken ct)
    {
        _logger.LogError(exception, "Unhandled exception");

        var problem = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "Server error",
            Detail = exception.Message,
        };

        ctx.Response.StatusCode = problem.Status!.Value;
        await ctx.Response.WriteAsJsonAsync(problem, ct);
        return true;  // Handled — stop propagation
    }
}

// Register:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();

ProblemDetails and RFC 9457

ASP.NET Core 7+ has first-class RFC 9457 support via IProblemDetailsService. Calling AddProblemDetails() enables automatic Problem Details for 4xx and 5xx status codes:

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Extensions["nodeId"] =
            Environment.MachineName;
    };
});

Return typed problems from Minimal API endpoints:

app.MapGet("/users/{id:int}", async (int id, IUserService svc) =>
{
    var user = await svc.FindAsync(id);
    return user is null
        ? Results.Problem(
            detail: $"User {id} not found",
            statusCode: 404,
            title: "Not Found"
          )
        : Results.Ok(user);
});

Model Validation and 422

By default, MVC returns 400 Bad Request for model binding and validation failures. Override the factory to return 422 Unprocessable Entity with field-level detail:

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
    options.InvalidModelStateResponseFactory = ctx =>
    {
        var errors = ctx.ModelState
            .Where(e => e.Value?.Errors.Count > 0)
            .ToDictionary(
                kvp => kvp.Key,
                kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
            );

        var problem = new ValidationProblemDetails(ctx.ModelState)
        {
            Status = StatusCodes.Status422UnprocessableEntity,
            Title = "Validation failed",
            Type = "https://protocolcodes.com/http/422",
        };

        return new UnprocessableEntityObjectResult(problem)
        {
            ContentTypes = { "application/problem+json" }
        };
    };
});

Custom Exception Filters

Exception filters run after action execution but before middleware. Use them to handle domain-specific exceptions in MVC controllers:

public class DomainExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        if (context.Exception is NotFoundException ex)
        {
            context.Result = new NotFoundObjectResult(new ProblemDetails
            {
                Status = 404,
                Title = "Not Found",
                Detail = ex.Message,
            });
            context.ExceptionHandled = true;
        }
    }
}

// Register globally:
builder.Services.AddControllers(options =>
    options.Filters.Add<DomainExceptionFilter>()
);

Health Checks in ASP.NET Core

ASP.NET Core has a built-in health check framework. Add checks for database, cache, and external dependencies:

builder.Services.AddHealthChecks()
    .AddNpgsql(connectionString, name: "postgres")
    .AddRedis(redisConnectionString, name: "redis")
    .AddUrlGroup(new Uri("https://api.external.com/health"), name: "external-api");

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
});

// Separate liveness (is process up?) from readiness (can serve traffic?)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false,  // No checks — just 200 if process is alive
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("readiness"),
});

Health check endpoints return 200 OK when healthy and 503 Service Unavailable when unhealthy, making them directly compatible with Kubernetes liveness and readiness probes without any additional configuration.

Logging and Correlation IDs

ASP.NET Core's built-in logging integrates with ILogger<T>. Attach the trace ID to every log entry so you can correlate logs with error reports:

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx, Exception exception, CancellationToken ct)
    {
        var traceId = Activity.Current?.Id ?? ctx.TraceIdentifier;

        // Log 5xx as Error, 4xx as Warning
        if (exception is BadHttpRequestException { StatusCode: < 500 })
        {
            _logger.LogWarning(exception, "Client error. TraceId={TraceId}", traceId);
        }
        else
        {
            _logger.LogError(exception, "Unhandled exception. TraceId={TraceId}", traceId);
        }

        var problem = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred",
            Extensions = { ["traceId"] = traceId },
        };

        ctx.Response.StatusCode = problem.Status!.Value;
        await ctx.Response.WriteAsJsonAsync(problem, ct);
        return true;
    }
}

ASP.NET Core error handling quick-reference:

  • UseExceptionHandler / IExceptionHandler — catch all unhandled exceptions
  • AddProblemDetails — automatic RFC 9457 wrapping for 4xx/5xx responses
  • ApiBehaviorOptions.InvalidModelStateResponseFactory — customize 422 shape
  • IExceptionFilter — MVC/Razor Pages domain exception mapping
  • AddHealthChecks — liveness, readiness, and dependency health probes
  • Always set Content-Type: application/problem+json for error responses to signal RFC 9457 compliance to clients

संबंधित प्रोटोकॉल

संबंधित शब्दावली शब्द

इसमें और Framework Cookbooks