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