Skip to content

Commit 8704168

Browse files
lesnik512claude
andauthored
docs: pages batch — FastAPI-users guide, vocabulary table, anti-patterns, non-goals (DOC-4/5/7/9) (#274)
* docs(planning): add docs-pages-batch bundle (DOC-4/5/7/9) Co-Authored-By: Claude Fable 5 <[email protected]> * docs: add cross-framework lifetime vocabulary table (DOC-5) Appends a "where is Singleton?" translation table to the comparison page: singleton/transient/request-scoped/runtime-value/interface-binding/test-override spelled in dependency-injector, dishka, wireup, svcs, FastAPI, and modern-di. Every competitor spelling verified against that framework's live docs. Co-Authored-By: Claude Fable 5 <[email protected]> * docs: add "modern-di for FastAPI users" page (DOC-4) Translates Depends idioms (use_cache, yield teardown, lru_cache singletons, dependency_overrides) into modern-di equivalents, and disambiguates FastAPI 0.121.0's Depends(scope=) — teardown timing — from modern-di's Scope, which is lifetime. Verified against FastAPI's live docs and 0.121.0 release notes. Co-Authored-By: Claude Fable 5 <[email protected]> * docs: add good-and-bad-practices anti-pattern catalog (DOC-7) Six named footguns (captive dependency, unvalidated graphs, cached factory built before set_context, container_provider service location, override leaks across tests, skip_creator_parsing without bound_type), each with a bad/good pair and the mechanism that catches it. Every runnable pair was spot-run against current source (RecursionError->CircularDependencyError conversion, InvalidScopeDependencyError, UserWarning, override leakage). Cross-linked from the quickstart's "Where to next". Co-Authored-By: Claude Fable 5 <[email protected]> * docs: document remaining deliberate non-goals (DOC-9) Extends design-decisions.md with the three genuinely missing omissions (auto-binding, in-package integrations, graph rendering/visualization) in the page's existing what/why/alternative format, and links to it from README.md and comparison.md. Co-Authored-By: Claude Fable 5 <[email protected]> * docs: narrow the graph non-goal to rendering only (ERR-7 text export stays undecided in public docs) Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]>
1 parent 92b2040 commit 8704168

9 files changed

Lines changed: 382 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ with Container(groups=[Dependencies], validate=True) as container:
6767
print(repo.settings.database_url)
6868
```
6969

70-
See the [documentation](https://modern-di.modern-python.org) for scopes, lifecycles, finalizers, and framework integrations.
70+
See the [documentation](https://modern-di.modern-python.org) for scopes, lifecycles, finalizers, and framework integrations. `modern-di` is deliberately conservative — see [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/) for what it leaves out on purpose, including auto-binding, in-package integrations, and graph rendering.
7171

7272
Usage examples:
7373

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,4 @@ provider registers an **async** finalizer — see [Lifecycle](providers/lifecycl
139139
- [Scopes](providers/scopes.md) — the APP → REQUEST lifetime model in one page.
140140
- [Lifecycle](providers/lifecycle.md) — finalizers, `close_async()`, validation.
141141
- [Recipes](recipes/sqlalchemy.md) — async SQLAlchemy, lifespan-managed resources, testing with overrides.
142+
- [Good and bad practices](recipes/good-and-bad-practices.md) — named footguns and the mechanism that catches each one.

docs/introduction/comparison.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,25 @@ modern-di earns its place once you have a second entrypoint, or need typed,
8888
scoped, app-wide singletons with overrides that work everywhere, not just on the
8989
HTTP path.
9090

91+
## Where is Singleton? — cross-framework vocabulary
92+
93+
modern-di deliberately has no `Singleton` class — "create once and reuse" is spelled via a scope
94+
plus `cache=True` on an ordinary `Factory`. Every arriving user speaks a different framework's
95+
lifetime dialect, so here is how the same six concepts translate:
96+
97+
| Concept | dependency-injector | dishka | wireup | svcs | FastAPI `Depends` | modern-di |
98+
|---|---|---|---|---|---|---|
99+
| Singleton (create once, share) | `providers.Singleton(...)` | `provide(Impl, scope=Scope.APP)` — cached by default within its scope | `@injectable` — default `lifetime="singleton"` | `registry.register_value(Type, value)` at startup | a dependency wrapped in `@lru_cache` | [`Factory(scope=Scope.APP, cache=True)`](../providers/factories.md#cached-factories) |
100+
| Transient (fresh instance every time) | `providers.Factory(...)` | `provide(Impl, cache=False)` | `@injectable(lifetime="transient")` | no dedicated provider — call the plain factory directly | `Depends(fn, use_cache=False)` | a plain [`Factory(...)`](../providers/factories.md) with no `cache` |
101+
| Request-scoped | `providers.Resource` + the `Closing` wiring marker | `provide(Impl, scope=Scope.REQUEST)` | `@injectable(lifetime="scoped")` | one instance per `svcs.Container` (built per request) | bare `Depends(fn)` — computed once per request by default | [`Factory(scope=Scope.REQUEST, cache=True)`](../providers/scopes.md) |
102+
| Runtime value (request object, etc.) | `providers.Configuration` / `.from_value()` | `from_context(provides=Type, scope=...)` declared, then `context={Type: value}` at scope entry | a typed constructor parameter resolved from the active scope's context | `registry.register_value(Type, value)`, or a per-container local factory | the framework injects `Request`/`WebSocket` directly by type | [`ContextProvider(context_type=...)`](../providers/context.md) + `context={...}` |
103+
| Interface binding (concrete → abstract type) | `providers.AbstractFactory` — must be overridden with a concrete `Factory` before use | `alias(source=Impl, provides=Interface)` | `@injectable(as_type=Interface)` | `register_factory(Interface, factory)` — svcs keys by whatever type you register under | n/a — `Depends` is callable-keyed, not type-keyed | [`Alias(source_type=Impl, bound_type=Interface)`](../providers/alias.md) |
104+
| Test override | `provider.override(...)`, or `with provider.override(...):` | no dedicated API — build a separate container from mock providers | `with container.override.injectable(Target, new=fake):` | re-call `register_value()`/`register_factory()`; `container.close()` first if already cached | `app.dependency_overrides[dep] = fake` | [`container.override(provider, mock)`](../recipes/testing-overrides.md) |
105+
91106
## See also
92107

93108
- [Design decisions](design-decisions.md) — the reasoning behind sync-only
94-
resolution, no global state, and a conservative core.
109+
resolution, no global state, a conservative core, and the deliberate
110+
[non-goals](design-decisions.md#non-goals) that keep it that way.
95111
- [that-depends or modern-di?](that-depends-or-modern-di.md) — choosing within
96112
the modern-python family.

docs/introduction/design-decisions.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,34 @@ The codebase is type-checked with `ty` and linted with ruff's full rule set (`se
3838

