Directory Structure - Hexagonal Architecture¶
Overview¶
Dashtam follows Hexagonal Architecture (Ports & Adapters) with strict layer separation. The domain layer is at the center, completely independent of frameworks and infrastructure.
Core Principle: Domain depends on NOTHING. All dependencies point inward toward the domain.
Architecture Diagram¶
graph TB
subgraph Presentation["Presentation Layer (src/presentation/)"]
Routers[FastAPI Routers<br/>HTTP Endpoints]
end
subgraph Application["Application Layer (src/application/)"]
Commands[Commands<br/>Write Operations]
Queries[Queries<br/>Read Operations]
end
subgraph Domain["Domain Layer (src/domain/) ← CORE"]
Entities[Entities<br/>Business Objects]
ValueObjects[Value Objects<br/>Immutable Values]
Protocols[Protocols<br/>Interfaces/Ports]
DomainEvents[Domain Events<br/>Things That Happened]
DomainErrors[Domain Errors<br/>ProviderError, etc.]
end
subgraph Infrastructure["Infrastructure Layer (src/infrastructure/)"]
Persistence[Persistence<br/>PostgreSQL Repos]
EventHandlers[Event Handlers<br/>Logging, Audit, Email]
Providers[Financial Providers<br/>Schwab, etc.]
Security[Security<br/>JWT, Encryption]
end
subgraph Core["Core (src/core/)"]
Result[Result Types<br/>Success/Failure]
BaseErrors[Base Errors<br/>DomainError]
Container[Container<br/>Dependency Injection]
end
Routers --> Commands
Routers --> Queries
Commands --> Entities
Commands --> Protocols
Queries --> Protocols
EventHandlers --> DomainEvents
Persistence -.implements.-> Protocols
Providers -.implements.-> Protocols
Security -.implements.-> Protocols
Entities --> Result
Entities --> DomainErrors
DomainErrors --> BaseErrors
Directory Structure¶
Core Layer (src/core/)¶
Purpose: Shared kernel utilities used across all layers.
Contents:
result.py- Result types for railway-oriented programming (Success[T],Failure[E])errors/- Core error classes (base and generic errors)domain_error.py- DomainError (base class for all errors)common_error.py- ValidationError, NotFoundError, ConflictError, etc.enums/- Core enums (error codes, environment)error_code.py- ErrorCode enum (domain-level error codes)environment.py- Environment enum (dev, test, prod, ci)validation.py- Common validation functionsconfig.py- Application settings with Pydanticcontainer/- Modularized dependency injection container__init__.py- Main exports and core factoriesauth_handlers.py- Authentication command/query handler factoriesauthorization.py- Authorization dependenciesdata_handlers.py- Data sync command/query handler factoriesevents.py- Event bus and handler wiring (uses Event Registry)infrastructure.py- Infrastructure adapter factoriesprovider_handlers.py- Provider command/query handler factoriesproviders.py- Provider factory registrationrepositories.py- Repository factories
Dependencies: None (pure Python 3.14+)
Rules:
- No business logic
- No framework imports (except FastAPI Depends in container)
- Only foundational utilities
- Keep flat (no subdirectories)
- Container provides both app-scoped (singletons) and request-scoped dependencies
See also: dependency-injection.md for container pattern details.
Domain Layer (src/domain/)¶
Purpose: Pure business logic - the heart of the application.
Structure:
src/domain/
├── __init__.py
├── entities/ # Domain entities (mutable, have identity)
├── value_objects/ # Value objects (immutable, no identity)
├── enums/ # Domain enums (audit actions, provider types, etc.)
├── errors/ # Domain-specific errors (audit, secrets, etc.)
├── protocols/ # ALL protocols consolidated here (repositories, services, etc.)
├── events/ # Domain events with Event Registry Pattern
│ ├── base_event.py # DomainEvent base class
│ ├── auth_events.py # Authentication events (28 events)
│ ├── authorization_events.py # Authorization events (6 events)
│ ├── provider_events.py # Provider events (9 events)
│ ├── data_events.py # Data sync events (12 events)
│ ├── session_events.py # Session events (8 events)
│ ├── rate_limit_events.py # Rate limit events (3 events)
│ └── registry.py # Event Registry (69 events total, single source of truth)
├── types.py # Annotated types (Email, Password, etc.)
└── validators.py # Centralized validation functions
Protocol Consolidation (CRITICAL):
All protocols are in domain/protocols/ - NO separate domain/repositories/ directory:
src/domain/protocols/
├── __init__.py # Exports all protocols
│
│ # Repository protocols (persistence)
├── user_repository.py # User persistence
├── account_repository.py # Account persistence
├── transaction_repository.py # Transaction persistence
├── session_repository.py # Session persistence
├── provider_repository.py # Provider metadata
├── provider_connection_repository.py # Provider connections
├── email_verification_token_repository.py # Email tokens
├── refresh_token_repository.py # Refresh tokens
├── password_reset_token_repository.py # Password reset tokens
├── security_config_repository.py # Security configuration
│
│ # Service protocols (business operations)
├── cache_protocol.py # Cache operations (Redis)
├── session_cache_protocol.py # Session caching
├── password_hashing_protocol.py # Password hashing (bcrypt)
├── token_generation_protocol.py # JWT/token generation
├── event_bus_protocol.py # Domain event publishing
├── audit_protocol.py # Audit trail recording
├── logger_protocol.py # Structured logging
├── secrets_protocol.py # Secrets management
├── authorization_protocol.py # RBAC authorization
├── rate_limit_protocol.py # Rate limiting
├── session_enricher_protocol.py # Session metadata enrichment
│
│ # Provider protocols
├── provider_protocol.py # Financial provider adapter
│
│ # Email protocols
├── email_protocol.py # Email sending
├── email_service_protocol.py # Email service
│
│ # Token service protocols
├── refresh_token_service_protocol.py # Refresh token operations
├── password_reset_token_service_protocol.py # Password reset operations
│
│ # Composite exports
└── repositories.py # Re-exports all repositories
Dependencies: src/core/ only (Result types, errors)
Rules:
- NO framework imports (FastAPI, SQLModel, etc.)
- NO infrastructure imports (database, Redis, etc.)
- Pure Python dataclasses and Protocol definitions
- All business rules live here
- Use
Protocolfor interfaces (NOT ABC)
Examples:
entities/user.py- User entity with business methodsvalue_objects/email.py- Email value object with validationenums/audit_action.py- AuditAction enum (extensible audit events)enums/provider_type.py- ProviderType enum (schwab, alpaca, chase, etc.)errors/audit_error.py- AuditError (audit system failures)errors/secrets_error.py- SecretsError (secrets retrieval failures)protocols/user_repository.py- UserRepository protocol (port)protocols/cache_protocol.py- CacheProtocol (port)events/auth_events.py- Authentication domain events (28 events)events/registry.py- Event Registry (69 events, automated container wiring)
Application Layer (src/application/)¶
Purpose: Use cases and orchestration following CQRS pattern.
Structure:
src/application/
├── __init__.py
├── commands/ # Write operations
│ ├── __init__.py
│ └── handlers/ # Command handler implementations
├── queries/ # Read operations
│ ├── __init__.py
│ └── handlers/ # Query handler implementations
├── dtos/ # Data Transfer Objects (handler results)
│ ├── __init__.py
│ ├── auth_dtos.py # Auth result DTOs (AuthenticatedUser, AuthTokens, etc.)
│ ├── sync_dtos.py # Sync result DTOs (SyncAccountsResult, etc.)
│ └── import_dtos.py # Import result DTO (ImportResult)
└── events/ # Event handlers
├── __init__.py
└── handlers/ # Domain event handler implementations
Dependencies: src/domain/ ONLY (protocols, entities, events, errors), src/core/
Rules:
- Commands change state (write operations)
- Queries fetch data (read-only, can cache)
- Event handlers react to domain events (side effects)
- No business logic (orchestrate domain entities)
- One handler per command/query
- CRITICAL: Application layer imports ONLY from
domain/andcore/- NEVER frominfrastructure/
Examples:
commands/register_user.py- RegisterUser command dataclasscommands/handlers/register_user_handler.py- Handler implementationqueries/get_user.py- GetUser query dataclassqueries/handlers/get_user_handler.py- Handler implementationevents/handlers/user_registered_handler.py- Send verification email
Infrastructure Layer (src/infrastructure/)¶
Purpose: Adapters that implement domain protocols (ports).
Structure:
src/infrastructure/
├── __init__.py
│
│ # Core infrastructure
├── persistence/ # Database adapters (PostgreSQL repositories, models)
├── providers/ # Financial provider integrations (Schwab, Chase)
├── external/ # External service clients
├── enums/ # Infrastructure enums (error codes)
├── errors/ # Infrastructure errors (database, cache)
│
│ # Security & auth
├── authorization/ # Casbin RBAC adapter
├── security/ # JWT service, encryption service
├── secrets/ # Secrets management adapters (env, AWS)
│
│ # Cross-cutting concerns
├── audit/ # Audit trail PostgreSQL adapter
├── cache/ # Redis cache adapter
├── email/ # Email service adapter
├── events/ # Event bus implementation, event handlers
├── logging/ # Structured logging adapter
├── rate_limit/ # Rate limiting storage (Redis)
└── enrichers/ # Session metadata enrichers
Dependencies: src/domain/, src/core/, external libraries
Rules:
- Implements domain protocols
- Contains framework imports (SQLModel, Redis, etc.)
- Database models live here (NOT in domain)
- Mapping functions: domain entity ↔ database model
- No business logic
Examples:
persistence/postgres_user_repository.py- PostgreSQL UserRepository implementationpersistence/models/user.py- SQLModel database modelexternal/redis_cache.py- Redis cache adapterproviders/schwab/oauth_client.py- Schwab OAuth implementation
Presentation Layer (src/presentation/)¶
Purpose: HTTP API endpoints using FastAPI.
Structure:
src/presentation/
├── __init__.py
└── routers/ # All route definitions
├── __init__.py
├── api/ # Versioned public API
│ ├── __init__.py
│ ├── middleware/ # Rate limiting, auth dependencies
│ └── v1/ # API version 1
│ ├── __init__.py
│ ├── admin/ # Admin endpoints (rotations, security)
│ ├── errors/ # Error response builders
│ ├── users.py
│ ├── sessions.py
│ ├── providers.py
│ ├── accounts.py
│ └── transactions.py
└── oauth_callbacks.py # OAuth callback handlers
Future versioning: Add routers/api/v2/ when needed.
Dependencies: src/application/, src/core/
Rules:
- Thin layer - NO business logic
- Dispatches commands/queries to application layer
- Translates HTTP → Command/Query → HTTP
- RESTful URLs (resource-based, NOT verb-based)
- Proper HTTP methods (GET/POST/PATCH/DELETE)
- All request/response schemas in
src/schemas/(NOT inline)
Examples:
routers/api/v1/users.py- User CRUD endpoints (/api/v1/users)routers/api/v1/sessions.py- Login/logout endpoints (/api/v1/sessions)routers/api/v1/admin/token_rotation.py- Token rotation admin endpointsrouters/oauth_callbacks.py- OAuth callback handlers
Request/Response Schemas (src/schemas/)¶
Purpose: Pydantic schemas for API request/response validation.
Structure:
src/schemas/
├── __init__.py
├── auth_schemas.py # Login, registration, password reset
├── session_schemas.py # Session management
├── provider_schemas.py # Provider connection
├── account_schemas.py # Account operations
├── transaction_schemas.py # Transaction queries
├── holding_schemas.py # Holdings/positions (v1.2.0)
├── balance_snapshot_schemas.py # Balance tracking (v1.2.0)
├── import_schemas.py # File imports (v1.4.0)
├── rotation_schemas.py # Token rotation admin
└── common_schemas.py # Shared schemas (pagination, errors)
Dependencies: pydantic, src/domain/types.py (Annotated types)
Rules:
- Request schemas: Validate incoming data, use domain Annotated types
- Response schemas: Define API response structure
- NO business logic (validation only)
- Schemas named
<Domain>Create,<Domain>Update,<Domain>Response
Examples:
auth_schemas.py-UserCreate,LoginRequest,TokenResponseaccount_schemas.py-AccountResponse,AccountListResponseholding_schemas.py-HoldingResponse,HoldingListResponseimport_schemas.py-FileImportRequest,ImportResultResponse
Test Structure (tests/)¶
Purpose: Test pyramid with isolated test layers.
Structure:
tests/
├── __init__.py
├── unit/ # 70% - Test domain logic in isolation
├── integration/ # 20% - Test cross-module interactions
├── api/ # 10% - Test HTTP endpoints end-to-end
└── smoke/ # Critical user journeys (E2E)
Rules:
- Unit tests: Mock all dependencies, test domain in isolation
- Integration tests: Real database, mocked external APIs
- API tests: Complete flows with TestClient
- Smoke tests: Critical user journeys (registration → login → data sync)
- All tests run in Docker (isolated test database, Redis)
Test File Naming (flat structure with naming pattern):
Test files use a flat structure with naming pattern: test_<layer>_<component>.py
tests/
├── unit/
│ ├── test_domain_user_entity.py
│ ├── test_domain_provider_connection.py
│ ├── test_application_register_user_handler.py
│ ├── test_application_connect_provider_handler.py
│ └── test_core_config.py
├── integration/
│ ├── test_database_postgres.py
│ ├── test_cache_redis.py
│ ├── test_account_repository.py
│ └── test_authorization_casbin.py
├── api/
│ ├── test_sessions_api.py
│ ├── test_providers_endpoints.py
│ └── test_rate_limit_middleware.py
└── smoke/
└── test_user_registration_flow.py
Examples:
unit/test_domain_user_entity.py- Test User entity validationintegration/test_account_repository.py- Test database operationsapi/test_sessions_api.py- Test POST /api/v1/sessions endpointsmoke/test_user_registration_flow.py- Test complete registration flow
Dependency Flow¶
CRITICAL: Dependencies flow inward toward the domain.
flowchart LR
Presentation --> Application
Application --> Domain
Infrastructure -.implements.-> Domain
Domain --> Core
Application --> Core
Infrastructure --> Core
Presentation --> Core
Key Points:
- Domain depends on NOTHING (except Core shared kernel)
- Infrastructure depends on Domain (implements protocols)
- Application depends on Domain (uses entities, protocols)
- Presentation depends on Application (dispatches commands/queries)
- NEVER let Domain depend on Infrastructure or Presentation
CQRS Pattern¶
Commands and Queries are separated from the start.
sequenceDiagram
participant API as Presentation Layer
participant CH as Command Handler
participant QH as Query Handler
participant Domain as Domain Layer
participant Infra as Infrastructure
Note over API,Infra: Write Operation (Command)
API->>CH: RegisterUser command
CH->>Domain: Create User entity
Domain->>Domain: Validate business rules
CH->>Infra: Save via repository
Infra-->>CH: Success
CH-->>API: User ID
Note over API,Infra: Read Operation (Query)
API->>QH: GetUser query
QH->>Infra: Fetch via repository
Infra-->>QH: User entity
QH-->>API: User data
File Naming Conventions¶
Python Files and Classes (PEP 8 Standard)¶
Universal Rule: File name should match the main class name in snake_case
Class Names: CapWords (also known as PascalCase)
UserRepository,RegisterUser,RedisAdapter
File Names: lowercase_with_underscores matching the class
user_repository.py→UserRepositoryclassregister_user.py→RegisterUserclassredis_adapter.py→RedisAdapterclassenv_adapter.py→EnvAdapterclassaws_adapter.py→AWSAdapterclass
Benefits:
- Easy to find: See
UserRepositoryin code? Look foruser_repository.py - PEP 8 compliant: Industry standard Python style
- IDE-friendly: Auto-imports work correctly
- Consistent: Same pattern everywhere
Examples Across Layers:
# Domain
src/domain/entities/user.py → class User
src/domain/value_objects/email.py → class Email
src/domain/protocols/user_repository.py → class UserRepository
# Application
src/application/commands/register_user.py → class RegisterUser
src/application/commands/handlers/register_user_handler.py → class RegisterUserHandler
# Infrastructure
src/infrastructure/persistence/repositories/user_repository.py → class UserRepository
src/infrastructure/cache/redis_adapter.py → class RedisAdapter
src/infrastructure/secrets/env_adapter.py → class EnvAdapter
src/infrastructure/secrets/aws_adapter.py → class AWSAdapter
Python Directories: snake_case/
value_objects/auth_strategies/
Documentation: kebab-case.md
directory-structure.mdoauth-flow.md
Config Files: kebab-case.yml
docker-compose.dev.ymldocker-compose.test.yml
Test Files: test_*.py (pytest convention)
test_user.pytest_user_repository.py
Key Architectural Decisions¶
1. Protocol over ABC¶
Use Protocol for all new interfaces (structural typing, no inheritance).
from typing import Protocol
class UserRepository(Protocol):
async def save(self, user: User) -> None: ...
async def find_by_email(self, email: str) -> User | None: ...
2. Result Types over Exceptions¶
Use Result types in domain layer (explicit error handling).
from src.core.result import Result, Success, Failure
def create_user(email: str) -> Result[User, ValidationError]:
if not is_valid_email(email):
return Failure(ValidationError(message="Invalid email"))
return Success(user)
3. CQRS from Day One¶
Separate commands (write) from queries (read) from the start.
# Command (write)
@dataclass(frozen=True, kw_only=True)
class RegisterUser:
email: str
password: str
# Query (read)
@dataclass(frozen=True, kw_only=True)
class GetUser:
user_id: UUID
4. Immutable Commands/Queries¶
All commands and queries are immutable (frozen=True, kw_only=True).
5. Domain Events for Critical Workflows¶
Emit domain events ONLY for critical workflows with side effects (3-state ATTEMPT → OUTCOME pattern).
@dataclass(frozen=True, kw_only=True)
class UserRegistrationSucceeded:
event_id: UUID
occurred_at: datetime
user_id: UUID
email: str
See: Event Registry Pattern in src/domain/events/registry.py (single source of truth for all 69 events)
Benefits of This Structure¶
Testability: Domain logic testable without database or HTTP.
Maintainability: Clear boundaries, easy to understand where code belongs.
Flexibility: Easy to swap implementations (PostgreSQL → MongoDB, Redis → Memcached).
Framework Independence: Domain survives framework upgrades.
Team Scalability: Multiple developers can work on different layers without conflicts.
Enum Organization¶
Architectural Decision: Enums are centralized in dedicated enums/ directories
for discoverability, maintainability, and to avoid circular import issues.
Why Centralized Enums?¶
Problem: As the project scales, we'll have 10+ enums. Inline enums cause:
- Large files mixing concerns (protocols + enums + errors)
- Hard to discover ("Where is
AuditActiondefined?") - Circular import risks (enum used by multiple modules)
- Violates Single Responsibility Principle
Solution: Treat enums as Value Objects in DDD - they get their own directory.
Directory Structure for Enums¶
src/core/enums/
├── __init__.py # Export all core enums
├── error_code.py # ErrorCode enum (domain errors)
└── environment.py # Environment enum (dev/test/prod/ci)
src/domain/enums/
├── __init__.py # Export all domain enums
├── audit_action.py # AuditAction enum (audit events)
├── provider_type.py # ProviderType enum (schwab, chase, fidelity)
├── account_type.py # AccountType enum (checking, savings, investment)
├── transaction_type.py # TransactionType enum (debit, credit, transfer)
└── sync_status.py # SyncStatus enum (pending, in_progress, completed)
src/infrastructure/enums/
├── __init__.py # Export infrastructure enums
└── infrastructure_error_code.py # InfrastructureErrorCode enum
Enum Naming Convention¶
File names: snake_case.py (e.g., audit_action.py)
Class names: PascalCase (e.g., AuditAction)
Enum values: UPPER_SNAKE_CASE (e.g., USER_LOGIN = "user_login")
Export all enums in __init__.py:
# src/domain/enums/__init__.py
from src.domain.enums.audit_action import AuditAction
from src.domain.enums.provider_type import ProviderType
from src.domain.enums.account_type import AccountType
__all__ = ["AuditAction", "ProviderType", "AccountType"]
Import from enums package:
# ✅ CORRECT: Import from enums package
from src.domain.enums import AuditAction, ProviderType
# ✅ ALSO CORRECT: Direct import (if only need one)
from src.domain.enums.audit_action import AuditAction
# ❌ WRONG: Don't import from parent module
from src.domain import AuditAction # No!
Enum Template¶
Every enum file follows this structure:
# src/domain/enums/audit_action.py
"""Audit action types for compliance tracking.
This enum defines all auditable actions in the system (PCI-DSS, SOC 2, GDPR).
Extensible via enum values - no database schema changes needed.
"""
from enum import Enum
class AuditAction(str, Enum):
"""Audit action types (extensible via enum).
Organized by category for clarity. Add new actions as needed
without database schema changes (metadata stores action-specific data).
Categories:
- Authentication (USER_*)
- Authorization (ACCESS_*)
- Data Operations (DATA_*)
- Administrative (ADMIN_*)
- Provider (PROVIDER_*)
"""
# Authentication events (PCI-DSS required)
USER_LOGIN = "user_login"
USER_LOGOUT = "user_logout"
USER_LOGIN_FAILED = "user_login_failed"
# ... more values ...
Benefits of Centralized Enums¶
✅ Discoverability: All enums in one place per layer
✅ Scalability: Easy to add new enums without bloating other files
✅ Single Responsibility: Each file has one enum
✅ Avoids Circular Imports: Enums are leaf nodes in dependency graph
✅ Industry Standard: Django, FastAPI complex projects use this pattern
✅ DDD Compliant: Enums are treated as Value Objects
Error Organization¶
Architectural Decision: Error classes are centralized in dedicated errors/
directories, organized by architectural layer (core vs domain vs infrastructure).
Why Centralized Errors?¶
Problem: As the project scales, we'll have 20+ error types. Scattered errors cause:
- Hard to discover ("Where is
AuditErrordefined?") - Inconsistent error handling
- Difficult to understand error hierarchy
- Mixed concerns (protocols + errors in same file)
Solution: Group errors by layer in dedicated directories.
Directory Structure for Errors¶
src/core/errors/
├── __init__.py # Export all core errors
├── domain_error.py # DomainError (base class for all errors)
└── common_error.py # ValidationError, NotFoundError, ConflictError,
# AuthenticationError, AuthorizationError
src/domain/errors/
├── __init__.py # Export all domain errors
├── account_error.py # AccountError (account validation/operations)
├── audit_error.py # AuditError (audit system failures)
├── authentication_error.py # AuthenticationError (login failures)
├── provider_connection_error.py # ProviderConnectionError (connection state)
├── provider_error.py # ProviderError (provider API failures - part of protocol)
├── rate_limit_error.py # RateLimitError (rate limit violations)
├── secrets_error.py # SecretsError (secrets retrieval failures)
└── transaction_error.py # TransactionError (transaction validation)
src/infrastructure/errors/
├── __init__.py # Export all infrastructure errors
└── infrastructure_error.py # InfrastructureError, DatabaseError, CacheError,
# ExternalServiceError
Note: Provider errors (ProviderError, ProviderAuthenticationError, etc.) are
defined in src/domain/errors/ because they are part of the ProviderProtocol contract.
When to Use Each Layer¶
Core Errors (src/core/errors/):
- DomainError: Base class for ALL application errors
- Generic errors: Used across ALL domains and layers
- Examples: ValidationError, NotFoundError, ConflictError, AuthenticationError
Domain Errors (src/domain/errors/):
- Domain-specific errors: Tied to specific domain concepts
- Protocol contract errors: Part of protocol return types (ProviderError hierarchy)
- Examples: AuditError, SecretsError, AccountError, TransactionError, ProviderError
Infrastructure Errors (src/infrastructure/errors/):
- Infrastructure failures: Database, cache connections
- Examples: DatabaseError, CacheError, ExternalServiceError
- Does NOT include provider errors (those are domain errors)
Error Naming Convention¶
File names: snake_case.py (e.g., audit_error.py)
Class names: PascalCase ending in Error (e.g., AuditError)
Export all errors in __init__.py:
# src/domain/errors/__init__.py
from src.domain.errors.account_error import AccountError
from src.domain.errors.audit_error import AuditError
from src.domain.errors.provider_error import (
ProviderError,
ProviderAuthenticationError,
)
from src.domain.errors.secrets_error import SecretsError
__all__ = ["AccountError", "AuditError", "ProviderError", ...]
Import from errors package:
# ✅ CORRECT: Import from errors package
from src.core.errors import DomainError, ValidationError
from src.domain.errors import AuditError, SecretsError
# ✅ ALSO CORRECT: Direct import (if only need one)
from src.domain.errors.audit_error import AuditError
# ❌ WRONG: Don't import from parent module
from src.domain import AuditError # No!
Error Template¶
Every error file follows this structure:
# src/domain/errors/audit_error.py
"""Audit trail error types.
Used when audit trail recording or querying fails.
Returned in Result types, not raised as exceptions.
"""
from dataclasses import dataclass
from src.core.errors import DomainError
@dataclass(frozen=True, slots=True, kw_only=True)
class AuditError(DomainError):
"""Audit system failure.
Used when audit trail recording fails (database error, connection loss).
Attributes:
code: ErrorCode enum (AUDIT_RECORD_FAILED, AUDIT_QUERY_FAILED).
message: Human-readable message.
details: Additional context.
"""
pass # Inherits all fields from DomainError
Protocol vs Inheritance: When to Use Each¶
CRITICAL DISTINCTION: Our "Protocol over ABC" rule applies to interfaces/behavior, NOT data structures.
Use Protocol (Structural Typing) For:
Interfaces that define BEHAVIOR - Multiple implementations with same interface:
# ✅ CORRECT: Protocol for behavior/interface
class CacheProtocol(Protocol):
async def get(self, key: str) -> Result[str | None, DomainError]: ...
async def set(self, key: str, value: str) -> Result[None, DomainError]: ...
# Implementations DON'T inherit (duck typing)
class RedisAdapter: # No inheritance
async def get(self, key: str) -> ...
class MemcachedAdapter: # No inheritance
async def get(self, key: str) -> ...
Why Protocol?
- Different implementations (Redis, Memcached, InMemory)
- Structural typing (no inheritance required)
- Easy to swap implementations
- Follows Python 3.14+ best practices
Examples: CacheProtocol, SecretsProtocol, LoggerProtocol, AuditProtocol
Use Inheritance (Dataclass) For:
Data structures that share FIELDS - Subclasses add additional fields:
# ✅ CORRECT: Inheritance for data structures
@dataclass(frozen=True, slots=True, kw_only=True)
class DomainError:
code: ErrorCode
message: str
details: dict | None = None
class ValidationError(DomainError): # Inherits base fields
field: str | None = None # Adds new field
class AuditError(DomainError): # Inherits base fields
pass # No additional fields
Why Inheritance?
- Errors are data structures (carry information)
- Subclasses extend base structure with additional fields
- Type safety:
Result[T, DomainError]accepts all error subclasses - Standard Python pattern (like
Exceptionhierarchy)
Examples: Error classes, Domain events, Commands/Queries
Summary Table¶
| Use Case | Pattern | Example | Why |
|---|---|---|---|
| Interfaces/Behavior | Protocol | CacheProtocol, LoggerProtocol | Multiple implementations |
| Data Structures | Inheritance | DomainError → ValidationError | Shared structure + fields |
Industry Examples:
- Django: Errors use inheritance ✅, Views use ABC/Protocol ✅
- FastAPI: HTTPException uses inheritance ✅, Dependencies use duck typing ✅
- Python stdlib: Exception uses inheritance ✅, collections.abc uses Protocol ✅
Our Architecture:
- ✅ Protocol for: Cache, Secrets, Logger, Audit (interfaces)
- ✅ Inheritance for: Errors, Events, Commands, Queries (data structures)
Benefits of Centralized Errors¶
✅ Discoverability: All errors in one place per layer
✅ Type Safety: Clear error hierarchy with base class
✅ Single Responsibility: Errors separated from protocols
✅ Consistency: Same pattern across all layers
✅ Industry Standard: Mirrors Django, FastAPI error organization
✅ DDD Compliant: Domain errors separate from infrastructure errors
Related Documentation¶
Created: 2025-11-08 | Last Updated: 2026-01-10