← Back to the blog

Dependency injection in Go without a framework

Go doesn't need a DI container. It needs constructor functions and a main.go that wires everything together. Here's the pattern we use on every project.

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.

Quick answerDependency injection in Go: each struct gets a constructor function that receives its dependencies. 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

Subscribe

More of this, once a month.