Skip to content
SHC Docs

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-singletons is a hard 0 (func (Set|Get)RuntimeService over 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.sh checks di-singletons (ratchet → 0) and layer-boundary (hard-zero).

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.

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-erased any, 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):

ClassWhere the service is builtRoot setterExample
W1a leafa daemon wireXunexported setXuser, schedule
W1b subsystem-publisheda lifecycle subsystem in implexported SetXService via an impl-declared publisher interfaceevents, vault
W1c resolver-onlya daemon wireX, no singletondual-writes a SetServiceResolver closurequota
W2 consumer edgen/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).

  • 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/Deps with 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.
  • 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 deliberate any cycle-break.
  • uber/fx/dig (runtime container): reflective, anti-idiomatic for Go infra; rejected.