Skip to content

Chapter 9: Dependency Injection

Dependency Injection (DI) makes code testable and loosely coupled by passing dependencies instead of creating them internally.

Dependency Injection is a fundamental design principle that dramatically improves code quality. At its core, DI is simple: instead of creating dependencies inside a struct or function, you pass them in from outside. This inversion of control unlocks testability, flexibility, and better code organization.

Go’s approach to DI is refreshingly straightforward compared to frameworks in other languages. No XML configuration, no annotations, no magic. Just interfaces, constructors, and explicit dependency passing. This simplicity makes DI accessible while maintaining all its benefits.

This chapter covers constructor injection, interface segregation, functional options for optional dependencies, and patterns for wiring applications together. You’ll learn when DI helps, when it’s overkill, and how to strike the right balance.

Without DI, code is tightly coupled and hard to test. Tight coupling means a type directly creates or accesses its dependencies rather than receiving them. This seems convenient at first - you just call NewDatabase() whenever you need a database. But it creates serious problems.

Testing Becomes Impossible: Want to test a service that connects to a real database? You need a running database for every test. Tests become slow, brittle, and environment-dependent. You can’t test locally without infrastructure.

Inflexibility: Switching implementations requires editing every place that creates the dependency. Want to replace PostgreSQL with MySQL? Find every NewPostgresDB() call and change it. Miss one, and the application breaks.

Hidden Dependencies: Looking at a struct’s fields doesn’t tell you what it depends on. Dependencies are created inside methods, making them invisible. This makes code hard to understand and reason about.

Circular Dependencies: When types create their own dependencies, circular dependency problems emerge. Type A creates type B, which creates type C, which creates type A. These cycles are difficult to detect and resolve.

Constructor injection solves tight coupling by passing dependencies when creating an object. Instead of NewService() creating its dependencies internally, NewService(deps) accepts them as parameters. The constructor becomes explicit about what the type needs.

In Go, constructor injection uses “New” functions that accept dependencies and return configured instances. These functions make dependencies visible and enforceable - you literally can’t create the object without providing what it needs.

The pattern has three parts:

  1. Define Interface - specify what capabilities you need, not concrete types
  2. Constructor Function - accept dependencies implementing those interfaces
  3. Store Dependencies - keep them in struct fields for method use

This inversion of control - dependencies flow in rather than being created inside - is what makes code testable and flexible.

Explicit Dependencies: Every dependency is visible in the constructor signature. Want to know what a service needs? Look at NewService(db Database, cache Cache). No surprises.

Compile-Time Safety: Forgot to pass a dependency? The code won’t compile. This catches errors immediately rather than at runtime.

Easy Testing: Pass mock implementations in tests. Production uses real implementations. Same code, different dependencies. No conditional logic, no build tags, just dependency injection.

Immutable After Construction: Dependencies are set once at creation and never change. This eliminates a whole class of bugs related to dependencies being swapped mid-execution.

Pass dependencies through the constructor:

Interface Segregation is the “I” in SOLID principles: clients shouldn’t depend on interfaces they don’t use. In practice, this means defining small, focused interfaces rather than large, monolithic ones.

A big interface like Repository with 20 methods forces implementers to implement all 20, even if they only need 3. It also forces clients to depend on 17 methods they don’t use. Small interfaces prevent this - define UserFinder, UserSaver, and UserDeleter separately.

Go’s interface composition makes this natural. Define small interfaces, compose them into larger ones when needed. A service that only reads users depends on UserFinder. A service that reads and writes depends on an interface composed of UserFinder and UserSaver.

Minimal Dependencies: Services depend only on what they actually use. A read-only service shouldn’t depend on write methods. This reduces coupling and makes intent clear.

Easier Mocking: Testing a service that depends on UserFinder (1 method) is trivial - implement one method. Testing a service depending on FullRepository (20 methods) means implementing 20 methods even if only 1 is used.

