ADR-0005: RuntimeServices Container¶
Status: Accepted Date: 2026-03-25
Context¶
The Dazzle runtime accumulated service state in module-level mutable singletons — one per concern (DB pool, event bus, channel registry, grant store, and others). This pattern was expedient but created two persistent problems:
- Test pollution — tests that instantiate a service mutate shared global state. Teardown is unreliable and order-dependent.
- Multi-tenancy unsafety — a future multi-tenant deployment cannot safely share module-level objects across request contexts.
Additionally, the MCP server maintained its own parallel globals (_server_state, _project_root) with no disciplined boundary between runtime and MCP concerns.
The runtime services implementation plan (issue #673) identified 11 module-level mutable singletons across src/dazzle/back/ and src/dazzle/mcp/.
Decision¶
Consolidate all FastAPI runtime service objects into a single RuntimeServices dataclass attached to app.state.services. MCP server state moves into a dedicated ServerState dataclass in the MCP layer.
Rules going forward:
- No new module-level mutable singletons in
src/dazzle/back/orsrc/dazzle/mcp/. - All existing necessary module-level globals annotated with
# noqa: PLW0603as a forcing function for future elimination. - Route handlers and middleware receive services via
request.app.state.servicesor FastAPIDepends()— never via module imports. - Tests construct a fresh
RuntimeServicesinstance per test; no teardown of globals required.
Container Shape¶
@dataclass
class RuntimeServices:
db: DatabasePool
events: EventBus
channels: ChannelRegistry
grants: GrantStore
cache: CacheBackend
ServerState mirrors the same principle for MCP: holds project root, KG reference, and active project selection.
Consequences¶
Positive¶
- Tests construct isolated service instances — no global teardown needed.
- Multi-tenant deployment can create one
RuntimeServicesper tenant context. - Dependency graph is explicit and readable at the call site.
# noqa: PLW0603annotations make all remaining globals visible in a singleruffreport.
Negative¶
- All route handlers must be updated to read from
request.app.state.servicesrather than importing module-level objects. lifespancontext manager must initialise and tear down the container.
Neutral¶
ServerStateis a parallel but separate change — MCP and runtime lifecycles differ.- No change to the public DSL, IR, or CLI surface.
Alternatives Considered¶
1. Scattered Module-Level Getters/Setters¶
Keep the existing pattern but add get_db() / set_db() accessor functions.
Rejected: Cosmetic fix only. Still global mutable state; still causes test pollution.
2. Dependency Injection Container (e.g. lagom, injector)¶
Use a third-party DI framework to manage lifetimes.
Rejected: Heavy dependency for a problem a plain dataclass solves. Adds learning overhead for future contributors.
3. Thread-Local / ContextVar Storage¶
Store services in contextvars.ContextVar so each async context sees its own copy.
Rejected: Adds implicit context propagation that is harder to trace than an explicit parameter. Still requires discipline to initialise correctly.
Implementation¶
See the runtime services implementation plan in dev_docs/ (issue #673) for the phased migration steps covering service extraction, lifespan wiring, route handler updates, and test fixture changes.