Architecture deep dive¶
Overview¶
hdmi is built around a simple two-phase architecture:
ContainerBuilder: Configuration phase - define services and their dependencies
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¶
graph TB
subgraph Configuration["🔧 Configuration Phase"]
Builder[ContainerBuilder]
Builder -->|register| Services[Service Definitions]
end
subgraph BuildValidation["✓ Build & Validation"]
Services -->|.build| Validate{ContainerBuilder<br/>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<br/>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
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 calledRuntime-ready: Used at runtime to lazily instantiate services on-demand
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¶
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<br/>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<br/>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<br/>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:
graph TD
Singleton[Singleton<br/>scoped=False, transient=False<br/>Cached in Container]
Scoped[Scoped<br/>scoped=True, transient=False<br/>Cached in ScopedContainer]
Transient[Transient<br/>scoped=False, transient=True<br/>Not cached, no scope required]
ScopedTransient[Scoped Transient<br/>scoped=True, transient=True<br/>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.
# ✅ 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:
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:
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:
# 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:
Inspects constructor signatures
Extracts type annotations
Builds the dependency graph
Validates scopes and cycles at
.build()time
Lifecycle management¶
Services behave differently based on their scope:
graph LR
subgraph Singleton["Singleton"]
S1[Instance] --> S1
S1 -.shared across all requests.-> S1
end
subgraph Scoped["Scoped"]
SC1[Instance A<br/>Request 1]
SC2[Instance B<br/>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:
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 calledMemory 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:
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¶
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.