Skip to content

Lifecycle

modern-di creates an instance on first resolve, keeps a cached instance in the container at its provider's scope, and runs its finalizer when that container closes, newest instance first.

The code blocks below assume the following import, and Dependencies is a user-defined Group:

from modern_di import Container, Scope, providers, exceptions

Lazy initialization

modern-di creates instances on first resolve. There is no init_resources() or "eager startup" call: if a provider is never resolved, its creator never runs.

If you want a provider warmed up at startup (e.g. eager-connect the database engine), call container.resolve(SomeType) for it in your application's startup hook.

container = Container(groups=[Dependencies])

# Warm caches at startup
container.resolve(AsyncEngine)
container.resolve(Settings)

Caching and finalizers

cache=True keeps one instance per container at the provider's scope; see Cached factories and Scope and caching. CacheSettings adds a finalizer, a callable that receives the cached instance when its container closes:

session = providers.Factory(
    create_session,
    scope=Scope.REQUEST,
    cache=providers.CacheSettings(finalizer=close_session),
)

A finalizer takes one argument, the instance, and its return value is ignored. Besides a function like close_session, an unbound method works, because Python passes the instance as self:

engine = providers.Factory(Engine, cache=providers.CacheSettings(finalizer=Engine.dispose))
async_engine = providers.Factory(AsyncEngine, cache=providers.CacheSettings(finalizer=AsyncEngine.dispose))

Finalizers can be sync or async. CacheSettings detects an async one with inspect.iscoroutinefunction(), and close_async() also awaits whatever a sync callable returns, so lambda client: client.aclose() works there. close_sync() cannot await either kind; see Async finalizers and close_sync().

Finalizers run newest first. A dependency is created before the instance that uses it, so the session closes before the engine it was built from.

Closing the container

Three ways to run finalizers:

# Sync
container.close_sync()

# Async
await container.close_async()

# Context manager (preferred: cleanup runs even on exceptions)
with container:
    ...

async with container:
    ...

Closing runs the container's finalizers newest first, then clears its cache.

Per-scope finalization

Each container finalizes only the instances it cached. When a child container exits its with block, the child's finalizers run and the parent's instances stay alive:

app_container = Container(groups=[Dependencies])

async with app_container.build_child_container(scope=Scope.REQUEST) as request_container:
    session = request_container.resolve(AsyncSession)
# the REQUEST container closed the session; the APP engine is still open

await app_container.close_async()
# now the engine is disposed

Framework integrations close each request's child container for you. Most also close the APP container at shutdown with close_async(). Flask, gRPC and Typer leave the APP container to you, and Celery closes it with close_sync(), so APP-scoped finalizers there must be sync. Each integration page says which applies.

Close-failure semantics

Every finalizer runs

Closing keeps going when a finalizer raises, so one broken finalizer does not leak the resources after it. The errors are raised together as one FinalizerError once every finalizer has run. FinalizerError is an ExceptionGroup: .exceptions holds the errors, and .is_async records whether close_sync() or close_async() raised it.

except* catches the finalizer errors by type, and except FinalizerError or except ModernDIError catches the whole group:

try:
    container.close_sync()
except* ConnectionError as group:
    # group is a FinalizerError holding only the ConnectionErrors; .is_async is kept
    for err in group.exceptions:
        print("cleanup failed:", err)

A finalizer that raises still drops its cached instance, and the next resolve after reopening builds a fresh one. With clear_cache=False the instance is kept, as it is after a finalizer that succeeds.

See FinalizerError for reading the group and fixing the finalizers that raised.

Async finalizers and close_sync()

close_sync() cannot await. When it reaches an instance with an async finalizer, it adds an AsyncFinalizerInSyncCloseError to the FinalizerError and keeps the instance cached, so a later await container.close_async() still finalizes it:

container.resolve(AsyncResource)

try:
    container.close_sync()
except* exceptions.AsyncFinalizerInSyncCloseError:
    ...  # the instance is still cached, nothing was finalized

await container.close_async()  # runs the async finalizer

Until then the instance stays cached: a repeated close_sync() reports it again, and resolving after a reopen returns the same object. Use async with container: or await container.close_async() when any provider has an async finalizer; see AsyncFinalizerInSyncCloseError.

Cancelled close

If close_async() is cancelled, or a finalizer raises a BaseException that is not an Exception (such as asyncio.CancelledError or KeyboardInterrupt), that exception propagates at once. The container is marked closed, and every instance whose finalizer has not completed, including the interrupted one, stays queued. Awaiting close_async() again runs the remaining finalizers:

try:
    await asyncio.wait_for(container.close_async(), timeout=5)
except TimeoutError:
    ...

await container.close_async()  # finalizes what the cancelled close did not reach

Closing and reopening

A new container is open, so it resolves right away with no open() call. A child from build_child_container() starts open too.

The container counts as closed as soon as close_sync() or close_async() starts, so a finalizer that resolves from its own container gets ContainerClosedError. Give a finalizer what it needs through the cached instance.

After close, resolving raises ContainerClosedError and the creator does not run. That includes a resolve through a child that reaches the closed container. A child of a closed container can still be built and resolves what it owns. open() reopens the container, and so does entering with container: or async with container: again:

container = Container(groups=[Dependencies])

with container:
    container.resolve(Settings)
# closed here: finalizers ran

container.resolve(Settings)  # raises ContainerClosedError

with container:                 # reopened by __enter__
    container.resolve(Settings)

See ContainerClosedError for what to check when a container is closed where you did not expect it, and Migration: To 4.x for the 3.x behavior, which warned and reopened instead.

What survives close and reopen depends on CacheSettings:

  • With the default clear_cache=True, the instance is finalized at close and rebuilt on the next resolve after reopening.
  • With clear_cache=False, reopening returns the same object, and its finalizer runs only at the first close. That object has already been through its finalizer, so use this only for a resource that still works afterwards.
  • Overrides are configuration, not cached instances, so closing does not touch them. Clear one with reset_override() or by exiting the with container.override(...) block.

The context manager is not reference-counted

Nesting with container: on the same object closes it on the inner with exit, not the outer one. Use one with block per container, or build a child container for the inner scope.

Validation

container.validate() checks the whole graph in one pass: cycles, dependencies on a deeper scope, and dependencies nothing provides. Nothing else validates, not construction, open(), add_providers or resolve(). Without it, a broken graph surfaces at the first resolve that reaches the problem. It skips a provider declared with bound_type=None unless a provider registered by type depends on it; see bound_type.

container = Container(groups=[Dependencies])
container.validate()  # raises ValidationFailedError listing every issue

It reports every issue in one exceptions.ValidationFailedError instead of stopping at the first; see ValidationFailedError. An integration that registers its own providers with add_providers should validate after that; see Writing an integration.

A repeat validate() returns at once until add_providers changes the registry, and validation adds nothing to resolve time. Call it at startup or in one test.

See also