|
| 1 | +# Good and bad practices |
| 2 | + |
| 3 | +modern-di's docs mostly show the happy path. This page collects the footguns instead — real |
| 4 | +mistakes the framework lets you make, each paired with the mechanism that catches or prevents it. |
| 5 | + |
| 6 | +## 1. Captive dependency: a wide-scoped provider holding a narrow-scoped one |
| 7 | + |
| 8 | +A provider is a *captive dependency* when it lives longer than something it depends on — an |
| 9 | +APP-scoped provider that (directly or transitively) needs a REQUEST-scoped one. The REQUEST |
| 10 | +instance would have to outlive its request, so it can't actually be supplied. |
| 11 | + |
| 12 | +```python |
| 13 | +class Dependencies(Group): |
| 14 | + session = providers.Factory(scope=Scope.REQUEST, creator=Session) |
| 15 | + |
| 16 | + # ❌ forgot scope=Scope.REQUEST — defaults to Scope.APP, which cannot hold `session` |
| 17 | + user_cache = providers.Factory(creator=UserCache) |
| 18 | + |
| 19 | + # ✅ matches the lifetime of what it consumes |
| 20 | + user_cache = providers.Factory(scope=Scope.REQUEST, creator=UserCache) |
| 21 | +``` |
| 22 | + |
| 23 | +**Caught by:** `Container(groups=[...], validate=True)` raises `InvalidScopeDependencyError` for |
| 24 | +this exact graph before anything is ever resolved — see |
| 25 | +[Scope chain violation](../troubleshooting/scope-chain.md). If the graph is never validated, the |
| 26 | +runtime failure is a `ScopeNotInitializedError`/`ScopeSkippedError` that (since the scope-error |
| 27 | +breadcrumb work) now names both the provider that captured the dependency and the one that |
| 28 | +actually failed — but it fires on the first request that hits it, not at startup. Prefer catching it |
| 29 | +statically. |
| 30 | + |
| 31 | +## 2. Shipping a never-validated graph |
| 32 | + |
| 33 | +`validate()` is the only thing that checks the *whole* graph — cycles, inverted scopes, and missing |
| 34 | +dependencies — before the first request. Skipping it doesn't remove the bugs, it just delays |
| 35 | +finding them to whichever resolve happens to hit one first. |
| 36 | + |
| 37 | +```python |
| 38 | +# ❌ wiring bugs surface one at a time, in production, on whatever request trips them |
| 39 | +container = Container(groups=[Dependencies]) |
| 40 | + |
| 41 | +# ✅ every wiring bug in the graph is reported at startup, all at once |
| 42 | +container = Container(groups=[Dependencies], validate=True) |
| 43 | +``` |
| 44 | + |
| 45 | +**Caught by:** `validate=True` (or an explicit `container.validate()` call). Note that an |
| 46 | +unvalidated circular graph is *not* a silent hang or a bare traceback anymore: the first resolve |
| 47 | +that overflows the stack is caught and re-raised as `CircularDependencyError` with the cycle path |
| 48 | +attached (see [Circular Dependency Error](../troubleshooting/circular-dependency.md)) — but that |
| 49 | +runtime cycle guard is a backstop for the *one* cycle a particular resolve happened to hit, not a |
| 50 | +substitute for `validate()` finding *every* issue up front. Leaving `validate` unset on a root |
| 51 | +container also emits `UnvalidatedContainerWarning`, since modern-di 3.0 turns validation on by |
| 52 | +default. |
| 53 | + |
| 54 | +## 3. A cached factory resolved before `set_context` |
| 55 | + |
| 56 | +Context values are read live on every resolve of a **non-cached** factory — but a **cached** |
| 57 | +factory is built once, and a later `set_context` does not rebuild it. |
| 58 | + |
| 59 | +```python |
| 60 | +class Dependencies(Group): |
| 61 | + tenant_id = providers.ContextProvider(scope=Scope.REQUEST, context_type=str) |
| 62 | + |
| 63 | + # ❌ cached: built on first resolve and frozen from then on |
| 64 | + tenant_config = providers.Factory(scope=Scope.REQUEST, creator=create_tenant_config, cache=True) |
| 65 | + |
| 66 | + # ✅ uncached: re-reads the live context on every resolve |
| 67 | + tenant_config = providers.Factory(scope=Scope.REQUEST, creator=create_tenant_config) |
| 68 | +``` |
| 69 | + |
| 70 | +If a request container resolves `tenant_config` before the real tenant ID is known (e.g. during |
| 71 | +setup), the cached version keeps serving that first value for the rest of the request even after |
| 72 | +`request.set_context(str, real_tenant_id)` runs. Either drop `cache=True` for anything whose |
| 73 | +correctness depends on context set later, or make sure `set_context` runs before the first resolve. |
| 74 | +**Caught by:** nothing automatic — this is a timing bug, not a wiring bug, so `validate()` cannot |
| 75 | +see it. See [Context propagation](../providers/context.md#context-propagation) for how `set_context` |
| 76 | +timing interacts with a provider's scope, and [Lifecycle](../providers/lifecycle.md) for caching. |
| 77 | + |
| 78 | +## 4. Service location via `container_provider` overuse |
| 79 | + |
| 80 | +`container_provider` lets a creator accept the resolving `Container` itself and pull dependencies |
| 81 | +out of it manually. Used for its intended purpose (a provider that genuinely needs the container, |
| 82 | +such as building a child container), it's fine. Used as a shortcut to avoid declaring real |
| 83 | +parameters, it turns type-driven DI into a service locator: the dependency is hidden from |
| 84 | +`validate()`, from readers, and from anyone trying to see the graph. |
| 85 | + |
| 86 | +```python |
| 87 | +# ❌ the real dependency (Settings) is invisible to validate() and to the signature |
| 88 | +def create_api_key(container: Container) -> str: |
| 89 | + return container.resolve(Settings).api_key |
| 90 | + |
| 91 | +# ✅ declared as an ordinary parameter — visible, validated, and testable via override |
| 92 | +def create_api_key(settings: Settings) -> str: |
| 93 | + return settings.api_key |
| 94 | +``` |
| 95 | + |
| 96 | +**Caught by:** nothing enforces this — it's a style discipline, not a validation rule. Reserve |
| 97 | +`container_provider` for cases that are actually about the container (building a child container, |
| 98 | +introspecting the current scope), and declare everything else as a typed parameter so |
| 99 | +`validate()` and [Resolving dependencies](../introduction/resolving.md) can see it. |
| 100 | + |
| 101 | +## 5. Override leaks across tests |
| 102 | + |
| 103 | +`container.override(provider, replacement)` is keyed by provider reference and shared across the |
| 104 | +*whole* container tree. Forgetting to reset it doesn't just affect the test that set it — every |
| 105 | +later test that shares the container inherits the replacement. |
| 106 | + |
| 107 | +```python |
| 108 | +# ❌ no reset: the next test that resolves Clock silently gets the fake |
| 109 | +def test_one() -> None: |
| 110 | + container.override(Dependencies.clock, fake_clock) |
| 111 | + ... |
| 112 | + |
| 113 | +# ✅ always reset, even if the test fails — a fixture teardown is the reliable place for this |
| 114 | +@pytest.fixture |
| 115 | +def frozen_clock() -> Mock: |
| 116 | + fake = Mock(spec=Clock) |
| 117 | + container.override(Dependencies.clock, fake) |
| 118 | + yield fake |
| 119 | + container.reset_override(Dependencies.clock) |
| 120 | +``` |
| 121 | + |
| 122 | +**Caught by:** nothing automatic mid-suite — `reset_override()` (or `reset_override()` with no |
| 123 | +arguments, to clear everything) is the fix, and closing the **root** container clears every override |
| 124 | +in the shared registry as a last resort. See |
| 125 | +[Testing with overrides](testing-overrides.md#pitfalls). |
| 126 | + |
| 127 | +## 6. `skip_creator_parsing=True` with no `bound_type` |
| 128 | + |
| 129 | +`skip_creator_parsing=True` turns off signature introspection — useful for callables that can't be |
| 130 | +reflected (C extensions, `functools.partial`). But skipping introspection also means modern-di has |
| 131 | +no idea what type the provider produces, so type-based resolution silently can't find it. |
| 132 | + |
| 133 | +```python |
| 134 | +# ❌ nothing else can resolve this provider by type — UserWarning at declaration time |
| 135 | +providers.Factory(scope=Scope.APP, creator=opaque_creator, skip_creator_parsing=True) |
| 136 | + |
| 137 | +# ✅ tell modern-di the type explicitly |
| 138 | +providers.Factory( |
| 139 | + scope=Scope.APP, |
| 140 | + creator=opaque_creator, |
| 141 | + skip_creator_parsing=True, |
| 142 | + bound_type=MyClass, |
| 143 | +) |
| 144 | +``` |
| 145 | + |
| 146 | +**Caught by:** a `UserWarning` at declaration time. It's easy to miss in test output — treat it as a |
| 147 | +signal to add `bound_type=`, not to ignore. |
| 148 | + |
| 149 | +## See also |
| 150 | + |
| 151 | +- [Errors and exceptions](../providers/errors-and-exceptions.md) — the full catalog this page draws |
| 152 | + its mechanisms from. |
| 153 | +- [Testing with overrides](testing-overrides.md) — the full override lifecycle. |
| 154 | +- [Lifecycle](../providers/lifecycle.md) — caching, finalizers, and `validate()`. |
0 commit comments