Dependency injection (DI) means giving a function or struct the things it needs, rather than having it create them itself. This sounds simple — and it is — but the consequences are significant: code that receives its dependencies through parameters is independently testable, independently deployable, and trivially swappable between real and fake implementations.
Go’s approach to DI is manual by convention. No framework required. Constructor functions take interfaces, return initialized structs. A single wire-up function at the top of the call tree assembles everything. This is enough for most services.
For the interface design that makes DI work see Go SOLID principles and Go interfaces.
The Problem DI Solves
Consider a service that creates its own database connection:
// ❌ Hard to test: creates its own dependencies
type OrderService struct{}
func (s *OrderService) GetOrder(id string) (*Order, error) {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return nil, err
}
defer db.Close()
// query db...
}
Testing GetOrder requires a real database. Changing the database driver requires editing OrderService. These are unnecessary couplings.
The fix is to receive dependencies as parameters rather than constructing them:
// ✅ Testable: dependencies are passed in
type OrderService struct {
db *sql.DB
log *slog.Logger
}
func NewOrderService(db *sql.DB, log *slog.Logger) *OrderService {
return &OrderService{db: db, log: log}
}
func (s *OrderService) GetOrder(ctx context.Context, id string) (*Order, error) {
// uses s.db and s.log — both provided by the caller
}
Now you can construct OrderService with a real database in production and a fake database in tests, without changing the service code at all.
Constructor Injection
Constructor injection passes dependencies to a struct through its constructor function (New*). This is the standard Go pattern:
// Define what the service needs as interfaces
type OrderRepository interface {
GetByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type PaymentGateway interface {
Charge(ctx context.Context, amount int, token string) (string, error)
}
type EventPublisher interface {
Publish(ctx context.Context, event any) error
}
// Service receives everything through its constructor
type OrderService struct {
orders OrderRepository
payments PaymentGateway
events EventPublisher
log *slog.Logger
}
func NewOrderService(
orders OrderRepository,
payments PaymentGateway,
events EventPublisher,
log *slog.Logger,
) *OrderService {
return &OrderService{
orders: orders,
payments: payments,
events: events,
log: log,
}
}
The constructor signature documents exactly what the service needs. Anyone reading NewOrderService can understand the full dependency surface without reading the implementation.
The Wire-Up Function
In main.go (or a separate cmd/ package), a single function assembles all the concrete implementations and wires them together:
func main() {
db := mustOpenDB(os.Getenv("DATABASE_URL"))
defer db.Close()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Build each layer from the bottom up
orderRepo := postgres.NewOrderRepository(db)
paymentGW := stripe.NewGateway(os.Getenv("STRIPE_KEY"))
eventBus := kafka.NewPublisher(os.Getenv("KAFKA_BROKERS"))
orderService := NewOrderService(orderRepo, paymentGW, eventBus, logger)
// Hand to the HTTP layer
handler := api.NewHandler(orderService, logger)
http.ListenAndServe(":8080", handler)
}
Everything concrete lives here. Business logic (OrderService) never imports postgres, stripe, or kafka — it only sees the interfaces. Swap stripe for adyen by changing one line in main.
Testing with Fakes
Because OrderService depends on interfaces, tests provide minimal fakes:
// Minimal in-memory implementation for tests
type fakeOrderRepo struct {
orders map[string]*Order
err error // inject error for failure path tests
}
func (f *fakeOrderRepo) GetByID(_ context.Context, id string) (*Order, error) {
if f.err != nil { return nil, f.err }
o, ok := f.orders[id]
if !ok { return nil, ErrNotFound }
return o, nil
}
func (f *fakeOrderRepo) Save(_ context.Context, o *Order) error {
if f.err != nil { return f.err }
f.orders[o.ID] = o
return nil
}
// Simple recorded fake for payment
type fakePayments struct{ charged []int }
func (f *fakePayments) Charge(_ context.Context, amount int, _ string) (string, error) {
f.charged = append(f.charged, amount)
return "txn_" + strconv.Itoa(len(f.charged)), nil
}
type fakeEvents struct{ published []any }
func (f *fakeEvents) Publish(_ context.Context, e any) error {
f.published = append(f.published, e)
return nil
}
// Test — no database, no network, no environment variables
func TestPlaceOrder(t *testing.T) {
repo := &fakeOrderRepo{orders: make(map[string]*Order)}
payments := &fakePayments{}
events := &fakeEvents{}
svc := NewOrderService(repo, payments, events, slog.Default())
order, err := svc.PlaceOrder(context.Background(), PlaceOrderRequest{
CustomerID: "cust-1",
Items: []Item{{ProductID: "prod-1", Qty: 2}},
PaymentToken: "tok_visa",
})
if err != nil {
t.Fatalf("PlaceOrder: %v", err)
}
if len(payments.charged) != 1 {
t.Errorf("expected 1 charge, got %d", len(payments.charged))
}
if len(events.published) != 1 {
t.Errorf("expected 1 event, got %d", len(events.published))
}
if _, ok := repo.orders[order.ID]; !ok {
t.Error("order not saved to repository")
}
}
This test runs in microseconds, requires no external services, and tests real behavior through the real OrderService code.
Avoiding Global State
Global variables are implicit dependencies — invisible in function signatures and impossible to swap in tests. Replace them with injected dependencies:
// ❌ Global logger — can't swap in tests, race conditions in parallel tests
var log = slog.Default()
func processOrder(id string) error {
log.Info("processing", "id", id)
// ...
}
// ✅ Injected logger — passed explicitly, swappable
func processOrder(ctx context.Context, log *slog.Logger, id string) error {
log.Info("processing", "id", id)
// ...
}
The one legitimate global in Go programs: initialized-once, read-only configuration (like a *regexp.Regexp compiled at startup). Mutable state should always be injected.
Functional Options for Optional Configuration
When a constructor has many optional parameters, functional options are cleaner than a large config struct:
type ServerOption func(*Server)
func WithTimeout(d time.Duration) ServerOption {
return func(s *Server) { s.timeout = d }
}
func WithLogger(log *slog.Logger) ServerOption {
return func(s *Server) { s.log = log }
}
func WithMaxConnections(n int) ServerOption {
return func(s *Server) { s.maxConns = n }
}
func NewServer(addr string, opts ...ServerOption) *Server {
s := &Server{
addr: addr,
timeout: 30 * time.Second, // defaults
maxConns: 100,
log: slog.Default(),
}
for _, opt := range opts {
opt(s)
}
return s
}
// Usage — clear, named, optional
srv := NewServer(":8080",
WithTimeout(10*time.Second),
WithLogger(myLogger),
)
This is the pattern used by grpc.Dial, http.NewServer, and many other Go libraries. Each option is a self-contained function that modifies one field.
google/wire for Large Codebases
For services with dozens of dependencies, manually writing wire-up functions becomes tedious. google/wire generates the wire-up code from provider function signatures:
go install github.com/google/wire/cmd/wire@latest
// wire.go — declarations that tell wire what to build
//go:build wireinject
package main
import "github.com/google/wire"
func InitializeOrderService(db *sql.DB, log *slog.Logger) (*OrderService, error) {
wire.Build(
postgres.NewOrderRepository,
stripe.NewGateway,
kafka.NewPublisher,
NewOrderService,
)
return nil, nil // wire replaces this
}
Running wire generates a wire_gen.go file with the fully wired constructor. The generated code is plain Go — no reflection, no magic, fully readable.
Wire is worth the setup for large services. For small services (< 10 components), manual wire-up is simpler.
Summary
- Constructor injection: pass every dependency through
New*functions — never create dependencies inside business logic - Keep the wire-up in
mainor a dedicated initialization package — business logic only sees interfaces - Fakes (hand-written stubs) are simpler, faster, and more controllable than mocking frameworks for most cases
- Replace global variables with injected parameters — global state is a hidden dependency that prevents parallel testing
- Functional options pattern (
WithX(...)) handles optional configuration cleanly for constructors with many parameters google/wireautomates wire-up for large services — generates plain Go code from provider signatures
Comments