Skip to content
Codeloom
Go

Dependency Injection Patterns in Go

Learn practical dependency injection in Go using interfaces, constructor injection, and functional options without heavy frameworks.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Why dependency injection matters for testable Go code
  • Constructor injection with interfaces
  • The functional options pattern for flexible configuration
  • Structuring a service layer with injected dependencies

Prerequisites

None — this post is self-contained.

Go does not have a dependency injection framework built into the language, and it does not need one. Interfaces, constructor functions, and a few well-established patterns give you clean dependency injection without magic. The result is code where every dependency is explicit, every service is testable, and you can swap implementations without changing business logic.

The Problem Without Injection

Code that creates its own dependencies is hard to test and hard to change:

type OrderService struct{}

func (s *OrderService) PlaceOrder(userID string, items []string) error {
    db, err := sql.Open("postgres", "host=localhost dbname=shop")
    if err != nil {
        return err
    }
    defer db.Close()

    // insert order into database
    _, err = db.Exec("INSERT INTO orders (user_id) VALUES ($1)", userID)
    return err
}

This function opens a real database connection every time it runs. You cannot test it without a running PostgreSQL instance. You cannot switch to a different database without editing the service. The dependency is hidden inside the method.

Constructor Injection With Interfaces

Define an interface for the dependency and accept it in the constructor:

type OrderStore interface {
    CreateOrder(ctx context.Context, userID string, items []string) error
    GetOrder(ctx context.Context, orderID string) (*Order, error)
}

type OrderService struct {
    store OrderStore
}

func NewOrderService(store OrderStore) *OrderService {
    return &OrderService{store: store}
}

func (s *OrderService) PlaceOrder(ctx context.Context, userID string, items []string) error {
    return s.store.CreateOrder(ctx, userID, items)
}

Now the service depends on the OrderStore interface, not a concrete database. In production, you pass a Postgres implementation. In tests, you pass a mock.

Implementing the Interface

The production implementation connects to a real database:

type PostgresOrderStore struct {
    db *sql.DB
}

func NewPostgresOrderStore(db *sql.DB) *PostgresOrderStore {
    return &PostgresOrderStore{db: db}
}

func (s *PostgresOrderStore) CreateOrder(ctx context.Context, userID string, items []string) error {
    _, err := s.db.ExecContext(ctx,
        "INSERT INTO orders (user_id, items) VALUES ($1, $2)",
        userID, pq.Array(items),
    )
    return err
}

func (s *PostgresOrderStore) GetOrder(ctx context.Context, orderID string) (*Order, error) {
    row := s.db.QueryRowContext(ctx,
        "SELECT id, user_id, items FROM orders WHERE id = $1", orderID,
    )
    var o Order
    err := row.Scan(&o.ID, &o.UserID, pq.Array(&o.Items))
    return &o, err
}

Testing With a Mock

Tests inject a mock that records calls without touching a database:

type MockOrderStore struct {
    CreateOrderFunc func(ctx context.Context, userID string, items []string) error
    GetOrderFunc    func(ctx context.Context, orderID string) (*Order, error)
}

func (m *MockOrderStore) CreateOrder(ctx context.Context, userID string, items []string) error {
    return m.CreateOrderFunc(ctx, userID, items)
}

func (m *MockOrderStore) GetOrder(ctx context.Context, orderID string) (*Order, error) {
    return m.GetOrderFunc(ctx, orderID)
}

func TestPlaceOrder(t *testing.T) {
    var called bool
    mock := &MockOrderStore{
        CreateOrderFunc: func(ctx context.Context, userID string, items []string) error {
            called = true
            if userID != "user-1" {
                t.Errorf("expected user-1, got %s", userID)
            }
            return nil
        },
    }

    svc := NewOrderService(mock)
    err := svc.PlaceOrder(context.Background(), "user-1", []string{"item-a"})
    if err != nil {
        t.Fatal(err)
    }
    if !called {
        t.Error("expected CreateOrder to be called")
    }
}

Multiple Dependencies

Real services have multiple dependencies. Accept all of them in the constructor:

type NotificationService interface {
    SendEmail(ctx context.Context, to, subject, body string) error
}

