Dependency Injection in Go: How Much Is Enough for Web APIs?
Source: Dev.to
Introduction
Dependency Injection (DI) in Go often sparks debates that feel disproportionate to the actual needs of most web APIs. Discussions quickly escalate to containers, lifecycles, scopes, and dynamic resolution—despite the fact that many services have a simple, static dependency graph resolved once at startup.
Key point:
For a typical web API, compile‑time dependency resolution is enough.
Characteristics of Typical Go Web APIs
- Stateless request handling
- Dependencies resolved once at startup
- Mostly tree‑shaped dependency graph
config → database → repository → service → handler
- No runtime rebinding of implementations
In this environment, DI is not about flexibility at runtime; it’s about wiring correctness and maintainability.
When Runtime DI Frameworks Are Overkill
Frameworks such as fx, dig, or generics‑based containers like do shine when an application behaves like a framework itself:
- Modules register themselves dynamically
- Lifecycle hooks (start/stop) matter
- Plugins or optional components are loaded at runtime
For typical web APIs, these features often bring downsides:
- Additional APIs to learn and remember
- Runtime error surfaces for what is fundamentally static wiring
- Harder‑to‑follow control flow during startup
When dependencies are fixed and known ahead of time, runtime DI solves a problem you don’t really have.
Static DI with wire
wire provides compile‑time dependency resolution without a runtime container, generating plain Go code. This model offers:
- Safety and predictability
- Zero runtime DI overhead
Wire’s Design
- Provider functions
- Provider sets
- Injector functions per root
While explicitness can be valuable, it may also become maintenance overhead:
- Provider sets need constant updates as the graph evolves
- DI‑specific files grow alongside business code
- Wiring itself becomes something developers must reason about
Minimal Static DI with a Field‑Tag Generator
If you want static wiring and generated constructors but with less DI‑specific code, a simpler model works well:
type Container struct {
Handler *UserHandler `inject:""`
}
The generated code is just Go:
func NewContainer() (*Container, error) {
cfg := NewConfig()
db, err := NewDatabase(cfg)
if err != nil {
return nil, err
}
svc := NewUserService(db)
h := NewUserHandler(svc)
return &Container{Handler: h}, nil
}
- No runtime container
- No provider sets
- No reflection
This is the idea behind injector, a field‑tag‑only DI code generator.
When Wire Still Makes Sense
- Provider sets are treated as a public composition API
- Explicit wiring boundaries are a deliberate design goal
- DI graphs are reviewed and curated as first‑class artifacts
In other words, wire’s strengths are organizational and process‑driven. If your project doesn’t need that level of explicitness, wire becomes harder to justify.
Comparison
| Approach | Characteristics | Typical Use‑Case |
|---|---|---|
| Runtime DI (fx/dig, do) | Powerful, dynamic registration, lifecycle hooks | Framework‑like applications, plugins |
| Static DI (wire) | Compile‑time resolution, explicit provider sets | Projects needing strong organizational boundaries |
| Static + Minimal (injector) | Field‑tag based generation, minimal ceremony | Standard stateless web APIs with stable graphs |
Conclusion
For the common case—stateless web APIs with stable dependency graphs—static wiring is enough. Reducing the amount of DI‑specific code you maintain leads to an even better outcome.
Dependency Injection doesn’t need to be a framework‑level decision for most Go web APIs. It’s a startup detail:
- Resolve dependencies once.
- Generate plain Go code.
- Keep the wiring boring.
If wire feels sufficient but a bit heavy, the field‑tag‑based approach (injector) is a natural next step. In that sense, it isn’t a radical alternative; it simply asks:
If static DI is enough… why not make it simpler?