Idempotent registration¶
The container distinguishes three outcomes when the same token is bound more than once: no-op, canonical return, and error. This page shows when each one fires and why the contract is what it is.
Identical re-bind is a no-op¶
from tripack_container import Container
container = Container()
container.bind(Clock, Clock)
container.bind(Clock, Clock) # no error, no second binding
The graph treats two Binding objects as identical when
their (token, factory, lifecycle, auto_inject) fields
match. A re-bind with the same tuple does not raise and
does not duplicate the entry. This is what makes
modules safe to install through more than one
path: the diamond install pattern collapses naturally.
Different factory raises BindingError¶
def alt_clock_factory() -> Clock:
return Clock()
container = Container()
container.bind(Clock, Clock)
container.bind(Clock, alt_clock_factory)
# -> BindingError: Conflicting binding for token <class 'Clock'>: ...
A different factory for the same token is a configuration mistake that the container catches at bind time, not at resolve time. The error names the token so the call site is obvious from the traceback.
Different lifecycle also raises¶
from tripack_contracts import Lifecycle
container = Container()
container.bind(Clock, Clock, lifecycle=Lifecycle.SINGLETON)
container.bind(Clock, Clock, lifecycle=Lifecycle.TRANSIENT)
# -> BindingError: Conflicting binding for token <class 'Clock'>: ...
The container does not silently downgrade or upgrade a binding's lifecycle. A token's lifecycle is part of its identity in the registry.
Async-construction race returns the canonical instance¶
Idempotent registration also protects against the
concurrent-resolve race. Two coroutines that
simultaneously resolve the same SINGLETON token can both
pass the cache-miss check, both invoke their factory, both
finish building - but only one wins the cache. The other
receives the canonical instance back:
import asyncio
from tripack_contracts import Lifecycle
from tripack_container import ContainerBuilder
can_finish = asyncio.Event()
async def slow_factory() -> Pool:
await can_finish.wait()
return Pool()
async def main() -> None:
container = (
ContainerBuilder()
.bind(Pool, slow_factory, lifecycle=Lifecycle.SINGLETON)
.build()
)
task_a = asyncio.create_task(container.aresolve(Pool))
task_b = asyncio.create_task(container.aresolve(Pool))
# Both tasks reach the factory's await point...
for _ in range(5):
await asyncio.sleep(0)
can_finish.set()
a, b = await asyncio.gather(task_a, task_b)
assert a is b # the idempotent guard returns the canonical instance
asyncio.run(main())
Both factory invocations complete, but only the first writer populates the cache. The container's teardown registry records the winner only - the discarded instance is considered orphaned and is not auto-closed (close it manually if it holds external resources, or rely on Python's garbage collector if it does not).
The same guard applies to SCOPED bindings under concurrent
aresolve calls within the same scope.
Sealed containers refuse all rebinds¶
A container produced by ContainerBuilder.build() is
sealed: any subsequent Container.bind call raises
BindingError, identical re-binds included.
container = ContainerBuilder().bind(Clock, Clock).build()
container.bind(Clock, Clock)
# -> BindingError: Container is sealed; bindings can no longer
# be added after ContainerBuilder.build().
The seal makes the wiring immutable in the application's hands. To add bindings, build a new container from a new builder.
When the guarantee matters¶
- Modules with overlapping dependencies. Two top-level
modules both pulling in a shared
CacheModule- the per-instance install guard plus the idempotent rebind make this safe. - Configuration loaders. A TOML file that lists the same token twice (perhaps via an override mechanism layered on top) does not produce a duplicate; conflicts are caught at load time.
- Concurrent async resolves. Two HTTP handlers that resolve the same SINGLETON simultaneously cannot create two pools by accident.
When it does NOT save you¶
- Two different factories for the same token. The container will tell you, but it cannot decide which one you meant.
- An expensive factory that runs on the discarded racer. The work is done; the result is just not cached. Avoid side-effects in factories that you do not want to repeat if the concurrent race happens.
- Override semantics in modules. The container does not
allow a later module to silently replace an earlier
binding. Use explicit
bindafter the modules to override.
Where to go next¶
- Modules: the install-by-identity pattern that relies on idempotent rebinding.
- Reference:
BindingError, Container.bind, Container builder.