From b1d5d977b476689c2ee05099b9989b908dfaf344 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 21:19:26 +0300 Subject: [PATCH] docs: seed architecture/ with capability pages and glossary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document existing behavior into the truth home (no code change): - container-lifecycle.md — setup_di, the lifespan reopen/close, and build_di_container's per-connection scoped child containers. - dependency-resolution.md — FromDI / Dependency and how endpoints resolve against the request-scoped container. - glossary.md — root vs child container, connection, scope mapping, context provider, dependency marker. - README.md — capability index. Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/README.md | 8 ++++ architecture/container-lifecycle.md | 57 +++++++++++++++++++++++++++ architecture/dependency-resolution.md | 48 ++++++++++++++++++++++ architecture/glossary.md | 48 ++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 architecture/container-lifecycle.md create mode 100644 architecture/dependency-resolution.md create mode 100644 architecture/glossary.md diff --git a/architecture/README.md b/architecture/README.md index ca31218..9e2e798 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -12,3 +12,11 @@ the *why* lives in the change bundle under Capability files and `glossary.md` are authored lazily — each appears when the first capability or term is worth pinning down. + +## Capabilities + +- [`container-lifecycle.md`](container-lifecycle.md) — wiring the container into + the app, the lifespan, and per-connection scoped child containers. +- [`dependency-resolution.md`](dependency-resolution.md) — `FromDI` and how + endpoints declare and receive resolved dependencies. +- [`glossary.md`](glossary.md) — the ubiquitous language. diff --git a/architecture/container-lifecycle.md b/architecture/container-lifecycle.md new file mode 100644 index 0000000..a2aa0a7 --- /dev/null +++ b/architecture/container-lifecycle.md @@ -0,0 +1,57 @@ +# Container lifecycle + +How a `modern_di.Container` is wired into a FastAPI app and how scoped child +containers are opened and closed around each connection. Terms in *italics* are +defined in the [glossary](glossary.md). + +## Installation — `setup_di(app, container)` + +`setup_di` attaches a caller-built *root container* to the app and is the single +entry point an application calls at startup. It does three things: + +1. Stores the container on `app.state.di_container` (read back by + `fetch_di_container(app)`). +2. Registers the two *context providers* + (`fastapi_request_provider`, `fastapi_websocket_provider`) on the container's + `providers_registry`, so the live `Request` / `WebSocket` can be resolved. +3. Chains an internal lifespan manager onto the app's existing + `lifespan_context` via `fastapi.routing._merge_lifespan_context`, preserving + any lifespan the app already had. + +It returns the same container for convenience. The application owns container +construction (groups, overrides); `setup_di` only wires it in. + +## Lifespan — open/close across cycles + +The chained `_lifespan_manager` runs `async with fetch_di_container(app):` — the +root container's `__aenter__` opens it on startup and `__aexit__` closes it on +shutdown. Using `async with` (rather than a one-shot open) means a **second +lifespan cycle against the same container reopens it** instead of raising +`ContainerClosedError`. This is what lets an app be started, stopped, and +started again (e.g. repeated `TestClient` contexts in tests) against one +container instance. + +## Per-connection containers — `build_di_container(connection)` + +`build_di_container` is an async FastAPI dependency that yields a *child +container* scoped to the current *connection*, then closes it: + +- It applies the *scope mapping*: a `fastapi.Request` → `Scope.REQUEST` with the + request placed in `context[fastapi.Request]`; a `fastapi.WebSocket` → + `Scope.SESSION` with the socket in `context[fastapi.WebSocket]`. Any other + `HTTPConnection` yields a child with `scope=None`. +- The child is built from the root container via + `build_child_container(context=..., scope=...)`. +- After the endpoint returns, the `finally` block calls + `container.close_async()`, tearing down anything opened in that scope. + +Finer scopes are reached by building further children from this one: an HTTP +endpoint can `build_child_container()` again for `ACTION` scope, and a WebSocket +handler (whose injected container is `SESSION`-scoped) builds a child for +`REQUEST` scope. + +## Accessor — `fetch_di_container(app)` + +Returns the root container off `app.state` (cast to `Container`). Used +internally by the lifespan and `build_di_container`, and available to +application code that needs the root container directly. diff --git a/architecture/dependency-resolution.md b/architecture/dependency-resolution.md new file mode 100644 index 0000000..b441b8d --- /dev/null +++ b/architecture/dependency-resolution.md @@ -0,0 +1,48 @@ +# Dependency resolution + +How a route or WebSocket handler declares a dependency and receives the resolved +instance. Terms in *italics* are defined in the [glossary](glossary.md); the +scoped container this resolution runs against comes from the +[container lifecycle](container-lifecycle.md). + +## The marker — `FromDI(dependency, *, use_cache=True)` + +`FromDI` is what an endpoint puts in its signature: + +```python +async def read_root( + instance: typing.Annotated[SimpleCreator, FromDI(Dependencies.app_factory)], +) -> ...: ... +``` + +It accepts either a `providers.AbstractProvider` or a plain type, and returns a +`fastapi.Depends` wrapping a `Dependency` instance — the *dependency marker*. +`use_cache` is forwarded to `fastapi.Depends`, so FastAPI's per-request +dependency caching applies as usual. The return is `cast` to the dependency's +type, so the annotated parameter type stays accurate. + +## The callable — `Dependency` + +`Dependency` is a frozen, slotted, generic dataclass holding the requested +`dependency`. Its `__call__` is itself a FastAPI dependency: it depends on +`build_di_container`, so it receives the *child container* for the current +connection, then resolves against it: + +- **Provider** (`isinstance(..., AbstractProvider)`) → + `request_container.resolve_provider(self.dependency)`. +- **Type** (anything else) → + `request_container.resolve(dependency_type=self.dependency)`. + +Because resolution flows through `build_di_container`, every `FromDI` +dependency in a request shares that request's scoped container and its +registered *context providers* — so resolving e.g. a `REQUEST`-scoped factory +that reads the live `fastapi.Request` works without the endpoint threading the +connection through by hand. + +## Scope reach + +`FromDI` resolves at the scope of the container `build_di_container` produced +(`REQUEST` for HTTP, `SESSION` for WebSocket). To resolve an `ACTION`-scoped (or, +from a WebSocket, `REQUEST`-scoped) dependency, an endpoint takes the container +directly — `typing.Annotated[Container, fastapi.Depends(build_di_container)]` — +and builds a further child to resolve against. diff --git a/architecture/glossary.md b/architecture/glossary.md new file mode 100644 index 0000000..8eb1804 --- /dev/null +++ b/architecture/glossary.md @@ -0,0 +1,48 @@ +# Glossary + +The ubiquitous language of `modern-di-fastapi` — the domain terms that code, +specs, and capability pages share. Living prose, no frontmatter, dated by git. +Most names come from [`modern-di`](https://modern-di.modern-python.org); this +page pins only the terms whose *meaning in the FastAPI integration* needs to be +fixed. + +**Root container**: +The app-level `modern_di.Container` stored on `app.state.di_container` by +`setup_di`. It owns `APP`-scoped state and is opened/closed by the app +[lifespan](container-lifecycle.md). +_Avoid_: global container, app container + +**Child container**: +A scoped container derived from a parent via `build_child_container`. One is +built per *connection* inside `build_di_container`, and finer scopes +(`ACTION`) are reached by building further children from it. +_Avoid_: sub-container, request container (it is *a* child container, not a +distinct kind) + +**Connection**: +A Starlette `HTTPConnection` — concretely a `fastapi.Request` (HTTP) or a +`fastapi.WebSocket`. The thing `build_di_container` keys its scope and context +off of. +_Avoid_: request (a request is only one kind of connection) + +**Scope mapping**: +The fixed correspondence this integration imposes between a connection kind and +a `modern_di.Scope`: a `Request` opens a `REQUEST`-scoped child container; a +`WebSocket` opens a `SESSION`-scoped one. Any other `HTTPConnection` gets no +scope (`None`). +_Avoid_: scope resolution + +**Context provider**: +A `providers.ContextProvider` that injects a runtime value into a scope rather +than constructing one. This package ships two — `fastapi_request_provider` +(`Request` → `REQUEST`) and `fastapi_websocket_provider` (`WebSocket` → +`SESSION`) — registered by `setup_di` so endpoints can resolve the live +connection object. + +**Dependency marker**: +The value returned by `FromDI(...)` — a `fastapi.Depends` wrapping the +`Dependency` callable — placed in an endpoint's `Annotated[...]` signature to +declare "resolve this from DI". The unit of [dependency +resolution](dependency-resolution.md). +_Avoid_: injector, provider (a provider is what gets resolved; the marker is how +an endpoint asks for it)