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 Errorwith 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);
});
});
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
}
}
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();
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>();
});
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"
}
Enabling globally:
builder.Services.AddProblemDetails();
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.") { }
}
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);
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)