Skip to content
Packages Examples Agents Blog Get started

Container Protocols

Oridecon uses structural subtyping to provide full type safety on the DI container. Rather than typing container parameters as a concrete class, you use Protocol types that describe exactly what operations a piece of code needs.

graph TB
    subgraph Protocols
        Registrar["ContainerRegistrarProtocol<br>singleton(), transient(), scoped(), has()"]
        Resolver["ContainerResolverProtocol<br>resolve(), resolve_optional(), resolve_all(), call(), create_scope()"]
        Validation["ContainerValidationProtocol<br>validate(), validate_no_orphans()"]
    end
    
    Registrar --> Boot["BootContainerProtocol<br>Registrar + Resolver"]
    Resolver --> Boot
    Registrar --> Full["ContainerProtocol<br>Registrar + Resolver + Validation"]
    Resolver --> Full
    Validation --> Full
    
    Boot --> ContainerImpl["Container (concrete)"]
    Full --> ContainerImpl
ProtocolAccessUse When
ContainerRegistrarProtocolsingleton(), transient(), scoped(), has()Module registration code that only binds services
ContainerResolverProtocolresolve(), resolve_optional(), resolve_all(), call(), create_scope()Code that only retrieves services
BootContainerProtocolRegistrar + ResolverProvider boot() methods that resolve and rebind services via bind()
ContainerValidationProtocolvalidate(), validate_no_orphans()Development-time validators
ContainerProtocolRegistrar + Resolver + ValidationFull container control; rarely needed directly

from oridecon import Provider
from oridecon.contracts.core import ProviderPriority
from oridecon.contracts.core.di import (
ContainerRegistrarProtocol,
BootContainerProtocol,
)
class BillingProvider(Provider):
name = "billing"
priority = ProviderPriority.APPLICATION
async def register(self, container: ContainerRegistrarProtocol) -> None:
"""Phase 1: Only registration. No service retrieval allowed."""
container.singleton(PaymentGateway, StripeGateway)
container.singleton(PaymentService, PaymentService())
async def boot(self, container: BootContainerProtocol) -> None:
"""Phase 2: Resolve existing services, wire them, replace via bind()."""
gateway = await container.resolve(PaymentGateway)
db = await container.resolve(InvoiceRepository)
# Replace an already-registered singleton with the wired instance
container.bind(PaymentService, PaymentService(gateway, db))

Key principle: The register() phase is purely declarative — it says what services exist, not how they are initialized. The boot() phase is where initialization and wiring happen.


Using the narrowest Protocol for each context enables mypy to catch errors at the call site:

# This fails at type-check time — register() can't resolve
async def register(self, container: ContainerRegistrarProtocol) -> None:
db = await container.resolve(DatabaseProtocol) # mypy: error!
# This is fine — boot() is allowed to resolve
async def boot(self, container: BootContainerProtocol) -> None:
db = await container.resolve(DatabaseProtocol) # OK
ProtocolPurposeForbidden Operations
ContainerRegistrarProtocolDeclare bindingsresolve(), resolve_optional(), call()
ContainerResolverProtocolRetrieve servicessingleton(), transient(), scoped()
BootContainerProtocolWire servicessingleton()/transient()/scoped() post-freeze; use bind() to rebind

When you register a Protocol as a service key, use the overload pattern:

# Concrete type — full type inference
container.singleton(UserService, UserServiceImpl())
# ↑ resolved as: UserServiceImpl
# ↓ registered as: type[UserService]
# Protocol type — uses Any fallback overload
container.singleton(LLMClientProtocol, ObservableLLMClient(...))
# Both resolve() and singleton() accept Protocol types via @overload

The dual @overload signatures on singleton(), resolve(), resolve_optional(), and resolve_all() ensure:

  • Concrete types get full type[T] -> T inference
  • Protocol types are accepted via an Any fallback overload

You never inherit from a Protocol — any object that has the required methods satisfies it:

from oridecon import Container
from oridecon.contracts.core.di import BootContainerProtocol
container = Container()
assert isinstance(container, BootContainerProtocol) # True

This means the orchestrator can pass the real Container instance wherever a Protocol is expected, and mypy knows exactly what operations are available.


Application.start() calls ProviderOrchestrator.boot_all(container). That method is the whole sequence, not just the boot phase:

await orchestrator.boot_all(container)
# internally:
# 1. register_all(container) — all register() in priority / dependency levels
# 2. container.freeze() — singleton/transient/scoped now raise
# 3. container.validate() — missing deps, cycles, module exports
# 4. boot_only(container) — all boot() in the same order

boot() receives BootContainerProtocol. After freeze, replace a singleton with bind(), not singleton().


Type-check your providers. Passing ContainerRegistrarProtocol into register() and BootContainerProtocol into boot() is what lets mypy catch resolve() during registration.

Terminal window
uv run mypy src/

A # type: ignore[attr-defined] on a container call usually means the method is on the wrong protocol.