Skip to main content

ASP.NET Core Global Exception Handling: Complete Guide

Learn ASP.NET Core global exception handling with middleware, IExceptionHandler and Problem Details. Copy runnable C# examples and ship safer APIs today.

If your API still wraps every action in a try/catch, you are paying a tax on every endpoint you write. ASP.NET Core global exception handling lets you catch, log, and format every unhandled exception in exactly one place — so your controllers stay focused on business logic and your clients always receive a predictable error payload. In this guide you will learn the three approaches that ship with .NET (exception handler middleware, custom middleware, and the modern IExceptionHandler interface), how to return standards-compliant Problem Details responses, and how to wire in structured logging with a correlation ID your support team can actually search.

Everything here targets .NET 8 and .NET 9/10, and every snippet is runnable in a minimal API or MVC project.

Why Global Exception Handling Matters

Three concrete problems get solved the moment you centralise error handling:

  • Information disclosure. An unhandled exception in production can leak stack traces, SQL fragments, connection strings, and internal type names. That is a genuine security finding in most penetration tests.
  • Inconsistent contracts. Without a central handler, one endpoint returns a plain string, another returns JSON, and a third returns an HTML error page. Front-end and mobile teams then write defensive parsing code for every call.
  • Unsearchable logs. If each catch block logs differently, you cannot correlate a user's complaint ("the app said something went wrong at 2pm") with a specific log entry.

A single handler fixes all three at once, and it keeps working for exceptions you never anticipated — the null reference in a library you do not own, the timeout from a dependency that was healthy yesterday.

Approach 1: UseExceptionHandler — the Built-In Middleware

ASP.NET Core ships with UseExceptionHandler. It sits near the top of the pipeline, catches anything thrown further down, and re-executes the request against a handler you supply.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.Map("/error", (HttpContext context) =>
{
    var feature = context.Features.Get<IExceptionHandlerFeature>();
    var exception = feature?.Error;

    return Results.Problem(
        title: "An unexpected error occurred.",
        statusCode: StatusCodes.Status500InternalServerError,
        extensions: new Dictionary<string, object?>
        {
            ["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier
        });
});

app.Run();

Two details are easy to miss. First, UseExceptionHandler must be registered before the middleware it is meant to protect — the pipeline is ordered, and it cannot catch what runs above it. Second, UseDeveloperExceptionPage is for local debugging only; never let it reach production, because it renders the full stack trace and environment variables to the browser.

The lambda overload

If you do not want a dedicated route, use the inline overload. This avoids re-executing routing and is slightly cheaper:

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/problem+json";

        var problem = new ProblemDetails
        {
            Title = "An unexpected error occurred.",
            Status = StatusCodes.Status500InternalServerError,
            Instance = context.Request.Path
        };

        await context.Response.WriteAsJsonAsync(problem);
    });
});

Approach 2: Custom Middleware (the Classic Pattern)

Before .NET 8, most teams wrote their own middleware. It is still perfectly valid, and it is the clearest way to map specific exception types to specific HTTP status codes.

public sealed 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)
        {
            await HandleAsync(context, ex);
        }
    }

    private async Task HandleAsync(HttpContext context, Exception ex)
    {
        var (status, title) = ex switch
        {
            ValidationException      => (StatusCodes.Status400BadRequest, "Validation failed."),
            NotFoundException        => (StatusCodes.Status404NotFound, "Resource not found."),
            UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Access denied."),
            OperationCanceledException  => (499, "Client closed request."),
            _                        => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
        };

        var traceId = Activity.Current?.Id ?? context.TraceIdentifier;

        if (status >= 500)
            _logger.LogError(ex, "Unhandled exception. TraceId: {TraceId}", traceId);
        else
            _logger.LogWarning(ex, "Handled exception. TraceId: {TraceId}", traceId);

        if (context.Response.HasStarted)
            return;   // Too late to change the response.

        context.Response.Clear();
        context.Response.StatusCode = status;
        context.Response.ContentType = "application/problem+json";

        await context.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Title = title,
            Status = status,
            Instance = $"{context.Request.Method} {context.Request.Path}",
            Extensions = { ["traceId"] = traceId }
        });
    }
}

Register it first in the pipeline:

app.UseMiddleware<ExceptionHandlingMiddleware>();

The Response.HasStarted check is the single most important line in that class. Once the first byte of the response body has been flushed, headers and status code are immutable — attempting to change them throws, and you end up with a truncated response and a second exception in your logs.

Approach 3: IExceptionHandler — the Modern .NET 8+ Way

.NET 8 introduced IExceptionHandler, and it is now the recommended approach for ASP.NET Core global exception handling. You register one or more handlers; the framework calls them in registration order until one returns true. This gives you a clean chain of responsibility instead of one enormous switch.

public sealed class ValidationExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;

    public ValidationExceptionHandler(IProblemDetailsService problemDetailsService)
        => _problemDetailsService = problemDetailsService;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not ValidationException validation)
            return false;   // Pass to the next handler.

        httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;

        return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Title  = "One or more validation errors occurred.",
                Status = StatusCodes.Status400BadRequest,
                Detail = validation.Message,
                Type   = "https://tools.ietf.org/html/rfc9110#section-15.5.1"
            }
        });
    }
}

And a catch-all fallback that logs at error level:

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 on {Method} {Path}",
            httpContext.Request.Method,
            httpContext.Request.Path);

        httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;

        return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Title  = "An unexpected error occurred.",
                Status = StatusCodes.Status500InternalServerError
            }
        });
    }
}

Wire everything up in Program.cs. Order matters — specific handlers first, catch-all last:

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Instance =
            $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";

        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;

        ctx.ProblemDetails.Extensions["requestId"] = ctx.HttpContext.TraceIdentifier;
    };
});

