Modules¶
A Module is a reusable bundle of bindings. Instead of
repeating the same bind calls in every composition root,
package them into a class with a register(builder) method
and install the module wherever it is needed.
A first module¶
from tripack_contracts import Lifecycle
from tripack_container import ContainerBuilder, Module
class Clock:
def now(self) -> float:
return 1234567890.0
class Cache:
def __init__(self) -> None:
self._store: dict[str, str] = {}
class InfrastructureModule:
def register(self, builder: ContainerBuilder) -> None:
builder.bind(Clock, Clock, lifecycle=Lifecycle.SINGLETON)
builder.bind(Cache, Cache, lifecycle=Lifecycle.SINGLETON)
container = (
ContainerBuilder()
.install(InfrastructureModule())
.build()
)
clock = container.resolve(Clock)
cache = container.resolve(Cache)
The Module Protocol has one shape: register(builder)
-> None. Any class that exposes that method is a module -
no inheritance, no base class, structural typing.
Composition¶
Modules can install other modules. The diamond install problem (two top-level modules each pulling in a common dependency) is handled by a per-instance guard:
class CacheModule:
def register(self, builder: ContainerBuilder) -> None:
builder.bind(Cache, Cache)
class LoggerModule:
def register(self, builder: ContainerBuilder) -> None:
builder.bind(Logger, Logger)
class AppModule:
def __init__(self) -> None:
# Cached shared instances so the diamond install
# below collapses to a single install.
self.cache_module = CacheModule()
self.logger_module = LoggerModule()
def register(self, builder: ContainerBuilder) -> None:
builder.install(self.cache_module)
builder.install(self.logger_module)
class WebModule:
def __init__(self, infra: AppModule) -> None:
self.infra = infra
def register(self, builder: ContainerBuilder) -> None:
builder.install(self.infra) # pulls Cache + Logger
builder.bind(HttpServer, HttpServer)
class CliModule:
def __init__(self, infra: AppModule) -> None:
self.infra = infra
def register(self, builder: ContainerBuilder) -> None:
builder.install(self.infra) # also pulls Cache + Logger
builder.bind(CliRunner, CliRunner)
# Both Web and CLI pull AppModule. Installing both at the
# top should NOT re-run AppModule's register.
infra = AppModule()
web = WebModule(infra)
cli = CliModule(infra)
container = (
ContainerBuilder()
.install(web)
.install(cli)
.build()
)
# `infra.register` ran exactly once; `cache_module.register`
# and `logger_module.register` each ran exactly once too.
install tracks each module by identity. A second
install(same_instance) is a no-op. Two distinct instances
of the same class are treated as separate modules and both
run - the underlying graph's idempotent register then
deduplicates the actual bindings if they happen to match.
Order matters for overrides¶
When two bindings target the same token, the last one installed is the one that takes effect:
class DefaultClockModule:
def register(self, builder: ContainerBuilder) -> None:
builder.bind(Clock, Clock)
class TestClockModule:
def register(self, builder: ContainerBuilder) -> None:
builder.bind(Clock, FakeClock)
# This raises BindingError: the second bind conflicts.
container = (
ContainerBuilder()
.install(DefaultClockModule())
.install(TestClockModule())
.build()
)
install does not silently overwrite. To pick a
different binding, omit the conflicting module - do not try
to layer two modules that bind the same token to different
factories.
For test scenarios that need to override a single binding, the recommended pattern is to install all the production modules except the one that owns the token to be overridden, then bind the override directly:
container = (
ContainerBuilder()
.install(WebModule(infra=AppModule())) # no DefaultClockModule inside
.bind(Clock, FakeClock) # explicit test override
.build()
)
When to extract a module¶
- Reusable feature. A "cache + clock + config" trio that five applications share belongs in a module so the five consumers don't repeat the binds.
- Plug-in shape. A third-party library that wants to
integrate with a Tripack-based application exposes a
Moduleso the consumer's composition root is oneinstall(...)line. - Test scoping. A
FakesModuleand aRealModuleswap in test vs production builds.
When the bindings are not shared, a module is overhead. A
single one-off composition root reads better with direct
builder.bind(...) chains.
Where to go next¶
- Idempotent registration: the
underlying guarantee that makes
installsafe to call twice. - Reference:
Module,ContainerBuilder.install.