type Logger interface {
    Info(msg string, args ...any)
    Error(msg string, args ...any)
}

type OrderService struct {
    store   OrderStore
    notifier NotificationService
    logger   Logger
}

func NewOrderService(
    store OrderStore,
    notifier NotificationService,
    logger Logger,
) *OrderService {
    return &OrderService{
        store:    store,
        notifier: notifier,
        logger:   logger,
    }
}

func (s *OrderService) PlaceOrder(ctx context.Context, userID string, items []string) error {
    s.logger.Info("placing order", "userID", userID, "items", len(items))

    if err := s.store.CreateOrder(ctx, userID, items); err != nil {
        s.logger.Error("failed to create order", "error", err)
        return err
    }

    _ = s.notifier.SendEmail(ctx, userID, "Order Confirmed", "Your order is placed.")
    return nil
}

Each dependency is an interface. Each can be replaced independently in tests.

The Functional Options Pattern

When a service has optional configuration alongside required dependencies, the functional options pattern keeps the constructor clean:

type ServerOption func(*Server)

type Server struct {
    handler http.Handler
    addr    string
    timeout time.Duration
    logger  Logger
}

func WithAddr(addr string) ServerOption {
    return func(s *Server) {
        s.addr = addr
    }
}

func WithTimeout(d time.Duration) ServerOption {
    return func(s *Server) {
        s.timeout = d
    }
}

func WithLogger(l Logger) ServerOption {
    return func(s *Server) {
        s.logger = l
    }
}

func NewServer(handler http.Handler, opts ...ServerOption) *Server {
    s := &Server{
        handler: handler,
        addr:    ":8080",
        timeout: 30 * time.Second,
        logger:  defaultLogger{},
    }

    for _, opt := range opts {
        opt(s)
    }
    return s
}

Usage is expressive and only specifies what differs from defaults:

srv := NewServer(
    mux,
    WithAddr(":3000"),
    WithTimeout(10*time.Second),
)

Wiring It All Together

At the application’s entry point, typically main(), you create concrete implementations and wire them into services:

func main() {
    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    store := NewPostgresOrderStore(db)
    notifier := NewSMTPNotifier(os.Getenv("SMTP_HOST"))
    logger := slog.Default()

    orderSvc := NewOrderService(store, notifier, logger)
    userSvc := NewUserService(store, logger)

    mux := http.NewServeMux()
    mux.HandleFunc("POST /orders", orderSvc.HandlePlaceOrder)
    mux.HandleFunc("GET /users/{id}", userSvc.HandleGetUser)

    srv := NewServer(mux, WithAddr(":"+os.Getenv("PORT")))
    log.Fatal(http.ListenAndServe(srv.addr, srv.handler))
}

All dependencies are visible in one place. There is no container, no reflection, and no annotations. The dependency graph is plain Go code.

Interface Segregation

Keep injected interfaces small. A service should not accept a 10-method interface when it only calls two methods:

// Too broad - forces mocks to implement everything
type UserRepository interface {
    Create(ctx context.Context, user *User) error
    GetByID(ctx context.Context, id string) (*User, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
    Update(ctx context.Context, user *User) error
    Delete(ctx context.Context, id string) error
    List(ctx context.Context, limit, offset int) ([]*User, error)
}

// Better - only what this service needs
type UserGetter interface {
    GetByID(ctx context.Context, id string) (*User, error)
}

type ProfileService struct {
    users UserGetter
}

Small interfaces are easier to mock, easier to implement, and make dependencies self-documenting.

When to Use a DI Container

For most Go applications, manual wiring in main() is sufficient and preferred. If the dependency graph becomes very large, with dozens of services and complex initialization order, tools like Google’s Wire can generate the wiring code for you. Wire uses code generation rather than runtime reflection, staying true to Go’s philosophy of explicit code.

The threshold for needing a tool is high. If your main() function is under 100 lines of wiring, manual injection is clearer and easier to debug.

Wrap Up

Dependency injection in Go is straightforward: define small interfaces for dependencies, accept them in constructors, and wire concrete implementations in main(). This pattern makes every dependency visible, every service testable with mocks, and every implementation swappable. No framework required.