Exception Hierarchy
This document defines the exception hierarchy. It is a critical contract. Deviating from it produces incorrect HTTP responses and breaks the agreement between the API layer and its clients.
Agent Quick Rules {#agent-quick-rules}
- Validators throw
CommandValidationException/QueryValidationExceptionsubclasses; MUST NOT useGuard.Against(see Why Not Guard.Against). - Domain invariants throw
DomainExceptionsubclasses; not-found throwsAggregateNotFoundExceptionsubclasses. ValidationErroris defined in both Contracts projects as identical types (see ValidationError placement); read and write contracts MUST NOT reference each other.- Unhandled exceptions map to HTTP 500 via
GlobalExceptionHandler.
Full convention: docs/conventions/backend/exception-hierarchy.md
Overview
Why This Matters
When a server returns a 500 response to a client, the client has no idea whether the operation failed because the input was invalid, the resource was not found, or an unexpected error occurred. Each situation requires a different client action: show a validation message, navigate to a not-found page, or show a generic error and retry.
Without a defined hierarchy, teams drift into patterns like throwing InvalidOperationException from domain logic and ArgumentException from validators. Both produce 500 responses in a default ASP.NET Core setup. The hierarchy defines exactly four failure categories for expected failures. Every exception class maps to exactly one category. The GlobalExceptionHandler maps categories to HTTP status codes automatically. This is the contract.
The Exception Hierarchy
classDiagram class Exception class DomainException { <<abstract>> +DomainException(string message) } class AggregateNotFoundException { <<abstract>> +AggregateNotFoundException(string message) } class CommandValidationException { <<abstract>> +CommandValidationException(string message) } class QueryValidationException { <<abstract>> +QueryValidationException(string message) }
Exception <|-- DomainException Exception <|-- CommandValidationException Exception <|-- QueryValidationException DomainException <|-- AggregateNotFoundExceptionThe Four Categories
| Category | Base Class | Location | HTTP Status | When to Throw |
|---|---|---|---|---|
| Command Validation Failure | CommandValidationException | Application.Write.Contracts/Shared/Exceptions/ | 400 | Thrown by ICommandValidator<TCommand> when command input is structurally invalid |
| Query Validation Failure | QueryValidationException | Application.Read.Contracts/Shared/Exceptions/ | 400 | Thrown by IQueryValidator<TQuery> when query input is structurally invalid |
| Resource Not Found | AggregateNotFoundException | Domain/Shared/Exceptions/ | 404 | Thrown by repository implementations when an aggregate cannot be found by ID |
| Domain Invariant Violation | DomainException | Domain/Shared/Exceptions/ | 409 | Thrown by aggregate methods when the requested operation is not permitted in the current state |
| Unhandled | (any Exception) | Anywhere | 500 | Any exception not matching the above. Indicates a bug or unexpected external failure |
Base Class Definitions
All base classes are abstract. Never throw a base class directly. Always throw a concrete subclass that names the specific failure.
Note that CommandValidationException and QueryValidationException have no shared base class beyond Exception. They are independent.
ValidationError placement
ValidationError is a sealed record with the same shape in both contracts projects:
| Project | Path |
|---|---|
Application.Write.Contracts | Shared/Exceptions/ValidationError.cs |
Application.Read.Contracts | Shared/Exceptions/ValidationError.cs |
Rules:
- The two definitions MUST remain byte-for-byte equivalent (same property names and types).
Application.Read.ContractsMUST NOT referenceApplication.Write.Contracts(and vice versa).- Do not introduce a third shared contracts project solely for this type unless a project ADR documents a migration (see
docs/decisions/validation-error-dual-contracts-placement.md). GlobalExceptionHandlermaps both exception families to the same RFC 7807invalidParamsshape.
/// <summary>/// The base class for all domain invariant violations./// Maps to HTTP 409 Conflict./// </summary>public abstract class DomainException : Exception{ protected DomainException(string message) : base(message) { }}
// Domain/Shared/Exceptions/AggregateNotFoundException.cs/// <summary>/// The base class for exceptions thrown when an aggregate cannot be/// found by its ID. Maps to HTTP 404 Not Found./// </summary>public abstract class AggregateNotFoundException : DomainException{ protected AggregateNotFoundException(string message) : base(message) { }}
// Application.Write.Contracts/Shared/Exceptions/ValidationError.cspublic sealed record ValidationError(string PropertyName, string ErrorMessage);
// Application.Write.Contracts/Shared/Exceptions/CommandValidationException.cs/// <summary>/// The base class for exceptions thrown by command validators when/// command input is structurally invalid. Maps to HTTP 400 Bad Request./// </summary>public abstract class CommandValidationException : Exception{ protected CommandValidationException(string message, string propertyName) : base(message) { ValidationErrors = [new ValidationError(propertyName, message)]; }
protected CommandValidationException( string message, IReadOnlyList<ValidationError> validationErrors) : base(message) { ValidationErrors = validationErrors; }
/// <summary> /// Field-level validation errors. Used by GlobalExceptionHandler to populate /// the RFC 7807 invalidParams extension. /// </summary> public IReadOnlyList<ValidationError> ValidationErrors { get; }}
// Application.Read.Contracts/Shared/Exceptions/QueryValidationException.cs/// <summary>/// The base class for exceptions thrown by query validators when/// query input is structurally invalid. Maps to HTTP 400 Bad Request./// </summary>public abstract class QueryValidationException : Exception{ protected QueryValidationException(string message, string propertyName) : base(message) { ValidationErrors = [new ValidationError(propertyName, message)]; }
protected QueryValidationException( string message, IReadOnlyList<ValidationError> validationErrors) : base(message) { ValidationErrors = validationErrors; }
/// <summary> /// Field-level validation errors. Used by GlobalExceptionHandler to populate /// the RFC 7807 invalidParams extension. /// </summary> public IReadOnlyList<ValidationError> ValidationErrors { get; }}Concrete Exception Examples
// AggregateNotFoundException subclass (in Domain/Posts/Exceptions/)public sealed class PostNotFoundException : AggregateNotFoundException{ public PostNotFoundException(PostId id) : base($"Post '{id.Value}' was not found.") { }}
// DomainException subclass (in Domain/Posts/Exceptions/)public sealed class PostAlreadyPublishedException : DomainException{ public PostAlreadyPublishedException(PostId id) : base($"Post '{id.Value}' is already published.") { }}
// CommandValidationException subclasses (in Application.Write.Contracts/Posts/Exceptions/)// PostTitleRequiredException — thrown by command validator onlypublic sealed class PostTitleRequiredException : CommandValidationException{ public PostTitleRequiredException() : base("A post title is required and cannot be empty.", nameof(CreatePostCommand.Title)) { }}
// PostTitleTooLongException — thrown by command validator for structural length checkpublic sealed class PostTitleTooLongException : CommandValidationException{ public PostTitleTooLongException(int length) : base($"Post title cannot exceed 200 characters (was {length}).", nameof(CreatePostCommand.Title)) { }}
// DomainException subclasses for value object last-resort defence (in Domain/Posts/Exceptions/)public sealed class PostTitleEmptyException : DomainException{ public PostTitleEmptyException() : base("A post title is required and cannot be empty.") { }}
public sealed class PostTitleExceedsMaximumLengthException : DomainException{ public PostTitleExceedsMaximumLengthException(int length) : base($"Post title cannot exceed 200 characters (was {length}).") { }}
// QueryValidationException subclass (in Application.Read.Contracts/Posts/Exceptions/)public sealed class PostIdRequiredException : QueryValidationException{ public PostIdRequiredException() : base("A post ID is required and cannot be the default value.", nameof(GetPostByIdQuery.PostId)) { }}
// Shared pagination validators (in Application.Read.Contracts/Shared/Exceptions/)public sealed class PageNumberMustBePositiveException : QueryValidationException{ public PageNumberMustBePositiveException() : base("Page number must be at least 1.", nameof(PaginationParameters.PageNumber)) { }}
public sealed class PageSizeMustBePositiveException : QueryValidationException{ public PageSizeMustBePositiveException() : base("Page size must be at least 1.", nameof(PaginationParameters.PageSize)) { }}
public sealed class PageSizeExceedsMaximumException : QueryValidationException{ public PageSizeExceedsMaximumException(int maxPageSize) : base($"Page size cannot exceed {maxPageSize}.", nameof(PaginationParameters.PageSize)) { }}The GlobalExceptionHandler
Endpoints MUST NOT contain try-catch blocks. All unhandled exceptions flow through a single GlobalExceptionHandler registered in Program.cs. The full implementation lives in this section. docs/conventions/backend/api-layer.md documents the HTTP status code table and the validation JSON schema the frontend consumes.
Exception mapping
| Exception type | HTTP status | Response detail |
|---|---|---|
CommandValidationException | 400 | invalidParams array, errorCode, optional traceId (see below) |
QueryValidationException | 400 | invalidParams array, errorCode, optional traceId |
AggregateNotFoundException | 404 | errorCode, detail, optional traceId |
DomainException | 409 | errorCode, detail, optional traceId |
DbUpdateConcurrencyException | 409 | Fixed conflict message (no exception text) |
| All other exceptions | 500 | Generic message only (see security note) |
DbUpdateConcurrencyException from EF Core MUST be caught alongside DomainException. See docs/conventions/backend/concurrency.md for client retry guidance.
Implementation
Create WebApi/Middleware/GlobalExceptionHandler.cs:
internal sealed class GlobalExceptionHandler : IExceptionHandler{ private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) { _logger = logger; }
public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { var (statusCode, title, detail) = exception switch { CommandValidationException ex => (StatusCodes.Status400BadRequest, "Validation failed.", BuildValidationProblem(ex, httpContext)),
QueryValidationException ex => (StatusCodes.Status400BadRequest, "Validation failed.", BuildValidationProblem(ex, httpContext)),
AggregateNotFoundException => (StatusCodes.Status404NotFound, "Resource not found.", (object)new ProblemDetails { Status = StatusCodes.Status404NotFound, Title = "Resource not found.", Detail = exception.Message, Extensions = { ["errorCode"] = "resource.not_found", ["traceId"] = httpContext.TraceIdentifier } }),
DomainException => (StatusCodes.Status409Conflict, "Domain conflict.", (object)new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = "Conflict", Detail = exception.Message, Extensions = { ["errorCode"] = "domain.conflict", ["traceId"] = httpContext.TraceIdentifier } }),
DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Conflict", (object)new ProblemDetails { Status = StatusCodes.Status409Conflict, Title = "Conflict", Detail = "The resource was modified by another actor. Retrieve the latest version and retry." }),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.", (object)new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Internal server error.", Detail = "An unexpected error occurred. Please contact support." }) };
if (statusCode == StatusCodes.Status500InternalServerError) { _logger.LogError(exception, "Unhandled exception"); }
httpContext.Response.StatusCode = statusCode; httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(detail, cancellationToken);
return true; }
private static object BuildValidationProblem( Exception ex, HttpContext httpContext) { var validationErrors = ex switch { CommandValidationException cve => cve.ValidationErrors, QueryValidationException qve => qve.ValidationErrors, _ => [] };
return new { type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", title = "Validation failed.", status = StatusCodes.Status400BadRequest, detail = "One or more fields failed validation.", instance = httpContext.Request.Path.Value, errorCode = "validation.failed", traceId = httpContext.TraceIdentifier, invalidParams = validationErrors.Select(e => new { name = ToCamelCase(e.PropertyName), reason = e.ErrorMessage }) }; }
private static string ToCamelCase(string name) => string.IsNullOrEmpty(name) ? name : char.ToLowerInvariant(name[0]) + name[1..];}Register the handler in Program.cs:
builder.Services.AddProblemDetails();builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// ...
app.UseExceptionHandler();ValidationErrors shape. The exact type of
ValidationErrorsonCommandValidationExceptionandQueryValidationExceptiondepends on the validation library in use. Adjust the projection to match the exception’s actual property names. TheinvalidParamsarray MUST use camelCasenamevalues that match command and query property names on the frontend.
Security note. Unhandled exceptions (HTTP 500) MUST NOT expose
exception.Messagein theDetailfield of the response. Stack traces and internal system details in API responses are an information disclosure risk (OWASP A05). TheGlobalExceptionHandlerMUST use a generic message for 500 responses, as shown in the default case above. The concurrency conflict case MUST NOT expose database-level exception text.
Stable Error Codes
Problem Details responses SHOULD include a machine-readable errorCode extension for expected failures:
| Category | Suggested errorCode |
|---|---|
| Validation | validation.failed (plus field-level invalidParams) |
| Not found | resource.not_found or aggregate-specific code in subclass |
| Domain conflict | domain.conflict or aggregate-specific code |
| Concurrency | concurrency.conflict |
Include traceId from HttpContext.TraceIdentifier for support correlation. Keep detail human-readable. Frontend clients SHOULD branch on errorCode, not on title or detail text.
Concrete exception subclasses MAY define more specific codes (for example post.already_published) when the frontend needs distinct handling.
Throw Site Contract
| Exception Category | Thrown By | Never Thrown By |
|---|---|---|
CommandValidationException | ICommandValidator<TCommand> implementations | Handlers, repositories, query validators, value objects, aggregates |
QueryValidationException | IQueryValidator<TQuery> implementations | Handlers, aggregates, repositories, command validators |
AggregateNotFoundException | Repository GetByIdAsync implementations | Handlers, validators, aggregates, endpoints |
DomainException | Aggregate root methods; value object constructors (last-resort invariant defence) | Handlers, validators, repositories, endpoints |
Validators and value objects
The same structural rule (for example, empty title) MAY appear in both a command validator and a value object constructor:
- The validator throws
CommandValidationExceptionsubclasses and produces structured HTTP 400 responses withinvalidParams. - The value object throws
DomainExceptionsubclasses as last-resort defence when domain code constructs the type outside the command pipeline.
Domain value objects MUST NOT reference Application.Write.Contracts or throw validation exception types from Application assemblies. See docs/conventions/principles.md (Input Validation vs. Invariant Enforcement).
Concrete exceptions MAY share similar messages across layers but MUST use the correct base class for their layer.
Why Not Guard.Against in Validators
Guard.Against from Ardalis.GuardClauses throws ArgumentException and ArgumentNullException by default. These are not CommandValidationException or QueryValidationException subclasses. They map to HTTP 500, not HTTP 400.
Do not use direct Guard.Against calls in validators. In domain code, use explicit checks that throw concrete DomainException subclasses, or project-owned custom guard extensions that throw concrete DomainException subclasses. Never let ArgumentException or ArgumentNullException cross the domain or application boundary.
// GOOD: throw CommandValidationException subclasses directlyinternal sealed class CreatePostCommandValidator : ICommandValidator<CreatePostCommand>{ public Task ValidateAsync(CreatePostCommand command, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(command.Title)) { throw new PostTitleRequiredException(); }
if (command.AuthorId == default) { throw new AuthorIdRequiredException(); }
if (command.Title.Length > 200) { throw new PostTitleTooLongException(command.Title.Length); }
return Task.CompletedTask; }}
// BAD: Guard.Against throws ArgumentException -> maps to HTTP 500internal sealed class CreatePostCommandValidator : ICommandValidator<CreatePostCommand>{ public Task ValidateAsync(CreatePostCommand command, CancellationToken cancellationToken) { Guard.Against.NullOrWhiteSpace(command.Title, nameof(command.Title)); // This throws ArgumentException, not PostTitleRequiredException. // The GlobalExceptionHandler maps it to 500, not 400. return Task.CompletedTask; }}What NOT to Do
// BAD: throwing a generic exception from a handlerinternal sealed class PublishPostCommandHandler : ICommandHandler<PublishPostCommand>{ public async Task HandleAsync(PublishPostCommand command, CancellationToken cancellationToken) { var post = await _repository.GetByIdAsync(command.PostId, cancellationToken);
if (post is null) { throw new InvalidOperationException("Post not found."); // BAD: wrong type, produces 500 }
post.Publish(); }}
// GOOD: repository throws the correct type; handler does not check for nullinternal sealed class PublishPostCommandHandler : ICommandHandler<PublishPostCommand>{ private readonly IPostRepository _postRepository;
public PublishPostCommandHandler(IPostRepository postRepository) { _postRepository = postRepository; }
public async Task HandleAsync(PublishPostCommand command, CancellationToken cancellationToken) { var post = await _postRepository.GetByIdAsync(command.PostId, cancellationToken); // GetByIdAsync throws PostNotFoundException (404) if not found
post.Publish(); // Publish() throws PostAlreadyPublishedException (409) if already published }}Document exception types in the relevant use case doc at docs/domain/{feature}/{use-case}.md and in the feature README when they are shared across use cases.