Global Exception Handler vs Custom Middleware in ASP.NET Core

Leader posted 3 min read

The problem we’re solving

Stuff goes wrong: timeouts, not-found resources, validation fails. You want one consistent error response and clean logs, without sprinkling try/catch everywhere.

Two good options

Option A — Global Exception Handler (recommended default)

Think of it as the framework’s built-in safety net. You plug in one handler and ASP.NET Core turns exceptions into ProblemDetails JSON automatically.

Why people like it

  • Minimal code, fewer edge cases.

  • Plays nice with content negotiation.

  • One central place to map exception → HTTP status.

Use when: you want standard errors and consistency across the app.

Option B — Custom Exception Middleware

Your own IMiddleware that wraps the pipeline in try/catch and writes whatever JSON shape you want.

Why people use it

-Full control over the response body and headers.

-Easy to keep a legacy “envelope” format.

-Use when: clients require a specific, non-ProblemDetails format or you need special side effects.

Decision guide

-Standard JSON errors and less boilerplate → Global handler

-Legacy/bespoke response body → Middleware

-Want a single place to map exceptions to statuses → Global handler

-Heavy custom logic on errors (headers, counters) → Middleware

-Using MediatR and want clean handlers → Use a MediatR behavior to log, then rethrow so the global handler shapes the response

1) Global handler wiring (Program.cs)

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

app.UseExceptionHandler(); // put this early in the pipeline

The handler itself (GlobalExceptionHandler.cs)

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

public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;
    private readonly IProblemDetailsService _pds;
    private readonly IHostEnvironment _env;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger, IProblemDetailsService pds, IHostEnvironment env)
        => (_logger, _pds, _env) = (logger, pds, env);

    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception ex, CancellationToken ct)
    {
        var (status, title) = ex switch
        {
            ValidationException => (422, "Validation failed"),
            NotFoundException   => (404, "Not found"),
            UnauthorizedAccessException => (401, "Unauthorized"),
            TimeoutException    => (503, "Service unavailable"),
            Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException => (409, "Concurrency conflict"),
            TaskCanceledException => (408, "Request timeout"),
            OperationCanceledException => (StatusCodes.Status499ClientClosedRequest, "Client closed request"),
            HttpRequestException => (502, "Bad gateway"),
            _ => (500, "Unexpected error")
        };

        if (status is 503 or 408) http.Response.Headers.TryAdd("Retry-After", "5");

        http.Response.StatusCode = status;

        var pd = new ProblemDetails
        {
            Status = status,
            Title = title,
            Detail = _env.IsDevelopment() ? ex.Message : null,
            Instance = http.Request.Path
        };
        if (ex is ValidationException ve) pd.Extensions["errors"] = ve.Errors;

        _logger.LogError(ex, "Unhandled exception -> HTTP {Status}", status);

        return await _pds.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            ProblemDetails = pd,
            Exception = ex
        });
    }
}

Custom middleware skeleton

public sealed class ExceptionEnvelopeMiddleware : IMiddleware
{
    private readonly ILogger<ExceptionEnvelopeMiddleware> _logger;
    private readonly IHostEnvironment _env;
    public ExceptionEnvelopeMiddleware(ILogger<ExceptionEnvelopeMiddleware> logger, IHostEnvironment env)
        => (_logger, _env) = (logger, env);

    public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
    {
        try { await next(ctx); }
        catch (Exception ex)
        {
            if (ctx.Response.HasStarted) throw;
            _logger.LogError(ex, "Unhandled exception");

            ctx.Response.Clear();
            ctx.Response.StatusCode = ex switch
            {
                ValidationException => StatusCodes.Status422UnprocessableEntity,
                NotFoundException   => StatusCodes.Status404NotFound,
                TimeoutException    => StatusCodes.Status503ServiceUnavailable,
                _ => StatusCodes.Status500InternalServerError
            };
            ctx.Response.ContentType = "application/json";

            var body = new
            {
                title = ctx.Response.StatusCode == 422 ? "Validation failed" :
                        ctx.Response.StatusCode == 404 ? "Not found" :
                        ctx.Response.StatusCode == 503 ? "Service unavailable" : "Unexpected error",
                status = ctx.Response.StatusCode,
                traceId = ctx.TraceIdentifier,
                detail = _env.IsDevelopment() ? ex.Message : null,
                errors = ex is ValidationException ve ? ve.Errors : null
            };
            await ctx.Response.WriteAsJsonAsync(body);
        }
    }
}

Using MediatR? Add a behavior

using MediatR;

public sealed class UnhandledExceptionBehavior<TRequest,TResponse> : IPipelineBehavior<TRequest,TResponse>
    where TRequest : notnull
{
    private readonly ILogger<UnhandledExceptionBehavior<TRequest,TResponse>> _logger;
    public UnhandledExceptionBehavior(ILogger<UnhandledExceptionBehavior<TRequest,TResponse>> logger) => _logger = logger;

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        try { return await next(); }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception in MediatR for {Request}", typeof(TRequest).Name);
            throw; // let the global handler shape ProblemDetails
        }
    }
}

Common pitfalls (and easy wins)

-Place the handler early. Put UseExceptionHandler() before routing and authorization.

-Don’t leak stack traces in production. Keep Detail empty unless in Development.

-Streaming/gRPC: once the response starts, you can’t switch to JSON. Log and stop.

-Map the big ones: validation → 422, not found → 404, timeouts → 503, concurrency → 409.

Takeaway

If you just want clean, predictable errors, use the global handler and be done. If your clients expect a specific JSON envelope, use a custom middleware. With MediatR, add a behavior to keep handlers tidy and let the handler (or middleware) do the final shaping.

Further Reading :

Error handling & ProblemDetails (official docs)
Handle errors in ASP.NET Core (overview).
Microsoft Learn

Handle errors in ASP.NET Core APIs (includes UseExceptionHandler patterns).
Microsoft Learn

Middleware (how it works & how to write your own)

Middleware fundamentals (ordering, short-circuiting).
Microsoft Learn

MediatR pipeline behaviors

Jimmy Bogard (creator) — pipeline posts & patterns.
Jimmy Bogard

FluentValidation in ASP.NET Core (official docs) for a validation behavior that throws a ValidationException.
fluentvalidation.net

Source Code

https://github.com/stevsharp?tab=repositories

More Posts

EF Core Global Query Filters: A Complete Guide

Spyros - Mar 2

How to Bind a MudSelect from an External Source in Blazor Using MudBlazor

Spyros - Apr 10

EF Core Bulk Updates and Deletes: Performance vs Change Tracking

Spyros - Mar 24

EF Core: Lazy Loading, Eager Loading, and Loading Data on Demand

Spyros - Mar 6

Why Records in C# Are Great (and So Nice to Work With)

Spyros - May 10
chevron_left