Skip to content

Good and bad practices

modern-di's docs mostly show the happy path. This page collects the footguns instead: real mistakes the framework lets you make, each paired with the mechanism that catches or prevents it.

1. Captive dependency: a wide-scoped provider holding a narrow-scoped one

A captive dependency is a wide-scoped provider holding a narrow-scoped one it cannot outlive. See the scope dependency rule for why.

class Dependencies(Group):
    session = providers.Factory(Session, scope=Scope.REQUEST)

    # Broken: forgot scope=Scope.REQUEST, so it defaults to Scope.APP, which cannot hold `session`
    user_cache = providers.Factory(UserCache)

    # Works: matches the lifetime of what it consumes
    user_cache = providers.Factory(UserCache, scope=Scope.REQUEST)

container.validate() reports the broken version before anything resolves: it raises ValidationFailedError carrying an InvalidScopeDependencyError; see InvalidScopeDependencyError. Without validate(), the first resolve of UserCache raises ScopeNotInitializedError, whose message names both UserCache and Session, on the first request that reaches it instead of at startup.

2. Shipping a never-validated graph

validate() is the only check of the whole graph: cycles, scope violations, and dependencies nothing provides. Nothing calls it for you; see Validation.

# Broken: never validated, so wiring bugs surface one at a time, in production, on whatever request trips them
container = Container(groups=[Dependencies])

# Works: validated explicitly, so every wiring bug is reported at once, at startup
container = Container(groups=[Dependencies])
container.validate()  # raises ValidationFailedError here if the graph is broken

validate() skips a provider declared with bound_type=None unless a provider registered by type depends on it, so such a provider can still fail on its first resolve. An unvalidated cyclic graph does not hang; see the runtime cycle guard.

3. A cached factory resolved before set_context

A cached factory is built once, and a later set_context does not rebuild it. An uncached factory reads the context again on every resolve.

class Dependencies(Group):
    tenant_id = providers.ContextProvider(str, scope=Scope.REQUEST)

    # Broken: cached, so it is built on first resolve and frozen from then on
    tenant_config = providers.Factory(create_tenant_config, scope=Scope.REQUEST, cache=True)

    # Works: uncached, so it re-reads the live context on every resolve
    tenant_config = providers.Factory(create_tenant_config, scope=Scope.REQUEST)

If a request container resolves tenant_config before the real tenant ID is set, the cached instance keeps the earlier value for the rest of the request. Drop cache=True for anything that depends on context set later, or set the context before the first resolve. validate() cannot catch this, because the graph is correct and only the timing is wrong. See Context propagation.

4. Service location through an injected Container

A creator can take the resolving Container as a parameter; see Injecting the container itself. Using it to pull the creator's real dependencies out of the container turns DI into a service locator: the dependency disappears from the signature and from validate().

# Broken: the real dependency (Settings) is invisible to validate() and to the signature
def create_api_key(container: Container) -> str:
    return container.resolve(Settings).api_key

# Works: declared as an ordinary parameter, so validate() checks it
def create_api_key(settings: Settings) -> str:
    return settings.api_key

When nothing provides Settings, the broken version passes validate() and raises ProviderNotRegisteredError on its first resolve, while validate() reports the declared parameter up front. Keep Container parameters for code that is about the container, such as building a child container.

5. Override leaks across tests

Overrides are shared by the whole container tree (see Testing with overrides), so one that is never reset reaches every later test that shares the container. Nothing reports the leak.

class Clock:
    def now(self) -> float:
        return 0.0


class Dependencies(Group):
    clock = providers.Factory(Clock)


container = Container(groups=[Dependencies])


# Broken: nothing resets the override, so every later test that resolves Clock gets the fake
def test_one() -> None:
    container.override(Dependencies.clock, Mock(spec=Clock))


# Works: the with block resets the override after each test, even when the test fails
@pytest.fixture
def fake_clock() -> typing.Iterator[Mock]:
    with container.override(Dependencies.clock, Mock(spec=Clock)) as fake:
        yield fake

reset_override(provider), or reset_override() with no argument, clears an imperative override. Closing the container does not. See Testing with overrides.

6. skip_creator_parsing=True with no bound_type

skip_creator_parsing=True turns off signature introspection, for creators modern-di cannot introspect. It also skips the return annotation, so the provider has no bound type unless you pass one: Factory(...) warns at declaration, and resolving the type raises ProviderNotRegisteredError. See skip_creator_parsing.

# Broken: nothing else can resolve this provider by type, and a UserWarning fires at declaration time
providers.Factory(opaque_creator, scope=Scope.APP, skip_creator_parsing=True)

# Works: the type is declared explicitly
providers.Factory(
    opaque_creator,
    scope=Scope.APP,
    skip_creator_parsing=True,
    bound_type=MyClass,
)

The warning is easy to miss in test output. Treat it as a signal to add bound_type=.

7. Async finalizer on a container closed with close_sync()

close_sync() cannot await, and a sync with container: block closes with it. An async finalizer there fails with AsyncFinalizerInSyncCloseError inside a FinalizerError, and the instance stays cached until close_async() runs. The Celery integration closes the APP container with close_sync(). See Async finalizers and close_sync().

class HttpClient: ...


async def close_client(client: HttpClient) -> None: ...


class ClientDependencies(Group):
    client = providers.Factory(HttpClient, cache=providers.CacheSettings(finalizer=close_client))
# Broken: `with` closes the container with close_sync(), which cannot await close_client
with Container(groups=[ClientDependencies]) as client_container:
    client_container.resolve(HttpClient)
# Works: `async with` closes the container with close_async()
async with Container(groups=[ClientDependencies]) as client_container:
    client_container.resolve(HttpClient)

See also