← Back to all posts

Global Exception Handling in .NET

Global Exception Handling in .NET

Reading Time: 8 minutes

The Problem

Exception handling often starts with scattered try/catch blocks and quickly becomes inconsistent:

  • different endpoints return different error shapes
  • internal exception details are accidentally exposed
  • logs are noisy but hard to correlate
  • clients cannot reliably parse failures

In production, this creates two major issues: poor client experience and weak operational visibility. If every team member handles exceptions differently, your API behavior becomes unpredictable and difficult to support.

The Solution

Use a single, centralized global exception handling strategy in ASP.NET Core:

  1. Catch unhandled exceptions in one place with UseExceptionHandler
  2. Return standardized RFC 7807 responses (application/problem+json) via ProblemDetails
  3. Log exceptions once, consistently, with structured logging
  4. Control how much detail is exposed based on environment

This gives clients a stable contract while preserving diagnostic detail for developers and operators.

Description

1. Configure global exception handling in Program.cs

ASP.NET Core provides built-in middleware for unhandled exceptions. Pair it with ProblemDetails to return consistent responses.

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

app.UseExceptionHandler(exceptionHandlerApp =>
{
    exceptionHandlerApp.Run(async httpContext =>
    {
        var exceptionFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
        var exception = exceptionFeature?.Error;

        var logger = httpContext.RequestServices
            .GetRequiredService<ILoggerFactory>()
            .CreateLogger("GlobalExceptionHandler");

        if (exception is not null)
        {
            logger.LogError(exception,
                "Unhandled exception for {Method} {Path}. TraceId: {TraceId}",
                httpContext.Request.Method,
                httpContext.Request.Path,
                httpContext.TraceIdentifier);
        }

        var statusCode = exception switch
        {
            ArgumentException => StatusCodes.Status400BadRequest,
            KeyNotFoundException => StatusCodes.Status404NotFound,
            _ => StatusCodes.Status500InternalServerError
        };

        httpContext.Response.StatusCode = statusCode;

        var problem = new ProblemDetails
        {
            Status = statusCode,
            Title = statusCode == 500 ? "An unexpected error occurred." : "Request failed.",
            Detail = app.Environment.IsDevelopment() ? exception?.Message : null,
            Type = $"https://httpstatuses.com/{statusCode}",
            Instance = httpContext.Request.Path
        };

        problem.Extensions["traceId"] = httpContext.TraceIdentifier;

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

app.MapGet("/orders/{id:int}", (int id) =>
{
    if (id <= 0) throw new ArgumentException("Order id must be greater than zero.");
    if (id == 404) throw new KeyNotFoundException("Order was not found.");
    if (id == 500) throw new InvalidOperationException("Simulated failure.");

    return Results.Ok(new { id, status = "Processed" });
});

app.Run();

2. Use explicit exception-to-status mapping

Map known business exceptions to the right HTTP status code. This avoids returning 500 for expected failures.

Example mapping policy:

  • ArgumentException -> 400 Bad Request
  • KeyNotFoundException -> 404 Not Found
  • UnauthorizedAccessException -> 403 Forbidden
  • unknown exceptions -> 500 Internal Server Error

Keep this mapping centralized so behavior stays consistent across the whole API.

3. Keep responses safe for production

Never leak stack traces, connection strings, or internal class names in production responses. Use environment checks to include technical details only in Development.

A safe default is:

  • in Development: include exception message
  • outside Development: return a generic message + trace identifier

This still lets support teams correlate issues by traceId without exposing internals.

4. Add structured logging for observability

Built-in logging is enough to start, but many teams add Serilog for richer structured logs and sink flexibility.

  • Serilog.AspNetCore version 10.0.0
  • Serilog.Sinks.Console version 6.1.1
dotnet add package Serilog.AspNetCore --version 10.0.0
dotnet add package Serilog.Sinks.Console --version 6.1.1

Minimal setup:

using Serilog;

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.Console());

5. Validate behavior with integration tests

A reliable global handler should be testable end to end:

  • unhandled exception returns RFC 7807 JSON
  • mapped exceptions return expected status code
  • response includes traceId
  • production mode does not return sensitive details

This prevents regressions when middleware order or custom mappings evolve.

Summary

Global exception handling in .NET should be centralized, consistent, and safe:

  • one middleware path for unhandled exceptions
  • standardized ProblemDetails responses
  • centralized exception-to-status mapping
  • structured logging with trace correlation

The result is better API ergonomics for clients and faster incident diagnosis for teams.

References