Skip to content

๐Ÿ“˜ 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)
  • ConfigInjector implements TD-011 injector protocol for configuration resolution
  • Update (2025-10-18): The static factory ConfigInjector.create() has been retired. ConfigInjectors now resolve a ConfigResolver from the container, so call sites must register an appropriate resolver (e.g., PydanticConfigResolver) before registering the injector.
  • ConfigurationRegistry loads, merges, and caches configuration from multiple sources
  • ConfigLoader protocol for extensible file format support (YAML, JSON, TOML)
  • ConfigResolver protocol for key resolution with type validation
  • Pydantic model support via PydanticConfigResolver wrapper
  • 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

  1. Provide a declarative configuration binding mechanism backed by typing.Annotated
  2. Integrate configuration binding with TD-011 property/method injectors
  3. Support multiple configuration sources with deterministic precedence
  4. Offer extension points for custom configuration providers and runtime overrides
  5. 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.Annotated usage 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 ConfigResolver implementations
  • No automatic type conversion/coercion (strict type checking)
  • Pydantic models receive special handling via PydanticConfigResolver wrapper
  • 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:

  1. Detect Config[T, "key"] annotations via type introspection
  2. Resolve configuration values via ConfigResolver
  3. Maintain TD-011 semantics (modify in-place, return unchanged)

Integration Points:

  • Requires ConfigResolver instance 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:

  1. Manage multiple ConfigLoader instances
  2. Load configuration from sources via matching loaders
  3. Merge configuration into cached state
  4. Resolve dotted keys ("database.host" โ†’ traverse nested structure)
  5. 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:

  1. Declare file format support via supports(source: str) method
  2. Load and parse configuration files
  3. Return configuration as JsonObject for 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 .yml and .yaml files via yaml.safe_load()
  • JsonConfigLoader: Supports .json files via json.load()
  • TomlConfigLoader: Supports .toml files via tomllib.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:

  1. Resolve configuration keys via dotted paths
  2. Validate resolved values match expected types
  3. 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:

  1. Resolve keys via ConfigurationRegistry
  2. Perform runtime type validation
  3. 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:

  1. Wrap another ConfigResolver instance
  2. Detect pydantic models via __pydantic_model__ attribute
  3. Instantiate pydantic models from dict configuration
  4. 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:

  1. Create ConfigInjector via static factory with config sources or custom resolver
  2. Register injector with container via container.register_injector()
  3. Configuration injection happens automatically during instance creation

TD-011 Semantics:

  • ConfigInjector registered alongside AutowiredInjector and 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 ConfigResolver implementation instead of the default PydanticConfigResolver.
  • 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_PATH environment variable or constructor parameter
  • Provider Customization: Clients register bespoke ConfigResolver implementations (e.g., wrapping secrets managers) before adding ConfigInjector to 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: ConfigurationError wraps pydantic's ValidationError when 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 ConfigurationError hierarchy
  • Injector Semantics: ConfigInjector modifies target in-place and returns unchanged (following TD-011 Autowired pattern)

๐Ÿงช 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 canonical Config[T, "key"] pattern
    • Test resolution with dotted keys ("database.host")
    • Test required vs. optional keys with defaults
    • Test error handling (ConfigurationMissingError, ConfigurationError wrapping)
    • Verify in-place modification and unchanged return (TD-011 semantics)
  • 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 config property returns deep copy of full configuration
    • Test ConfigurationProviderError when 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
  • BaseConfigResolver:

    • Test resolve() with valid keys and types
    • Test strict type validation (isinstance checks)
    • Test ConfigurationMissingError for missing keys
    • Test ConfigurationTypeError for type mismatches
  • 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
  • Static Factory:

    • Test ConfigInjector.create(sources=[...]) with multiple sources
    • Test ConfigInjector.create(resolver=...) custom resolver
    • Test default resolver stack creation (Registry โ†’ Base โ†’ Pydantic)

