Skip to main content

Dependency Injection in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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 main or 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/wire automates wire-up for large services — generates plain Go code from provider signatures

Resources

Comments

👍 Was this article helpful?