Frontend Labs
⋯

55. Dependency injection without a framework: functional options

55.1 The problem: configurable constructors without exploding parameter lists

// Unworkable past 3-4 options: which bool is which? what's the order?
func NewServer(host string, port int, timeout time.Duration, tls bool, maxConns int) *Server

55.2 The functional options pattern

type Server struct {
	host     string
	port     int
	timeout  time.Duration
	tls      bool
	maxConns int
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
	return func(s *Server) { s.timeout = d }
}
func WithTLS() Option {
	return func(s *Server) { s.tls = true }
}
func WithMaxConns(n int) Option {
	return func(s *Server) { s.maxConns = n }
}

func NewServer(host string, port int, opts ...Option) *Server {
	s := &Server{host: host, port: port, timeout: 30 * time.Second, maxConns: 100}
	for _, opt := range opts {
		opt(s)
	}
	return s
}

srv := NewServer("localhost", 8080,
	WithTLS(),
	WithTimeout(5*time.Second),
)

Every option is self-documenting at the call site, new options are purely additive (existing call sites never need to change), and sensible defaults live in one place. This is how most well-designed Go SDKs (cloud provider clients, gRPC's DialOption, http.Client-adjacent libraries) expose configuration.

55.3 Dependency injection as plain constructor parameters

Go doesn't need (and mostly avoids) DI frameworks — passing dependencies explicitly as constructor parameters, typed as interfaces where you want substitutability (Chapter 54.1), is dependency injection:

func NewOrderService(db Database, cache Cache, logger *slog.Logger) *OrderService {
	return &OrderService{db: db, cache: cache, logger: logger}
}

Tests substitute a fake Database/Cache implementation; production wiring happens once, explicitly, in main(). No reflection-based container, no annotations, no magic — the entire dependency graph is readable by following function calls.


Generated from real-life-golang-guide.md · 8 Oct 2026 16:36