Stage 7 · Master
SOLID in Go, No Framework
Constructor Injection & Testability
Wire every dependency in main so the service graph is visible, boring, and easy to swap in tests without framework magic.
main.go Is Where Wiring Belongs
One of the easiest ways to make a Go service mysterious is to hide construction behind package globals. The first version feels convenient: init a logger in one package, load config into a singleton, let repositories reach for a global pool, and expose helper functions that quietly pull dependencies from shared state. Fieldwork tried a lighter version of that pattern early on and hit the same result most teams do: you could use a service, but you could not see how it was assembled without opening half the repository.
We rejected framework-style service locators for the same reason. They save typing at the call site, but they make dependency graphs implicit. In a multi-service backend, implicit graphs are operational debt. When the task API starts slowly because it opens two Postgres pools and an HTTP client per request, or when a test accidentally talks to a real Kafka broker, the root cause is often that dependency creation was hidden behind convenience. So Fieldwork standardized on constructor injection and explicit wiring in main.
Explicit Dependencies Beat Global Registries
Constructor injection only works if constructors are honest. A service constructor that secretly reaches into a package-level logger or opens its own pgx pool is not injected, even if it accepts one dependency as a parameter. Fieldwork moved to a simple rule: every dependency required for useful work must appear in the constructor signature, and constructors must validate obviously broken inputs immediately. That way a bad wiring choice fails during process startup instead of surfacing as a nil pointer in the middle of a request.
We also rejected the argument that globals are fine because the application only has one logger or one database anyway. The issue is not count; it is control. A single database pool still needs lifecycle management, replacement in tests, and visibility during review. Once a package can fetch it from anywhere, you lose all three. Dependency injection in this codebase is less about inversion-of-control as a concept and more about preserving ownership of process resources.
| Pattern | Short-term convenience | Long-term cost |
|---|---|---|
| Package globals | Minimal constructor arguments | Hidden coupling, hard tests, resource lifecycle confusion |
| Service locator / container | Cleaner call sites after registration | Dependency graph becomes indirect and harder to review |
| Constructor injection | More wiring in main | Clear ownership, easy swapping, predictable tests |
Tests should build the object graph they need directly. They should not depend on package init order or hidden singleton replacement hooks.
Tests Got Smaller When Construction Got Boring
The quality payoff showed up immediately in tests. Before constructor injection, a task service test had to initialize config, replace package globals, and sometimes stub HTTP transport at process scope. After constructor injection, the same test could instantiate a fake membership authorizer and an in-memory task repository in three lines. That reduced not just flakiness but cognitive load. You can read the test and know which dependencies matter to the behavior under scrutiny.
We deliberately kept most tests one layer below the HTTP handler and one layer above the repository. That is where constructor injection shines. The service test does not need Gin, Zap, or a real pgx pool; it only needs the task service with explicit collaborators. The smaller the constructor surface, the easier it is to write focused tests around tenant scoping, membership rules, and task creation semantics.
What We Standardized in Service Startup
By the time the pattern settled, each Fieldwork service had the same shape: load config, build shared infrastructure once, construct repositories and clients, inject them into services, inject services into handlers, and then start the server. No hidden singletons, no magical auto-wiring, and no constructors that quietly reached back into global state. That consistency matters when you have several services in the same repo because engineers can move between them without learning different bootstrapping folklore each time.
- Create long-lived resources like pgx pools, HTTP clients, and Zap loggers in main and pass them downward.
- Make constructors honest: every required collaborator appears in the signature.
- Fail fast during startup when wiring is impossible or incomplete.
- Keep service tests below the transport layer by injecting fakes directly.
- Prefer a little visible boilerplate over invisible runtime magic.
Once the object graph was explicit, the tests could focus on business edges like tenant isolation and membership rules instead of spending half their setup time undoing implicit global state.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.