Error Handling Best Practices in ASP.NET Core Web API (.NET 10+)
Discover best practices for error handling in ASP.NET Core Web API with .NET 10+. Compare middleware vs IExceptionHandler for performance and maintainability. Implement custom error responses without ProblemDetails for robust APIs.
What are the best practices for error handling in ASP.NET Core Web API using .NET 10+? How do middlewares compare to the IExceptionHandler approach in terms of performance and maintainability? What are the recommended options for implementing custom error responses (not using ProblemDetails) in modern .NET applications?
In ASP.NET Core Web API apps targeting .NET 10+, top error handling sticks to global middleware like UseExceptionHandler or the IExceptionHandler interface for catching unhandled exceptions early in the pipeline. Middleware is dead simple to set up but carries a tiny perf overhead on every request; IExceptionHandler shines for better performance since it only kicks in on actual errors, plus it’s easier to chain multiple handlers. For custom error responses without ProblemDetails, craft JSON payloads directly in a lambda handler or a custom IExceptionHandler implementation—keeps things lightweight and tailored.
Contents
- Best Practices for Error Handling in ASP.NET Core Web API
- The Middleware Approach
- IExceptionHandler: The Modern Alternative
- Performance and Maintainability: Head-to-Head
- Custom Error Responses Without ProblemDetails
- Putting It All Together: Real-World Implementation
- Sources
- Conclusion
Best Practices for Error Handling in ASP.NET Core Web API
You’ve got a Web API humming along in .NET 10+, but what happens when something blows up? Unhandled exceptions shouldn’t leak stack traces to clients—that’s a security nightmare and a poor user experience. The golden rule: handle errors globally at the middleware level, not scattered in every controller action.
Start with the built-in ExceptionHandlerMiddleware. Slap it early in your pipeline, right after routing but before endpoint execution. Why? It catches anything that slips through, logs the mess, and sends a clean response. Avoid exception filters unless you need action-specific logic—they’re heavier and less efficient for APIs.
Log everything meaningfully. Use ILogger to capture the exception, request path, and maybe user context. And differentiate errors: validation issues get 400s, not founds are 404s, server glitches 500s. .NET 10+ makes this smoother with improved async handling in middleware.
But wait, is middleware always the way? Not quite. Let’s break down the options.
The Middleware Approach
Middleware has been the go-to for global error handling since ASP.NET Core’s early days. Picture the request pipeline as a chain: each component processes the HttpContext, and UseExceptionHandler wraps the rest in a safety net.
Setting it up is a breeze. In Program.cs (or Startup.cs if you’re old-school):
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var errorResponse = new { Message = "Something went wrong", Path = context.Request.Path };
await context.Response.WriteAsJsonAsync(errorResponse);
});
});
Single-pass execution means low overhead for simple cases—no DI registrations needed. It just works. But every request flows through that try-catch block under the hood. On high-traffic APIs, does it matter? Barely, but it adds up.
Pros? Predictable, no extra services. Cons? Hard to extend without nesting more middleware or custom extensions. If you need per-exception-type logic, it gets clunky fast.
IExceptionHandler: The Modern Alternative
.NET 8 brought IExceptionHandler to the party, and .NET 10+ polishes it further. This interface lets you register multiple handlers via DI, invoked in order only when an exception hits.
Implement it like this:
public class GlobalExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
httpContext.Response.ContentType = "application/json";
httpContext.Response.StatusCode = 500;
var response = new { Error = exception.Message, RequestId = httpContext.TraceIdentifier };
await httpContext.Response.WriteAsJsonAsync(response, cancellationToken: cancellationToken);
return true; // Handled, stop propagation
}
}
Register in Program.cs:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// Or chain: AddExceptionHandler<ValidationExceptionHandler>().AddExceptionHandler<GlobalExceptionHandler>();
Each handler checks if it can process the exception and returns true to short-circuit. Love it for APIs needing specific logic per error type—like ArgumentException gets a 400, while database timeouts get retry headers.
Why switch from middleware? Flexibility without pipeline bloat.
Performance and Maintainability: Head-to-Head
So, middleware versus IExceptionHandler—which wins? Let’s cut through the noise with real talk.
Performance-wise, middleware wraps every request in a try-catch. Milan Jovanovic’s benchmarks clock it at negligible overhead—maybe 1-2% on RPS in high-load tests. But IExceptionHandler? Zero cost until boom. Handlers spin up via DI only on exceptions, and you control the chain to avoid unnecessary calls. For APIs slamming 10k+ req/sec, that’s your edge.
Maintainability is where IExceptionHandler laps middleware. Middleware forces lambdas or custom classes jammed in Program.cs—quick for prototypes, messy for teams. With IExceptionHandler, each concern gets its class: one for auth failures, another for validation. Register them in order, test independently. Stack Overflow consensus backs this: use handlers for anything beyond basics.
| Aspect | Middleware | IExceptionHandler |
|---|---|---|
| Perf Overhead | Always-on try-catch | Exception-only |
| Setup | One-liner in pipeline | DI registration |
| Extensibility | Nested or extensions | Chained handlers |
| Best For | Simple APIs | Complex, typed errors |
Bottom line: middleware for MVPs, IExceptionHandler for production.
Custom Error Responses Without ProblemDetails
ProblemDetails is RFC-compliant and all, but sometimes you want your own shape—like { "error": "msg", "code": 500, "traceId": "xyz" }. No sweat in .NET 10+.
Option 1: Middleware Lambda. Easiest upgrade from defaults. Grab the exception feature, serialize your record:
record CustomError(string Error, int Code, string? TraceId = null);
app.UseExceptionHandler(appError => appError.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
await context.Response.WriteAsJsonAsync(new CustomError("Oops", 500, context.TraceIdentifier));
}));
Option 2: IExceptionHandler Custom Class. More power. Access DI services like loggers or mappers inside TryHandleAsync. Microsoft recommends this for tailored responses.
Option 3: Extension Methods. Wrap it neatly:
public static class ExceptionMiddlewareExtensions
{
public static void UseCustomExceptionHandler(this IApplicationBuilder app)
{
app.UseExceptionHandler(errorApp => errorApp.Run(async context => { /* custom logic */ }));
}
}
Skip ProblemDetails by not calling the default factory. Always set ContentType to application/json and StatusCode explicitly. Secure it: strip stack traces in prod via env checks.
What about logging? Pipe the exception to Serilog or whatever before responding—clients never see internals.
Putting It All Together: Real-World Implementation
Imagine a user API. Controller throws ValidationException on bad input. Here’s the stack:
-
Register handlers:
builder.Services.AddExceptionHandler<ValidationHandler>().AddExceptionHandler<GlobalHandler>(); -
ValidationHandler:
public class ValidationHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is ValidationException)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { errors = ((ValidationException)ex).Errors });
return true;
}
return false;
}
}
Global catches the rest. Test with xUnit: throw exceptions, assert JSON shapes. Deploy to Azure? It scales seamlessly.
Pro tip: Combine with UseStatusCodePages for non-exception 4xx/5xx. And in .NET 10+, async improvements mean no deadlocks in handlers.
One gotcha—order matters. Specific handlers first.
Sources
- Handle errors in ASP.NET Core — Official guidance on middleware and IExceptionHandler with code samples: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-10.0
- Global Error Handling in ASP.NET Core 8 — Performance comparisons and custom handler examples: https://www.milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-8
- ASP.NET Core Web API exception handling — Community best practices for middleware vs handlers: https://stackoverflow.com/questions/38630076/asp-net-core-web-api-exception-handling
- Global Error Handling in ASP.NET Core Web API — Custom middleware extensions and JSON responses: https://code-maze.com/global-error-handling-aspnetcore/
- Handle errors in ASP.NET Core controller-based web APIs — Web API specifics and custom response overrides: https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-8.0
Conclusion
For ASP.NET Core Web API in .NET 10+, lean on IExceptionHandler over plain middleware when your app grows—better perf, cleaner code. Custom error responses? Lambdas or handlers let you shape JSON exactly as needed, sans ProblemDetails bloat. Nail logging and status codes, test thoroughly, and your API stays robust. Scale confidently; exceptions won’t derail you.