Learnixo
Back to blog
AI Systemsintermediate

Error Handling — Problem Details, Global Exception Middleware, and Result Mapping

How to handle errors consistently in Clean Architecture: Problem Details RFC 7807, global exception middleware for unexpected failures, Result pattern for expected failures, and the production issues that come from inconsistent error responses.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETError HandlingProblem DetailsMiddlewareExceptions
Share:𝕏

Two Categories of Error

Expected failures (use Result pattern):
  → Patient not found
  → MRN already exists
  → Inactive patient cannot receive a prescription
  → Validation failed (missing required field)

Unexpected failures (use exception middleware):
  → Database connection timeout
  → NullReferenceException (a bug)
  → StackOverflowException
  → Third-party API unreachable

Production issue I've seen: A system returned raw exception stack traces in production API responses. A junior developer caught a SqlException and returned it directly as the response body. The response included the database server name, the table schema, and the full SQL query. This is an information disclosure vulnerability — attackers can map your database schema from your error responses.


Problem Details — RFC 7807

.NET includes built-in ProblemDetails support. Every error response should use this format:

JSON
{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Patient.NotFound",
  "status": 404,
  "detail": "Patient with the given ID was not found.",
  "traceId": "00-4abc123-01"
}
C#
// Api/Extensions/ErrorExtensions.cs
public static class ErrorExtensions
{
    public static IActionResult ToProblemResult(this Error error)
    {
        var statusCode = error.Code switch
        {
            var c when c.EndsWith(".NotFound")      => StatusCodes.Status404NotFound,
            var c when c.EndsWith(".AlreadyExists") => StatusCodes.Status409Conflict,
            var c when c.StartsWith("Validation.")  => StatusCodes.Status422UnprocessableEntity,
            var c when c.EndsWith(".Unauthorized")  => StatusCodes.Status403Forbidden,
            _                                       => StatusCodes.Status400BadRequest,
        };

        var problem = new ProblemDetails
        {
            Title  = error.Code,
            Detail = error.Description,
            Status = statusCode,
            Type   = $"https://errors.systemforge.io/{error.Code.ToLowerInvariant().Replace('.', '-')}",
        };

        return new ObjectResult(problem) { StatusCode = statusCode };
    }
}

Global Exception Middleware

This catches unexpected exceptions — bugs, infrastructure failures — and returns a generic 500 without exposing internal details.

C#
// Api/Middleware/ExceptionHandlingMiddleware.cs
namespace SystemForge.Api.Middleware;

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

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

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

            await HandleExceptionAsync(context, ex);
        }
    }

    private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var statusCode = exception switch
        {
            OperationCanceledException => StatusCodes.Status408RequestTimeout,
            _                          => StatusCodes.Status500InternalServerError,
        };

        // Never expose internal exception details in production
        var problem = new ProblemDetails
        {
            Status = statusCode,
            Title  = "An unexpected error occurred.",
            Detail = "The server encountered an error processing your request.",
            Type   = "https://tools.ietf.org/html/rfc7807",
        };

        // Add trace ID for correlation with logs
        problem.Extensions["traceId"] = context.TraceIdentifier;

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

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

Using the Built-In Exception Handler (.NET 8+)

.NET 8 introduced IExceptionHandler as an alternative to custom middleware:

C#
// Api/ExceptionHandlers/GlobalExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;

public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken ct)
    {
        _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);

        var problem = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title  = "Internal Server Error",
            Detail = "An unexpected error occurred.",
        };
        problem.Extensions["traceId"] = context.TraceIdentifier;

        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        await context.Response.WriteAsJsonAsync(problem, ct);
        return true;   // handled
    }
}

// Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

// ...
app.UseExceptionHandler();

Registering the Middleware

C#
// Api/Program.cs
app.UseMiddleware<ExceptionHandlingMiddleware>();
// OR (using built-in):
app.UseExceptionHandler();

// Always register BEFORE UseAuthentication so auth failures also get consistent responses

Validation Errors as Problem Details

When FluentValidation fails, return a 422 with all field errors:

C#
// Controller extension for validation failures
public static IActionResult ToValidationProblemResult(
    this ValidationResult validationResult,
    ControllerBase controller)
{
    foreach (var error in validationResult.Errors)
        controller.ModelState.AddModelError(error.PropertyName, error.ErrorMessage);

    return controller.ValidationProblem();
    // This returns 422 with the standard validation problem format:
    // {
    //   "title": "One or more validation errors occurred.",
    //   "status": 422,
    //   "errors": {
    //     "Name": ["Patient name is required."],
    //     "MRN": ["MRN must contain only uppercase letters, digits, and hyphens."]
    //   }
    // }
}

Development vs Production Detail Level

C#
// Show stack traces in development, hide them in production
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();   // full stack trace, query details
}
else
{
    app.UseExceptionHandler();         // generic 500, no internal details
}

PRO TIP — Correlate Errors With Logs

Every error response should include a traceId that matches the server log entry. When a developer reports "I got a 500 on patient creation," you can paste the traceId into your log search and find the exact exception, stack trace, and request context in seconds.

C#
problem.Extensions["traceId"] = context.TraceIdentifier;
// Activity.Current?.Id also works if you're using distributed tracing

Red Flag Answers

Red flag: "I catch exceptions in controllers and return them as part of the response body."

Stack traces, SQL queries, and internal class names in HTTP responses are information disclosure vulnerabilities. Use middleware for unexpected exceptions and return generic messages.

Red flag: "Different endpoints return errors in different formats — some return a string, some return an object, some throw HTTP exceptions."

Frontend developers cannot write a consistent error handler. Every new endpoint has to be discovered trial-and-error. Use Problem Details (RFC 7807) everywhere.


Key Takeaway

Error handling has two parts: business failures handled by Result pattern (expected, returns typed errors), and unexpected failures handled by global middleware (unexpected, returns generic Problem Details). The separation is clean: handlers never throw for business logic, middleware never swallows unexpected exceptions. Every response has a consistent format, and every error has a traceId that links to the server logs.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.