Understanding Dependency Injection in Go

| Categories Go  | Tags Go  Dependency Injection  Architecture Design  wire  dig  fx 

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

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

  1. Pass all dependencies via constructors.
  2. Define interfaces for testable mocking.
  3. Keep main() free of complex initialization.
  4. Use wire for intricate dependency graphs.
  5. 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.