Understanding Dependency Injection in Go: More Than a Technique, It’s a Philosophy
When building a Go application, have you often encountered initialization logic like this?
db := NewDatabase(cfg)
logger := NewLogger()
repo := NewRepo(db)
svc := NewService(repo, logger)
handler := NewHandler(svc)
It may seem harmless, but as dependencies grow and combinations become more complex, does it start feeling “messy,” “repetitive,” or “hard to test”?
This is where Dependency Injection (DI) comes in.
What Is Dependency Injection?
Let’s explain with a real-life analogy:
You are a service (Service) that needs water (Logger) and electricity (DB).
If you install the pipes and wires yourself, you’re tied to specific implementations.
But if someone else provides them to you, you can focus on delivering the service and even switch suppliers (e.g., mocks) easily.
This is the essence of dependency injection:
“Providing an object with its dependencies from the outside, rather than creating them internally.”
Why Is DI Still Important in Go?
Go lacks traditional OOP features like inheritance, annotations, or IoC containers, but it still faces these challenges:
- How to avoid components directly
new()
-ing their dependencies? - How to replace dependencies for unit testing?
- How to cleanly manage initialization flows?
Dependency injection is an architectural philosophy, not tied to any specific framework.
Practical DI Approaches in Go
🧩 1. Manual Injection (Recommended First Choice)
type Service struct {
Repo *Repo
Logger *Logger
}
func NewService(repo *Repo, logger *Logger) *Service {
return &Service{Repo: repo, Logger: logger}
}
Pass dependencies layer by layer in main()
:
db := NewDatabase()
repo := NewRepo(db)
logger := NewLogger()
svc := NewService(repo, logger)
✅ Pros:
- Explicit dependencies, clear structure
- No reflection, no hidden behavior
- Easy to test
🧰 2. Google Wire (Compile-Time Injection)
func InitApp() *Service {
wire.Build(NewDatabase, NewRepo, NewLogger, NewService)
return nil
}
Run the wire
command to generate initialization code—all resolved at compile time with zero runtime overhead.
✅ Pros:
- Compile-time safety, transparent structure
- Avoids repetitive manual wiring
🧪 3. Uber Dig (Runtime DI Container)
c := dig.New()
c.Provide(NewDatabase)
c.Provide(NewRepo)
c.Provide(NewService)
c.Invoke(func(s *Service) {
s.DoSomething()
})
Dependencies are registered in a container and resolved at runtime.
✅ Best for: Complex or dynamic dependency graphs.
Do You Always Need a Framework?
No.
DI is a philosophy, not a tool.
The most Go-idiomatic approach is: Start with constructors, clarify dependencies, and introduce frameworks only when necessary for efficiency.
📊 Go DI Tools Comparison: Wire vs. Dig vs. Fx
Feature / Tool | google/wire |
uber-go/dig |
uber-go/fx |
---|---|---|---|
📦 Type | Code generator | Runtime container | Application framework |
🚀 Mechanism | Codegen via func combos | Container + Invoke | Provides + lifecycle |
🔍 Uses reflection? | ❌ No | ✅ Yes | ✅ Yes |
🧰 Lifecycle | ❌ None | ⚠️ Manual | ✅ Hooks (Start/Stop) |
⚙️ Debugging | ✅ Best (code = logic) | ⚠️ Stack traces | ⚠️ Framework logs |
⏱ Performance | Zero runtime cost | Reflection overhead | Same as dig |
🧪 Mocking | Constructor swaps | Provide overrides | fx.Replace |
🎯 Use Case | Small/medium projects | Complex dependencies | Service frameworks |
🔧 Initialization Flow
+-------------+ +-------------+ +--------------+
| Start App | ---> | Initialize | ---> | Build Graph |
| | | Dependencies| | of Services |
+-------------+ +-------------+ +--------------+
| | |
v v v
+---------------+ +----------------+ +-----------------+
| Wire: | | Dig: | | Fx: |
| Codegen gen | | Runtime graph | | Runtime graph + |
| dependency | | construction | | Lifecycle hooks |
+---------------+ +----------------+ +-----------------+
| | |
v v v
+----------------+ +----------------+ +------------------+
| Compile time | | Runtime error | | Runtime error |
| validation | | detection | | detection |
+----------------+ +----------------+ +------------------+
✅ Key Takeaways
- Pass all dependencies via constructors.
- Define interfaces for testable mocking.
- Keep
main()
free of complex initialization. - Use
wire
for intricate dependency graphs. - Adopt
fx
for service-oriented applications.
Closing Thoughts
Dependency injection is a powerful tool for decoupling, testing, and maintainability.
In Go, it doesn’t require complex frameworks or magical syntax.
You can achieve clean, controllable architecture with just New()
and interfaces,
or leverage wire
, dig
, or fx
to boost productivity when needed.
Tools are just forms; the philosophy is what matters.
Understand “who depends on whom” and “who constructs whom,” and you’re already on the right path.