Exception Handling in ASP.NET Core Web API: A Complete Guide
Master exception handling in ASP.NET Core. Covers try/catch basics, custom exceptions, global middleware, Problem Details, IExceptionHandler (.NET 8), logging, and production-safe error responses.
Why Global Exception Handling Matters
Without a strategy, every controller has its own try/catch:
// ❌ Repeated in every action — scattered, inconsistent
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
try
{
var order = await _repo.GetByIdAsync(id);
if (order is null) return NotFound();
return Ok(order);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting order {Id}", id);
return StatusCode(500, "Something went wrong");
}
}Problems: duplicate code in every action, inconsistent error shapes, easy to forget in a new action, no central place to change error format.
The solution: handle exceptions once, centrally.
Custom Domain Exceptions
Define specific exceptions for expected failure scenarios:
// Base for all domain exceptions
public abstract class AppException : Exception
{
protected AppException(string message) : base(message) { }
}
// Business rule violation — 422
public class DomainException : AppException
{
public DomainException(string message) : base(message) { }
}
// Resource not found — 404
public class NotFoundException : AppException
{
public NotFoundException(string resource, object id)
: base($"{resource} with id '{id}' was not found.") { }
}
// Access denied — 403
public class ForbiddenException : AppException
{
public ForbiddenException(string message = "Access denied.") : base(message) { }
}
// Validation failed — 422
public class ValidationException : AppException
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException(IDictionary<string, string[]> errors)
: base("One or more validation errors occurred.")
{
Errors = errors;
}
}
// External service failed — 502
public class ExternalServiceException : AppException
{
public string ServiceName { get; }
public ExternalServiceException(string serviceName, string message)
: base(message)
{
ServiceName = serviceName;
}
}
// Conflict — 409
public class ConflictException : AppException
{
public ConflictException(string message) : base(message) { }
}Option 1: Exception Handling Middleware
// Middleware/ExceptionHandlingMiddleware.cs
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
// Log — include trace ID for correlation
var logLevel = exception is AppException ? LogLevel.Warning : LogLevel.Error;
_logger.Log(logLevel, exception,
"Exception occurred. TraceId: {TraceId}", traceId);
// Map to HTTP response
var (statusCode, title, errors) = MapException(exception);
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
var problemDetails = new ProblemDetails
{
Type = $"https://api.example.com/errors/{title.ToLower().Replace(' ', '-')}",
Title = title,
Status = statusCode,
Detail = statusCode < 500 ? exception.Message : "An unexpected error occurred.",
Instance = context.Request.Path,
Extensions = { ["traceId"] = traceId }
};
if (errors is not null)
problemDetails.Extensions["errors"] = errors;
await context.Response.WriteAsJsonAsync(problemDetails);
}
private static (int StatusCode, string Title, object? Errors) MapException(Exception ex)
=> ex switch
{
NotFoundException => (404, "Not Found", null),
ForbiddenException => (403, "Forbidden", null),
ValidationException ve => (422, "Validation Failed", ve.Errors),
DomainException => (422, "Business Rule Violation", null),
ConflictException => (409, "Conflict", null),
ExternalServiceException => (502, "External Service Error", null),
_ => (500, "Internal Server Error", null)
};
}
// Register in Program.cs — MUST be first middleware
app.UseMiddleware<ExceptionHandlingMiddleware>();Option 2: IExceptionHandler (.NET 8+)
.NET 8 introduced IExceptionHandler — a cleaner, DI-friendly alternative:
// Handlers/DomainExceptionHandler.cs
public class DomainExceptionHandler : IExceptionHandler
{
private readonly ILogger<DomainExceptionHandler> _logger;
public DomainExceptionHandler(ILogger<DomainExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken ct)
{
if (exception is not AppException appException)
return false; // pass to next handler
_logger.LogWarning(exception, "Domain exception: {Message}", exception.Message);
var statusCode = appException switch
{
NotFoundException => StatusCodes.Status404NotFound,
ForbiddenException => StatusCodes.Status403Forbidden,
ConflictException => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity
};
context.Response.StatusCode = statusCode;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = "Request Failed",
Detail = exception.Message,
Extensions = { ["traceId"] = Activity.Current?.TraceId.ToString() }
}, ct);
return true; // handled
}
}
// Catch-all handler for unexpected exceptions
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
_logger.LogError(exception, "Unhandled exception");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 500,
Title = "Internal Server Error",
Detail = "An unexpected error occurred. Please try again later.",
Extensions = { ["traceId"] = Activity.Current?.TraceId.ToString() }
}, ct);
return true;
}
}
// Register
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // last = catch-all
builder.Services.AddProblemDetails();
app.UseExceptionHandler();Option 3: UseExceptionHandler with Lambda
For simpler scenarios:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (exception is null) return;
(int code, string message) = exception switch
{
NotFoundException ex => (404, ex.Message),
DomainException ex => (422, ex.Message),
_ => (500, "An unexpected error occurred.")
};
context.Response.StatusCode = code;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new { status = code, detail = message });
});
});Throwing Exceptions in Domain Code
// Domain layer — throw, never return error codes
public class Order
{
public void Submit()
{
if (Status != OrderStatus.Pending)
throw new DomainException($"Cannot submit an order in {Status} status.");
if (!Lines.Any())
throw new DomainException("Cannot submit an order with no lines.");
Status = OrderStatus.Submitted;
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Delivered)
throw new DomainException("Delivered orders cannot be cancelled.");
}
}
// Application layer — not found throws
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(request.OrderId, ct)
?? throw new NotFoundException("Order", request.OrderId);
if (order.CustomerId != _currentUser.UserId)
throw new ForbiddenException("You don't have access to this order.");
return MapToDto(order);
}
}Validation Exceptions with FluentValidation
// Validator
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty().WithMessage("Customer ID is required.");
RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one line is required.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThan(0);
});
}
}
// MediatR validation pipeline behaviour — throws ValidationException automatically
public class ValidationBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!_validators.Any()) return await next();
var failures = _validators
.Select(v => v.Validate(new ValidationContext<TRequest>(request)))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.GroupBy(f => f.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(f => f.ErrorMessage).ToArray());
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}Error Response Format
Every error returns the same shape (RFC 7807 Problem Details):
{
"type": "https://api.orderflow.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Order with id 'abc-123' was not found.",
"instance": "/api/orders/abc-123",
"traceId": "4bf92f3577b34da6"
}
// Validation error (422)
{
"type": "https://api.orderflow.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "One or more validation errors occurred.",
"traceId": "4bf92f3577b34da6",
"errors": {
"CustomerId": ["Customer ID is required."],
"Lines": ["At least one line is required."]
}
}Never Expose Internal Details in Production
// ❌ Exposes stack trace and internal code paths
return StatusCode(500, ex.ToString());
// ✅ Generic message in production, detail in development
var detail = app.Environment.IsDevelopment()
? exception.ToString()
: "An unexpected error occurred. Please try again later.";Interview Questions
Q: Why use global exception middleware instead of try/catch in every controller? DRY — no repeated logging and error formatting code. Consistency — every error uses the same shape. Safety — you can't forget to add a try/catch to a new action. Testability — the exception handling logic is in one class, easy to test.
Q: What is the difference between IExceptionHandler (.NET 8) and middleware?
IExceptionHandler is registered as a DI service — you can inject other services via the constructor. Multiple handlers can be chained and each returns true/false to indicate whether it handled the exception. Middleware is more low-level. For most .NET 8+ apps, prefer IExceptionHandler.
Q: When should you throw an exception vs return an error code? Throw exceptions for truly exceptional conditions — resource not found, business rule violated, access denied. These are not expected as part of normal flow for the calling code. Use return values for expected outcomes within normal flow (e.g., a search returning no results is not exceptional).
Q: How do you prevent exposing stack traces in production?
Map all unhandled exceptions to a generic 500 message in the global handler. Only log the full exception (with stack trace) server-side. In development, you can return more detail. Never return ex.ToString() in a production API.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.