Learnixo
Back to blog
Backend Systemsintermediate

Global Exception Handling in ASP.NET Core — ProblemDetails and IExceptionHandler

Centralise error handling in ASP.NET Core: IExceptionHandler, ProblemDetails RFC 9457, custom exception taxonomy, validation errors, and consistent error responses across your API.

Asma Hafeez KhanMay 24, 20265 min read
.NETC#ASP.NET Coreerror handlingProblemDetailsAPI designmiddleware
Share:𝕏

Global Exception Handling in ASP.NET Core — ProblemDetails and IExceptionHandler

Unhandled exceptions should never leak stack traces to clients. This guide builds a consistent, RFC-compliant error response system using ASP.NET Core 8's built-in tools.


ProblemDetails — RFC 9457

JSON
// A standards-compliant error response:
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 42 was not found.",
  "instance": "/api/orders/42",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

Step 1: Define an Exception Taxonomy

C#
// Base exception — maps to 500 Internal Server Error
public abstract class AppException(string message, string? detail = null)
    : Exception(message)
{
    public virtual  int    StatusCode  => StatusCodes.Status500InternalServerError;
    public          string Detail      => detail ?? message;
}

// 404 Not Found
public class NotFoundException(string resourceName, object key)
    : AppException($"{resourceName} with key '{key}' was not found.")
{
    public override int StatusCode => StatusCodes.Status404NotFound;
}

// 409 Conflict
public class ConflictException(string message)
    : AppException(message)
{
    public override int StatusCode => StatusCodes.Status409Conflict;
}

// 400 Bad Request (not validation — use ValidationException for that)
public class BadRequestException(string message)
    : AppException(message)
{
    public override int StatusCode => StatusCodes.Status400BadRequest;
}

// 403 Forbidden
public class ForbiddenException(string message = "You do not have permission to perform this action.")
    : AppException(message)
{
    public override int StatusCode => StatusCodes.Status403Forbidden;
}

Step 2: IExceptionHandler (ASP.NET Core 8+)

C#
// Handles domain exceptions and maps them to ProblemDetails
public class AppExceptionHandler(IProblemDetailsService problemDetailsService)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx,
        Exception   exception,
        CancellationToken ct)
    {
        if (exception is not AppException appEx)
            return false;   // not handled — let next handler try

        ctx.Response.StatusCode = appEx.StatusCode;

        await problemDetailsService.WriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            Exception   = exception,
            ProblemDetails = new ProblemDetails
            {
                Type     = GetType(appEx.StatusCode),
                Title    = GetTitle(appEx.StatusCode),
                Status   = appEx.StatusCode,
                Detail   = appEx.Detail,
                Instance = ctx.Request.Path,
            },
        });

        return true;   // handled — stop propagation
    }

    private static string GetType(int statusCode) => statusCode switch
    {
        404 => "https://tools.ietf.org/html/rfc9110#section-15.5.5",
        409 => "https://tools.ietf.org/html/rfc9110#section-15.5.10",
        400 => "https://tools.ietf.org/html/rfc9110#section-15.5.1",
        403 => "https://tools.ietf.org/html/rfc9110#section-15.5.4",
        _   => "https://tools.ietf.org/html/rfc9110#section-15.6.1",
    };

    private static string GetTitle(int statusCode) => statusCode switch
    {
        400 => "Bad Request",
        403 => "Forbidden",
        404 => "Not Found",
        409 => "Conflict",
        _   => "Internal Server Error",
    };
}
C#
// Handles FluentValidation exceptions
public class ValidationExceptionHandler(IProblemDetailsService problemDetailsService)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx,
        Exception   exception,
        CancellationToken ct)
    {
        if (exception is not ValidationException validationEx)
            return false;

        ctx.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;

        var errors = validationEx.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(e => e.ErrorMessage).ToArray());

        var problemDetails = new ValidationProblemDetails(errors)
        {
            Type     = "https://tools.ietf.org/html/rfc9110#section-15.5.21",
            Title    = "Validation Failed",
            Status   = StatusCodes.Status422UnprocessableEntity,
            Detail   = "One or more validation errors occurred.",
            Instance = ctx.Request.Path,
        };

        await problemDetailsService.WriteAsync(new ProblemDetailsContext
        {
            HttpContext    = ctx,
            Exception      = exception,
            ProblemDetails = problemDetails,
        });

        return true;
    }
}
C#
// Catch-all handler — logs and returns 500 for any unhandled exception
public class UnhandledExceptionHandler(
    IProblemDetailsService problemDetailsService,
    ILogger<UnhandledExceptionHandler> logger)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx,
        Exception   exception,
        CancellationToken ct)
    {
        logger.LogError(exception,
            "Unhandled exception on {Method} {Path}",
            ctx.Request.Method,
            ctx.Request.Path);

        ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;

        await problemDetailsService.WriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            Exception   = exception,
            ProblemDetails = new ProblemDetails
            {
                Type     = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
                Title    = "Internal Server Error",
                Status   = 500,
                Detail   = "An unexpected error occurred. Please try again later.",
                Instance = ctx.Request.Path,
            },
        });

        return true;   // always handles — stops the exception from propagating further
    }
}

