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 actually outlive — see the scope dependency rule for why.
class Dependencies(Group):
session = providers.Factory(Session, scope=Scope.REQUEST)
# ❌ forgot scope=Scope.REQUEST — defaults to Scope.APP, which cannot hold `session`
user_cache = providers.Factory(UserCache)
# ✅ matches the lifetime of what it consumes
user_cache = providers.Factory(UserCache, scope=Scope.REQUEST)
Caught by: an explicit container.validate() call, which raises ValidationFailedError
carrying an InvalidScopeDependencyError for this exact graph before anything is ever resolved
— see Scope chain violation. Nothing validates automatically, so if
the graph is never validated, the runtime failure is a ScopeNotInitializedError/ScopeSkippedError
that (since the scope-error breadcrumb work) now names both the provider that captured the
dependency and the one that actually failed — but it fires on the first request that hits it, not at
startup. Prefer catching it statically with an explicit validate() call.
2. Shipping a never-validated graph¶
validate() is the only thing that checks the whole graph — cycles, inverted scopes, and missing
dependencies. Nothing calls it for you: not construction, not open(), not add_providers, not
resolve(). Skipping it doesn't remove the bugs, it just delays finding them to whichever resolve
happens to hit one first.
# ❌ never validated: wiring bugs surface one at a time, in production, on whatever request trips them
container = Container(groups=[Dependencies])
# ✅ validated explicitly: every wiring bug is reported at once, at startup
container = Container(groups=[Dependencies])
container.validate() # raises ValidationFailedError here if the graph is broken
Caught by: an explicit container.validate() call — it is the only thing that finds every issue
in the graph up front; without it, each wiring bug surfaces individually, at whichever resolve first
reaches it. Container(validate=...) is deprecated and does nothing (see Migration: To
3.x). An unvalidated cyclic
graph still isn't a silent hang — see
the runtime cycle guard.
3. A cached factory resolved before set_context¶
Context values are read live on every resolve of a non-cached factory — but a cached
factory is built once, and a later set_context does not rebuild it.
class Dependencies(Group):
tenant_id = providers.ContextProvider(str, scope=Scope.REQUEST)
# ❌ cached: built on first resolve and frozen from then on
tenant_config = providers.Factory(create_tenant_config, scope=Scope.REQUEST, cache=True)
# ✅ uncached: 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 known (e.g. during
setup), the cached version keeps serving that first value for the rest of the request even after
request.set_context(str, real_tenant_id) runs. Either drop cache=True for anything whose
correctness depends on context set later, or make sure set_context runs before the first resolve.
Caught by: nothing automatic — this is a timing bug, not a wiring bug, so validate() cannot
see it. See Context propagation for how set_context
timing interacts with a provider's scope, and Lifecycle for caching.
4. Service location via container_provider overuse¶
container_provider lets a creator accept the resolving Container itself and pull dependencies
out of it manually. Used for its intended purpose (a provider that genuinely needs the container,
such as building a child container), it's fine. Used as a shortcut to avoid declaring real
parameters, it turns type-driven DI into a service locator: the dependency is hidden from
validate(), from readers, and from anyone trying to see the graph.
# ❌ 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
# ✅ declared as an ordinary parameter — visible, validated, and testable via override
def create_api_key(settings: Settings) -> str:
return settings.api_key
Caught by: nothing enforces this — it's a style discipline, not a validation rule. Reserve
container_provider for cases that are actually about the container (building a child container,
introspecting the current scope), and declare everything else as a typed parameter so
validate() and Resolving dependencies can see it.
5. Override leaks across tests¶
container.override(provider, replacement) replacements are shared across the whole container
tree — see Testing with overrides for the mechanics. Forgetting to reset
it doesn't just affect the test that set it — every later test that shares the container inherits
the replacement.
# ❌ no reset: the next test that resolves Clock silently gets the fake
def test_one() -> None:
container.override(Dependencies.clock, fake_clock)
...
# ✅ always reset, even if the test fails — a fixture teardown is the reliable place for this
@pytest.fixture
def frozen_clock() -> Mock:
fake = Mock(spec=Clock)
container.override(Dependencies.clock, fake)
yield fake
container.reset_override(Dependencies.clock)
Caught by: nothing automatic mid-suite — reset_override(provider) (or reset_override() with no
arguments, to clear everything) is the fix, and closing the root container clears every override
in the shared registry as a last resort. See
Testing with overrides.
6. skip_creator_parsing=True with no bound_type¶
skip_creator_parsing=True turns off signature introspection — useful for callables that can't be
reflected (C extensions, functools.partial). But skipping introspection also means modern-di has
no idea what type the provider produces, so type-based resolution silently can't find it.
# ❌ nothing else can resolve this provider by type — UserWarning at declaration time
providers.Factory(opaque_creator, scope=Scope.APP, skip_creator_parsing=True)
# ✅ tell modern-di the type explicitly
providers.Factory(
opaque_creator,
scope=Scope.APP,
skip_creator_parsing=True,
bound_type=MyClass,
)
Caught by: a UserWarning at declaration time. It's easy to miss in test output — treat it as a
signal to add bound_type=, not to ignore.
See also¶
- Errors and exceptions — the full catalog this page draws its mechanisms from.
- Testing with overrides — the full override lifecycle.
- Lifecycle — caching, finalizers, and
validate().