๐ Technical Design 019 โ DI Configuration Binding & Sources¶
| Field | Value |
|---|---|
| ID | TD-019 |
| Title | DI Configuration Binding & Sources |
| Status | โ Implemented |
| Owner | Contributors |
| Audience | Contributors, maintainers, DI system users |
| Created | 2025-10-06 |
| Last Updated | 2025-10-12 |
| Implemented | 2025-10-12 |
| Reviewers | Contributors |
| Approvers | Contributors |
| Tracking | Phase 4 of DI Roadmap |
| Related | 001-dependency-injection-roadmap.md |
| 012-di-enhanced-annotations.md | |
| 013-di-module-scanning-discovery.md |
๐งญ 1. Summary¶
Configuration binding system that enables declarative configuration injection using typing.Annotated patterns. Services and bean factories declare configuration dependencies via Config[T, "path"] annotations, which are resolved by a dedicated injector during property injection and bean factory invocation.
Key Points
Config[T, "path"]type alias for declaring configuration dependencies (single canonical pattern)ConfigInjectorimplements TD-011 injector protocol for configuration resolution- Update (2025-10-18): The static factory
ConfigInjector.create()has been retired. ConfigInjectors now resolve aConfigResolverfrom the container, so call sites must register an appropriate resolver (e.g.,PydanticConfigResolver) before registering the injector. ConfigurationRegistryloads, merges, and caches configuration from multiple sourcesConfigLoaderprotocol for extensible file format support (YAML, JSON, TOML)ConfigResolverprotocol for key resolution with type validation- Pydantic model support via
PydanticConfigResolverwrapper - Immutable deep copies returned from registry (no mutation)
- Fail-fast error handling with specific configuration error hierarchy
๐ 2. Context and Problem Statement¶
TD-012 establishes annotation metadata for services and configuration classes but intentionally defers configuration binding. Current limitations include:
- Ad-hoc Binding โ Services manually parse configuration files or read environment variables
- Inconsistent Semantics โ No standard precedence rules across formats or environments
- Limited Testing Support โ Configuration-heavy services are difficult to test in isolation
- Injector Gap โ TD-011 injectors cover Autowired and method proxies but not configuration values
2.1 Goals¶
- Provide a declarative configuration binding mechanism backed by
typing.Annotated - Integrate configuration binding with TD-011 property/method injectors
- Support multiple configuration sources with deterministic precedence
- Offer extension points for custom configuration providers and runtime overrides
- Keep configuration binding orthogonal to annotation metadata (TD-012) and scanning (TD-013)
2.2 Out of Scope¶
- Automatic module scanning (handled by TD-013)
- Secret management or secure storage solutions beyond source loading
- Full schema validation beyond optional hooks
- Live reload/hot configuration swapping (future consideration)
2.4 Assumptions and Constraints¶
Assumptions:
- TD-011 injector pipeline is available and extensible
- TD-012 provides configuration class metadata but does not bind values
typing.Annotatedusage is acceptable across supported Python versions (3.13)- Projects store configuration as files or provide custom providers
Constraints:
- Must integrate with existing TD-011 injector pipeline
- Must support Python 3.13+ type system features
- Must maintain backward compatibility with manual configuration
- Must provide clear error messages for configuration failures
๐งฉ 3. Design Overview¶
Configuration binding enables declarative configuration injection using typing.Annotated patterns. Services and bean factories declare configuration dependencies via Config[T, "path"] annotations, which are resolved by a dedicated injector during property injection and bean factory invocation.
3.1 Key Design Decisions¶
Configuration Loading
- Multiple sources loaded and merged via
ConfigurationRegistry - Loader selection based on file extension via
supports()method - Later sources override earlier sources (precedence by load order)
- Configuration cached after loading (lazy load on first access)
- Deep copies returned to prevent mutation
Type Handling
- Type validation performed by
ConfigResolverimplementations - No automatic type conversion/coercion (strict type checking)
- Pydantic models receive special handling via
PydanticConfigResolverwrapper - Type mismatches raise
ConfigurationTypeError
Error Handling
- Fail-fast on first missing required key
- ConfigurationError hierarchy for specific failure modes
- Clear, actionable error messages with configuration key context
Integration
- ConfigInjector implements TD-011 injector protocol
- Injector order independence guideline:
a(b(x)) โ b(a(x)) - Works alongside AutowiredInjector and custom injectors
3.2 Key Components¶
| Component | Purpose |
|---|---|
Config[T, "key"] | Type alias for declaring configuration dependencies (single canonical pattern) |
_ConfigMarker | Internal dataclass embedded in Config annotations for runtime detection |
ConfigLoader | Protocol for loading and parsing configuration files (YAML, JSON, TOML) |
ConfigurationRegistry | Loads, merges, and caches configuration from multiple sources with dotted-key resolution |
ConfigResolver | Protocol for resolving configuration keys with type validation |
BaseConfigResolver | Base resolver implementation using ConfigurationRegistry with strict type checking |
PydanticConfigResolver | Wrapper resolver adding pydantic model instantiation support |
ConfigInjector | TD-011 injector that resolves configuration values and modifies target in-place |
ConfigResolver Registration | Application registers a resolver (e.g., PydanticConfigResolver) with the container before using ConfigInjector |
ConfigurationError | Exception hierarchy for configuration-related failures |
3.3 Architecture¶
graph TB
subgraph "Configuration Files"
A1[YAML Files]
A2[JSON Files]
A3[TOML Files]
end
subgraph "Loading Layer"
B1[YamlConfigLoader]
B2[JsonConfigLoader]
B3[TomlConfigLoader]
end
subgraph "Registry Layer"
C[ConfigurationRegistry]
C1[Cached Config]
end
subgraph "Resolution Layer"
D1[BaseConfigResolver]
D2[PydanticConfigResolver]
end
subgraph "Injection Layer"
E[ConfigInjector]
F[(TD-011 Injector Pipeline)]
end
subgraph "Application Services"
G[Config-Annotated Services]
H[Bean Factories]
end
A1 --> B1
A2 --> B2
A3 --> B3
B1 --> C
B2 --> C
B3 --> C
C --> C1
C1 --> D1
D1 --> D2
D2 --> E
E --> F
F --> G
F --> H
%% Styling
classDef source fill:none,stroke:#01579b,color:#01579b,stroke-width:2px
classDef loader fill:none,stroke:#4a148c,color:#4a148c,stroke-width:2px
classDef registry fill:none,stroke:#1b5e20,color:#1b5e20,stroke-width:2px
classDef resolver fill:none,stroke:#6a1b9a,color:#6a1b9a,stroke-width:2px
classDef injector fill:none,stroke:#e65100,color:#e65100,stroke-width:2px
classDef services fill:none,stroke:#880e4f,color:#880e4f,stroke-width:2px
class A1,A2,A3 source
class B1,B2,B3 loader
class C,C1 registry
class D1,D2 resolver
class E,F injector
class G,H services 3.4 Configuration Resolution Flow¶
graph LR
A[Config Annotation] --> B[ConfigInjector]
B --> C[ConfigResolver]
C --> D[ConfigurationRegistry]
D --> E{Key Found?}
E -->|Yes| F[Type Validation]
E -->|No| G{Required?}
F --> H{Type Match?}
H -->|Yes| I[Inject Value]
H -->|No| J[Raise ConfigurationTypeError]
G -->|Yes| K[Raise ConfigurationMissingError]
G -->|No| L[Use Default]
L --> I
%% Styling
classDef annotation fill:none,stroke:#1565c0,color:#1565c0,stroke-width:2px
classDef injector fill:none,stroke:#c2185b,color:#c2185b,stroke-width:2px
classDef resolver fill:none,stroke:#6a1b9a,color:#6a1b9a,stroke-width:2px
classDef registry fill:none,stroke:#2e7d32,color:#2e7d32,stroke-width:2px
classDef decision fill:none,stroke:#ef6c00,color:#ef6c00,stroke-width:2px
classDef success fill:none,stroke:#2e7d32,color:#2e7d32,stroke-width:2px
classDef failure fill:none,stroke:#c62828,color:#c62828,stroke-width:2px
class A annotation
class B injector
class C resolver
class D registry
class E,G,H decision
class I,L success
class J,K failure
class F success ๐ 4. Implementation Details¶
4.1 Error Hierarchy¶
class ConfigurationError(Exception):
"""Base exception for configuration-related errors."""
class ConfigurationMissingError(ConfigurationError):
"""Raised when a required configuration key is missing."""
class ConfigurationProviderError(ConfigurationError):
"""Raised when a configuration provider fails to load."""
class ConfigurationTypeError(ConfigurationError):
"""Raised when configuration value cannot be coerced to expected type."""
4.2 Configuration Metadata¶
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from typing import Any
from typing import TypeAlias
from typing import TypeVar
T = TypeVar('T')
@dataclass(frozen=True)
class _ConfigMarker:
"""Runtime marker embedded in Config annotations.
:param key: Dotted configuration key (e.g., "database.host")
:type key: str
:param required: Whether key must exist in configuration
:type required: bool
:param default: Default value when key is missing and not required
:type default: Any | None
"""
key: str
required: bool = True
default: Any | None = None
# Canonical pattern: host: Config[str, "database.host"]
Config: TypeAlias = Annotated[T, _ConfigMarker()]
4.3 ConfigInjector¶
Key Responsibilities:
- Detect
Config[T, "key"]annotations via type introspection - Resolve configuration values via
ConfigResolver - Maintain TD-011 semantics (modify in-place, return unchanged)
Integration Points:
- Requires
ConfigResolverinstance at construction - Registered with container via
container.register_injector()
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from loryth.common.di.container import Container
class ConfigInjector:
"""TD-011 injector for Config-annotated properties.
Maintains TD-011 semantics: modifies target in-place, returns unchanged.
:param resolver: Configuration resolver instance
:type resolver: ConfigResolver
"""
def __init__(self, resolver: ConfigResolver) -> None:
"""Initialize with configuration resolver instance."""
self._resolver = resolver
@staticmethod
def create(
sources: Sequence[str] | None = None,
resolver: ConfigResolver | None = None,
) -> ConfigInjector:
"""Static factory for injector construction.
:param sources: Configuration source paths (e.g., ['config/app.yaml', 'config/local.yaml'])
:type sources: Sequence[str] | None
:param resolver: Custom resolver (overrides default with sources)
:type resolver: ConfigResolver | None
:return: Configured injector instance
:rtype: ConfigInjector
:raises ConfigurationProviderError: If no loader supports sources
Strategy:
1. If explicit resolver provided, use it directly
2. Otherwise, create default resolver stack:
a. Create ConfigurationRegistry with default loaders
b. Load all sources via registry.load(source)
c. Create BaseConfigResolver with registry
d. Wrap in PydanticConfigResolver for pydantic support
3. Return ConfigInjector with resolver
"""
...
def inject(self, target: object, container: Container) -> object:
"""Inject configuration values following TD-011 semantics.
:param target: Object instance to inject into
:type target: object
:param container: DI container (unused for configuration)
:type container: Container
:return: Same object instance with properties injected
:rtype: object
:raises ConfigurationError: If injection fails
Algorithm:
1. Detect Config[T, "key"] annotations via type introspection
2. For each dependency:
- Extract key and type from Config annotation
- Resolve value via resolver.resolve(key, type)
- Handle required/optional logic with defaults
- Set property via setattr()
3. Return target unchanged
"""
...
def _detect_config_dependencies(
self, cls: type
) -> dict[str, tuple[type, _ConfigMarker]]:
"""Detect Config annotations via type introspection.
:param cls: Class to inspect
:type cls: type
:return: Property name -> (value_type, marker) mapping
:rtype: dict[str, tuple[type, _ConfigMarker]]
Implementation notes:
- Scan __annotations__ for Config[T, "key"] pattern
- Use typing.get_origin() and typing.get_args() for type alias detection
- Extract _ConfigMarker metadata from Annotated args
"""
...
4.4 ConfigurationRegistry¶
Central registry that loads configuration from multiple sources, merges them, and provides dotted-key resolution. Returns deep copies to prevent external mutation of cached configuration.
Key Responsibilities:
- Manage multiple ConfigLoader instances
- Load configuration from sources via matching loaders
- Merge configuration into cached state
- Resolve dotted keys (
"database.host"โ traverse nested structure) - Return deep copies for immutability
from __future__ import annotations
from collections.abc import Sequence
from copy import deepcopy
from loryth.common.json import JsonObject
from loryth.common.json import JsonValue
class ConfigurationRegistry:
"""Registry for loading, merging, and resolving configuration.
Manages multiple loaders, caches merged configuration, and provides
dotted-key resolution with immutable deep copies.
"""
def __init__(self, loaders: Sequence[ConfigLoader] | None = None) -> None:
"""Initialize registry with optional loaders.
:param loaders: Configuration loaders to register
:type loaders: Sequence[ConfigLoader] | None
Default loaders if None:
- YamlConfigLoader
- JsonConfigLoader
- TomlConfigLoader
"""
self._loaders: list[ConfigLoader] = list(loaders) if loaders else []
self._cached_config: JsonObject = {}
@staticmethod
def create(loaders: Sequence[ConfigLoader] | None = None) -> ConfigurationRegistry:
"""Static factory for creating registry with default loaders.
:param loaders: Custom loaders (overrides defaults if provided)
:type loaders: Sequence[ConfigLoader] | None
:return: Registry instance with loaders
:rtype: ConfigurationRegistry
If loaders is None, creates default loaders:
- YamlConfigLoader (supports .yml, .yaml)
- JsonConfigLoader (supports .json)
- TomlConfigLoader (supports .toml)
"""
...
def add_loader(self, loader: ConfigLoader) -> None:
"""Add a configuration loader to the registry.
:param loader: Loader to add
:type loader: ConfigLoader
"""
...
def load(self, source: str) -> None:
"""Load configuration from source and merge into cache.
:param source: Configuration source identifier (typically file path)
:type source: str
:raises ConfigurationProviderError: If no loader supports source or loading fails
Algorithm:
1. Find first loader where supports(source) returns True
2. Load configuration via loader.load(source)
3. Deep merge result into cached configuration
4. Later sources override earlier sources
"""
...
def get(self, key: str) -> JsonValue:
"""Resolve dotted key to configuration value (returns deep copy).
:param key: Dotted key path (e.g., "database.host")
:type key: str
:return: Deep copy of configuration value or None if not found
:rtype: JsonValue
Algorithm:
- Split key by '.' into segments
- Traverse nested dicts for each segment
- Return deep copy of value if found
- Return None if any segment missing
"""
...
@property
def config(self) -> JsonObject:
"""Get deep copy of full cached configuration.
:return: Deep copy of entire configuration
:rtype: JsonObject
Returns immutable copy to prevent external mutation.
"""
return deepcopy(self._cached_config)
4.5 ConfigLoader¶
Extensible protocol for configuration source loading. Implementations handle specific formats (YAML, JSON, TOML, etc.).
Key Responsibilities:
- Declare file format support via
supports(source: str)method - Load and parse configuration files
- Return configuration as
JsonObjectfor registry consumption
from __future__ import annotations
from typing import Protocol
from loryth.common.json import JsonObject
class ConfigLoader(Protocol):
"""Protocol for configuration source loaders.
Implementations handle specific formats (YAML, JSON, TOML, etc.).
"""
def supports(self, source: str) -> bool:
"""Check if loader supports the given source.
:param source: Configuration source identifier (typically file path)
:type source: str
:return: True if loader can load this source
:rtype: bool
Implementation strategy:
- Check file extension for file-based loaders
- Check URI scheme for URL-based loaders
- Custom logic for specialized loaders
"""
...
def load(self, source: str) -> JsonObject:
"""Load and parse configuration from source.
:param source: Configuration source identifier
:type source: str
:return: Configuration data as JsonObject
:rtype: JsonObject
:raises ConfigurationProviderError: If loading fails
Implementation notes:
- Parse file content based on format
- Return empty dict if file is empty
- Raise ConfigurationProviderError for I/O or parsing errors
"""
...
Default Implementations:
YamlConfigLoader: Supports.ymland.yamlfiles viayaml.safe_load()JsonConfigLoader: Supports.jsonfiles viajson.load()TomlConfigLoader: Supports.tomlfiles viatomllib.load()
4.6 ConfigResolver¶
Protocol for resolving configuration keys with type validation. Implementations resolve dotted paths and validate the returned value matches the expected type.
Key Responsibilities:
- Resolve configuration keys via dotted paths
- Validate resolved values match expected types
- Raise ConfigurationTypeError on type mismatches
from __future__ import annotations
from typing import Protocol
from typing import TypeVar
T = TypeVar('T')
class ConfigResolver(Protocol):
"""Protocol for configuration resolution with type validation.
Resolves dotted configuration paths and validates type conformance.
"""
def resolve[T](self, path: str, config_type: type[T]) -> T:
"""Resolve configuration key with type validation.
:param path: Dotted configuration key (e.g., "database.host")
:type path: str
:param config_type: Expected type of configuration value
:type config_type: type[T]
:return: Resolved configuration value
:rtype: T
:raises ConfigurationMissingError: If key is missing
:raises ConfigurationTypeError: If value type mismatches config_type
Resolution strategy:
1. Retrieve value from configuration source
2. Validate value matches config_type
3. Return value if valid, raise ConfigurationTypeError otherwise
"""
...
4.7 BaseConfigResolver¶
Base implementation of ConfigResolver that uses ConfigurationRegistry for value lookup and performs strict type checking.
Key Responsibilities:
- Resolve keys via ConfigurationRegistry
- Perform runtime type validation
- Raise appropriate configuration errors
from __future__ import annotations
class BaseConfigResolver:
"""Base resolver implementation using ConfigurationRegistry.
Performs strict type checking without automatic conversion.
"""
def __init__(self, registry: ConfigurationRegistry) -> None:
"""Initialize with configuration registry.
:param registry: Registry for configuration lookup
:type registry: ConfigurationRegistry
"""
self._registry = registry
def resolve[T](self, path: str, config_type: type[T]) -> T:
"""Resolve configuration key with strict type validation.
:param path: Dotted configuration key (e.g., "database.host")
:type path: str
:param config_type: Expected type of configuration value
:type config_type: type[T]
:return: Resolved configuration value
:rtype: T
:raises ConfigurationMissingError: If key is missing
:raises ConfigurationTypeError: If value type mismatches config_type
Algorithm:
1. Call registry.get(path) to retrieve value
2. If value is None, raise ConfigurationMissingError
3. Use isinstance() to validate value matches config_type
4. If type matches, return value
5. If type mismatches, raise ConfigurationTypeError with context
"""
...
4.8 PydanticConfigResolver¶
Wrapper resolver that adds pydantic model instantiation support. Checks for __pydantic_model__ attribute and instantiates models from dict configuration, wrapping ValidationError in ConfigurationError.
Key Responsibilities:
- Wrap another ConfigResolver instance
- Detect pydantic models via
__pydantic_model__attribute - Instantiate pydantic models from dict configuration
- Wrap pydantic ValidationError in ConfigurationError
from __future__ import annotations
from pydantic import ValidationError
class PydanticConfigResolver:
"""Resolver wrapper adding pydantic model support.
Wraps another resolver and adds pydantic model instantiation.
"""
def __init__(self, resolver: ConfigResolver) -> None:
"""Initialize with wrapped resolver.
:param resolver: Base resolver to wrap
:type resolver: ConfigResolver
"""
self._resolver = resolver
def resolve[T](self, path: str, config_type: type[T]) -> T:
"""Resolve configuration with pydantic model support.
:param path: Dotted configuration key (e.g., "database.host")
:type path: str
:param config_type: Expected type of configuration value
:type config_type: type[T]
:return: Resolved configuration value or pydantic model instance
:rtype: T
:raises ConfigurationMissingError: If key is missing
:raises ConfigurationTypeError: If value type mismatches config_type
:raises ConfigurationError: If pydantic validation fails
Algorithm:
1. Check if config_type has __pydantic_model__ attribute
2. If not pydantic model, delegate to wrapped resolver
3. If pydantic model:
a. Resolve value via wrapped resolver (may be dict)
b. If value is dict, instantiate model: config_type(**value)
c. Catch ValidationError and wrap in ConfigurationError
d. Return model instance if successful
"""
...
4.9 Container Integration¶
Integration Points:
- Create
ConfigInjectorvia static factory with config sources or custom resolver - Register injector with container via
container.register_injector() - Configuration injection happens automatically during instance creation
TD-011 Semantics:
ConfigInjectorregistered alongsideAutowiredInjectorand custom injectors- Injector execution order does not matter (guideline:
a(b(x)) โ b(a(x))) - Configuration injection happens for all resolved instances:
- Singletons: During bootstrap (eager resolution)
- Transients: On-demand during each
container.resolve()call
- Injector modifies target in-place and returns unchanged
Updated ConfigInjector Interface:
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from loryth.common.di.container import Container
class ConfigInjector:
"""TD-011 injector for Config-annotated properties.
Maintains TD-011 semantics: modifies target in-place, returns unchanged.
:param resolver: Configuration resolver instance
:type resolver: ConfigResolver
"""
def __init__(self, resolver: ConfigResolver) -> None:
"""Initialize with configuration resolver instance."""
self._resolver = resolver
@staticmethod
def create(
sources: Sequence[str] | None = None,
resolver: ConfigResolver | None = None,
) -> ConfigInjector:
"""Static factory for injector construction.
:param sources: Configuration source paths (e.g., ['config/app.yaml', 'config/local.yaml'])
:type sources: Sequence[str] | None
:param resolver: Custom resolver (overrides default with sources)
:type resolver: ConfigResolver | None
:return: Configured injector instance
:rtype: ConfigInjector
:raises ConfigurationProviderError: If no loader supports sources
Strategy:
1. If explicit resolver provided, use it directly
2. Otherwise, create default resolver stack:
a. Create ConfigurationRegistry with default loaders
b. Load all sources via registry.load(source)
c. Create BaseConfigResolver with registry
d. Wrap in PydanticConfigResolver for pydantic support
3. Return ConfigInjector with resolver
"""
...
def inject(self, target: object, container: Container) -> object:
"""Inject configuration values following TD-011 semantics.
:param target: Object instance to inject into
:type target: object
:param container: DI container (unused for configuration)
:type container: Container
:return: Same object instance with properties injected
:rtype: object
:raises ConfigurationError: If injection fails
Algorithm:
1. Detect Config[T, "key"] annotations via type introspection
2. For each dependency:
- Extract key and type from Config annotation
- Resolve value via resolver.resolve(key, type)
- Handle required/optional logic with defaults
- Set property via setattr()
3. Return target unchanged
"""
...
Workflow:
from loryth.common.di.bootstrap import BootstrapPlanBuilder
from loryth.common.di.bootstrap import BootstrapRuntime
from loryth.common.di.configuration import BaseConfigResolver
from loryth.common.di.configuration import ConfigInjector
from loryth.common.di.configuration import ConfigResolver
from loryth.common.di.configuration import ConfigurationRegistry
from loryth.common.di.configuration import PydanticConfigResolver
from loryth.common.di.container import Container
# 1. Build resolver with desired sources (later sources override earlier ones)
registry = ConfigurationRegistry.create()
for source in (
"config/application.yaml",
"config/local.yaml",
):
registry.load(source)
resolver = PydanticConfigResolver(BaseConfigResolver(registry))
# 2. Register resolver and injector with the container
container = Container()
container.register_instance(ConfigResolver, resolver)
container.register_injector(ConfigInjector())
# 3. Bootstrap with scanning (configuration injected during bootstrap)
plan_builder = BootstrapPlanBuilder.create()
plan = plan_builder.scan_packages(['myapp.services']).build()
runtime = BootstrapRuntime()
runtime.bootstrap(container, plan)
# 4. Services have Config properties injected automatically
service = container.resolve(MyService)
Customization Points:
- Multiple config files: include additional paths in the resolver setup loop.
- Custom resolver: register your own
ConfigResolverimplementation instead of the defaultPydanticConfigResolver. - Environment-specific config: select sources dynamically prior to resolver registration.
- Custom loaders: Create registry with custom loaders, wrap in BaseConfigResolver
โ๏ธ 5. Semantics¶
- Configuration Loading: Single provider instance loads configuration from
CONFIG_PATHenvironment variable or constructor parameter - Provider Customization: Clients register bespoke
ConfigResolverimplementations (e.g., wrapping secrets managers) before addingConfigInjectorto the pipeline - Key Resolution: Missing optional keys fall back to defaults; required keys raise
ConfigurationError - Type Handling: No automatic type conversion/coercion - client responsibility to parse values correctly
- Pydantic Integration:
ConfigurationErrorwraps pydantic'sValidationErrorwhen using pydantic models - Immutability: Configuration is immutable once loaded; no runtime refresh to avoid hot-swapping complexity
- Error Handling: Fail-fast on first missing key with specific
ConfigurationErrorhierarchy - Injector Semantics:
ConfigInjectormodifies target in-place and returns unchanged (following TD-011Autowiredpattern)
๐งช 6. Examples¶
6.1 Property Injection¶
from typing import Annotated
from loryth.common.di.annotations import service
from loryth.common.di.configuration import Config
@service
class DatabaseClient:
"""Service demonstrating basic configuration property injection."""
host: Config[str, "database.host"]
port: Config[int, "database.port", {"default": 5432}]
ssl_enabled: Config[bool, "database.ssl", {"default": False}]
@service
class EmailService:
"""Service with optional configuration and defaults."""
smtp_host: Config[str, "email.smtp.host", {"required": False, "default": "localhost"}]
smtp_port: Config[int, "email.smtp.port", {"default": 587}]
timeout: Config[int, "email.timeout", {"default": 30}]
6.2 Bean Factory Parameter Injection¶
from typing import Annotated
from loryth.common.di.annotations import configuration, bean
from loryth.common.di.configuration import Config
@configuration(prefix='billing')
class BillingConfiguration:
"""Configuration class demonstrating bean factory parameter injection."""
@bean
def billing_client(
self,
api_key: Config[str, "credentials.api_key"],
endpoint: Config[str, "endpoints.billing"],
timeout: Config[int, "timeouts.billing", {"default": 30}],
) -> BillingClient:
"""Create billing client with injected configuration."""
return BillingClient(
api_key=api_key,
endpoint=endpoint,
timeout=timeout,
)
@configuration(prefix='cache')
class CacheConfiguration:
"""Configuration with complex nested configuration."""
@bean
def redis_client(
self,
host: Config[str, "redis.host"],
port: Config[int, "redis.port", {"default": 6379}],
password: Config[str, "redis.password", {"required": False}],
database: Config[int, "redis.database", {"default": 0}],
) -> RedisClient:
"""Create Redis client with injected configuration."""
return RedisClient(
host=host,
port=port,
password=password,
database=database,
)
6.3 Pydantic Models¶
Pydantic models receive special handling in ConfigInjector: when the value type has a __pydantic_model__ attribute and the configuration value is a dict, ConfigInjector instantiates the model automatically. This enables validation and type safety without general type coercion.
from typing import Annotated
from pydantic import BaseModel, ValidationError
from loryth.common.di.annotations import service
from loryth.common.di.configuration import Config, ConfigurationError
class DatabaseConfig(BaseModel):
"""Pydantic model for database configuration.
ConfigInjector will instantiate this model from dict configuration and wrap
any ValidationError in ConfigurationError.
"""
host: str
port: int = 5432
database: str
username: str
password: str
ssl_enabled: bool = False
class EmailConfig(BaseModel):
"""Pydantic model for email configuration."""
smtp_host: str
smtp_port: int = 587
username: str
password: str
use_tls: bool = True
@service
class DatabaseClient:
"""Service using pydantic models for configuration.
ConfigInjector will:
1. Load config dict from "database" key
2. Detect DatabaseConfig has __pydantic_model__
3. Instantiate DatabaseConfig(**config_dict)
4. Wrap any ValidationError in ConfigurationError
"""
db_config: Config[DatabaseConfig, "database"]
email_config: Config[EmailConfig, "email"]
def connect(self) -> None:
"""Connect using validated configuration."""
# db_config is already a validated DatabaseConfig instance
print(f"Connecting to {self.db_config.host}:{self.db_config.port}")
# Example configuration file (application.yaml):
# database:
# host: localhost
# port: 5432
# database: myapp
# username: admin
# password: secret
# ssl_enabled: true
# email:
# smtp_host: smtp.example.com
# smtp_port: 587
# username: noreply@example.com
# password: secret
# use_tls: true
6.4 Complete Setup¶
from loryth.common.di.container import Container
from loryth.common.di.configuration import ConfigInjector
from loryth.common.di.configuration import ConfigResolver
from loryth.common.di.configuration import ConfigurationRegistry
from loryth.common.di.configuration import PydanticConfigResolver
from loryth.common.di.configuration import BaseConfigResolver
from loryth.common.di.bootstrap import BootstrapRuntime, BootstrapPlanBuilder
# Create configuration resolver with multiple sources (later sources override earlier)
registry = ConfigurationRegistry.create()
for source in (
"config/application.yaml",
"config/local.yaml", # Local overrides for development
):
registry.load(source)
resolver = PydanticConfigResolver(BaseConfigResolver(registry))
# Create container and register configuration injector
container = Container()
container.register_instance(ConfigResolver, resolver)
container.register_injector(ConfigInjector())
# Bootstrap DI container with scanning
plan_builder = BootstrapPlanBuilder.create()
plan = plan_builder.scan_packages(['myapp.services']).build()
runtime = BootstrapRuntime()
runtime.bootstrap(container, plan)
# Services now have configuration injected automatically
email_service = container.resolve(EmailService)
database_client = container.resolve(DatabaseClient)
6.5 Custom Loaders¶
import os
from pathlib import Path
from loryth.common.di.configuration import ConfigLoader
from loryth.common.di.configuration import ConfigurationRegistry
from loryth.common.di.configuration import BaseConfigResolver
from loryth.common.di.configuration import PydanticConfigResolver
from loryth.common.di.configuration import ConfigInjector
from loryth.common.json import JsonObject
class EnvironmentConfigLoader:
"""Custom loader for environment variable configuration."""
def supports(self, source: str) -> bool:
"""Check if source is env:// URI."""
return source == 'env://'
def load(self, source: str) -> JsonObject:
"""Load configuration from environment variables."""
return {
'database': {
'host': os.environ.get('DATABASE_HOST', 'localhost'),
'port': int(os.environ.get('DATABASE_PORT', '5432')),
},
'email': {
'smtp': {
'host': os.environ.get('SMTP_HOST', 'localhost'),
'port': int(os.environ.get('SMTP_PORT', '587')),
}
}
}
class JsonFileConfigLoader:
"""Custom loader for JSON file configuration."""
def supports(self, source: str) -> bool:
"""Check if source is .json file."""
return source.endswith('.json')
def load(self, source: str) -> JsonObject:
"""Load configuration from JSON file."""
import json
with Path(source).open('r', encoding='utf-8') as handle:
return json.load(handle)
# Use custom loaders with registry and resolver
registry = ConfigurationRegistry([
EnvironmentConfigLoader(),
JsonFileConfigLoader(),
# Built-in loaders can be added too
])
# Load multiple sources (later sources override earlier)
registry.load('env://')
registry.load('config/custom.json')
# Create resolver stack and injector
base_resolver = BaseConfigResolver(registry)
pydantic_resolver = PydanticConfigResolver(base_resolver)
injector = ConfigInjector(pydantic_resolver)
๐งช 7. Test Matrix¶
Purpose: Define comprehensive test coverage strategy for configuration binding implementation.
Test Organization:
tests/unit/di/configuration/
โโโ test_config_injector.py # ConfigInjector unit tests
โโโ test_config_loader.py # ConfigLoader protocol unit tests
โโโ test_configuration_registry.py # Registry loading and merging tests
โโโ test_base_config_resolver.py # BaseConfigResolver unit tests
โโโ test_pydantic_config_resolver.py # PydanticConfigResolver unit tests
โโโ test_config_marker.py # Config metadata tests
tests/integration/di/configuration/
โโโ test_config_integration.py # End-to-end configuration binding
โโโ test_loader_integration.py # Custom loader integration
โโโ test_resolver_integration.py # Custom resolver integration
โโโ test_injector_integration.py # Injector pipeline integration
tests/regression/di/configuration/
โโโ test_configuration_stability.py # Long-term stability tests
โโโ test_backward_compatibility.py # Migration path validation
7.1 Unit Coverage¶
-
ConfigInjector:
- Test
_detect_config_dependencies()with canonicalConfig[T, "key"]pattern - Test resolution with dotted keys (
"database.host") - Test required vs. optional keys with defaults
- Test error handling (
ConfigurationMissingError,ConfigurationErrorwrapping) - Verify in-place modification and unchanged return (TD-011 semantics)
- Test
-
ConfigurationRegistry:
- Test loader registration and management
- Test
load()method with matching loaders - Test deep merge logic for multiple sources
- Test
get()dotted-key resolution ("database.host"โ nested dict traversal) - Test
get()returns deep copies (immutability) - Test
configproperty returns deep copy of full configuration - Test
ConfigurationProviderErrorwhen no loader supports source
-
ConfigLoader:
- Test
supports()method for file extension matching - Test
load()method for YAML, JSON, TOML formats - Test error handling for I/O and parsing failures
- Test
-
BaseConfigResolver:
- Test
resolve()with valid keys and types - Test strict type validation (isinstance checks)
- Test
ConfigurationMissingErrorfor missing keys - Test
ConfigurationTypeErrorfor type mismatches
- Test
-
PydanticConfigResolver:
- Test pydantic model detection via
__pydantic_model__ - Test model instantiation from dict configuration
- Test ValidationError wrapping in ConfigurationError
- Test delegation to wrapped resolver for non-pydantic types
- Test pydantic model detection via
-
Static Factory:
- Test
ConfigInjector.create(sources=[...])with multiple sources - Test
ConfigInjector.create(resolver=...)custom resolver - Test default resolver stack creation (Registry โ Base โ Pydantic)
- Test
7.2 Integration Coverage¶
-
@service + Config Integration:
- Test
@serviceclass withConfig[str, "key"]properties - Verify configuration injection happens alongside
Autowiredinjection - Test injector order independence
- Test
-
@bean + Config Integration:
- Test
@beanfactory methods withConfigparameters - Verify configuration resolves before bean factory invocation
- Test
-
Pydantic Models:
- Test
Config[PydanticModel, "key"]instantiation from dict config - Test
ValidationErrorwrapping inConfigurationError
- Test
-
Custom Loaders:
- Test custom loader implementation and registration
- Verify loader selection logic with custom extensions
- Test multiple sources with precedence rules
-
End-to-End Workflows:
- Test complete setup with sources โ registry โ resolver โ injector
- Test multiple configuration sources with override semantics
- Test custom resolver implementations
Testing notes:
- Unit tests use lightweight fakes/test fixtures for configuration sources
- Integration tests use real YAML/JSON/TOML files with
@serviceand@beanannotations - All tests follow function-based pattern (no test classes) per CLAUDE.md guidelines
โ 8. Acceptance Criteria¶
Status: โ All criteria met - Implementation complete (2025-10-12)
-
Config[T, "key"]type alias implemented usingtyping.Annotated(single canonical pattern only) -
_ConfigMarkerdataclass captures key, required flag, and default value -
ConfigInjectorimplements TD-011Injectorprotocol (modify in-place, return unchanged) -
ConfigInjector.create()static factory supportssourcesandresolverparameters -
ConfigLoaderprotocol defined withsupports()andload()methods returningJsonObject -
ConfigurationRegistrymanages loaders, loads/merges sources, caches configuration -
ConfigurationRegistry.get()implements dotted-key resolution and returns deep copies -
ConfigurationRegistry.configproperty returns deep copy of full configuration -
ConfigResolverprotocol defined withresolve[T](path, config_type)method -
BaseConfigResolverperforms strict type validation without automatic conversion -
PydanticConfigResolverwraps another resolver and adds pydantic model support - Missing required keys raise
ConfigurationMissingErrorwith clear context - Type mismatches raise
ConfigurationTypeErrorwith context - Optional keys with defaults work without raising errors
- Pydantic model instantiation supported via
PydanticConfigResolver - Pydantic
ValidationErrorwrapped inConfigurationError - Multiple sources supported with later sources overriding earlier sources
- Custom loaders can be provided via
ConfigurationRegistryconstructor - Custom resolvers can be provided via
ConfigInjector.create(resolver=...) - Configuration binding works with
@serviceclasses - Configuration binding works with
@beanfactory method parameters - Injector order independence verified (Config + Autowired work together)
- Integration tests demonstrate
@service+Config+@beancombinations
๐ 9. Rollout and Migration¶
9.1 Implementation Phases¶
Phase 1: Core Infrastructure
- Implement
Config[T, "key"]type alias and_ConfigMarkerdataclass - Create
ConfigurationErrorhierarchy - Outcome: Core configuration contracts defined
Phase 2: Loading Layer
- Implement
ConfigLoaderprotocol withJsonObjectreturn type - Build default loaders (
YamlConfigLoader,JsonConfigLoader,TomlConfigLoader) - Implement
ConfigurationRegistrywith loader management, source loading, and deep merge - Add dotted-key resolution to
ConfigurationRegistry.get() - Implement deep copy behavior for immutability
- Outcome: Configuration loading and merging pipeline operational
Phase 3: Resolution Layer
- Implement
ConfigResolverprotocol withresolve[T](path, config_type)method - Build
BaseConfigResolverwith strict type validation - Implement
PydanticConfigResolverwrapper with model instantiation - Add pydantic
ValidationErrorwrapping inConfigurationError - Outcome: Type-safe configuration resolution with pydantic support
Phase 4: Injector Integration
- Implement
ConfigInjectorfollowing TD-011 semantics (modify in-place, return unchanged) - Build static factory
ConfigInjector.create()with sources/resolver parameters - Implement
_detect_config_dependencies()for canonicalConfig[T, "key"]pattern - Integrate with TD-011 injector pipeline via
container.register_injector() - Outcome: Configuration injection working with DI container
Phase 5: Testing and Documentation
- Add comprehensive unit tests (registry, loaders, resolvers, injector)
- Add integration tests (
@service+Config,@bean+Config, pydantic models) - Create usage examples demonstrating canonical patterns
- Document custom loader and resolver extension points
- Outcome: Production-ready configuration binding
9.2 Migration Strategy¶
- Manual configuration loading remains possible; injector is opt-in
- Existing services without configuration annotations remain unaffected
- Future hot-reload features can extend
ConfigurationRegistrywithout breaking the contract - Provide migration examples showing before/after patterns
โ ๏ธ 10. Risks and Mitigations¶
| Risk | Mitigation |
|---|---|
| Configuration drift between environments | Provide precedence documentation, optional validation hooks, and diff tooling |
| Provider proliferation creating confusion | Offer simple defaults and encourage custom providers to wrap loader rather than bypass it |
| Loading large configuration files slows startup | Cache parsed values and allow selective reloads |
| Type coercion failures in production | Comprehensive error messages with context and validation hooks |
| Integration complexity with existing DI patterns | Clear migration guides and backward compatibility guarantees |
10.1 Configuration Drift Risk¶
Risk: Divergent configuration between environments leads to runtime surprises Mitigation: Provide precedence documentation, optional validation hooks, and diff tooling
10.2 Provider Proliferation Risk¶
Risk: Multiple competing providers create confusion Mitigation: Offer simple defaults and encourage custom providers to wrap loader rather than bypass it
10.3 Performance Risk¶
Risk: Loading large configuration files slows startup Mitigation: Cache parsed values and allow selective reloads
๐ 11. Progress¶
Legend: โณ Pending ยท ๐ง In Progress ยท โ Completed
Status: TD-019 is โ 100% IMPLEMENTED and production-ready
Phase 1 โ Core Infrastructure โ
- [โ
]
Config[T, "key"]type alias and_ConfigMarkerimplementation - [โ
]
ConfigurationErrorhierarchy
Phase 2 โ Loading Layer โ
- [โ
]
ConfigLoaderprotocol withJsonObjectreturn type - [โ ] Default loaders (YamlConfigLoader, JsonConfigLoader, TomlConfigLoader)
- [โ
]
ConfigurationRegistrywith loader management and source loading - [โ
] Dotted-key resolution in
ConfigurationRegistry.get() - [โ ] Deep copy behavior for immutability
Phase 3 โ Resolution Layer โ
- [โ
]
ConfigResolverprotocol withresolve[T](path, config_type)method - [โ
]
BaseConfigResolverwith strict type validation - [โ
]
PydanticConfigResolverwrapper with model instantiation - [โ
] Pydantic
ValidationErrorwrapping inConfigurationError
Phase 4 โ Injector Integration โ
- [โ
]
ConfigInjectorfollowing TD-011 semantics (modify in-place, return unchanged) - [โ
] Static factory
ConfigInjector.create()with sources/resolver parameters - [โ
]
_detect_config_dependencies()for canonical Config pattern - [โ
] Integration with TD-011 injector pipeline via
container.register_injector()
Phase 5 โ Testing and Documentation โ
- [โ ] Unit tests (registry, loaders, resolvers, injector) - 76 tests
- [โ
] Integration tests (
@service+ Config,@bean+ Config, pydantic models) - 6 tests - [โ ] Usage examples demonstrating canonical patterns
- [โ ] Documentation for custom loader and resolver extension points
Implementation Summary: - Total Tests: 82 (76 unit + 6 integration), all passing - Code Quality: 100% mypy compliance (strict mode), 100% ruff compliance - Full Test Suite: 668 tests passing (no regressions) - Implementation Date: 2025-10-12 - Files Created: 7 source files, 6 test files, 4 test resource files
๐ 12. Changelog¶
- 2025-10-12: โ
IMPLEMENTATION COMPLETE - Full TD-019 implementation:
- Implemented complete configuration binding system in
src/loryth/common/di/configuration/ - Created 7 source files:
__init__.py,types.py,exceptions.py,loader.py,registry.py,resolver.py,injector.py - Created comprehensive test suite: 76 unit tests + 6 integration tests (82 total), all passing
- Achieved 100% mypy compliance (strict mode) and 100% ruff compliance
- Zero regressions - full test suite (668 tests) passing
- Implemented all features from design specification:
Config[T, "key"]type alias with_ConfigMarkermetadata- ConfigurationError hierarchy (4 exception types)
- ConfigLoader protocol with YAML/JSON/TOML loaders
- ConfigurationRegistry with deep merge and dotted-key resolution
- ConfigResolver protocol with BaseConfigResolver and PydanticConfigResolver
- ConfigInjector with TD-011 integration
- Static factory pattern for easy setup
- Immutable deep copies for configuration safety
- Pydantic v2 model support with validation error wrapping
- Special handling for bool vs int type checking (Python quirk)
- Updated
src/loryth/common/di/__init__.pyto export configuration components - Created test resources with sample YAML, JSON, and TOML configuration files
- All code follows CLAUDE.md standards (module-level imports, Sphinx docstrings, Python 3.13+ types)
- Implemented complete configuration binding system in
- 2025-10-12: Major architecture redesign (document updates):
- Replaced
ConfigProviderprotocol withConfigLoaderprotocol returningJsonObject - Redesigned
ConfigurationRegistryto manage multiple loaders, load/merge sources, and cache configuration - Added
ConfigResolverprotocol for key resolution with type validation - Added
BaseConfigResolverimplementation with strict type checking - Added
PydanticConfigResolverwrapper for pydantic model support - Updated
ConfigInjectorto use resolver instead of provider directly - Changed
ConfigInjector.create()to acceptsourcesinstead ofconfig_path - Added deep copy semantics to registry for immutability guarantees
- Updated all code examples, test matrix, acceptance criteria, and implementation phases
- Removed
ConfigurationLoadercomponent (replaced by registry) - Updated component table, architecture diagrams, and glossary
- Replaced
- 2025-10-11: Major design refinements (document updates):
- Updated
Configimplementation design to matchAutowiredfactory pattern - Simplified architecture by removing
ConfigurationSnapshotconcept - Removed
ConfigInjectorBridgeabstraction from design - Fixed
ConfigInjectorspecification to properly implement TD-011 injector protocol - Added pydantic model examples and complete setup workflow
- Restructured examples and fixed duplicate section titles
- Updated
- 2025-10-11: Final decisions on open questions (design decisions):
- Profile detection via
CONFIG_PATHenvironment variable - No built-in validation (encourage pydantic models, wrap
ValidationErrorinConfigurationError) - No type conversion/coercion (client responsibility)
- No runtime refresh (immutable configuration)
- No built-in secret handling or test utilities
- Fail-fast error handling with specific error hierarchy
- Profile detection via
- 2025-10-06: Initial draft created (extracted from TD-012 configuration content)
๐ฎ 13. Future Work¶
Out-of-Scope Items for Future Enhancement¶
Type Conversion System
- Built-in converters for common types (enums, durations, URLs, etc.)
- Extensible converter registry for custom type handling
Secret Manager Integration
- Built-in support for common secret managers (AWS Secrets Manager, HashiCorp Vault, etc.)
- Secure credential injection without embedding in configuration files
Advanced Validation
- Optional pydantic integration for schema validation
- Configuration validation hooks and pre-load validation
Runtime Refresh
- File watching for configuration changes
- Hot-reload capabilities for long-running services
- Graceful configuration updates without service restart
Research Items¶
- Environment Variable Integration Patterns: Investigate Spring Boot and other framework approaches to environment variable binding
๐ 14. Glossary¶
- Config Injector โ TD-011 injector that supplies configuration values declared with
typing.Annotated - Config Loader โ Protocol-based component responsible for loading and parsing configuration files (YAML, JSON, TOML)
- Configuration Registry โ Central component that manages loaders, loads sources, merges configuration, and caches results
- Config Resolver โ Protocol-based component responsible for resolving configuration keys with type validation
- Base Config Resolver โ Default resolver implementation using ConfigurationRegistry with strict type checking
- Pydantic Config Resolver โ Wrapper resolver that adds pydantic model instantiation support
- Dotted Key โ Canonical string path (e.g.,
database.host) used to retrieve values from configuration - Deep Copy โ Immutable copy of configuration data returned by registry to prevent external mutation