Skip to content
GitHubDiscord

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.

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 wire and re-register services
ContainerValidationProtocolvalidate(), validate_no_orphans()Development-time validators
ContainerProtocolRegistrar + Resolver + ValidationFull container control; rarely needed directly

from lexigram.di.provider import Provider
from lexigram.contracts.core.provider import ProviderPriority
from 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.


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 servicesNone — full access

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 lexigram.di.container import Container
from lexigram.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.


The ProviderOrchestrator coordinates the two-phase boot:

await orchestrator.register_all(container) # Phase 1: all register() in priority order
container.freeze() # No more registrations
await orchestrator.boot_all(container) # Phase 2: all boot() in priority order

It passes BootContainerProtocol to each provider’s boot() method — giving each provider exactly the access it needs, nothing more.


Run mypy on your provider package to confirm the container is fully typed:

Terminal window
uv run mypy lexigram/src/ # Expect: Success: no issues found

Any # type: ignore[attr-defined] or # type: ignore[type-abstract] on container method calls indicates a signature needs updating.