![exception-handling]()
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.
![global-exception-handling]()
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.
![3]()
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
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.json
Architecture & 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;
    }
}
DefaultExceptionToProblemDetailsConverter
Middleware that catches all unhandled exceptions and converts them to appropriate HTTP responses.
Uses ProblemDetails format (RFC 7807) for consistent error responses.
using GlobalExceptionHandling.ProblemDetails;
using System.Text.Json;
namespace GlobalExceptionHandling.Middleware;
/// <summary>
/// Middleware that catches all unhandled exceptions and converts them to appropriate HTTP responses.
/// Uses ProblemDetails format (RFC 7807) for consistent error responses.
/// </summary>
public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;
    private readonly IExceptionToProblemDetailsConverter _converter;
    private readonly IHostEnvironment _environment;
    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger,
        IExceptionToProblemDetailsConverter converter,
        IHostEnvironment environment)
    {
        _next = next;
        _logger = logger;
        _converter = converter;
        _environment = environment;
    }
    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)
    {
        // Get correlation ID for tracking
        var correlationId = context.Items["CorrelationId"]?.ToString() ?? "N/A";
        // Log the exception with correlation ID
        _logger.LogError(
            exception,
            "An unhandled exception occurred. CorrelationId: {CorrelationId}",
            correlationId);
        // Convert exception to ProblemDetails
        var problemDetails = _converter.Convert(exception, context);
        // In development, include stack trace for easier debugging
        if (_environment.IsDevelopment())
        {
            problemDetails.Extensions["exception"] = exception.GetType().Name;
            problemDetails.Extensions["stackTrace"] = exception.StackTrace;
            problemDetails.Extensions["innerException"] = exception.InnerException?.Message;
        }
        // Set response
        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = problemDetails.Status ?? StatusCodes.Status500InternalServerError;
        var options = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            WriteIndented = true
        };
        await context.Response.WriteAsJsonAsync(problemDetails, options);
    }
}
Adding Extensions
Extension methods for configuring the application middleware pipeline.
using GlobalExceptionHandling.Middleware;
namespace GlobalExceptionHandling.Extensions;
/// <summary>
/// Extension methods for configuring the application middleware pipeline.
/// </summary>
public static class ApplicationBuilderExtensions
{
    /// <summary>
    /// Adds global exception handling middleware to the application pipeline.
    /// This should be added early in the pipeline to catch all exceptions.
    /// </summary>
    /// <param name="app">The application builder.</param>
    /// <returns>The application builder for chaining.</returns>
    public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder app)
    {
        app.UseMiddleware<CorrelationIdMiddleware>();
        app.UseMiddleware<ExceptionHandlingMiddleware>();
        return app;
    }
}
ServiceCollectionExtensions
Extension methods for registering global exception handling services.
using GlobalExceptionHandling.ProblemDetails;
namespace GlobalExceptionHandling.Extensions;
/// <summary>
/// Extension methods for registering global exception handling services.
/// </summary>
public static class ServiceCollectionExtensions
{
    /// <summary>
    /// Adds global exception handling services to the service collection.
    /// </summary>
    /// <param name="services">The service collection.</param>
    /// <returns>The service collection for chaining.</returns>
    public static IServiceCollection AddGlobalExceptionHandling(this IServiceCollection services)
    {
        services.AddSingleton<IExceptionToProblemDetailsConverter, DefaultExceptionToProblemDetailsConverter>();
        return services;
    }
}
Summary of Key Components
CorrelationIdMiddleware
- Generates or extracts correlation IDs from request headers 
- Stores the correlation ID in - HttpContext.Itemsfor use throughout the request
 