3939
New features get added only when existing primitives genuinely cannot solve the task. The core has three concrete provider types (`Factory`, `Alias`, `ContextProvider`), plus the `AbstractProvider` base and the pre-built `container_provider` singleton — most other DI frameworks have two to three times that. This is deliberate: a small, composable core is easier to learn, easier to test, and easier to keep correct.
4040

41+
## Non-goals
42+
43+
Beyond the choices above, three more things are deliberately out of scope. Naming them here is meant to save you from filing (or us from re-litigating) the same feature request.
44+
45+
### Auto-binding / auto-registration
46+
47+
**What:** modern-di never registers a provider for a type you didn't declare, and never infers wiring by scanning your codebase (import scanning, decorator scanning, `auto_bind`-style fallbacks some frameworks offer).
48+
49+
**Why:** Auto-binding defers a missing-provider error from declaration time — where modern-di already raises `UnsupportedCreatorParameterError` — to whichever request first exercises the untested path. That's the opposite of the framework's declaration-time-failure bet, and it invites automagic wiring nobody can trace back to a source.
50+
51+
**Alternative:** Register the provider explicitly in a `Group`. If the boilerplate is real, write a small helper that builds several `Factory` instances from a list of classes — that's application code, not a framework feature.
52+
53+
### In-package framework integrations
54+
55+
**What:** The core `modern-di` package ships no aiohttp/FastAPI/FastStream/Litestar/Starlette/Typer/pytest code. Each integration is a separate `modern-di-*` package with its own release cadence.
56+
57+
**Why:** Bundling integrations into core would couple the library's release cadence to every framework's own churn, and would erode the zero-dependency guarantee that lets `modern-di` itself stay dependency-free. The separate-repo model is a standing architectural decision (see [`writing-integrations.md`](../integrations/writing-integrations.md)), not an oversight.
58+
59+
**Alternative:** Install the matching adapter package — see the [Quickstart](../index.md) for the current list — or write your own following [Writing an integration](../integrations/writing-integrations.md).
60+
61+
### Graph rendering / visualization tooling
62+
63+
**What:** modern-di has no built-in way to render the dependency graph as a picture — no ASCII art, no bundled renderer, no `plot()`/`render()` call, no image output.
64+
65+
**Why:** Rendering is a standalone subsystem (choosing, drawing, and maintaining a diagram toolchain) rather than an extension of an existing primitive, so it sits outside the conservative feature set and the zero-dependency guarantee. `validate()`'s aggregated, all-errors-at-once text report already surfaces the graph's problems without a new dependency or output format.
66+
67+
**Alternative:** None shipped today. If you need a picture of the graph, walk `Group.get_providers()` yourself and feed the edges to the diagram tool of your choice.
68+
4169
## See also
4270

