Dependency injection in Go is just passing dependencies as function arguments or struct fields — no framework required. The language already gives you everything you need: interfaces, constructor functions, and a main.gowhere you assemble the pieces. Adding a DI container on top solves a problem you don't have.
main.gobuilds everything bottom-up — database first, stores next, services next, handlers last. That's it.What dependency injection actually means
DI means a component doesn't create its own dependencies — they're handed to it from outside. The alternative is global state or package-level vars that components reach for directly. Both are hard to test and hard to reason about under concurrency.
The motivation isn't about frameworks or patterns. It's about making components testable in isolation and making the dependency graph explicit.
Constructor injection in Go
Every struct that has dependencies gets a New function:
// Store depends on a database pool.
type InvoiceStore struct {
db *pgxpool.Pool
}
func NewInvoiceStore(db *pgxpool.Pool) *InvoiceStore {
return &InvoiceStore{db: db}
}
// Service depends on the store (via interface).
type InvoiceService struct {
store InvoiceStorer
mailer Mailer
}
func NewInvoiceService(store InvoiceStorer, mailer Mailer) *InvoiceService {
return &InvoiceService{store: store, mailer: mailer}
}The main.go wiring pattern
All wiring happens in main.go. Build bottom-up: infrastructure first, application second, delivery last.
func main() {
cfg := config.Load()
// 1. Infrastructure
db, err := pgxpool.New(ctx, cfg.DatabaseURL)
mailer := smtp.NewMailer(cfg.SMTPHost, cfg.SMTPPort)
// 2. Stores
invoiceStore := invoice.NewInvoiceStore(db)
userStore := user.NewUserStore(db)
// 3. Services
invoiceSvc := invoice.NewInvoiceService(invoiceStore, mailer)
userSvc := user.NewUserService(userStore)
// 4. Handlers and router
router := chi.NewRouter()
invoice.RegisterRoutes(router, invoiceSvc)
user.RegisterRoutes(router, userSvc)
http.ListenAndServe(cfg.Addr, router)
}The graph is visible, explicit, and compilable. If you add a dependency and forget to wire it, the compiler tells you. No magic, no reflection, no startup-time surprises.
When to add interfaces
Define an interface when you have a real reason:
- You want to mock the dependency in tests.
- You have two real implementations (e.g. in-memory cache + Redis).
- The dependency is external (email, payments, SMS).
Don't define an interface just to have one. Go's implicit interfaces mean you can always add one later — it's not a commitment you need to make upfront.
Why skip the frameworks
DI frameworks like Wire (compile-time code generation) or Uber's fx (runtime reflection) add complexity in exchange for solving the wiring problem at scale. That trade-off pays off around the 50+ provider mark. Most backends never get there, and the cost is learning another tool, debugging generated code, and adding build steps.
Start with manual wiring. If main.go genuinely becomes unmanageable (this usually happens around 20+ services), reach for Wire. Not before.
See the clean architecture post for how this fits into the broader structure of a Go backend.
End of article · Thanks for reading