ADR-0001 — Pure DI at a composition root (no DI container)
- Status: Accepted (2026-06-22); sweep COMPLETE (2026-07-04). The
conversion phase and the legacy-singleton sweep both landed —
di-singletonsis a hard 0 (func (Set|Get)RuntimeServiceover non-test Go returns 0) and the dual-write bridge described below is deleted. See ADR-0006 (the sweep’s COMPLETE record). The dual-write-bridge narration below is retained as the historical decision; the end state is bridge-free. - Enforced by:
scripts/arch-fitness.shchecksdi-singletons(ratchet → 0) andlayer-boundary(hard-zero).
Context
Section titled “Context”The daemon historically wired services through 30 per-module package-level
singletons (SetRuntimeService/GetRuntimeService over a sync.RWMutex) — the
service-locator / ambient-context anti-pattern. Each module reached its own and
others’ collaborators from a global. This produced the textbook symptoms:
hand-maintained “wire X before Y” ordering comments, hundreds of “not wired”
nil-guards, tests that cannot run in parallel (they mutate process globals), and
init-order bugs that the type system cannot catch.
Go’s idiomatic answer — used by Kubernetes, Docker/Moby, etcd, Prometheus — is
Pure DI: hand-wire the object graph at a single composition root with
explicit constructor calls. No runtime container (Spring/fx/dig), no codegen
(wire). A container is a machine you install at the composition root; Go infra
projects keep the root and skip the machine.
Decision
Section titled “Decision”daemon/composition.go (type Services) is the single wiring
authority. Services are constructed in dependency order and handed to consumers
by injection:
- HTTP/CLI builders receive
registry.Deps.Services(type-erasedany, asserted to a consumer-defined narrow interface — “accept interfaces”). - Subsystems receive the same root via
lifecycle.Resolver.Services.
The dependency direction is fixed: daemon imports modules + subsystems
to construct them; modules and subsystems must never import daemon
(it would cycle and invert the dependency). Where a subsystem must publish back to
the root, it declares the small interface it needs in its own package (Dependency
Inversion), which *daemon.Services satisfies structurally.
There are four transform classes (named so they don’t read as drift):
| Class | Where the service is built | Root setter | Example |
|---|---|---|---|
| W1a leaf | a daemon wireX | unexported setX | user, schedule |
| W1b subsystem-published | a lifecycle subsystem in impl | exported SetXService via an impl-declared publisher interface | events, vault |
| W1c resolver-only | a daemon wireX, no singleton | dual-writes a SetServiceResolver closure | quota |
| W2 consumer edge | n/a (consuming another module) | a per-call resolver closure func() *T { return d.services.TService() } | ca→vault |
The W2 invariant is load-bearing: injected resolvers are per-call closures read
at invocation, never a wire-time-captured handle (callees publish at
lifecycle.Start, after the wire funcs run; a capture pins nil).
A dual-write bridge (each setX also calls the legacy SetRuntimeService)
keeps unconverted readers working during the migration. It is scaffolding, not
the end state — deleted module-by-module as the last reader converts (the sweep).
Consequences
Section titled “Consequences”- Wiring order is the topological order of the root, compiler-checked; the “wire X before Y” comments and lazy-lookup adapters go away.
- Tests construct a
Services/Depswith injected fakes — parallel-safe, no global swap. - The cost is the breadth of the conversion (every reader, ~300 test fixtures, the auth middleware) — paid down by the sweep, with the ratchet preventing regression.
Alternatives rejected
Section titled “Alternatives rejected”google/wire(codegen): would generate this same root; reserve for if the hand-wiring ever sprawls. Not needed now, and it cannot express the lifecycle ordering or the deliberateanycycle-break.uber/fx/dig(runtime container): reflective, anti-idiomatic for Go infra; rejected.