Step 3: Register in Program.cs

C#
// Program.cs
builder.Services.AddProblemDetails();   // required for IProblemDetailsService

// Register handlers in order — first match wins
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();

var app = builder.Build();

// Must be before UseRouting
app.UseExceptionHandler();

// Adds TraceId to ProblemDetails automatically
app.UseStatusCodePages();

Step 4: Add TraceId to Every Response

C#
// Enrich all ProblemDetails with the current TraceId
builder.Services.AddProblemDetails(opts =>
{
    opts.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;

        ctx.ProblemDetails.Extensions["requestId"] =
            ctx.HttpContext.Request.Headers.RequestId.FirstOrDefault();
    };
});

Step 5: Throwing Exceptions in Handlers

C#
// Domain exceptions are thrown anywhere in the application
// IExceptionHandler catches them at the boundary

public class GetOrderHandler(IOrderRepository repo)
    : IRequestHandler<GetOrderQuery, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderQuery q, CancellationToken ct)
    {
        var order = await repo.GetByIdAsync(q.OrderId, ct);

        if (order is null)
            throw new NotFoundException(nameof(Order), q.OrderId);

        if (order.CustomerId != q.RequestingCustomerId)
            throw new ForbiddenException("You can only view your own orders");

        return new OrderDto(order.Id, order.Total, order.Status);
    }
}

Before IExceptionHandler: Middleware Approach (Legacy)

C#
// Alternative for .NET 6/7 — global exception middleware
public class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext ctx)
    {
        try
        {
            await next(ctx);
        }
        catch (AppException ex)
        {
            ctx.Response.StatusCode      = ex.StatusCode;
            ctx.Response.ContentType     = "application/problem+json";
            await ctx.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Status = ex.StatusCode,
                Title  = ex.Message,
                Detail = ex.Detail,
            });
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unhandled exception");
            ctx.Response.StatusCode = 500;
            ctx.Response.ContentType = "application/problem+json";
            await ctx.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Status = 500,
                Title  = "Internal Server Error",
                Detail = "An unexpected error occurred.",
            });
        }
    }
}

Interview Answer

"ASP.NET Core 8 introduces IExceptionHandler — register multiple handlers in DI, each returning true if it handled the exception. The pipeline tries them in registration order, stopping at the first handler that returns true. Use this to separate domain exception handling (NotFoundException, ForbiddenException) from validation exception handling (FluentValidation) from the catch-all handler that logs and returns 500. All responses should be ProblemDetails (RFC 9457) — a standard JSON format with type, title, status, detail, and instance fields; clients can reliably parse errors without reverse-engineering your format. ValidationProblemDetails adds an errors dictionary with per-field messages. Register AddProblemDetails and use CustomizeProblemDetails to inject the TraceId into every error response — this lets support staff correlate client errors to server logs. Never expose stack traces in production; the catch-all handler should log the exception and return a generic message."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.