refactor(domain): introduce domain exceptions boundary
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- Create domain/errors.py as canonical error hierarchy: DomainError, InvalidStateTransition, PermissionViolation, BusinessRuleViolation, EntityNotFoundError, DuplicateEntityError

- InvalidOperationError now inherits from BusinessRuleViolation for semantic consistency

- Convert domain/exceptions.py to backward-compatible re-export shim with legacy aliases (DomainException, InvalidTransitionError, AuthorizationError)

- Update error_handler.py to import from domain/errors.py and map all new error types

- Update main.py to register DomainError (new base) as the exception handler root
This commit is contained in:
2026-02-18 13:44:47 +01:00
parent 55dba1e00a
commit 611e10620e
4 changed files with 130 additions and 77 deletions

View File

@@ -0,0 +1,96 @@
"""Canonical domain error hierarchy for Aegis.
Every service-layer error should be a subclass of :class:`DomainError`.
The global exception handler in ``app.middleware.error_handler`` maps
each concrete subclass to an appropriate HTTP status code so that
services never depend on FastAPI.
Existing code that imports from ``app.domain.exceptions`` continues to
work — that module re-exports everything defined here.
"""
from __future__ import annotations
class DomainError(Exception):
"""Base for all domain errors."""
def __init__(self, message: str, *, code: str = "DOMAIN_ERROR") -> None:
self.message = message
self.code = code
super().__init__(message)
# ── Entity lifecycle ──────────────────────────────────────────────────
class EntityNotFoundError(DomainError):
"""A requested entity does not exist."""
def __init__(self, entity: str, identifier: str) -> None:
super().__init__(f"{entity} not found: {identifier}", code="NOT_FOUND")
self.entity = entity
self.identifier = identifier
class DuplicateEntityError(DomainError):
"""Creating an entity that already exists."""
def __init__(self, entity: str, field: str, value: str) -> None:
super().__init__(
f"{entity} with {field}='{value}' already exists",
code="DUPLICATE",
)
# ── State machine ────────────────────────────────────────────────────
class InvalidStateTransition(DomainError):
"""A state-machine transition is not allowed."""
def __init__(
self,
current_state: str,
target_state: str,
valid_transitions: list[str] | None = None,
) -> None:
msg = f"Cannot transition from '{current_state}' to '{target_state}'"
if valid_transitions:
msg += f". Valid transitions: {valid_transitions}"
super().__init__(msg, code="INVALID_TRANSITION")
self.current_state = current_state
self.target_state = target_state
self.valid_transitions = valid_transitions or []
# ── Business rules ────────────────────────────────────────────────────
class BusinessRuleViolation(DomainError):
"""An operation violates a business invariant."""
def __init__(self, message: str) -> None:
super().__init__(message, code="BUSINESS_RULE_VIOLATION")
class InvalidOperationError(BusinessRuleViolation):
"""An operation is invalid in the current context.
Kept for backward compatibility; new code should prefer
:class:`BusinessRuleViolation` directly.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.code = "INVALID_OPERATION"
# ── Authorization ────────────────────────────────────────────────────
class PermissionViolation(DomainError):
"""The user lacks permissions for an action."""
def __init__(self, message: str = "Insufficient permissions") -> None:
super().__init__(message, code="FORBIDDEN")

View File

@@ -1,67 +1,22 @@
"""Domain exceptions for Aegis business logic.
"""Backward-compatible re-exports from :mod:`app.domain.errors`.
These exceptions are raised by service-layer code and automatically
mapped to HTTP responses by the error-handler middleware registered
in ``app.main``. This keeps the service layer free from any HTTP
or framework coupling.
All domain errors now live in ``errors.py``. This module preserves the
old import paths so that existing code keeps working without changes::
from app.domain.exceptions import InvalidTransitionError # still works
"""
from app.domain.errors import ( # noqa: F401
BusinessRuleViolation,
DomainError,
DuplicateEntityError,
EntityNotFoundError,
InvalidOperationError,
InvalidStateTransition,
PermissionViolation,
)
class DomainException(Exception):
"""Base for all domain exceptions."""
def __init__(self, message: str, code: str = "DOMAIN_ERROR"):
self.message = message
self.code = code
super().__init__(message)
class EntityNotFoundError(DomainException):
"""Raised when a requested entity does not exist."""
def __init__(self, entity: str, identifier: str):
super().__init__(f"{entity} not found: {identifier}", "NOT_FOUND")
self.entity = entity
self.identifier = identifier
class DuplicateEntityError(DomainException):
"""Raised when creating an entity that already exists."""
def __init__(self, entity: str, field: str, value: str):
super().__init__(
f"{entity} with {field}='{value}' already exists",
"DUPLICATE",
)
class InvalidTransitionError(DomainException):
"""Raised when a state-machine transition is not allowed."""
def __init__(
self,
current_state: str,
target_state: str,
valid_transitions: list[str] | None = None,
):
msg = f"Cannot transition from '{current_state}' to '{target_state}'"
if valid_transitions:
msg += f". Valid transitions: {valid_transitions}"
super().__init__(msg, "INVALID_TRANSITION")
self.current_state = current_state
self.target_state = target_state
self.valid_transitions = valid_transitions or []
class InvalidOperationError(DomainException):
"""Raised when an operation is invalid in the current context."""
def __init__(self, message: str):
super().__init__(message, "INVALID_OPERATION")
class AuthorizationError(DomainException):
"""Raised when the user lacks permissions for an action."""
def __init__(self, message: str = "Insufficient permissions"):
super().__init__(message, "FORBIDDEN")
# Legacy aliases — old name → new name
DomainException = DomainError
InvalidTransitionError = InvalidStateTransition
AuthorizationError = PermissionViolation