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.
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 unreachableProduction issue I've seen: A system returned raw exception stack traces in production API responses. A junior developer caught a
SqlExceptionand 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:
{
"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"
}// 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.
// 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:
// 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
// Api/Program.cs
app.UseMiddleware<ExceptionHandlingMiddleware>();
// OR (using built-in):
app.UseExceptionHandler();
// Always register BEFORE UseAuthentication so auth failures also get consistent responsesValidation Errors as Problem Details
When FluentValidation fails, return a 422 with all field errors:
// 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
// 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
traceIdthat matches the server log entry. When a developer reports "I got a 500 on patient creation," you can paste thetraceIdinto your log search and find the exact exception, stack trace, and request context in seconds.
problem.Extensions["traceId"] = context.TraceIdentifier;
// Activity.Current?.Id also works if you're using distributed tracingRed 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
traceIdthat links to the server logs.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.