Common Questions This Answers
- What is the standard format for API error responses?
- How do I implement global exception handling in ASP.NET Core?
- What's the difference between ProblemDetails and ValidationProblemDetails?
- How do I customize error responses without exposing sensitive information?
- When should I use IExceptionHandler vs middleware?
Definition
ProblemDetails is an RFC 7807-compliant response format for HTTP API errors. Instead of returning inconsistent error shapes across your API, ProblemDetails provides a standardized structure that clients can parse predictably. ASP.NET Core has built-in support for generating ProblemDetails responses.
Terms Used
- RFC 7807: Internet standard defining Problem Details for HTTP APIs
- ProblemDetails: The .NET class implementing RFC 7807
- ValidationProblemDetails: Extended ProblemDetails for model validation errors
- IExceptionHandler: Interface for handling exceptions (.NET 8+)
- IProblemDetailsService: Service for generating ProblemDetails responses
Reader Contract
After reading this article, you will:
- Understand the RFC 7807 ProblemDetails format
- Configure global exception handling with IExceptionHandler
- Customize error responses for different exception types
- Handle validation errors with ValidationProblemDetails
- Prevent sensitive data leakage in error responses
Prerequisites: Basic ASP.NET Core Web API knowledge. See Minimal APIs vs Controllers for API architecture decisions.
Time to implement: 30 minutes for basic setup, 1-2 hours for comprehensive error handling.
Quick Start (10 Minutes)
Enable ProblemDetails for your API:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run();
Now unhandled exceptions and error status codes return RFC 7807 responses:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500
}
RFC 7807 ProblemDetails Format
RFC 7807 defines five standard members:
| Member | Type | Purpose |
|---|---|---|
type |
URI | Identifies the problem category. Defaults to about:blank |
title |
string | Brief, human-readable summary. Should not change between occurrences |
status |
number | HTTP status code |
detail |
string | Occurrence-specific explanation |
instance |
URI | Identifies this specific occurrence |
Example Response
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 403,
"detail": "Your account balance is 30, but the transaction requires 50.",
"instance": "/transactions/abc123"
}
Extension Members
You can add custom properties beyond the standard five:
{
"type": "https://example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 403,
"detail": "Your account balance is 30, but the transaction requires 50.",
"balance": 30,
"required": 50,
"accountId": "12345"
}
Clients must ignore extension members they don't recognize.
Built-in ProblemDetails Support
The ProblemDetails Class
ASP.NET Core's ProblemDetails class maps directly to RFC 7807:
public class ProblemDetails
{
public string? Type { get; set; }
public string? Title { get; set; }
public int? Status { get; set; }
public string? Detail { get; set; }
public string? Instance { get; set; }
public IDictionary<string, object?> Extensions { get; }
}
Returning ProblemDetails from Controllers
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetOrder(int id)
{
var order = FindOrder(id);
if (order is null)
{
return Problem(
title: "Order not found",
detail: $"No order exists with ID {id}",
statusCode: StatusCodes.Status404NotFound);
}
return Ok(order);
}
}
Returning ProblemDetails from Minimal APIs
app.MapGet("/orders/{id}", (int id) =>
{
var order = FindOrder(id);
return order is null
? Results.Problem(
title: "Order not found",
detail: $"No order exists with ID {id}",
statusCode: StatusCodes.Status404NotFound)
: Results.Ok(order);
});
IExceptionHandler (.NET 8+)
IExceptionHandler provides a clean way to handle exceptions globally. Implement the interface and register it:
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception occurred");
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred",
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1"
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // Exception handled
}
}
Register the handler:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); // Required to activate IExceptionHandler
Multiple Exception Handlers
Register multiple handlers for different exception types. Handlers run in registration order:
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // Catch-all last
Each handler returns true to stop processing or false to continue to the next handler:
public class NotFoundExceptionHandler : IExceptionHandler
{
public ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not NotFoundException notFound)
{
return ValueTask.FromResult(false); // Not handled, try next
}
// Handle NotFoundException...
return ValueTask.FromResult(true);
}
}
Global Exception Handling Patterns
Mapping Exception Types to Status Codes
Create a structured approach for consistent error responses:
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IHostEnvironment environment) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Exception: {Message}", exception.Message);
var (statusCode, title) = exception switch
{
ArgumentException => (StatusCodes.Status400BadRequest, "Invalid argument"),
KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
InvalidOperationException => (StatusCodes.Status409Conflict, "Operation not valid"),
_ => (StatusCodes.Status500InternalServerError, "An error occurred")
};
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Instance = httpContext.Request.Path
};
// Only include detail in development
if (environment.IsDevelopment())
{
problemDetails.Detail = exception.Message;
}
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Custom Exception Types
Define domain exceptions that map to specific error responses:
public abstract class DomainException : Exception
{
public abstract int StatusCode { get; }
public abstract string ErrorCode { get; }
protected DomainException(string message) : base(message) { }
}
public class EntityNotFoundException : DomainException
{
public override int StatusCode => StatusCodes.Status404NotFound;
public override string ErrorCode => "ENTITY_NOT_FOUND";
public string EntityType { get; }
public string EntityId { get; }
public EntityNotFoundException(string entityType, string entityId)
: base($"{entityType} with ID '{entityId}' was not found")
{
EntityType = entityType;
EntityId = entityId;
}
}
public class BusinessRuleException : DomainException
{
public override int StatusCode => StatusCodes.Status422UnprocessableEntity;
public override string ErrorCode => "BUSINESS_RULE_VIOLATION";
public BusinessRuleException(string message) : base(message) { }
}
Handle domain exceptions with a dedicated handler:
public class DomainExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not DomainException domainException)
{
return false;
}
var problemDetails = new ProblemDetails
{
Status = domainException.StatusCode,
Title = domainException.ErrorCode,
Detail = domainException.Message,
Instance = httpContext.Request.Path
};
// Add domain-specific extensions
if (domainException is EntityNotFoundException notFound)
{
problemDetails.Extensions["entityType"] = notFound.EntityType;
problemDetails.Extensions["entityId"] = notFound.EntityId;
}
httpContext.Response.StatusCode = domainException.StatusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Validation Error Responses
ValidationProblemDetails
For validation errors, use ValidationProblemDetails which includes an Errors dictionary:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["The Email field is required."],
"Age": ["Age must be between 0 and 150."]
}
}
Automatic Validation in Controllers
Controllers with [ApiController] automatically return ValidationProblemDetails for invalid model state:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserRequest request)
{
// If ModelState is invalid, ASP.NET Core automatically returns
// ValidationProblemDetails with 400 status
return Ok(CreateUser(request));
}
}
public class CreateUserRequest
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Range(0, 150)]
public int Age { get; set; }
}
Custom Validation Response Factory
Customize the validation response format:
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Type = "https://example.com/problems/validation-error",
Title = "Validation failed",
Status = StatusCodes.Status400BadRequest,
Instance = context.HttpContext.Request.Path
};
return new BadRequestObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" }
};
};
});
Handling FluentValidation Errors
If using FluentValidation, convert validation failures to ValidationProblemDetails:
public class ValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validationException)
{
return false;
}
var errors = validationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
var problemDetails = new ValidationProblemDetails(errors)
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Sensitive Data in Errors
The Problem
Exposing internal details creates security vulnerabilities:
{
"type": "https://example.com/errors/database",
"title": "Database Error",
"status": 500,
"detail": "SqlException: Login failed for user 'sa'. Server: prod-db-01.internal"
}
This exposes server names, usernames, and infrastructure details.
Environment-Aware Responses
Include details only in development:
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IHostEnvironment environment) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
logger.LogError(
exception,
"Unhandled exception. TraceId: {TraceId}",
traceId);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred while processing your request.",
Instance = httpContext.Request.Path
};
// Always include trace ID for correlation
problemDetails.Extensions["traceId"] = traceId;
// Only include sensitive details in development
if (environment.IsDevelopment())
{
problemDetails.Detail = exception.ToString();
}
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Safe Error Messages
Map internal exceptions to user-safe messages:
private static string GetSafeErrorMessage(Exception exception) => exception switch
{
DbUpdateException => "A database error occurred. Please try again.",
TimeoutException => "The operation timed out. Please try again.",
HttpRequestException => "An external service is unavailable.",
_ => "An unexpected error occurred."
};
Correlation IDs
Always include a trace ID so users can reference specific errors in support requests:
problemDetails.Extensions["traceId"] = Activity.Current?.Id ?? httpContext.TraceIdentifier;
Response:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-abc123-def456-00"
}
Customizing ProblemDetails
Using CustomizeProblemDetails
Modify all ProblemDetails responses in one place:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
// Always include trace ID
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
// Include timestamp
context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
// Set type URIs based on status code
context.ProblemDetails.Type ??= context.ProblemDetails.Status switch
{
400 => "https://example.com/problems/bad-request",
404 => "https://example.com/problems/not-found",
500 => "https://example.com/problems/internal-error",
_ => "https://example.com/problems/unknown"
};
};
});
Custom ProblemDetailsFactory
For complete control, implement a custom factory:
public class CustomProblemDetailsFactory : ProblemDetailsFactory
{
public override ProblemDetails CreateProblemDetails(
HttpContext httpContext,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
var problemDetails = new ProblemDetails
{
Status = statusCode ?? StatusCodes.Status500InternalServerError,
Title = title ?? GetDefaultTitle(statusCode),
Type = type ?? GetDefaultType(statusCode),
Detail = detail,
Instance = instance ?? httpContext.Request.Path
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
return problemDetails;
}
public override ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
var problemDetails = new ValidationProblemDetails(modelStateDictionary)
{
Status = statusCode ?? StatusCodes.Status400BadRequest,
Title = title ?? "Validation failed",
Type = type ?? "https://example.com/problems/validation-error",
Instance = instance ?? httpContext.Request.Path
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
return problemDetails;
}
private static string GetDefaultTitle(int? statusCode) => statusCode switch
{
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Error"
};
private static string GetDefaultType(int? statusCode) =>
$"https://httpstatuses.io/{statusCode ?? 500}";
}
Register the custom factory:
builder.Services.AddTransient<ProblemDetailsFactory, CustomProblemDetailsFactory>();
Copy/Paste Artifact: Production Error Handling
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
};
});
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run();
// GlobalExceptionHandler.cs
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IHostEnvironment environment) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
logger.LogError(exception, "Unhandled exception. TraceId: {TraceId}", traceId);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred while processing your request.",
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
Instance = httpContext.Request.Path
};
problemDetails.Extensions["traceId"] = traceId;
if (environment.IsDevelopment())
{
problemDetails.Detail = exception.ToString();
}
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Common Failure Modes
Inconsistent Error Formats
Some endpoints return ProblemDetails, others return custom formats:
// Bad: Mixed response types
return new { error = "Not found" }; // Custom format
return NotFound(); // Empty response
return Problem("Not found"); // ProblemDetails
Solution: Use ProblemDetails consistently across all endpoints. Use Results.Problem() in Minimal APIs and Problem() in controllers.
Leaking Stack Traces
Production responses include exception details:
{
"detail": "System.NullReferenceException: Object reference not set...\n at OrderService.GetOrder()"
}
Solution: Only include details in development environments. Log the full exception server-side.
Missing Correlation
Errors don't include trace IDs, making debugging impossible:
Solution: Always include traceId in the extensions. Log with the same trace ID server-side.
Validation Errors as 500
Validation exceptions thrown from services return 500 instead of 400:
// Bad: Validation thrown as exception returns 500
throw new Exception("Email is invalid");
Solution: Use ValidationException with a dedicated handler that returns 400, or validate in controllers before calling services.
Checklist
Before deploying error handling:
-
AddProblemDetails()registered -
UseExceptionHandler()in middleware pipeline - Global exception handler logs all exceptions
- Trace ID included in all error responses
- Sensitive details excluded in production
- Domain exceptions mapped to appropriate status codes
- Validation errors return 400 with ValidationProblemDetails
- Consistent ProblemDetails format across all endpoints
- Error type URIs documented for clients
FAQ
Should I use IExceptionHandler or middleware?
Use IExceptionHandler for .NET 8+. It's cleaner, supports DI, and allows multiple handlers. Use middleware only for older versions or special cases.
What status code for business rule violations?
Use 422 Unprocessable Entity for validation that passes format checks but fails business rules. Use 400 Bad Request for format/syntax errors.
How do I include field-level errors?
Use ValidationProblemDetails with its Errors dictionary, or add an errors extension to regular ProblemDetails.
Should I use custom type URIs?
For simple APIs, the default RFC links are fine. For larger APIs, define custom type URIs that link to your documentation.
How do I handle 404 for missing routes vs missing resources?
Configure UseStatusCodePages() for route 404s. Return Problem() with 404 from controllers for missing resources. Both produce ProblemDetails.
What to do next
Enable AddProblemDetails() in your application today. Then implement IExceptionHandler to customize error responses and add correlation ID logging.
For more on building robust ASP.NET Core APIs, read Minimal API Validation in .NET 10.
If you want help implementing consistent error handling in your API, reach out via Contact.
References
- Handle errors in ASP.NET Core web APIs
- Handle errors in ASP.NET Core
- RFC 7807: Problem Details for HTTP APIs
- ProblemDetails Class
- IExceptionHandler Interface
- ValidationProblemDetails Class
Author notes
Decisions:
- Use IExceptionHandler for global exception handling. Rationale: cleaner than middleware-based exception filters and integrates with ASP.NET Core's error handling pipeline.
- Include correlation IDs in all error responses. Rationale: enables matching client-reported errors with server logs.
- Never include stack traces or internal details in production error responses. Rationale: security risk and not useful to API consumers.
Observations:
- Inconsistent error formats across endpoints confuse API consumers and complicate client error handling.
- Missing correlation IDs make production debugging significantly harder.
- Overly detailed error messages in production have led to information disclosure vulnerabilities.