Build Bulletproof APIs: Global Exception Handling in .NET 10
Master exception handling in ASP.NET Core .NET 10. Learn IExceptionHandler, custom exceptions, ProblemDetails, handler chaining, and SuppressDiagnosticsCallback for production-ready APIs.
Think about the last time you debugged a production issue at 2 AM. Someone reports a vague error, you check the logs, and all you see is "500 Internal Server Error" with zero context. No trace ID. No structured information. Just... nothing.
I've been there. Early in my career, I built an API that worked perfectly in development but became a nightmare in production. Every time something broke, I had to dig through millions of log entries, trying to correlate timestamps and piece together what actually happened. It was exhausting.
The problem? I treated exception handling as an afterthought. I threw in some try-catch blocks here and there, maybe logged a few things, and called it done. Big mistake.
Exception handling isn't just about catching errors → it's about building systems that fail gracefully, give you enough context to fix issues fast, and protect your users from seeing sensitive internal details.
In this guide, we'll walk through everything you need to know about global exception handling in ASP.NET Core. We'll start with the old-school approaches (and why they suck), move to the modern IExceptionHandler interface introduced in .NET 8, and cover the newest .NET 10 feature: SuppressDiagnosticsCallback. By the end, you'll have a production-ready exception handling setup that you can actually maintain.
Let's dive in.
Should You Even Be Reading This?
Quick decision tree:
- Building a new .NET 8+ API? Read this. You need
IExceptionHandler. - Maintaining legacy code with custom middleware? Read this. Learn what you should migrate to.
- Still throwing exceptions everywhere and hoping for the best? Definitely read this. You're one null reference away from a bad day.
- Using .NET 7 or earlier? Still useful, but some features won't apply to you.
What Actually Happens When an Exception Goes Unhandled?
Before we get into solutions, let's understand the problem.
When your API throws an unhandled exception, ASP.NET Core's default behavior is... not great:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-abc123-xyz789-00"
}
Okay, that's somewhat structured. But here's what's missing:
- No indication of what went wrong
- No context about where it happened
- No way to correlate this with your logs
- In development, you might get a stack trace, but in production? Nothing.
Even worse, some exceptions might leak sensitive data:
System.NullReferenceException: Object reference not set to an instance of object.
at YourApp.Services.PaymentService.ProcessPayment() in /home/runner/work/app/PaymentService.cs:line 47
Congratulations, you just told potential attackers your file structure, method names, and that line 47 is handling payments without null checks. Not ideal.
The goal of proper exception handling is to:
- Catch every exception before it reaches the client
- Log it properly with enough context to debug later
- Return a consistent, safe response that doesn't leak internals
- Map different exception types to appropriate HTTP status codes
Let's see how to do this right.
The Evolution: From Try-Catch Hell to IExceptionHandler
Approach 1: Try-Catch Everywhere (Don't Do This)
This is where everyone starts:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
try
{
var product = await _productService.GetByIdAsync(id);
return Ok(product);
}
catch (NotFoundException ex)
{
_logger.LogWarning(ex, "Product not found: {ProductId}", id);
return NotFound(new { message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching product: {ProductId}", id);
return StatusCode(500, new { message = "Internal server error" });
}
}
Looks reasonable, right? Now imagine writing this in every single endpoint. Your codebase becomes:
- 40% business logic
- 60% error handling boilerplate
Plus, every developer on your team will handle errors slightly differently. One returns { error: "..." }, another returns { message: "..." }, someone else just returns the exception message directly. It's chaos.
Verdict: Only use try-catch for localized error recovery where you actually do something different based on the error. Not for returning HTTP responses.
Approach 2: UseExceptionHandler Lambda (Quick But Limited)
ASP.NET Core has built-in middleware for this:
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
if (exceptionFeature is not null)
{
var error = new { message = "An unexpected error occurred" };
await context.Response.WriteAsJsonAsync(error);
}
});
});
This works. All exceptions flow through one place. But it has problems:
- You're writing inline code in
Program.cs→ not maintainable - Hard to unit test
- Handling different exception types requires ugly nested if-else blocks
- No dependency injection for logging or other services
It's fine for tiny projects or prototypes, but it doesn't scale.
Approach 3: Custom Middleware (The Old Standard)
Before .NET 8, this was the recommended approach:
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)
{
_logger.LogError(ex, "Unhandled exception");
await HandleExceptionAsync(context, ex);
}
}
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var statusCode = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
BadRequestException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
};
context.Response.StatusCode = statusCode;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = GetTitle(exception),
Detail = exception.Message
};
await context.Response.WriteAsJsonAsync(problemDetails);
}
private static string GetTitle(Exception exception) => exception switch
{
NotFoundException => "Resource not found",
BadRequestException => "Invalid request",
_ => "An error occurred"
};
}
Register it:
app.UseMiddleware<ExceptionHandlingMiddleware>();
This is better. It's testable, reusable, and you can inject services. But there's a newer, cleaner way.
Approach 4: IExceptionHandler (The Modern Way - .NET 8+)
This is the recommended approach for any new project on .NET 8 or later.
IExceptionHandler is an interface that plugs directly into ASP.NET Core's exception handling middleware. It gives you:
- Dependency injection for all your services
- Testability without spinning up the HTTP pipeline
- Chainability so you can register multiple handlers for different exception types
- Framework integration so you benefit from Microsoft's improvements automatically
Here's what the interface looks like:
public interface IExceptionHandler
{
ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken);
}
Simple, right? You get the exception, you handle it, and you return true if you handled it or false if the next handler should try.
Let's build a real implementation.
Building a Production-Ready Exception Handler
Step 1: Create Custom Exception Classes
First, we need meaningful exceptions. Don't just throw Exception everywhere → create specific types that carry context.
Here's a base class:
public abstract class AppException : Exception
{
public HttpStatusCode StatusCode { get; }
protected AppException(string message, HttpStatusCode statusCode = HttpStatusCode.InternalServerError)
: base(message)
{
StatusCode = statusCode;
}
}
Why does this matter? Because the exception itself knows what HTTP status code it should return. No more giant switch statements trying to figure it out.
Now create specific exceptions:
public sealed class NotFoundException : AppException
{
public NotFoundException(string resourceName, object key)
: base($"{resourceName} with identifier '{key}' was not found.", HttpStatusCode.NotFound)
{
}
}
public sealed class BadRequestException : AppException
{
public BadRequestException(string message)
: base(message, HttpStatusCode.BadRequest)
{
}
}
public sealed class ConflictException : AppException
{
public ConflictException(string message)
: base(message, HttpStatusCode.Conflict)
{
}
}
Notice how NotFoundException builds a descriptive message automatically. When you throw:
throw new NotFoundException("Product", productId);
The exception message becomes: "Product with identifier '123' was not found." Anyone reading the logs knows exactly what happened.
For validation scenarios, we need something special:
public sealed class ValidationException : AppException
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException(IDictionary<string, string[]> errors)
: base("One or more validation errors occurred.", HttpStatusCode.BadRequest)
{
Errors = errors;
}
public ValidationException(string field, string error)
: base("One or more validation errors occurred.", HttpStatusCode.BadRequest)
{
Errors = new Dictionary<string, string[]>
{
{ field, new[] { error } }
};
}
}
This carries field-level validation errors in a dictionary, which we'll map to ValidationProblemDetails later.
Step 2: Implement IExceptionHandler
Here's a complete, production-ready handler:
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
private readonly IProblemDetailsService _problemDetailsService;
public GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IProblemDetailsService problemDetailsService)
{
_logger = logger;
_problemDetailsService = problemDetailsService;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Unhandled exception occurred. TraceId: {TraceId}",
httpContext.TraceIdentifier);
var (statusCode, title) = MapException(exception);
httpContext.Response.StatusCode = statusCode;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Type = GetProblemType(statusCode),
Instance = httpContext.Request.Path,
Detail = GetSafeErrorMessage(exception, httpContext)
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
problemDetails.Extensions["timestamp"] = DateTime.UtcNow;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails
});
}
private static (int StatusCode, string Title) MapException(Exception exception) => exception switch
{
AppException appEx => ((int)appEx.StatusCode, appEx.Message),
ArgumentNullException => (StatusCodes.Status400BadRequest, "Invalid argument provided"),
ArgumentException => (StatusCodes.Status400BadRequest, "Invalid argument provided"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
private static string GetProblemType(int statusCode) => statusCode switch
{
400 => "https://tools.ietf.org/html/rfc9110#section-15.5.1",
401 => "https://tools.ietf.org/html/rfc9110#section-15.5.2",
403 => "https://tools.ietf.org/html/rfc9110#section-15.5.4",
404 => "https://tools.ietf.org/html/rfc9110#section-15.5.5",
409 => "https://tools.ietf.org/html/rfc9110#section-15.5.10",
_ => "https://tools.ietf.org/html/rfc9110#section-15.6.1"
};
private static string? GetSafeErrorMessage(Exception exception, HttpContext context)
{
var env = context.RequestServices.GetRequiredService<IHostEnvironment>();
// In development, show everything for debugging
if (env.IsDevelopment())
{
return exception.Message;
}
// In production, only expose our own exception messages
return exception is AppException ? exception.Message : null;
}
}
Let me break down the key parts:
Logging (Lines 19-20): We log every exception with the trace ID. This is crucial. When a user reports an error, they can give you the trace ID from the response, and you can find the exact exception in your logs instantly.
Status Code Mapping (Lines 22-23): The MapException method uses pattern matching to determine the HTTP status code and title. For our custom AppException types, we use the status code they carry. For built-in .NET exceptions like ArgumentException, we map them to 400. Everything else defaults to 500.
ProblemDetails Construction (Lines 25-36): We follow RFC 9457 (formerly RFC 7807) for consistent error responses. Every error includes:
status: The HTTP status codetitle: A short descriptiontype: A URI pointing to documentation about this error typeinstance: The exact path that caused the errordetail: Additional info (only safe messages in production)traceIdandtimestampin extensions for debugging
Safe Error Messages (Lines 59-69): This is critical for security. In development, we show full exception messages for debugging. In production, we only expose messages from our own AppException types → never system exceptions that might leak internal details like database connection strings or file paths.
IProblemDetailsService (Lines 38-42): Instead of manually serializing JSON, we use ASP.NET Core's built-in service. This handles content negotiation, applies global customizations, and ensures consistency.
Step 3: Register Everything
In your Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Register ProblemDetails services
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
// These will be applied to ALL ProblemDetails responses
ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
};
});
// Register our exception handler
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
// Add exception handling middleware
app.UseExceptionHandler();
app.MapGet("/", () => "Exception Handling Demo");
app.Run();
Done. Every exception in your API will now be handled consistently.
What's New in .NET 10: SuppressDiagnosticsCallback
Here's something that annoyed people in .NET 8 and 9: the exception handling middleware would always emit diagnostic logs (at Error level), even if your IExceptionHandler already logged the exception.
Result? Duplicate logs everywhere.
.NET 10 changes this. Now, when your TryHandleAsync method returns true, the middleware assumes you've handled it and doesn't emit its own logs. Much cleaner.
But what if you want the old behavior? Or what if you want fine-grained control?
Use SuppressDiagnosticsCallback:
// Option 1: Revert to .NET 8/9 behavior (always emit diagnostics)
app.UseExceptionHandler(new ExceptionHandlerOptions
{
SuppressDiagnosticsCallback = _ => false
});
Returning false means "don't suppress diagnostics," so the middleware will log every exception.
// Option 2: Suppress diagnostics only for specific exception types
app.UseExceptionHandler(new ExceptionHandlerOptions
{
SuppressDiagnosticsCallback = context =>
context.Exception is NotFoundException or BadRequestException
});
Here, we suppress diagnostics for expected business errors (NotFoundException, BadRequestException) but allow the middleware to log unexpected errors. This keeps your logs clean while still capturing genuinely unexpected failures.
When would you use this?
- You have high-volume endpoints where certain errors are expected and don't need middleware-level logging
- You're migrating from .NET 9 and want consistent logging behavior during the transition
- You want different logging strategies for different exception types
Chaining Multiple Handlers
One of the coolest features of IExceptionHandler is chaining. You can register multiple handlers, and they'll run in order until one returns true.
Here's a handler specifically for not-found errors:
public sealed class NotFoundExceptionHandler : IExceptionHandler
{
private readonly ILogger<NotFoundExceptionHandler> _logger;
public NotFoundExceptionHandler(ILogger<NotFoundExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
// Guard clause: if it's not our exception, pass to the next handler
if (exception is not NotFoundException notFound)
{
return false;
}
// We handle this exception
_logger.LogWarning("Resource not found: {Message}", notFound.Message);
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 404,
Title = "Resource Not Found",
Detail = notFound.Message,
Instance = httpContext.Request.Path
}, cancellationToken);
return true; // Stop processing
}
}
The key pattern: check the exception type first, return false if you can't handle it, return true if you can.
Here's another one for validation errors:
public sealed class ValidationExceptionHandler : IExceptionHandler
{
private readonly ILogger<ValidationExceptionHandler> _logger;
public ValidationExceptionHandler(ILogger<ValidationExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validation)
{
return false;
}
_logger.LogWarning("Validation failed: {Message}", validation.Message);
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails
{
Status = 400,
Title = "Validation Failed",
Errors = validation.Errors
}, cancellationToken);
return true;
}
}
Notice we use ValidationProblemDetails instead of ProblemDetails. This subclass includes an Errors dictionary that maps field names to error messages → perfect for form validation.
Register them in order:
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // Catch-all
Order matters:
NotFoundExceptionHandlerruns first. If it's aNotFoundException, it handles it.ValidationExceptionHandlerruns next. If it's aValidationException, it handles it.GlobalExceptionHandleris the catch-all. It should handle every exception (never returnfalse).
This keeps each handler focused on one exception type, making them easy to test and maintain.
Complete Working Example
Here's everything in one file you can copy-paste into a new .NET 10 project:
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
};
});
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.MapGet("/", () => "Exception Handling Demo - .NET 10");
app.MapGet("/products/{id:guid}", (Guid id) =>
{
throw new NotFoundException("Product", id);
});
app.MapPost("/products", (ProductRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new BadRequestException("Product name is required");
}
return Results.Created($"/products/{Guid.NewGuid()}", request);
});
app.MapGet("/error", () =>
{
throw new InvalidOperationException("Something went wrong!");
});
app.Run();
// Models
public record ProductRequest(string Name, decimal Price);
// Exceptions
public abstract class AppException : Exception
{
public HttpStatusCode StatusCode { get; }
protected AppException(string message, HttpStatusCode statusCode = HttpStatusCode.InternalServerError)
: base(message)
{
StatusCode = statusCode;
}
}
public sealed class NotFoundException : AppException
{
public NotFoundException(string resourceName, object key)
: base($"{resourceName} with identifier '{key}' was not found.", HttpStatusCode.NotFound)
{
}
}
public sealed class BadRequestException : AppException
{
public BadRequestException(string message)
: base(message, HttpStatusCode.BadRequest)
{
}
}
// Exception Handler
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
private readonly IProblemDetailsService _problemDetailsService;
public GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IProblemDetailsService problemDetailsService)
{
_logger = logger;
_problemDetailsService = problemDetailsService;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Exception occurred. TraceId: {TraceId}", httpContext.TraceIdentifier);
var (statusCode, title) = exception switch
{
AppException appEx => ((int)appEx.StatusCode, appEx.Message),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
httpContext.Response.StatusCode = statusCode;
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = new ProblemDetails
{
Status = statusCode,
Title = title
}
});
}
}
Run this and hit the endpoints:
GET /products/some-guid:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Product with identifier 'some-guid' was not found.",
"status": 404,
"traceId": "0HN123ABC:00000001",
"timestamp": "2026-02-15T10:30:00.000Z"
}
POST /products with empty name:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Product name is required",
"status": 400,
"traceId": "0HN123ABC:00000002",
"timestamp": "2026-02-15T10:31:00.000Z"
}
Clean, consistent, and safe.
Production-Ready Folder Structure
For real projects, don't put everything in one file. Here's how to organize it:
YourApi/
├── Exceptions/
│ ├── AppException.cs
│ ├── NotFoundException.cs
│ ├── BadRequestException.cs
│ ├── ConflictException.cs
│ └── ValidationException.cs
├── Handlers/
│ ├── GlobalExceptionHandler.cs
│ ├── NotFoundExceptionHandler.cs
│ └── ValidationExceptionHandler.cs
├── Program.cs
└── appsettings.json
Each exception in its own file, each handler in its own file. Easy to navigate, easy to test.
Performance Consideration: The Result Pattern
Here's something I need to mention: exceptions are expensive.
When you throw an exception, the .NET runtime has to:
- Capture the stack trace
- Walk the call stack looking for a catch block
- Allocate memory for the exception object
- Unwind the stack
This is fine for truly exceptional situations → things that shouldn't happen during normal operation. But for expected business failures? It's overkill.
Think about it: is "product not found" really an exception? Or is it just... one of the possible outcomes?
This is where the Result pattern comes in:
public record Result<T>
{
public T? Value { get; init; }
public string? Error { get; init; }
public bool IsSuccess => Error is null;
public static Result<T> Success(T value) => new() { Value = value };
public static Result<T> Failure(string error) => new() { Error = error };
}
Usage:
public Result<Product> GetById(Guid id)
{
var product = _repository.Find(id);
return product is not null
? Result<Product>.Success(product)
: Result<Product>.Failure($"Product {id} not found");
}
In your endpoint:
app.MapGet("/products/{id:guid}", (Guid id, ProductService service) =>
{
var result = service.GetById(id);
return result.IsSuccess
? Results.Ok(result.Value)
: Results.NotFound(new { error = result.Error });
});
The advantage? No stack unwinding, no expensive exception objects. Just a simple record allocation. Under heavy load, this can make a noticeable difference.
When to use exceptions vs Result pattern:
- Exceptions: Truly exceptional situations you can't predict (database connection lost, out of memory, external service timeout, file system errors)
- Result pattern: Expected business failures (not found, validation errors, insufficient permissions, duplicate entries)
For more on this, check out libraries like FluentResults or ErrorOr that provide rich Result implementations with typed errors, validation support, and LINQ-friendly APIs.
Wrapping Up
Global exception handling isn't glamorous, but it's essential. Done right, it:
- Makes debugging production issues 10x easier
- Protects your users from seeing sensitive internal details
- Keeps your codebase clean and maintainable
- Provides consistent error responses across your entire API
Key takeaways:
- Use
IExceptionHandlerfor .NET 8+ projects → it's cleaner, testable, and maintainable - Create specific exception types that carry context (like
NotFoundException,ValidationException) - Use
IProblemDetailsServicefor RFC 9457-compliant responses - Leverage .NET 10's
SuppressDiagnosticsCallbackto control diagnostic output - Chain multiple handlers for different exception types
- Consider the Result pattern for expected failures to avoid performance overhead
- Never expose internal error details in production
Set this up early in your project. Future you (and your team) will thank you when things inevitably go wrong.
Happy coding!
