Skip to content

Lifetimes and scopes

A binding's scope answers two questions: how often is the value built, and who tears it down.

Scope Built Cached on Torn down by
Scope.SINGLETON Once, on first resolution The container close() / aclose()
Scope.SCOPED Once per active scope The scope frame Exit of scope() / ascope()
Scope.TRANSIENT Every resolution Nothing Nothing

SINGLETON is the default, because most of a dependency graph — configuration, clients, connection pools, stateless services — is built once and shared.

Providers that own a resource

A provider that has cleanup to do is written as a generator. Everything before the yield is setup, everything after is teardown:

from collections.abc import Generator

from depin import Container, Scope


class Connection:
    def close(self) -> None: ...


def checkout() -> Generator[Connection]:
    conn = Connection()
    yield conn
    conn.close()


di = Container().bind(checkout, scope=Scope.SCOPED).freeze()

@contextmanager and @asynccontextmanager factories work the same way, as do async generators. What they have in common is a teardown, and that is why they cannot be TRANSIENT: a transient value is never cached, so nothing would ever hold a reference to run the teardown from. freeze() rejects that combination with InvalidScopeError.

Scopes

A scope is a unit of work — an HTTP request, a job, a CLI invocation. Scoped values are built once inside it and drained when it ends, in reverse order of construction:

with di.scope():
    conn = di[Connection]  # built here
    ...
# conn.close() has run

Use ascope() instead when any provider in the scope is async. Both forms yield the scope's ScopeFrame, which is also how you hand values into the scope — see scope_value below.

If several teardowns fail, every failure is reported: the errors are collected into an ExceptionGroup rather than the first one hiding the rest.

Nesting

Scopes nest, and a nested scope inherits the outer scope's instances rather than rebuilding them:

with di.scope():
    outer = di[Connection]
    with di.scope():
        assert di[Connection] is outer

When you want independent instances, open sibling scopes, not nested ones.

Why a singleton cannot depend on a scoped provider

A singleton is built once and lives for the life of the container. A scoped value lives for one unit of work. If a singleton took a scoped dependency it would capture the first scope's instance and quietly reuse it in every scope afterwards — a connection from a closed request, reused for the next thousand.

freeze() refuses:

>>> from depin import Container, Scope
>>> from depin.errors import CaptiveDependencyError
>>> class Session: ...
>>> class Repo:
...     def __init__(self, session: Session) -> None: ...
>>> try:
...     Container().bind(Session, scope=Scope.SCOPED).bind(Repo).freeze()
... except CaptiveDependencyError as exc:
...     print(type(exc).__name__)
CaptiveDependencyError

The check looks through chains of transients, because a transient is inlined into whoever asked for it and carries the capture along with it.

Shutdown

Singletons that own resources are drained by close(), or aclose() when any of them is async:

di = build()
try:
    run(di)
finally:
    di.close()

Calling it twice is harmless: the second call finds nothing left to drain. Calling close() on a graph with an async singleton teardown raises AsyncInSyncContextError before any teardown runs — that teardown needs an event loop, so use aclose().

Values supplied by the scope

Some values are not built by the graph at all; they arrive with the unit of work. An HTTP request, an authenticated principal, a job payload. Declare them with scope_value() and hand them in when you open the scope:

>>> from depin import Container, Scope
>>> class Principal:
...     def __init__(self, name: str) -> None:
...         self.name = name
>>> class Audit:
...     def __init__(self, who: Principal) -> None:
...         self.who = who
>>> di = Container().scope_value(Principal).bind(Audit, scope=Scope.SCOPED).freeze()
>>> with di.scope() as frame:
...     frame.provide(Principal, Principal('ana'))
...     di[Audit].who.name
'ana'

Resolving such a key outside a scope raises OutsideScopeError; resolving it inside a scope that never received the value raises MissingProviderError. This is how the FastAPI integration exposes the per-request Request.

Concurrency

A frozen container is safe to share across threads and tasks. Scopes are tracked per contextvars.Context, so two concurrent requests never see each other's scoped instances, and construction of a cached provider is single-flighted: a singleton is built exactly once no matter how many threads or tasks race for it, under a thread lock on the synchronous path and an asyncio lock on the asynchronous one.