Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
57 changes: 57 additions & 0 deletions architecture/container-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions architecture/dependency-resolution.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions architecture/glossary.md
Original file line number Diff line number Diff line change
@@ -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)