Flexibility: Small interfaces can be implemented by multiple types. UserFinder might be implemented by PostgresRepo, CachedRepo, or MockRepo. Each provides finding in different ways. A large interface locks you into specific implementations.

Clear Contracts: Small interfaces document exactly what capabilities a type needs. NewService(finder UserFinder) clearly states “this service finds users.” Compare to NewService(repo Repository) - what does it actually do with that repository?

Define small, focused interfaces:

The functional options pattern itself is covered twice elsewhere in this book - the mechanics in Chapter 7 and the general-purpose configuration version in Chapter 12. This section is only about the DI-specific angle, which is genuinely different: using options to inject optional collaborators rather than scalar settings.

The distinction matters:

  • Required dependencies go in the constructor’s parameter list. NewService(db *sql.DB, ...opts). If the type cannot function without it, the compiler should refuse to build code that omits it. Hiding a required dependency behind an option turns a compile error into a nil-pointer panic at 3am.
  • Optional collaborators go in options. A logger, a cache, a metrics recorder, a clock. The type has a sensible default (usually a no-op) and works fine without one.

The second rule that matters here: default optional dependencies to a working no-op, never to nil. A no-op logger means s.logger.Printf(...) is always safe; a nil logger means every call site needs an if s.logger != nil guard, and one day somebody will forget.

Do You Need a DI Framework? (Almost Certainly Not)

Section titled “Do You Need a DI Framework? (Almost Certainly Not)”

Older Go material - and a lot of AI-generated Go - reaches for google/wire as soon as a dependency graph gets more than a few nodes. That advice has aged badly.

Wire is a code generator: you declare provider sets, run wire, and it writes the wire_gen.go that calls your constructors in the right order.

wire.go
//go:build wireinject
package main
import "github.com/google/wire"
func InitializeApp() *App {
wire.Build(NewDatabase, NewUserRepository, NewUserService, NewApp)
return nil
}

The problem is that this buys you very little in exchange for a generated file, a build tag, an extra tool in CI, and error messages that point at generated code. Wire has been in maintenance mode for years - it still works, but it is not where the ecosystem is going, and starting a new project on it is not the default any more.

1. Manual wiring in main.go - the default. This is what most production Go does, including very large services. Constructors called in order, in one function, in one file. It is explicit, it is greppable, the compiler checks it, and a new team member can read the entire dependency graph top to bottom without learning a tool. The section below shows exactly this. If your main.go is a hundred lines of x := NewX(y), that is not a problem to solve - that is your architecture, written down.

2. uber-go/fx - when you genuinely need lifecycle management. fx is a runtime DI container, and its real selling point is not the injection but fx.Lifecycle: ordered startup, ordered graceful shutdown, and health/readiness hooks across dozens of components. Reach for it when you have many long-lived components that must start and stop in dependency order - servers, consumers, connection pools, background workers - and hand-rolling that ordering has become error-prone.

The cost is real: fx resolves the graph with reflection at runtime, so a missing provider is a startup panic rather than a compile error, and stack traces get deeper.

Rule of thumb: start with manual wiring. Move to fx only when lifecycle ordering - not injection - is the thing hurting you. Do not start a new project on Wire.

For simpler projects, wire dependencies manually:

  1. Constructor injection - pass dependencies via constructors
  2. Depend on interfaces - not concrete types, and define the interface where it is consumed
  3. Interface segregation - small, focused interfaces are trivially mockable
  4. Required dependencies are parameters; optional ones are functional options - and optional ones default to a working no-op, never nil
  5. Wire manually in main.go - explicit, compiler-checked, greppable. This is the default for projects of every size
  6. Skip the DI frameworks - google/wire is in maintenance mode; reach for uber-go/fx only when lifecycle ordering across many long-lived components is the actual problem, and accept that its errors move from compile time to startup

Testable HTTP Client

medium

Create an HTTP client wrapper that accepts a Doer interface (matching http.Client.Do). This makes it testable without real HTTP calls.


Chapter in progress
0 / 14 chapters completed

Next up: Chapter 10: Testing Strategies