Exception Hierarchy
Domain, application, infrastructure, and validation exception types — how to structure and organise them.
Status: Extracted
Purpose: Exception hierarchy design, exception types, and exception organization.
Exception Hierarchy
Exception Types
Domain Exceptions:
- Business rule violations
- Invariant violations
- Domain-specific errors
Application Exceptions:
- Use case failures
- Orchestration errors
- Application-level errors
Infrastructure Exceptions:
- Database errors
- External service errors
- Infrastructure-level errors
Validation Exceptions:
- Pydantic validation errors
- Request validation errors
- Input validation errors
Exception Organization
Exception Location
API Exceptions:
- Location:
api/exceptions.py - HTTP-related exceptions
- API-specific errors
Domain Exceptions:
- Location:
domain/{aggregate}/exceptions.py - Domain-specific errors
- Business rule violations
Application Exceptions:
- Location:
application/exceptions.pyorapplication/{domain}/exceptions.py - Application-level errors
- Use case failures
Infrastructure Exceptions:
- Location:
infrastructure/{component}/exceptions.py - Infrastructure-specific errors
- External service errors
Exception Examples
Domain Exceptions
Pattern:
# domain/account/exceptions.py
class AccountNotFoundError(Exception):
"""Account not found error."""
def __init__(self, account_id: str):
self.account_id = account_id
super().__init__(f"Account with ID '{account_id}' not found")
class AccountNotAuthorizedError(Exception):
"""Account not authorized error."""
def __init__(self, account_id: str, reason: str):
self.account_id = account_id
self.reason = reason
super().__init__(f"Account '{account_id}' not authorized: {reason}")Application Exceptions
Pattern:
# application/exceptions.py
class UseCaseError(Exception):
"""Base exception for use case errors."""
pass
class OrchestrationError(UseCaseError):
"""Orchestration error."""
passInfrastructure Exceptions
Pattern:
# infrastructure/database/exceptions.py
class DatabaseError(Exception):
"""Base exception for database errors."""
pass
class ConnectionError(DatabaseError):
"""Database connection error."""
passValidation Exceptions
Pattern:
# api/exceptions.py
class ValidationError(Exception):
"""Validation error."""
def __init__(self, message: str, field: str | None = None):
self.field = field
super().__init__(message)Exception Best Practices
Exception Design
- Specific exceptions: Create specific exceptions for different error cases
- Exception messages: Clear, user-friendly error messages
- Exception context: Include relevant context (IDs, fields, etc.)
- Exception hierarchy: Use inheritance for related exceptions
Exception Usage
- Throw exceptions: Use exceptions for exceptional cases
- Don't use for control flow: Exceptions are for errors, not normal flow
- Handle at boundaries: Handle exceptions at layer boundaries (API layer)
- Log exceptions: Log all exceptions with context
Related Documents
- Error Handling Middleware - Middleware patterns
- Error Handling Strategy - Error handling approach
Reference implementation: services/account-api/domain/errors.py
Framework implementations
Abstract reference: 13-error-handling-resilience/
Errors flow outward through layers. Domain errors are raised in the domain and application layers. Exception handlers in api/middleware/error_handler.py catch them and map them to HTTP responses.
Error Hierarchy
Exception
└── DomainError # base for all domain errors (domain/)
├── EntityNotFoundError # entity does not exist
├── NotAuthorizedError # actor lacks permission
├── ValidationError # business rule violated
└── ConflictError # e.g. duplicate, state conflict
└── ApplicationError # base for application-layer errors (application/)
└── (add as needed)
└── InfrastructureError # base for infra errors (infrastructure/)
└── (add as needed)
Where each type is raised:
DomainErrorsubclasses — raised indomain/andapplication/handlers. Never raised inapi/orinfrastructure/.ValidationError(from Pydantic) — raised by FastAPI automatically on request schema failures.InfrastructureError— raised ininfrastructure/adapters. Should be caught and wrapped inApplicationErrorat the application boundary if they need to propagate.
Domain Error Base (domain/errors.py)
class DomainError(Exception):
"""Base for all domain errors. Carries a machine-readable error code."""
def __init__(self, message: str, error_code: str, is_retryable: bool = False):
super().__init__(message)
self.error_code = error_code
self.is_retryable = is_retryable
class EntityNotFoundError(DomainError):
def __init__(self, entity_type: str, entity_id: str):
super().__init__(
message=f"{entity_type} not found: {entity_id}",
error_code=f"{entity_type.upper()}_NOT_FOUND",
)
self.entity_type = entity_type
self.entity_id = entity_id
class NotAuthorizedError(DomainError):
def __init__(self, reason: str):
super().__init__(message=reason, error_code="NOT_AUTHORIZED")
class ConflictError(DomainError):
def __init__(self, message: str):
super().__init__(message=message, error_code="CONFLICT")Exception Handlers (api/middleware/error_handler.py)
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from domain.errors import EntityNotFoundError, NotAuthorizedError, ConflictError, DomainError
import logging, traceback
logger = logging.getLogger(__name__)
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(EntityNotFoundError)
async def not_found_handler(request: Request, exc: EntityNotFoundError):
_log(logger.warning, "entity.not_found", exc, request)
return JSONResponse(
{"detail": str(exc), "error_code": exc.error_code},
status_code=404,
)
@app.exception_handler(NotAuthorizedError)
async def not_authorized_handler(request: Request, exc: NotAuthorizedError):
_log(logger.warning, "not.authorized", exc, request)
return JSONResponse(
{"detail": str(exc), "error_code": exc.error_code},
status_code=403,
)
@app.exception_handler(ConflictError)
async def conflict_handler(request: Request, exc: ConflictError):
_log(logger.warning, "conflict", exc, request)
return JSONResponse(
{"detail": str(exc), "error_code": exc.error_code},
status_code=409,
)
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
{"detail": exc.errors(), "error_code": "VALIDATION_ERROR"},
status_code=422,
)
@app.exception_handler(Exception)
async def catchall_handler(request: Request, exc: Exception):
ctx = getattr(request.state, "ctx", {})
logger.error(
"unhandled.exception",
extra={
"request_id": ctx.get("request_id"),
"exception_type": type(exc).__name__,
"exception_message": str(exc),
"traceback": traceback.format_exc(),
"error_code": "INTERNAL_SERVER_ERROR",
"is_retryable": False,
},
exc_info=True,
)
return JSONResponse(
{
"detail": "Internal server error",
"request_id": ctx.get("request_id"),
"error_code": "INTERNAL_SERVER_ERROR",
},
status_code=500,
)
def _log(log_fn, event: str, exc: Exception, request: Request) -> None:
ctx = getattr(request.state, "ctx", {})
log_fn(event, extra={
"request_id": ctx.get("request_id"),
"error_code": getattr(exc, "error_code", None),
"exception_message": str(exc),
})Error Response Shape
All error responses follow the same JSON shape:
{
"detail": "Human-readable message",
"error_code": "MACHINE_READABLE_CODE",
"request_id": "550e8400-..."
}request_id is included on 5xx errors (for support tracing). error_code is included on all errors.
Webhook Special Case
Webhooks should always return 200 to prevent the sending provider from retrying. Add path-based routing in the catch-all handler:
@app.exception_handler(Exception)
async def catchall_handler(request: Request, exc: Exception):
if request.url.path.startswith("/webhooks/"):
logger.warning("webhook.error", extra={"path": request.url.path, "error": str(exc)})
return JSONResponse({"status": "received"}, status_code=200)
# ... normal 500 handlingRules
- Domain and application layers raise typed exceptions — they never return error dicts or None to signal failure.
- Route handlers do not catch exceptions — they let exception handlers do it.
- Never raise
HTTPExceptionin application or domain layers. Raise domain errors; let the handler map them to HTTP codes. - Every domain error has a machine-readable
error_codestring. This is what client applications key off — not the HTTP status code or the human message. is_retryableon domain errors tells consumers whether it's worth retrying the operation.