DEV Community

Cover image for Global Exception Handling in ASP.NET Core — The Complete Guide
Libin Tom Baby
Libin Tom Baby

Posted on

Global Exception Handling in ASP.NET Core — The Complete Guide

UseExceptionHandler, IExceptionHandler (.NET 8), ProblemDetails, middleware vs filters, logging errors

Unhandled exceptions are inevitable.

The question is not whether they will happen — it's whether your API returns a clear, structured error response or exposes a raw stack trace to the client.


Why You Need Global Exception Handling

Without it:

  • Unhandled exceptions return 500 Internal Server Error with no useful body
  • Stack traces may be leaked to clients in non-production environments
  • Logging is inconsistent — some exceptions get logged, others disappear
  • Every controller has its own try-catch — duplicated and inconsistent

With it:

  • Every unhandled exception is caught in one place
  • Errors are logged consistently
  • Clients receive a structured, predictable error response
  • Controllers stay clean — no try-catch everywhere

Approach 1: UseExceptionHandler Middleware

The simplest and most compatible approach. Works in all ASP.NET Core versions.

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var error = context.Features.Get<IExceptionHandlerFeature>();
        var exception = error?.Error;

        context.Response.StatusCode = exception switch
        {
            NotFoundException => StatusCodes.Status404NotFound,
            ValidationException => StatusCodes.Status400BadRequest,
            UnauthorizedException => StatusCodes.Status401Unauthorized,
            _ => StatusCodes.Status500InternalServerError
        };

        context.Response.ContentType = "application/problem+json";

        var problem = new ProblemDetails
        {
            Status = context.Response.StatusCode,
            Title = GetTitle(exception),
            Detail = exception?.Message,
            Instance = context.Request.Path
        };

        await context.Response.WriteAsJsonAsync(problem);
    });
});
Enter fullscreen mode Exit fullscreen mode

Approach 2: IExceptionHandler (.NET 8+)

The modern approach — typed handlers that can be chained.

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

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

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken ct)
    {
        if (exception is not ValidationException validationEx)
            return false; // Pass to next handler

        _logger.LogWarning("Validation failed: {Errors}", validationEx.Errors);

        context.Response.StatusCode = StatusCodes.Status400BadRequest;

        var problem = new ValidationProblemDetails(validationEx.ToDictionary())
        {
            Status = StatusCodes.Status400BadRequest,
            Title = "Validation failed",
            Instance = context.Request.Path
        };

        await context.Response.WriteAsJsonAsync(problem, ct);

        return true; // Handled — stop processing
    }
}
Enter fullscreen mode Exit fullscreen mode

Register handlers in order — first match wins.

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // catch-all last

builder.Services.AddProblemDetails();

app.UseExceptionHandler();
Enter fullscreen mode Exit fullscreen mode

Approach 3: Exception Filter (MVC only)

For MVC controllers specifically — not minimal APIs.

public class ApiExceptionFilter : IExceptionFilter
{
    private readonly ILogger<ApiExceptionFilter> _logger;

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

    public void OnException(ExceptionContext context)
    {
        _logger.LogError(context.Exception, "Unhandled exception");

        context.Result = context.Exception switch
        {
            NotFoundException ex => new NotFoundObjectResult(ex.Message),
            ValidationException ex => new BadRequestObjectResult(ex.Message),
            _ => new ObjectResult("An error occurred")
                { StatusCode = StatusCodes.Status500InternalServerError }
        };

        context.ExceptionHandled = true;
    }
}

// Register globally
builder.Services.AddControllers(options =>
{
    options.Filters.Add<ApiExceptionFilter>();
});
Enter fullscreen mode Exit fullscreen mode

ProblemDetails — RFC 7807

ProblemDetails is the standard structured error response format.

{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Validation failed",
  "status": 400,
  "detail": "The 'Name' field is required",
  "instance": "/api/orders",
  "traceId": "00-8a3e12b1c456d789-b23f4a12c3d45e67-00"
}
Enter fullscreen mode Exit fullscreen mode

Enabling globally:

builder.Services.AddProblemDetails();
Enter fullscreen mode Exit fullscreen mode

Custom Domain Exceptions

public abstract class DomainException : Exception
{
    protected DomainException(string message) : base(message) { }
}

public class NotFoundException : DomainException
{
    public NotFoundException(string entity, object key)
        : base($"{entity} with key {key} was not found.") { }
}

public class ValidationException : DomainException
{
    public IReadOnlyList<string> Errors { get; }

    public ValidationException(IEnumerable<string> errors)
        : base("One or more validation failures occurred.")
    {
        Errors = errors.ToList();
    }
}

public class UnauthorizedException : DomainException
{
    public UnauthorizedException() : base("Unauthorised.") { }
}
Enter fullscreen mode Exit fullscreen mode

Throw them anywhere in your application code — the global handler catches and maps them.


Logging Exceptions

_logger.LogError(
    exception,
    "Unhandled exception for {Method} {Path} — TraceId: {TraceId}",
    context.Request.Method,
    context.Request.Path,
    Activity.Current?.Id ?? context.TraceIdentifier);
Enter fullscreen mode Exit fullscreen mode

Interview-Ready Summary

  • UseExceptionHandler = catch-all middleware, compatible with all ASP.NET Core versions
  • IExceptionHandler (.NET 8+) = typed, chainable handlers — preferred for new projects
  • Exception filters = MVC-specific, controller-level
  • ProblemDetails = RFC 7807 standard for structured error responses
  • Define a domain exception hierarchy to map business errors to HTTP status codes
  • Log every exception with request context and a correlation/trace ID
  • Never expose raw stack traces to clients

A strong interview answer:

"In ASP.NET Core, global exception handling is best done with UseExceptionHandler middleware or the IExceptionHandler interface introduced in .NET 8. Both catch all unhandled exceptions in one place, log them, and return a structured ProblemDetails response. IExceptionHandler allows typed, chainable handlers where each handles a specific exception type. The key is to keep controllers free of try-catch blocks and let the global handler decide the HTTP status code."

Top comments (0)