Architecture deep dive ====================== Overview -------- **hdmi** is built around a simple two-phase architecture: 1. **ContainerBuilder**: Configuration phase - define services and their dependencies 2. **Container**: Runtime phase - lazily instantiate validated services on-demand The **ContainerBuilder** performs all validation during the ``.build()`` call, producing an immutable, validated **Container**. This separation ensures that configuration errors are caught early (during build), while actual instantiation happens lazily (just-in-time) when services are first accessed. Core components --------------- .. mermaid:: graph TB subgraph Configuration["🔧 Configuration Phase"] Builder[ContainerBuilder] Builder -->|register| Services[Service Definitions] end subgraph BuildValidation["✓ Build & Validation"] Services -->|.build| Validate{ContainerBuilder
validates} Validate -->|checks| Graph[Dependency Graph] Validate -->|checks| Cycles[No Cycles] Validate -->|checks| Scopes[Scope Safety] Validate -->|ensures| Resolvable[All Resolvable] Validate -->|produces| Container[Container
Validated & Immutable] end subgraph Runtime["⚡ Runtime Phase"] Container -->|.get| Instance[Service Instance] Instance -->|lazy| Dependencies[Dependent Services] end style Builder fill:#e1f5ff style Validate fill:#fff4e1 style Container fill:#d4edda style Instance fill:#e1ffe1 The two phases -------------- Phase 1: Configuration (ContainerBuilder) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The **ContainerBuilder** is a mutable builder that accumulates service definitions. It's where you: - Register service types - Define dependencies (via type annotations) - Configure lifecycles (singleton, scoped, transient) - Set up factories and custom constructors **Key characteristics:** - Mutable: you can add/modify service definitions - No validation yet: misconfiguration won't be detected - Lightweight: just collecting configuration data .. code-block:: python from hdmi import ContainerBuilder builder = ContainerBuilder() # Register services using boolean flags for scope configuration builder.register(DatabaseConnection) # singleton (default: scoped=False, transient=False) builder.register(UserRepository, scoped=True) # scoped service builder.register(UserService, transient=True) # transient service Phase 2: Build & Validation (ContainerBuilder → Container) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When you call ``builder.build()``, the **ContainerBuilder** performs all validation: - Constructs the dependency graph - Detects circular dependencies - **Validates scope hierarchy** (ensures services only depend on same/higher scopes) - Validates all dependencies can be resolved - Checks type compatibility If all validation passes, the ContainerBuilder produces a **Container** - an immutable, validated dependency graph ready for runtime use. **Container characteristics:** - **Immutable**: the dependency graph cannot be modified after build - **Pre-validated**: all configuration errors were caught during ``.build()`` - **No instances yet**: services aren't instantiated until ``.get()`` is called - **Runtime-ready**: Used at runtime to lazily instantiate services on-demand .. code-block:: python import asyncio async def main(): # ContainerBuilder validates during .build() async with builder.build() as container: # ↑ All validation happens HERE, by the ContainerBuilder # - Dependency graph constructed and validated # - Cycles checked # - Scope hierarchy validated # Container is now immutable and validated # At this point: # - All dependencies are validated ✓ # - No cycles exist ✓ # - Scope hierarchy is correct ✓ # - No services are instantiated yet # Later, at runtime, Container just resolves: user_service = await container.get(UserService) # Lazy instantiation asyncio.run(main()) Service resolution flow ----------------------- .. mermaid:: sequenceDiagram participant User participant Builder as ContainerBuilder participant Container participant ServiceA participant ServiceB User->>Builder: register(ServiceA) # singleton (default) User->>Builder: register(ServiceB, scoped=True) # scoped Note over Builder: Configuration Phase
No validation yet User->>Builder: build() Note over Builder: Builder validates: Builder->>Builder: construct dependency graph Builder->>Builder: check for cycles Builder->>Builder: validate scope hierarchy Builder->>Builder: ensure all resolvable Builder->>Container: create validated Container Note over Container: Immutable & Validated
Graph is frozen Container-->>User: Container instance User->>Container: get(ServiceA) Container->>ServiceB: instantiate (dependency) ServiceB-->>Container: instance Container->>ServiceA: instantiate(ServiceB) ServiceA-->>Container: instance Container-->>User: ServiceA instance Note over User,ServiceA: Resolution Phase
Lazy instantiation Scope hierarchy and validation ------------------------------- One of **hdmi**'s key features is **scope-aware dependency validation**. Services have lifecycles (scopes) that determine when they are created and how long they live. The four service types ~~~~~~~~~~~~~~~~~~~~~~ Services are configured using two boolean flags that combine to create four distinct types: .. mermaid:: graph TD Singleton[Singleton
scoped=False, transient=False
Cached in Container] Scoped[Scoped
scoped=True, transient=False
Cached in ScopedContainer] Transient[Transient
scoped=False, transient=True
Not cached, no scope required] ScopedTransient[Scoped Transient
scoped=True, transient=True
Not cached, requires scope] style Singleton fill:#e1ffe1 style Scoped fill:#fff4e1 style Transient fill:#ffe1e1 style ScopedTransient fill:#f0e1ff **Singleton** (scoped=False, transient=False - default) - Created once per container - Lives for the entire container lifetime - Ideal for: configurations, database connections, caches **Scoped** (scoped=True, transient=False) - Created once per scope (e.g., per web request, per operation) - Lives for the duration of the scope - Ideal for: request-specific services, unit of work patterns **Transient** (scoped=False, transient=True) - Created every time it's requested - No reuse across calls - Ideal for: stateful operations, disposable services **Scoped Transient** (scoped=True, transient=True) - Created every time it's requested within a scope - Requires scope context but not cached - Ideal for: per-request commands, non-reusable scope-aware operations Scope safety rules ~~~~~~~~~~~~~~~~~~ **Critical principle**: Non-scoped services cannot depend on scoped services. The validation rules have been simplified: the only invalid dependency pattern is when a non-scoped service (singleton or transient) attempts to depend on a scoped service, because scoped services only exist within a scope context. .. code-block:: python # ✅ VALID: Any service can depend on Singleton class Config: # singleton (default) pass class Database: # singleton def __init__(self, config: Config): self.config = config # ✅ VALID: Scoped depends on Singleton class RequestHandler: # scoped def __init__(self, db: Database): # Database is singleton self.db = db # ✅ VALID: Singleton can now depend on Transient class SingletonService: # singleton def __init__(self, loader: ConfigLoader): # ConfigLoader is transient # The transient is created once during singleton construction # and lives for the singleton's lifetime self.loader = loader # ✅ VALID: Scoped can depend on Transient class ScopedService: # scoped def __init__(self, cmd: CommandProcessor): # CommandProcessor is transient # The transient is created once per scoped instance self.cmd = cmd # ❌ INVALID: Singleton depends on Scoped class SingletonService: # singleton def __init__(self, handler: RequestHandler): # RequestHandler is scoped! # ERROR: Singleton cannot access scoped services # which only exist within a scope context self.handler = handler # ❌ INVALID: Transient depends on Scoped (when resolved from Container) class TransientService: # transient (scoped=False) def __init__(self, handler: RequestHandler): # RequestHandler is scoped! # ERROR: Non-scoped transient cannot depend on scoped services self.handler = handler Validation matrix ~~~~~~~~~~~~~~~~~ This table shows which dependencies are allowed: ======================== ================== ================ ================== ==================== Service Type Can Depend On ======================== ================== ================ ================== ==================== **Singleton** ✅ Singleton ❌ Scoped ✅ Transient ❌ Scoped Transient **Scoped** ✅ Singleton ✅ Scoped ✅ Transient ✅ Scoped Transient **Transient** ✅ Singleton ❌ Scoped ✅ Transient ❌ Scoped Transient **Scoped Transient** ✅ Singleton ✅ Scoped ✅ Transient ✅ Scoped Transient ======================== ================== ================ ================== ==================== **Important note about transient dependencies:** When a transient service is injected as a dependency, it's created **once** during the dependent's construction and lives for the dependent's lifetime. This means: - A singleton with a transient dependency gets one transient instance for its entire lifetime - A scoped service with a transient dependency gets one transient instance per scope - Direct requests for transient services still create new instances each time will outlive the service Build-time validation ~~~~~~~~~~~~~~~~~~~~~ The **ContainerBuilder** catches scope violations during ``.build()``, not at runtime: .. code-block:: python builder = ContainerBuilder() builder.register(SingletonService) # singleton (default) builder.register(RequestHandler, scoped=True) # scoped # ContainerBuilder validates during .build() and fails immediately: container = builder.build() # Raises ScopeViolationError: # "SingletonService (singleton) cannot depend on RequestHandler (scoped)" This "fail fast" approach ensures that lifetime bugs are caught during development by the ContainerBuilder, not in production by the Container. Type annotations and dependency discovery ------------------------------------------ **hdmi** uses Python's standard type annotations to discover dependencies automatically: .. code-block:: python # Dependencies are inferred from type annotations class DatabaseConnection: def __init__(self): self.connected = True class UserRepository: def __init__(self, db: DatabaseConnection): self.db = db class UserService: def __init__(self, repo: UserRepository): self.repo = repo # Register with scopes using boolean flags builder = ContainerBuilder() builder.register(DatabaseConnection) # singleton (default) builder.register(UserRepository, scoped=True) # scoped service builder.register(UserService, transient=True) # transient service container = builder.build() When you register these services, **hdmi** automatically: 1. Inspects constructor signatures 2. Extracts type annotations 3. Builds the dependency graph 4. Validates scopes and cycles at ``.build()`` time Lifecycle management -------------------- Services behave differently based on their scope: .. mermaid:: graph LR subgraph Singleton["Singleton"] S1[Instance] --> S1 S1 -.shared across all requests.-> S1 end subgraph Scoped["Scoped"] SC1[Instance A
Request 1] SC2[Instance B
Request 2] SC1 -.shared within request 1.-> SC1 SC2 -.shared within request 2.-> SC2 SC1 -.different request.-> SC2 end subgraph Transient["Transient"] T1[Instance 1] T2[Instance 2] T3[Instance 3] T1 -.new instance each time.-> T2 T2 -.new instance each time.-> T3 end Example with Scopes: .. code-block:: python import asyncio async def main(): # Singleton: Created once db = await container.get(Database) db2 = await container.get(Database) assert db is db2 # Same instance # Scoped: Created once per scope async with container.scope() as scoped: handler1 = await scoped.get(RequestHandler) handler2 = await scoped.get(RequestHandler) assert handler1 is handler2 # Same instance within scope async with container.scope() as scoped2: handler3 = await scoped2.get(RequestHandler) assert handler1 is not handler3 # Different instance in different scope # Transient: New instance every time cmd1 = await container.get(CommandProcessor) cmd2 = await container.get(CommandProcessor) assert cmd1 is not cmd2 # Always different instances asyncio.run(main()) Design principles ----------------- Early validation, late instantiation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ContainerBuilder performs all validation during ``.build()``, producing an immutable Container for runtime resolution. This separation ensures: - **ContainerBuilder validates early**: Configuration errors (including scope violations) are caught immediately during ``.build()`` - **Container resolves late**: Services are instantiated only when ``.get()`` is called - Memory is conserved by not creating unused services Immutability after validation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Once a Container is built, it's immutable. This ensures: - Thread safety - Predictable behavior - No runtime surprises from configuration changes Type-driven configuration ~~~~~~~~~~~~~~~~~~~~~~~~~ By using Python's type annotations, we get: - IDE autocomplete and type checking - Self-documenting code - Less boilerplate configuration - Compile-time safety (with mypy/pyright) Scope safety ~~~~~~~~~~~~ Scope validation prevents common lifetime bugs: - Capturing short-lived services in long-lived ones - Reusing transient services across operations - Accessing disposed services Simplicity over features ~~~~~~~~~~~~~~~~~~~~~~~~ Unlike the harp/rodi implementation, **hdmi** focuses on: - Minimal API surface (ContainerBuilder, Container) - No YAML configuration (Python-native) - Standard library patterns - Clear phase separation (configuration → validation/runtime) Design philosophy ----------------- **hdmi** follows a minimalist approach with just two core concepts: .. code-block:: text hdmi architecture: ├── ContainerBuilder (configuration) ├── Container (validation + runtime resolution) └── ServiceDefinition (optional advanced configuration) **Key design choices:** - **Python-native configuration**: Use type annotations, no external DSLs - **Two-phase architecture**: Clear separation between configuration and runtime - **Build-time validation**: Catch all configuration errors before runtime - **Minimal dependencies**: Only requires anyio for async support - **Type-driven**: Leverage Python's typing system for safety and IDE support - **Explicit over implicit**: Clear phase transitions and error messages Error handling -------------- .. mermaid:: graph TD Start[Register Services] --> Build{build} Build -->|Success| Container[Container Created] Build -->|Cycle Detected| CycleError[CircularDependencyError] Build -->|Missing Dependency| MissingError[UnresolvableDependencyError] Build -->|Scope Violation| ScopeError[ScopeViolationError] Container --> Get{get} Get -->|Success| Instance[Service Instance] Get -->|Not Registered| GetError[UnresolvableDependencyError] Get -->|Scoped from Container| ScopeGetError[ScopeViolationError] style CycleError fill:#ffcccc style MissingError fill:#ffcccc style ScopeError fill:#ffcccc style GetError fill:#ffcccc style ScopeGetError fill:#ffcccc All configuration errors are detected at **build time**, not at runtime: - **CircularDependencyError**: Service A depends on B, which depends on A - **UnresolvableDependencyError**: Required dependency is not registered - **ScopeViolationError**: Non-scoped service depends on scoped service Runtime errors (when calling ``.get()``): - **UnresolvableDependencyError**: Service type not registered in container - **ScopeViolationError**: Attempting to resolve scoped service from root Container (use ``container.scope()`` instead) - Standard Python exceptions if service constructor fails This ensures "fail fast" behavior - catch configuration issues early during setup, not in production.