
Exception handling is one of those fundamental aspects of software development that often gets overlooked until something goes wrong in production. In any .NET application, exceptions are inevitable — they can occur due to invalid inputs, failed database connections, or unexpected runtime behaviors. The most common approach developers take is wrapping code in try-catch blocks and logging errors locally within methods or controllers. While this may appear sufficient for small or isolated components, it quickly becomes unmanageable in real-world, distributed systems.
Inconsistent or poorly designed exception handling introduces several problems:
Inconsistent responses: Different endpoints may return errors in varying formats, leaving API consumers guessing how to interpret them.
Limited observability: Without standardized logging or correlation IDs, tracing a failure across services or understanding its root cause becomes extremely difficult.
Code duplication and clutter: Repetitive
try-catchlogic spreads across controllers and services, reducing readability and increasing maintenance overhead.Hidden failures: Some exceptions get swallowed silently, leading to undetected issues that surface only in production.
Security vulnerabilities: Returning raw exception messages or stack traces exposes internal implementation details, potentially leaking sensitive information.
To address these challenges, a centralized and global exception handling strategy is essential. Rather than relying on scattered error-handling code, we can intercept all unhandled exceptions through a dedicated middleware layer. This allows us to translate exceptions into standardized, client-friendly responses, enrich logs with correlation and trace identifiers, and ensure consistent, secure, and observable error management across the entire API surface.
In modern ASP.NET Core applications, Global Exception Handling isn't just a best practice — it's a foundational part of building resilient, maintainable, and production-ready systems.
This article walks you through a robust, extensible, and production-ready global exception handling strategy for ASP.NET Core APIs. It's written from the perspective of a Senior Software Engineer / Architect and includes design rationale, concrete patterns, full example code you can drop into a project, testing guidance, and operational tips.

Why this matters
Errors happen. How they are handled determines:
What your clients receive (predictable schema vs random stack traces)
What logs/telemetry contain (traceability and triage speed)
Whether secrets (PII) are leaked
How maintainable and testable your system is
A robust global strategy gives a single place for consistent error shape (RFC 7807 ProblemDetails), correlation/tracing, structured logs, and pluggable mapping for domain errors.

