Skip to content

Architecture Decision Record (ADR)

Title

Encapsulate Resolution State in EnvResolver Class with Module Facade

Status

Accepted

Date

2025-10-14

Context

Early iterations of the secret resolution API maintained provider registrations and resolver state in module-level globals (_PROVIDERS, _RESOLVER). Although simple, this approach leaked mutable global state, required global declarations when mutating, and made it difficult to instantiate isolated resolvers for tests or advanced scenarios.

We needed a structure that:

  • Removes direct global manipulation from public functions
  • Keeps the existing top-level API (envresolve.resolve_secret, load_env) for backward compatibility
  • Allows creation of multiple resolver instances for specialized use cases (e.g., custom registries in tests)
  • Keeps the provider registration pattern defined in ADR-0009

Decision

Introduce an EnvResolver class that encapsulates provider registration and resolution logic, and expose a singleton instance through module-level facade functions.

class EnvResolver:
    def __init__(self) -> None:
        self._providers: dict[str, SecretProvider] = {}
        self._resolver: SecretResolver | None = None

    def register_azure_kv_provider(self, **kwargs: Any) -> None:
        ...

    def resolve_secret(self, uri: str) -> str:
        ...

    def load_env(self, path: PathLike[str] | None = None) -> dict[str, str]:
        ...


_DEFAULT_RESOLVER = EnvResolver()


def resolve_secret(uri: str) -> str:
    return _DEFAULT_RESOLVER.resolve_secret(uri)


def register_azure_kv_provider(**kwargs: Any) -> None:
    _DEFAULT_RESOLVER.register_azure_kv_provider(**kwargs)

Rationale

  • Encapsulation: All mutable state (providers, cached resolver) is confined to an instance, eliminating global mutation patterns.
  • Testability: Tests can instantiate EnvResolver() directly, register mock providers, and assert behavior without touching the global singleton.
  • Extensibility: Future features (per-resolver caches, alternative registries) can build on the class without breaking the public facade.
  • Backward compatibility: Existing users continue to call module-level functions; no API breakage.

Implications

Positive Implications

  • Cleaner separation between public facade and implementation details.
  • Multiple resolvers can coexist in the same process if needed (e.g., multi-tenant scenarios).
  • Easier to reason about initialization order—EnvResolver constructor localizes default setup.

Concerns

  • Slightly higher indirection: Developers must look inside the class to understand state transitions. Mitigation: Comprehensive docstrings and ADR references.
  • Singleton management: The _DEFAULT_RESOLVER remains module-level global state. Mitigation: Singleton usage isolated to facade; alternative resolvers supported when required.

Alternatives

Keep Module-Level Globals

  • Pros: Minimal code; fewer indirections.
  • Cons: global keyword required for updates; difficult to create isolated resolver instances; tightly couples API to implementation details.
  • Rejection reason: Conflicts with encapsulation and testability goals.

Dependency Injection via Function Parameters

  • Pros: Explicitly pass provider registry/resolver to every function.
  • Cons: Verbose public API; callers must thread dependencies through each call; hurts ergonomics.
  • Rejection reason: Overly burdensome for primary use cases; the facade pattern strikes a better balance.

Metaclass-Based Singleton

  • Pros: Guarantees only one instance ever exists.
  • Cons: Unnecessarily complex; prevents intentional multiple instances in tests.
  • Rejection reason: Simpler explicit singleton (module-level instance) suffices.

Future Direction

  • Provide factory helpers (e.g., create_resolver()) that pre-register common providers.
  • Expose hooks for dependency injection (custom cache, custom secret resolver) during EnvResolver initialization.
  • Evaluate whether the _DEFAULT_RESOLVER should be lazy-initialized to reduce import-time side effects once provider registrations grow.

References

  • Implementation: src/envresolve/api.py
  • Tests: tests/unit/test_resolver.py, tests/e2e/test_nested_resolution.py
  • Related ADRs: 0009 (manual provider registration), 0010 (iterative URI resolution), 0014 (lazy imports for optional providers)