Container Protocols
Lexigram 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 wire and re-register services |
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 lexigram.di.provider import Providerfrom lexigram.contracts.core.provider import ProviderPriorityfrom lexigram.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)
async def boot(self, container: BootContainerProtocol) -> None: """Phase 2: Resolve existing services, wire them, register new ones.""" gateway = await container.resolve(PaymentGateway) db = await container.resolve(InvoiceRepository) container.singleton(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, wiring, and conditional re-registration 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 | None — full access |
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 lexigram.di.container import Containerfrom lexigram.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 LifecycleManager’s Role
Section titled “6. The LifecycleManager’s Role”The ProviderOrchestrator coordinates the two-phase boot:
await orchestrator.register_all(container) # Phase 1: all register() in priority ordercontainer.freeze() # No more registrationsawait orchestrator.boot_all(container) # Phase 2: all boot() in priority orderIt passes BootContainerProtocol to each provider’s boot() method — giving each provider exactly the access it needs, nothing more.
7. Verification
Section titled “7. Verification”Run mypy on your provider package to confirm the container is fully typed:
uv run mypy lexigram/src/ # Expect: Success: no issues foundAny # type: ignore[attr-defined] or # type: ignore[type-abstract] on container method calls indicates a signature needs updating.