Design goals (high level)
Single Responsibility: one middleware to translate all unhandled exceptions into HTTP responses.
Consistent Response Shape: use RFC 7807
application/problem+json(ProblemDetails/ValidationProblemDetails).Observability: correlation ID, trace ID, and structured logs.
Extensibility: pluggable mapping for domain exceptions.
Safety: environment-aware (no stack traces in production).
Testability: integration tests via
WebApplicationFactory.Performance: avoid blocking the request path for heavy logging — optionally queue DB writes.
Implementing Global Exception Handling in ASP.NET Web API with .NET 9
Let's commence with a real example with an ASP.NET Web API. This project demonstrates a robust, production-ready approach to handling exceptions in ASP.NET Core Web API applications using .NET 9.
Prerequisites
.NET 9 SDK
Your favorite IDE (Visual Studio, VS Code, Rider)
I am using VS Code for this implementation. Create an ASP.NET Web API project and choose .NET 9.
Project Structure
GlobalExceptionHandling/
├── Controllers/
│ ├── ProductsController.cs # Sample controller demonstrating exceptions
│ └── UsersController.cs # Sample controller demonstrating correlation ID
├── Middleware/
│ ├── CorrelationIdMiddleware.cs # Generates/propagates correlation IDs
│ └── ExceptionHandlingMiddleware.cs # Catches and formats exceptions
├── Exceptions/
│ ├── AppException.cs # Base application exception
│ ├── NotFoundException.cs # 404 Not Found exception
│ └── ValidationFailureException.cs # 400 Validation error exception
├── ProblemDetails/
│ ├── IExceptionToProblemDetailsConverter.cs # Converter interface
│ └── DefaultExceptionToProblemDetailsConverter.cs # Default converter
├── Extensions/
│ ├── ServiceCollectionExtensions.cs # DI configuration
│ └── ApplicationBuilderExtensions.cs # Middleware configuration
├── Program.cs
└── appsettings.jsonArchitecture & Design
The middleware components are registered in the following order:
CorrelationIdMiddleware: Ensures every request has a correlation ID
ExceptionHandlingMiddleware: Catches all unhandled exceptions
Add CorrelationIdMiddleware
namespace GlobalExceptionHandling.Middleware;
/// <summary>
/// Middleware that generates or retrieves a correlation ID for each request.
/// This helps track requests across different services and logs.
/// </summary>
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeaderName = "X-Correlation-Id";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Try to get correlation ID from request header
var correlationId = context.Request.Headers[CorrelationIdHeaderName].FirstOrDefault();
// If not present, generate a new one
if (string.IsNullOrWhiteSpace(correlationId))
{
correlationId = Guid.NewGuid().ToString();
}
// Store in HttpContext for later use
context.Items["CorrelationId"] = correlationId;
// Add to response headers
context.Response.Headers[CorrelationIdHeaderName] = correlationId;
await _next(context);
}
}AppException + commonly used domain exceptions (Add as per project folder structure).
Exception Hierarchy
Exception
└── AppException (base, 500)
├── NotFoundException (404)
└── ValidationFailureException (400)namespace GlobalExceptionHandling.Exceptions;
/// <summary>
/// Base application exception class that all custom exceptions should inherit from.
/// </summary>
public class AppException : Exception
{
public int StatusCode { get; set; }
public AppException(string message, int statusCode = 500) : base(message)
{
StatusCode = statusCode;
}
public AppException(string message, Exception innerException, int statusCode = 500)
: base(message, innerException)
{
StatusCode = statusCode;
}
}
namespace GlobalExceptionHandling.Exceptions;
/// <summary>
/// Exception thrown when a requested resource is not found.
/// </summary>
public class NotFoundException : AppException
{
public NotFoundException(string message) : base(message, 404)
{
}
public NotFoundException(string resourceName, object key)
: base($"{resourceName} with id '{key}' was not found.", 404)
{
}
}
namespace GlobalExceptionHandling.Exceptions;
/// <summary>
/// Exception thrown when validation fails.
/// </summary>
public class ValidationFailureException : AppException
{
public IDictionary<string, string[]> Errors { get; }
public ValidationFailureException(IDictionary<string, string[]> errors)
: base("One or more validation failures have occurred.", 400)
{
Errors = errors;
}
public ValidationFailureException(string field, string error)
: base("Validation failure occurred.", 400)
{
Errors = new Dictionary<string, string[]>
{
{ field, new[] { error } }
};
}
}We may add some other exception types.
Creating Custom Exceptions
// Simple custom exception
public class UnauthorizedException : AppException
{
public UnauthorizedException(string message)
: base(message, 401)
{
}
}
// Usage in controller
throw new UnauthorizedException("Invalid credentials");IExceptionToProblemDetailsConverter and default implementation
Converts various exception types to appropriate ProblemDetails responses. You can create custom converters for specific exception types:
IExceptionToProblemDetailsConverter
using Microsoft.AspNetCore.Mvc;
namespace GlobalExceptionHandling.ProblemDetails;
/// <summary>
/// Interface for converting exceptions to ProblemDetails.
/// </summary>
public interface IExceptionToProblemDetailsConverter
{
/// <summary>
/// Converts an exception to a ProblemDetails object.
/// </summary>
/// <param name="exception">The exception to convert.</param>
/// <param name="context">The HTTP context.</param>
/// <returns>A ProblemDetails object representing the exception.</returns>
Microsoft.AspNetCore.Mvc.ProblemDetails Convert(Exception exception, HttpContext context);
}DefaultExceptionToProblemDetailsConverter
using GlobalExceptionHandling.Exceptions;
using Microsoft.AspNetCore.Mvc;
namespace GlobalExceptionHandling.ProblemDetails;
/// <summary>
/// Default implementation of IExceptionToProblemDetailsConverter.
/// Converts various exception types to appropriate ProblemDetails responses.
/// </summary>
public class DefaultExceptionToProblemDetailsConverter : IExceptionToProblemDetailsConverter
{
public Microsoft.AspNetCore.Mvc.ProblemDetails Convert(Exception exception, HttpContext context)
{
var problemDetails = new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Instance = context.Request.Path
};
// Add correlation ID if available
if (context.Items.TryGetValue("CorrelationId", out var correlationId))
{
problemDetails.Extensions["correlationId"] = correlationId;
}
switch (exception)
{
case ValidationFailureException validationException:
problemDetails.Status = validationException.StatusCode;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Validation Error";
problemDetails.Detail = validationException.Message;
problemDetails.Extensions["errors"] = validationException.Errors;
break;
case NotFoundException notFoundException:
problemDetails.Status = notFoundException.StatusCode;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.5";
problemDetails.Title = "Resource Not Found";
problemDetails.Detail = notFoundException.Message;
break;
case AppException appException:
problemDetails.Status = appException.StatusCode;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1";
problemDetails.Title = "Application Error";
problemDetails.Detail = appException.Message;
break;
default:
problemDetails.Status = StatusCodes.Status500InternalServerError;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1";
problemDetails.Title = "Internal Server Error";
problemDetails.Detail = "An unexpected error occurred. Please try again later.";
break;
}
return problemDetails;
}
}
Join the conversation! Your thoughts help the community grow.