Learn ASP.NET Core global error handling with middleware, ProblemDetails, and logging. Copy-paste C# examples and best practices — start building resilient APIs today.
If you have ever shipped an ASP.NET Core API only to watch it leak an ugly stack trace to the client, you already know why ASP.NET Core global error handling matters. A single, centralized place to catch, log, and shape exceptions is the difference between a professional API and one that exposes internals, confuses front-end teams, and buries you in inconsistent error responses. In this tutorial you will learn how to implement global error handling in ASP.NET Core the modern way — using exception handling middleware, the IExceptionHandler interface, standardized ProblemDetails responses, and structured logging.
This guide targets .NET 8 and .NET 9 (the same patterns apply going forward into 2026), and every example is runnable. Whether you are a beginner searching "how to handle exceptions in ASP.NET Core," an intermediate developer looking for best practices, or a senior engineer wanting advanced patterns, you will find something actionable here.
Why You Need Global Error Handling in ASP.NET Core
Wrapping every controller action in try/catch is a code smell. It duplicates logic, clutters your business code, and inevitably someone forgets a block and an unhandled exception escapes. Centralized error handling solves several problems at once:
- Consistency — every error returns the same predictable JSON shape, which front-end and mobile clients can parse reliably.
- Security — you never leak stack traces, connection strings, or internal type names to the outside world.
- Observability — exceptions are logged in one place with correlation IDs, so you can trace failures in production.
- Maintainability — your controllers stay focused on business logic, not plumbing.
The goal of global error handling in ASP.NET Core is simple: catch anything that bubbles up, log it with enough context to debug, and return a clean, standards-compliant response to the caller.
Option 1: The Built-In Exception Handler Middleware
The fastest way to get started is the built-in UseExceptionHandler middleware. In production you point it at an error-handling endpoint or an inline lambda. This middleware sits at the very top of the request pipeline, so it catches exceptions thrown anywhere downstream.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var exception = feature?.Error;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Detail = "Please contact support if the problem persists.",
Instance = context.Request.Path
});
});
});
}
app.MapControllers();
app.Run();
Notice what we did not do: we never send exception.Message or exception.StackTrace to the client. That is intentional. In development you can use app.UseDeveloperExceptionPage() (or simply skip the custom handler) to see full details, but production responses should be sanitized.
Option 2: IExceptionHandler — The Modern .NET 8+ Approach
Since .NET 8, the recommended pattern for ASP.NET Core global error handling is the IExceptionHandler interface. It is cleaner than an inline lambda, fully testable, supports dependency injection, and lets you chain multiple handlers for different exception types. This is the approach most senior developers reach for in 2026.
Step 1: Create a Global Exception Handler
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(
exception,
"Unhandled exception for {Method} {Path}. TraceId: {TraceId}",
httpContext.Request.Method,
httpContext.Request.Path,
httpContext.TraceIdentifier);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server error",
Detail = "An unexpected error occurred while processing your request.",
Instance = httpContext.Request.Path
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response
.WriteAsJsonAsync(problemDetails, cancellationToken);
// return true = we handled it; false = let the next handler try
return true;
}
}
Step 2: Register It in Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapControllers();
app.Run();
That is the whole setup. Any unhandled exception in your controllers, minimal APIs, or downstream services now flows through TryHandleAsync. Because the handler is a DI-registered service, you can inject anything — a logger, a metrics client, a feature flag service — and unit test it in isolation.
Returning Standardized Errors with ProblemDetails
Returning raw strings or ad-hoc JSON objects for errors is a common pitfall. The industry standard is RFC 9457 (formerly RFC 7807), application/problem+json, and ASP.NET Core supports it natively through the ProblemDetails class. A conformant error looks like this:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Server error",
"status": 500,
"detail": "An unexpected error occurred while processing your request.",
"instance": "/api/orders/42",
"traceId": "0HN7B2K9V3M1L:00000003"
}
Using ProblemDetails means every client — Angular, React, a mobile app, or another microservice — gets a machine-readable, self-documenting error contract. Calling AddProblemDetails() also upgrades the framework's automatic responses (validation failures, 404s, 401s) to the same format, giving you end-to-end consistency for free.
Mapping Domain Exceptions to Status Codes
Real applications throw meaningful exceptions: a record is not found, a user lacks permission, or a request fails validation. A single 500 for all of them is a mistake. The elegant solution is to map exception types to the correct HTTP status code inside your handler. Chaining multiple IExceptionHandler implementations keeps this clean.
public sealed class NotFoundExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not NotFoundException notFound)
return false; // not our concern — pass to the next handler
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Resource not found",
Detail = notFound.Message,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response
.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Register the specific handlers before the catch-all. Order matters — handlers run in registration order, and the first one to return true wins:
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // fallback last
This pattern gives you fine-grained control while keeping controllers free of try/catch noise — a core best practice for scalable ASP.NET Core APIs.
Logging Exceptions the Right Way
Good error handling in ASP.NET Core is inseparable from good logging. A caught exception you do not log is a production incident you cannot diagnose. Follow these logging best practices:
- Log the full exception object, not just
ex.Message. Passing the exception as the first argument toLogErrorpreserves the stack trace. - Use structured logging — named placeholders like
{TraceId}and{Path}so log aggregators (Seq, Elastic, Application Insights) can index and query them. - Include a correlation/trace ID in both the log entry and the client response, so a user's error report maps directly to a log line.
- Never log sensitive data such as passwords, tokens, or full request bodies containing PII.
For richer structured logging, Serilog remains the most popular choice in 2026. A minimal setup that enriches every log with the trace ID looks like this:
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.Seq("http://localhost:5341"));
With Enrich.FromLogContext(), any properties you push onto the log context — including the trace identifier — automatically appear on the exception log written by your global handler.
Common Pitfalls to Avoid
- Leaking exception details in production. Never return
exception.ToString()to clients. Keep detailed messages in your logs only. - Placing the middleware in the wrong order.
UseExceptionHandler()must be registered early, before routing and endpoints, or downstream exceptions will slip past it. - Swallowing exceptions silently. Returning a clean response is only half the job — always log before you respond.
- Forgetting
AddProblemDetails(). Without it, framework-generated errors will not match your custom ProblemDetails format, breaking client consistency. - Catching
Exceptionwhere a specific type belongs. Map known domain errors (not found, validation, unauthorized) to precise status codes; reserve 500 for genuinely unexpected failures. - Not testing the handler. Because
IExceptionHandleris a plain injectable class, write unit tests asserting the status code and response body.
Development vs. Production Configuration
A practical setup exposes rich detail locally while sanitizing production output. Combine the developer exception page in development with your global handler everywhere else:
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler();
app.UseHsts();
}
This gives your team full stack traces while debugging and safe, standardized ProblemDetails responses for real users — the best of both worlds.
Conclusion: Key Takeaways
ASP.NET Core global error handling is one of the highest-leverage improvements you can make to any API. By centralizing exception handling you gain consistency, security, and observability while keeping your controllers clean. Here is what to remember:
- Use
IExceptionHandlerwithAddExceptionHandlerandUseExceptionHandler()— the modern, testable pattern for .NET 8 and later. - Return standardized
ProblemDetails(RFC 9457) so every client gets a predictable error contract; always callAddProblemDetails(). - Map domain exceptions to precise HTTP status codes by chaining specific handlers before a catch-all fallback.
- Log the full exception with structured logging and a trace ID, and never leak internal details to clients.
- Show rich errors in development, sanitized responses in production.
Implement these patterns today and your ASP.NET Core APIs will be more secure, more debuggable, and far more professional. Start with the IExceptionHandler example above, wire in ProblemDetails and logging, and you will have production-grade global error handling in under an hour.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment