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.