4371
- [About DI](about-di.md) — the framework-agnostic introduction.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# modern-di for FastAPI users
2+
3+
FastAPI's own `Depends` system covers a single request-scoped web service well. You reach for
4+
modern-di once you need a second entrypoint (a worker, a CLI), typed app-wide singletons with real
5+
teardown, or overrides that work outside the HTTP path — see
6+
[Do you even need a DI container?](comparison.md#do-you-even-need-a-di-container). This page
7+
translates the `Depends` idioms you already know into their modern-di equivalents.
8+
9+
## Translation table
10+
11+
| FastAPI `Depends` | modern-di | Notes |
12+
|---|---|---|
13+
| `Depends(fn)` | `Factory(creator=fn)` | Both auto-wire the callable's parameters; modern-di matches by type annotation instead of by the callable's own parameter defaults. |
14+
| bare `Depends(fn)` (`use_cache=True`, the default) | `Factory(scope=Scope.REQUEST, creator=fn, cache=True)` | FastAPI memoizes a dependency for the rest of the *same request* once it's been called; the REQUEST-scoped cached `Factory` is the equivalent — one shared instance per request container. |
15+
| `Depends(fn, use_cache=False)` | a bare `Factory(creator=fn)` — no `cache` | Without `cache`, a `Factory` builds a fresh instance on every resolve, matching `use_cache=False`. |
16+
| `yield`-based teardown (`def fn(): ...; yield x; ...cleanup...`) | `cache=CacheSettings(finalizer=cleanup_fn)` | modern-di has no generator-creator form (see [Design decisions](design-decisions.md)); teardown is a second, explicit object instead of code after `yield`. `finalizer` may be sync or async — see [Lifecycle](../providers/lifecycle.md). |
17+
| `@lru_cache`-wrapped dependency (process-wide singleton) | `Factory(scope=Scope.APP, creator=fn, cache=True)`, optionally with a `finalizer` | `lru_cache` has no cleanup hook; the APP-scoped cached `Factory` adds one via `CacheSettings(finalizer=...)` if the singleton needs to release anything on shutdown. |
18+
| `app.dependency_overrides[fn] = fake` | `container.override(provider, fake)` | modern-di overrides are keyed by **provider reference**, not by callable, and apply across the whole container tree — see [Testing with overrides](../recipes/testing-overrides.md). Reset with `container.reset_override(provider)`. |
19+
20+
## Two meanings of "scope"
21+
22+
Since FastAPI 0.121.0, `Depends(scope="function" | "request")` controls **when the code after
23+
`yield` runs** relative to the response: `scope="function"` tears down right after your path
24+
operation function returns (before the response is sent), and `scope="request"` — the default for
25+
a `yield` dependency — tears down after the response has been sent back to the client. It says
26+
nothing about how many times the dependency is *constructed*; that's `use_cache`'s job.
27+
28+
modern-di's `Scope` (`APP → SESSION → REQUEST → ACTION → STEP`) answers a different question
29+
entirely: **how long a provider's cached instance lives**, not when its finalizer fires relative to
30+
a response. The two `scope`s share a word but not an axis — FastAPI's is teardown timing, modern-di's
31+
is lifetime. See [Scopes](../providers/scopes.md) for the full model.
32+
33+
## Example: request-scoped session with teardown
34+
35+
```python
36+
import dataclasses
37+
38+
from modern_di import Group, Scope, providers
39+
40+
41+
@dataclasses.dataclass(kw_only=True, slots=True)
42+
class Session:
43+
connection_string: str
44+
45+
46+
def create_session() -> Session:
47+
return Session(connection_string="postgresql+asyncpg://localhost/app")
48+
49+
50+
def close_session(session: Session) -> None:
51+
... # release the connection
52+
53+
54+
class Dependencies(Group):
55+
session = providers.Factory(
56+
scope=Scope.REQUEST,
57+
creator=create_session,
58+
cache=providers.CacheSettings(finalizer=close_session),
59+
)
60+
```
61+
62+
This is the modern-di equivalent of a FastAPI `yield`-dependency that hands out one session per
63+
request and closes it afterward — but with the container's finalizer, not code after `yield`, and
64+
`Scope.REQUEST` naming the lifetime rather than the teardown moment.
65+
66+
## See also
67+
68+
- [modern-di vs other libraries](comparison.md) — including the cross-framework vocabulary table.
69+
- [FastAPI integration](../integrations/fastapi.md)`setup_di`, `FromDI`, and websocket scopes.
70+
- [Design decisions](design-decisions.md) — why modern-di has no generator-based teardown.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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

Comments
 (0)