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.
1. Protocol Hierarchy
Section titled “1. Protocol Hierarchy”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
| Protocol | Access | Use When |
|---|---|---|
ContainerRegistrarProtocol | singleton(), transient(), scoped(), has() | Module registration code that only binds services |
ContainerResolverProtocol | resolve(), resolve_optional(), resolve_all(), call(), create_scope() | Code that only retrieves services |
BootContainerProtocol | Registrar + Resolver | Provider boot() methods that resolve and rebind services via bind() |
ContainerValidationProtocol | validate(), validate_no_orphans() | Development-time validators |
ContainerProtocol | Registrar + Resolver + Validation | Full container control; rarely needed directly |
2. Register vs. Boot
Section titled “2. Register vs. Boot”from oridecon import Providerfrom oridecon.contracts.core import ProviderPriorityfrom 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.
3. Why Three Registration Protocols?
Section titled “3. Why Three Registration Protocols?”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 resolveasync def register(self, container: ContainerRegistrarProtocol) -> None: db = await container.resolve(DatabaseProtocol) # mypy: error!
# This is fine — boot() is allowed to resolveasync def boot(self, container: BootContainerProtocol) -> None: db = await container.resolve(DatabaseProtocol) # OK| Protocol | Purpose | Forbidden Operations |
|---|---|---|
ContainerRegistrarProtocol | Declare bindings | resolve(), resolve_optional(), call() |
ContainerResolverProtocol | Retrieve services | singleton(), transient(), scoped() |
BootContainerProtocol | Wire services | singleton()/transient()/scoped() post-freeze; use bind() to rebind |
4. Protocol Types in singleton()
Section titled “4. Protocol Types in singleton()”When you register a Protocol as a service key, use the overload pattern:
# Concrete type — full type inferencecontainer.singleton(UserService, UserServiceImpl())# ↑ resolved as: UserServiceImpl# ↓ registered as: type[UserService]
# Protocol type — uses Any fallback overloadcontainer.singleton(LLMClientProtocol, ObservableLLMClient(...))# Both resolve() and singleton() accept Protocol types via @overloadThe dual @overload signatures on singleton(), resolve(), resolve_optional(), and resolve_all() ensure:
- Concrete types get full
type[T] -> Tinference - Protocol types are accepted via an
Anyfallback overload
5. Structural Subtyping in Practice
Section titled “5. Structural Subtyping in Practice”You never inherit from a Protocol — any object that has the required methods satisfies it:
from oridecon import Containerfrom oridecon.contracts.core.di import BootContainerProtocol
container = Container()assert isinstance(container, BootContainerProtocol) # TrueThis means the orchestrator can pass the real Container instance wherever a Protocol is expected, and mypy knows exactly what operations are available.
6. The orchestrator’s role
Section titled “6. The orchestrator’s role”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 orderboot() receives BootContainerProtocol. After freeze, replace a singleton with bind(), not singleton().
7. Verification
Section titled “7. Verification”Type-check your providers. Passing ContainerRegistrarProtocol into register() and BootContainerProtocol into boot() is what lets mypy catch resolve() during registration.
uv run mypy src/A # type: ignore[attr-defined] on a container call usually means the method is on the wrong protocol.
Next Steps
Section titled “Next Steps”- Providers — the two-phase lifecycle that uses these protocols
- Boot Sequence — freeze, validate, rollback
- Dependency Injection — scopes and constructor injection
- Common mistakes —
register()that resolves