- Adds correlation ID to response headers for client tracking 
ExceptionHandlingMiddleware
- Catches all unhandled exceptions 
- Logs exceptions with correlation ID for traceability 
- Converts exceptions to RFC 7807 compliant ProblemDetails 
- Includes stack traces in the development environment only 
- Ensures consistent error response format 
Exception Classes
- AppException: Base exception with customizable status code 
- NotFoundException: For resource not found scenarios (404) 
- ValidationFailureException: For validation errors with field-level details (400) 
We will add exception handling middleware to the request pipeline. (Program.cs)
using GlobalExceptionHandling.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
// Add global exception handling services
builder.Services.AddGlobalExceptionHandling();
var app = builder.Build();
// Configure the HTTP request pipeline
// Note: Exception handling middleware should be added FIRST to catch all exceptions
app.UseGlobalExceptionHandling();
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
Let's add controllers with endpoints that throw:
ProductsController
using GlobalExceptionHandling.Exceptions;
using Microsoft.AspNetCore.Mvc;
namespace GlobalExceptionHandling.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly ILogger<ProductsController> _logger;
    public ProductsController(ILogger<ProductsController> logger)
    {
        _logger = logger;
    }
    /// <summary>
    /// Gets a product by ID. Throws NotFoundException if not found.
    /// </summary>
    [HttpGet("{id}")]
    public IActionResult GetProduct(int id)
    {
        _logger.LogInformation("Getting product with ID: {ProductId}", id);
        if (id <= 0)
        {
            throw new ValidationFailureException("id", "Product ID must be greater than 0");
        }
        if (id > 100)
        {
            throw new NotFoundException("Product", id);
        }
        return Ok(new { Id = id, Name = $"Product {id}", Price = 99.99 });
    }
    /// <summary>
    /// Creates a new product. Demonstrates validation failure.
    /// </summary>
    [HttpPost]
    public IActionResult CreateProduct([FromBody] CreateProductRequest request)
    {
        _logger.LogInformation("Creating product: {ProductName}", request.Name);
        var errors = new Dictionary<string, string[]>();
        if (string.IsNullOrWhiteSpace(request.Name))
        {
            errors.Add("name", new[] { "Product name is required" });
        }
        if (request.Price <= 0)
        {
            errors.Add("price", new[] { "Price must be greater than 0" });
        }
        if (errors.Any())
        {
            throw new ValidationFailureException(errors);
        }
        return CreatedAtAction(nameof(GetProduct), new { id = 1 }, request);
    }
    /// <summary>
    /// Demonstrates a generic application exception.
    /// </summary>
    [HttpGet("error")]
    public IActionResult TriggerError()
    {
        throw new AppException("A custom application error occurred", 503);
    }
    /// <summary>
    /// Demonstrates an unhandled exception.
    /// </summary>
    [HttpGet("unhandled")]
    public IActionResult TriggerUnhandledException()
    {
        throw new InvalidOperationException("This is an unhandled exception for testing");
    }
}
public record CreateProductRequest(string Name, decimal Price);
UsersController
using GlobalExceptionHandling.Exceptions;
using Microsoft.AspNetCore.Mvc;
namespace GlobalExceptionHandling.Controllers;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly ILogger<UsersController> _logger;
    public UsersController(ILogger<UsersController> logger)
    {
        _logger = logger;
    }
    /// <summary>
    /// Gets a user by ID. Demonstrates NotFoundException.
    /// </summary>
    [HttpGet("{id}")]
    public IActionResult GetUser(string id)
    {
        _logger.LogInformation("Getting user with ID: {UserId}", id);
        if (string.IsNullOrWhiteSpace(id))
        {
            throw new ValidationFailureException("id", "User ID cannot be empty");
        }
        // Simulate user not found
        if (id != "123")
        {
            throw new NotFoundException("User", id);
        }
        return Ok(new { Id = id, Name = "John Doe", Email = "[email protected]" });
    }
    /// <summary>
    /// Gets user correlation ID from the current request.
    /// </summary>
    [HttpGet("correlation")]
    public IActionResult GetCorrelationId()
    {
        var correlationId = HttpContext.Items["CorrelationId"]?.ToString() ?? "N/A";
        return Ok(new { CorrelationId = correlationId });
    }
}
Testing
You can test various exception scenarios using the provided endpoints:
- GET /api/products/{id}– Returns product or NotFoundException
 
- GET /api/products/{id}(with negative ID) – ValidationFailureException
 
- POST /api/products– Demonstrates validation with multiple fields
 
- GET /api/products/error– Custom AppException
 
- GET /api/products/unhandled– Unhandled exception
 
- GET /api/users/correlation– View current correlation ID
 
Validation Errors (400 Bad Request)
curl http://localhost:5254/api/products/-5
Response
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Validation Error",
  "status": 400,
  "detail": "Validation failure occurred.",
  "instance": "/api/products/-5",
  "correlationId": "4e5c5b80-77c8-49a7-b4dd-12bca5377189",
  "errors": {
    "id": ["Product ID must be greater than 0"]
  }
}
Resource Not Found (404 Not Found)
curl http://localhost:5254/api/products/999
Response
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "Product with id '999' was not found.",
  "instance": "/api/products/999",
  "correlationId": "8df29550-4c02-4f8a-a86a-77b74b693dd1"
}
Custom Application Error (503 Service Unavailable)
curl http://localhost:5254/api/products/error
Response
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "Application Error",
  "status": 503,
  "detail": "A custom application error occurred",
  "instance": "/api/products/error",
  "correlationId": "546de90c-b3c4-4179-9b31-a04fa3e09df1"
}
Unhandled Exception (500 Internal Server Error)
curl http://localhost:5254/api/products/unhandled
Response
{
  "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": "/api/products/unhandled",
  "correlationId": "78d0a3c8-0457-416d-ae48-350dd015bb66"
}
Note: In development mode, responses include additional debugging information like exception, stackTrace, and innerException.
Production Considerations
Security
- Never expose sensitive data in error messages 
- Stack traces only shown in the development environment 
- Generic error messages for unexpected exceptions in production 
- Correlation IDs for secure error tracking 
Performance
- Middleware is highly performant 
- Exception handling only kicks in when exceptions occur 
- Logging is async and non-blocking 
Monitoring & Observability
- All exceptions are logged with structured logging 
- Correlation IDs enable end-to-end request tracking 
- Compatible with Application Insights, Serilog, etc. 
Best Practices
- Use specific exception types: Create custom exceptions for different business scenarios 
- Include correlation IDs: Essential for debugging in distributed systems 
- Log at the right level: Use appropriate log levels (Error for exceptions) 
- Don't catch what you can't handle: Let the middleware handle unexpected exceptions 
- Validate early: Validate input as early as possible in your controllers 
- Use ProblemDetails: Follow RFC 7807 for consistent API error responses 
Conclusion
Implementing a robust global exception handling strategy centralizes and standardizes how your API responds to unexpected failures. It ensures a single, consistent location to shape all error responses, providing predictable behavior across every endpoint. With structured logging, correlation IDs, and trace identifiers, it becomes significantly easier to achieve full observability and traceability throughout the system. This approach also promotes extensibility, allowing domain-specific exceptions and mapping rules to evolve cleanly as the application grows. Most importantly, it safeguards production environments by preventing sensitive data leaks while still offering detailed debugging insights in non-production setups.
For teams looking to go further, this design can be extended into a fully operational solution — complete with a GitHub-ready sample project, unit and integration test templates using xUnit, and advanced background logging to a PostgreSQL activity table via an asynchronous channel queue for high-throughput, fault-tolerant environments.
Reference