ASP.NET Core Global Error Handling — ProblemDetails and Exception Middleware

Inconsistent error responses are one of the most common API quality issues — some endpoints return { "error": "..." }, others return { "message": "..." }, and unhandled exceptions return an HTML 500 page. RFC 7807 ProblemDetails is the standard JSON format for API errors, and ASP.NET Core’s built-in support makes it straightforward to return consistent, machine-readable error responses from every endpoint with minimal boilerplate.

ProblemDetails and Global Exception Handling

// ── Program.cs — ProblemDetails configuration ─────────────────────────────
builder.Services.AddProblemDetails(opts =>
{
    opts.CustomizeProblemDetails = ctx =>
    {
        // Add TraceId for log correlation
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;

        // Add instance (the request path)
        ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path;

        // Mask internal details in production
        if (!ctx.HttpContext.RequestServices
                .GetRequiredService<IHostEnvironment>()
                .IsDevelopment())
        {
            if (ctx.ProblemDetails.Status >= 500)
                ctx.ProblemDetails.Detail = "An unexpected error occurred. Please try again.";
        }
    };
});

// ── Custom exception types ────────────────────────────────────────────────
public class NotFoundException  : Exception { public NotFoundException(string m)  : base(m) {} }
public class ConflictException  : Exception { public ConflictException(string m)  : base(m) {} }
public class ForbiddenException : Exception { public ForbiddenException(string m) : base(m) {} }
public class DomainException    : Exception { public DomainException(string m)    : base(m) {} }

// ── IExceptionHandler — typed exception mapping ────────────────────────────
public class BlogAppExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetails;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext ctx, Exception ex, CancellationToken ct)
    {
        var (status, title) = ex switch
        {
            NotFoundException    => (404, "Not Found"),
            ConflictException    => (409, "Conflict"),
            ForbiddenException   => (403, "Forbidden"),
            DomainException      => (400, "Bad Request"),
            ValidationException  => (422, "Validation Error"),
            _                    => (500, "Internal Server Error"),
        };

        ctx.Response.StatusCode = status;

        await _problemDetails.WriteAsync(new ProblemDetailsContext
        {
            HttpContext    = ctx,
            ProblemDetails = new ProblemDetails
            {
                Status = status,
                Title  = title,
                Detail = ex.Message,
            },
            Exception      = ex,
        });

        return true;  // exception handled
    }
}

// ── Register ──────────────────────────────────────────────────────────────
builder.Services.AddExceptionHandler<BlogAppExceptionHandler>();
builder.Services.AddProblemDetails();
// In middleware pipeline:
app.UseExceptionHandler();

// ── Result: consistent ProblemDetails JSON for all errors ─────────────────
// GET /api/posts/non-existent-slug → 404:
// {
//   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
//   "title": "Not Found",
//   "status": 404,
//   "detail": "Post 'non-existent-slug' was not found.",
//   "instance": "/api/posts/non-existent-slug",
//   "traceId": "00-4e8a9c2d1f3b6e7a-8d2c1f4e6a9b3c7d-01"
// }
Note: The traceId extension field is the critical link between the API error response and the server-side log entry. When a user reports an error, their app captures the traceId and includes it in the support ticket. The developer finds the exact log entry (and full stack trace in production logs) by searching for that traceId. Without this correlation ID, production debugging requires guessing which error in hundreds of log entries corresponds to the user’s report. Always include traceId in every error response.
Tip: Throw typed domain exceptions in services rather than returning error result objects or using generic Exception. When PostsService.GetBySlugAsync() throws NotFoundException, the exception handler automatically maps it to a 404 ProblemDetails response. This keeps service code clean (no return type pollution with Result<T> or nullable returns for not-found cases) and controller code clean (no if/else on service results). The downside: exceptions are slower than return values — use them for error conditions, not for expected control flow.
Warning: Never expose exception stack traces in production error responses. Stack traces reveal internal implementation details, library versions, file paths, and line numbers — all useful to attackers. The CustomizeProblemDetails hook in this lesson replaces the Detail field with a generic message for 5xx errors in production. In development, the full stack trace in the detail is helpful for debugging. Use IHostEnvironment.IsDevelopment() to toggle between the two behaviors.

Common Mistakes

Mistake 1 — Inconsistent error response shapes across endpoints (poor client DX)

❌ Wrong — some endpoints return { "error": "..." }, some return HTML, some return ProblemDetails; client needs multiple parsers.

✅ Correct — AddProblemDetails() + UseExceptionHandler(); every error returns the same RFC 7807 ProblemDetails shape.

Mistake 2 — Stack trace in production error response (information disclosure)

❌ Wrong — Detail = ex.ToString() in production; exposes internal paths, library versions, line numbers to callers.

✅ Correct — generic message in production; full detail only in development.

🧠 Test Yourself

A controller calls a service method that throws NotFoundException("Post not found"). No try/catch in the controller. What happens?