ADR-039: @app.state Shared-State Factory¶
Status¶
Accepted Date: 2026-04-25
Context¶
cosalette device handlers receive dependencies via type-based DI. Shared mutable
state constructed at bootstrap (e.g. a sensor registry built from settings) has
no DI-friendly path without the lifespan= trick added in ADR-027. That pattern
requires an async context manager function, a lifespan= argument on App(), a
cast() call for settings, and a re-export shim module for backward compatibility.
The ceremony outweighs the logic.
ADR-027 solved a real problem but stopped short of a first-class API. The pattern it introduced (yield the state from the lifespan CM) is indirect: the lifespan's primary responsibility becomes state construction instead of side-effect startup.
Decision¶
Add @app.state as a first-class decorator on App. A factory decorated with
@app.state is called once at bootstrap; its return value is registered in the DI
container by its return type and injected into any handler declaring that type.
Four factory forms are supported, detected by return annotation at registration time:
| Form | Teardown |
|---|---|
def f(...) -> T |
None (simple return) |
def f(...) -> ContextManager[T] |
__exit__ on shutdown |
async def f(...) -> AsyncIterator[T] |
generator finalized on shutdown |
async def f(...) -> AsyncContextManager[T] |
__aexit__ on shutdown |
Bootstrap order:
1. Settings resolved
2. All @app.state factories called, in registration order
3. Lifecycle adapters entered
4. lifespan= context manager entered
5. Devices started
Teardown order (reverse):
1. Devices cancelled
2. lifespan= exited
3. Lifecycle adapters exited
4. @app.state generators/CMs exited in reverse registration order
DI key: The unwrapped return type T (stripping Iterator[T], ContextManager[T], etc.).
Duplicate detection: Two factories with the same return type raise ValueError at registration.
Settings injection: If the factory's first parameter is annotated with Settings or a
subclass, the framework passes the resolved settings instance narrowed to that type. No parameter is also valid (zero-arg factory).
Testing: AppHarness.override_state(type_, instance) bypasses the factory in tests.
Consequences¶
- Apps using
lifespan=purely for DI state can migrate to@app.state. lifespan=continues to work for side-effect startup (HTTP servers, etc.).- ADR-027 (lifespan-yielded DI state) is superseded by this ADR for state-object
use cases.
lifespan=remains supported forNone-yield side-effect patterns.
Supersedes¶
ADR-027 (for state-object use cases; lifespan= remains valid for side-effect startup).