var app = builder.Build();

app.UseExceptionHandler();      // Required — activates the handlers above.
app.UseStatusCodePages();       // Problem Details for bare 404s and 401s too.

Note the bare app.UseExceptionHandler() with no arguments. Registering IExceptionHandler services alone does nothing; the middleware is what invokes them. Forgetting this line is the number one reason developers report that "IExceptionHandler is not being called".

Problem Details: Why RFC 9457 Beats a Custom Error Class

Problem Details is a standardised JSON error format (RFC 9457, which obsoletes RFC 7807). A response looks like this:

// HTTP/1.1 400 Bad Request
// Content-Type: application/problem+json
//
// {
//   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
//   "title": "One or more validation errors occurred.",
//   "status": 400,
//   "detail": "Email address is not valid.",
//   "instance": "POST /api/customers",
//   "traceId": "00-8f2a1c9e4b7d3a56-1a2b3c4d5e6f7a8b-01"
// }

Why use it instead of your own ApiError class? Because clients already understand it. Refit, NSwag, the Azure SDKs, and most OpenAPI code generators deserialise application/problem+json out of the box. Publishing a standard shape means one less integration document for your consumers, and it makes your OpenAPI spec honest about what failures look like.

The traceId extension is the piece that pays for itself. Surface it in your UI ("Error reference: 00-8f2a…") and a user's screenshot becomes a direct query into Application Insights, Seq, or Datadog.

Structured Logging That You Can Actually Query

Log the exception object itself, not ex.Message — passing the exception to the ILogger overload preserves the stack trace and inner exceptions:

// Wrong — stack trace is lost, and the message is interpolated into
// the template, so you cannot group by it.
_logger.LogError($"Something failed: {ex.Message}");

// Right — structured properties, full exception, searchable template.
_logger.LogError(ex,
    "Order {OrderId} failed for tenant {TenantId}",
    orderId, tenantId);

Add a scope so every log line inside a request carries the same identifiers:

app.Use(async (context, next) =>
{
    using (_logger.BeginScope(new Dictionary<string, object>
    {
        ["TraceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
        ["UserId"]  = context.User.FindFirst("sub")?.Value ?? "anonymous"
    }))
    {
        await next();
    }
});

With Serilog or OpenTelemetry writing to a structured sink, you can now filter by TraceId and see the full story of one request across every service it touched.

ASP.NET Core Error Handling Best Practices

  • Register the handler first. Exception middleware must be at or near the top of the pipeline, before routing, authentication, and endpoints.
  • Never return exception details in production. Gate ex.ToString() behind IHostEnvironment.IsDevelopment(), and prefer never returning it at all — the traceId is enough for support.
  • Do not use exceptions for expected flow. A missing record is a 404, not a thrown exception. Exceptions are expensive (roughly microseconds each, plus stack capture) and they pollute your error dashboards. Consider a Result type for predictable failures.
  • Treat OperationCanceledException separately. When a user navigates away, the request is cancelled — logging that as an error creates phantom 500s. Check context.RequestAborted.IsCancellationRequested and log at information level.
  • Map status codes deliberately. 400 for bad input, 401 for missing credentials, 403 for insufficient permissions, 404 for missing resources, 409 for concurrency conflicts, 422 for semantic validation failures, 500 only for genuine bugs.
  • Test your handler. A WebApplicationFactory integration test that hits an endpoint which throws, then asserts on the status code and the problem+json content type, costs ten minutes and catches pipeline-ordering regressions forever.
  • Watch out for Response.HasStarted. Streaming endpoints and Server-Sent Events can begin writing before the exception occurs; your handler must bail out gracefully.
  • Remember middleware cannot catch everything. Exceptions thrown during host startup, inside IHostedService.StartAsync, or on background threads never enter the request pipeline. Handle those separately with AppDomain.CurrentDomain.UnhandledException and try/catch inside your hosted services.

Common Pitfalls

Double handling. If you register both a custom middleware and UseExceptionHandler, whichever is outermost wins and the inner one may never fire. Pick one strategy.

Swallowing exceptions. An empty catch { } anywhere in your code means the global handler never sees the problem and you get a silent 200 with garbage data. If you must catch locally, log and rethrow with throw; — never throw ex;, which resets the stack trace.

Forgetting AddProblemDetails(). Without it, IProblemDetailsService.TryWriteAsync returns false and your handler silently falls through to a blank 500.

Which Approach Should You Choose?

For any new project on .NET 8 or later, use IExceptionHandler plus AddProblemDetails. You get dependency injection, testable units, a clean chain for exception-specific handling, and full framework support. Reach for custom middleware only when you need to do something the interface cannot express — such as inspecting the response body or wrapping the pipeline in a transaction scope. Use the route-based UseExceptionHandler("/error") form when you are maintaining an older codebase or rendering HTML error pages in an MVC app.

Conclusion and Key Takeaways

Done properly, ASP.NET Core global exception handling is about fifty lines of code that you write once and never think about again — while every endpoint you add afterwards inherits safe, consistent, well-logged error behaviour for free.

  • Prefer IExceptionHandler on .NET 8+; register specific handlers before the catch-all and always call app.UseExceptionHandler().
  • Return RFC 9457 Problem Details so clients get a predictable, tool-friendly error contract.
  • Attach a traceId to every response and every log line so support tickets map to log entries in one search.
  • Log the exception object with structured properties — never interpolate ex.Message into the template.
  • Never leak stack traces in production, and never use exceptions for expected control flow.
  • Guard against Response.HasStarted, and handle background-thread and startup exceptions outside the pipeline.

Add the handler to your next project before you write your first endpoint. It is the cheapest reliability improvement available in ASP.NET Core.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...