Skip to main content

ASP.NET Core Global Error Handling: Complete Guide

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

Unhandled exceptions are inevitable — a database goes down, a third-party API times out, a null sneaks past your validation. What is not inevitable is leaking stack traces to clients, returning inconsistent error payloads, or losing the log entry that would have told you what went wrong. Solid ASP.NET Core exception handling solves all three problems in one place: a single global pipeline that catches every unhandled exception, logs it with full context, and returns a clean, standards-compliant response. In this guide you'll build global error handling for ASP.NET Core step by step — from classic custom middleware, to the modern IExceptionHandler interface in .NET 8+, to RFC 9457 Problem Details and structured logging.

Why Global Exception Handling in ASP.NET Core Matters

Wrapping every controller action in try/catch is the most common mistake developers make with error handling. It fails for three reasons:

  • Repetition breeds inconsistency. Fifty scattered catch blocks means fifty chances to format the error response differently, forget a log call, or return the wrong status code. Your API's error contract becomes whatever each developer felt like that day.
  • Security leaks. The moment one endpoint forgets to catch, ASP.NET Core's developer exception page (or a raw 500) can expose stack traces, connection strings, and internal type names — a gift to attackers.
  • Cross-cutting concerns belong in the pipeline. Exception handling, like authentication and logging, applies to every request. ASP.NET Core's middleware pipeline exists precisely so you write that logic once.

A global handler gives you one place to enforce the rules: log every failure, map known exception types to correct HTTP status codes, and return a consistent body for everything else. Let's build it.

Option 1: Global Exception Handling Middleware (Works on All Versions)

Custom middleware is the classic approach and still the right choice if you're on .NET 6 or 7, or you want full control. The middleware sits early in the pipeline, wraps the rest of the request in a try/catch, and converts exceptions into responses.

public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;

    public GlobalExceptionMiddleware(RequestDelegate next,
        ILogger<GlobalExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Unhandled exception for {Method} {Path}",
                context.Request.Method, context.Request.Path);

            await WriteProblemResponseAsync(context, ex);
        }
    }

    private static async Task WriteProblemResponseAsync(
        HttpContext context, Exception ex)
    {
        var (statusCode, title) = ex switch
        {
            KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
            UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
            ArgumentException => (StatusCodes.Status400BadRequest, "Invalid request"),
            _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
        };

        context.Response.StatusCode = statusCode;
        context.Response.ContentType = "application/problem+json";

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

Register it first in Program.cs so it wraps everything downstream:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();

app.UseMiddleware<GlobalExceptionMiddleware>();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

Why the pattern-matching switch? Mapping exception types to status codes centrally means your domain code can throw meaningful exceptions (KeyNotFoundException when an entity doesn't exist) without knowing anything about HTTP. Your business layer stays clean; the translation happens once, at the edge.

Why include a trace ID? When a customer reports "I got an error," the traceId in the response body is the key that lets your support team find the exact log entry. Never return the exception message itself for 500s — return the correlation ID and keep the details server-side.

Option 2: IExceptionHandler — the Modern .NET 8+ Approach

.NET 8 introduced IExceptionHandler, and it's now the recommended way to do global error handling in ASP.NET Core. Instead of hand-rolling middleware, you implement a small interface and plug it into the built-in exception handler middleware. The framework handles the plumbing; you handle the policy.

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

        var (statusCode, title) = exception switch
        {
            KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
            ArgumentException => (StatusCodes.Status400BadRequest, "Invalid request"),
            _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
        };

        httpContext.Response.StatusCode = statusCode;

        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Instance = httpContext.Request.Path,
            Extensions = { ["traceId"] = httpContext.TraceIdentifier }
        }, cancellationToken);

        return true; // true = handled; false = pass to the next IExceptionHandler
    }
}

Wire it up with two lines:

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

var app = builder.Build();
app.UseExceptionHandler();

Why prefer this over custom middleware? Three reasons. First, the return value of TryHandleAsync lets you chain handlers: register a ValidationExceptionHandler first, a NotFoundExceptionHandler second, and a catch-all last — each returns false to pass exceptions it doesn't recognize. Second, AddProblemDetails() gives you a framework-managed fallback, so even an exception thrown inside your handler still produces a sane response. Third, it composes with the rest of the framework (status code pages, developer exception page in development) instead of fighting it.

Chaining Multiple Handlers

builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // fallback, registered last
builder.Services.AddProblemDetails();

Handlers run in registration order. This keeps each handler small and single-purpose — a genuine improvement over one giant switch statement as your application grows.