7.2 Integration Coverage

  • @service + Config Integration:

    • Test @service class with Config[str, "key"] properties
    • Verify configuration injection happens alongside Autowired injection
    • Test injector order independence
  • @bean + Config Integration:

    • Test @bean factory methods with Config parameters
    • Verify configuration resolves before bean factory invocation
  • Pydantic Models:

    • Test Config[PydanticModel, "key"] instantiation from dict config
    • Test ValidationError wrapping in ConfigurationError
  • 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 @service and @bean annotations
  • 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 using typing.Annotated (single canonical pattern only)
  • _ConfigMarker dataclass captures key, required flag, and default value
  • ConfigInjector implements TD-011 Injector protocol (modify in-place, return unchanged)
  • ConfigInjector.create() static factory supports sources and resolver parameters
  • ConfigLoader protocol defined with supports() and load() methods returning JsonObject
  • ConfigurationRegistry manages loaders, loads/merges sources, caches configuration
  • ConfigurationRegistry.get() implements dotted-key resolution and returns deep copies
  • ConfigurationRegistry.config property returns deep copy of full configuration
  • ConfigResolver protocol defined with resolve[T](path, config_type) method
  • BaseConfigResolver performs strict type validation without automatic conversion
  • PydanticConfigResolver wraps another resolver and adds pydantic model support
  • Missing required keys raise ConfigurationMissingError with clear context
  • Type mismatches raise ConfigurationTypeError with context
  • Optional keys with defaults work without raising errors
  • Pydantic model instantiation supported via PydanticConfigResolver
  • Pydantic ValidationError wrapped in ConfigurationError
  • Multiple sources supported with later sources overriding earlier sources
  • Custom loaders can be provided via ConfigurationRegistry constructor
  • Custom resolvers can be provided via ConfigInjector.create(resolver=...)
  • Configuration binding works with @service classes
  • Configuration binding works with @bean factory method parameters
  • Injector order independence verified (Config + Autowired work together)
  • Integration tests demonstrate @service + Config + @bean combinations

๐Ÿš€ 9. Rollout and Migration

9.1 Implementation Phases

Phase 1: Core Infrastructure

  • Implement Config[T, "key"] type alias and _ConfigMarker dataclass
  • Create ConfigurationError hierarchy
  • Outcome: Core configuration contracts defined

Phase 2: Loading Layer

  • Implement ConfigLoader protocol with JsonObject return type
  • Build default loaders (YamlConfigLoader, JsonConfigLoader, TomlConfigLoader)
  • Implement ConfigurationRegistry with 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 ConfigResolver protocol with resolve[T](path, config_type) method
  • Build BaseConfigResolver with strict type validation
  • Implement PydanticConfigResolver wrapper with model instantiation
  • Add pydantic ValidationError wrapping in ConfigurationError
  • Outcome: Type-safe configuration resolution with pydantic support

Phase 4: Injector Integration

  • Implement ConfigInjector following TD-011 semantics (modify in-place, return unchanged)
  • Build static factory ConfigInjector.create() with sources/resolver parameters
  • Implement _detect_config_dependencies() for canonical Config[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 ConfigurationRegistry without 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 _ConfigMarker implementation
  • [โœ…] ConfigurationError hierarchy

Phase 2 โ€” Loading Layer โœ…

  • [โœ…] ConfigLoader protocol with JsonObject return type
  • [โœ…] Default loaders (YamlConfigLoader, JsonConfigLoader, TomlConfigLoader)
  • [โœ…] ConfigurationRegistry with loader management and source loading
  • [โœ…] Dotted-key resolution in ConfigurationRegistry.get()
  • [โœ…] Deep copy behavior for immutability

Phase 3 โ€” Resolution Layer โœ…

  • [โœ…] ConfigResolver protocol with resolve[T](path, config_type) method
  • [โœ…] BaseConfigResolver with strict type validation
  • [โœ…] PydanticConfigResolver wrapper with model instantiation
  • [โœ…] Pydantic ValidationError wrapping in ConfigurationError

Phase 4 โ€” Injector Integration โœ…

  • [โœ…] ConfigInjector following 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 _ConfigMarker metadata
    • 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__.py to 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)
  • 2025-10-12: Major architecture redesign (document updates):
    • Replaced ConfigProvider protocol with ConfigLoader protocol returning JsonObject
    • Redesigned ConfigurationRegistry to manage multiple loaders, load/merge sources, and cache configuration
    • Added ConfigResolver protocol for key resolution with type validation
    • Added BaseConfigResolver implementation with strict type checking
    • Added PydanticConfigResolver wrapper for pydantic model support
    • Updated ConfigInjector to use resolver instead of provider directly
    • Changed ConfigInjector.create() to accept sources instead of config_path
    • Added deep copy semantics to registry for immutability guarantees
    • Updated all code examples, test matrix, acceptance criteria, and implementation phases
    • Removed ConfigurationLoader component (replaced by registry)
    • Updated component table, architecture diagrams, and glossary
  • 2025-10-11: Major design refinements (document updates):
    • Updated Config implementation design to match Autowired factory pattern
    • Simplified architecture by removing ConfigurationSnapshot concept
    • Removed ConfigInjectorBridge abstraction from design
    • Fixed ConfigInjector specification to properly implement TD-011 injector protocol
    • Added pydantic model examples and complete setup workflow
    • Restructured examples and fixed duplicate section titles
  • 2025-10-11: Final decisions on open questions (design decisions):
    • Profile detection via CONFIG_PATH environment variable
    • No built-in validation (encourage pydantic models, wrap ValidationError in ConfigurationError)
    • 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
  • 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