Problem Details: The Standard Error Format Your API Should Speak

Both examples above return ProblemDetails, and that's deliberate. RFC 9457 (which supersedes RFC 7807) defines a machine-readable error format that API clients across every language already understand:

{
  "type": "https://csharp-coder.com/errors/not-found",
  "title": "Resource not found",
  "status": 404,
  "detail": "Order 42 does not exist.",
  "instance": "/api/orders/42",
  "traceId": "0HMVFE0A284AM:00000001"
}

Why use a standard instead of your own { "error": "..." } shape? Because clients — SPAs, mobile apps, other services — can write one error-handling path for your entire API surface, and tooling (OpenAPI generators, HTTP client libraries, API gateways) recognizes application/problem+json out of the box. ASP.NET Core's AddProblemDetails() also lets you enrich every response globally:

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Extensions["machine"] = Environment.MachineName;
    };
});

Logging Exceptions the Right Way

Catching exceptions without logging them properly just converts crashes into silent failures. Three rules matter most:

1. Pass the exception as the first argument

// ❌ Wrong — stack trace is lost, message is a flat string
_logger.LogError("Error: " + ex.Message);

// ✅ Right — full exception with stack trace, structured template
_logger.LogError(ex, "Failed to process order {OrderId}", orderId);

Why: the first overload throws away the stack trace and inner exceptions — the two things you actually need at 2 a.m. Structured templates ({OrderId} rather than string interpolation) let log platforms like Seq, Application Insights, or Elasticsearch index the value, so you can query "all failures for order 12345."

2. Log once, at the boundary

If every layer catches, logs, and rethrows, one failure produces five log entries and your error rate metrics become fiction. Let exceptions bubble up to the global handler and log there — with request path, method, and trace ID for context. Lower layers should only catch when they can genuinely recover.

3. Choose severity deliberately

A 404 caused by a mistyped URL is not an error — logging it as one buries real failures in noise. A reasonable policy: LogWarning for client-caused 4xx exceptions, LogError for unexpected 5xx failures, and LogCritical only for conditions that threaten the whole process.

Common Pitfalls in ASP.NET Core Error Handling

  • Registering the middleware too late. Exception middleware only catches exceptions from middleware after it. Register it first (or call app.UseExceptionHandler() before routing/auth) or exceptions thrown in earlier components slip past.
  • Swallowing exceptions with catch (Exception) { }. An empty catch block is a bug you've scheduled for later. If you can't handle it, don't catch it.
  • throw ex; instead of throw;. Rethrowing with the variable resets the stack trace to the current line, destroying the evidence. Use the bare throw; statement.
  • Using exceptions for flow control. Exceptions are expensive to throw and make hot paths slow. For expected outcomes ("user not found"), consider result objects; reserve exceptions for genuinely exceptional states.
  • Leaking internals in production. Never put ex.Message or ex.StackTrace in a 500 response. Gate detailed errors behind app.Environment.IsDevelopment() and the built-in UseDeveloperExceptionPage.
  • Forgetting that middleware can't catch everything. Exceptions thrown after the response has started streaming, or from background Tasks not awaited within the request, won't reach your handler. For fire-and-forget work, use IHostedService/background queues with their own error handling.

Quick Reference: Which Approach Should You Use?

  • .NET 8 or newer: IExceptionHandler + AddProblemDetails(). It's the framework-endorsed pattern, chains cleanly, and requires the least code.
  • .NET 6/7 or need full pipeline control: custom global exception middleware, registered first.
  • Minimal APIs and MVC alike: both approaches work identically — the pipeline doesn't care how your endpoints are defined.
  • Avoid: MVC exception filters as your global strategy — they only cover MVC actions, missing exceptions from other middleware, minimal APIs, and routing.

Conclusion

Great ASP.NET Core exception handling isn't about catching more exceptions — it's about handling all of them in exactly one place, consistently. Key takeaways:

  • Centralize error handling globally: use IExceptionHandler on .NET 8+, or custom middleware on earlier versions — never scattered try/catch blocks in controllers.
  • Return RFC 9457 Problem Details (application/problem+json) so every client gets a predictable, standards-based error contract.
  • Map domain exceptions to HTTP status codes at the edge, keeping your business logic HTTP-agnostic.
  • Log once, at the boundary, with the exception object and structured templates — and always include a trace ID in the response so users can be matched to logs.
  • Never leak stack traces or internal details in production responses.

Drop the GlobalExceptionHandler from this guide into your next project and you'll ship an API that fails gracefully, logs usefully, and keeps its secrets. Your future on-call self will thank you.

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...