Dependency Injection in Go: Wire and Manual DI

Go developers often dismiss dependency injection as unnecessary Java-style ceremony. This misses the point entirely. DI isn't about frameworks or annotations—it's about inverting control so that...

Key Insights

  • Manual dependency injection in Go works excellently for small-to-medium projects using constructor functions and the “accept interfaces, return structs” pattern—no framework required.
  • Google Wire generates dependency wiring code at compile time, eliminating runtime reflection overhead while solving the “constructor hell” problem that emerges in larger applications.
  • Choose manual DI when your dependency graph has fewer than 15-20 components; switch to Wire when constructor ordering becomes a maintenance burden or when onboarding new developers to complex initialization logic.

Introduction to Dependency Injection

Go developers often dismiss dependency injection as unnecessary Java-style ceremony. This misses the point entirely. DI isn’t about frameworks or annotations—it’s about inverting control so that components receive their dependencies rather than creating them internally.

The benefits remain constant regardless of language: testability through mock substitution, flexibility through interface abstraction, and explicit dependency graphs that document component relationships. Go’s interface system and first-class functions make DI patterns natural to implement, even without a framework.

The real question isn’t whether to use DI in Go—you probably already do when you pass a database connection to a service constructor. The question is how to manage that wiring as your application grows.

Manual Dependency Injection in Go

Go’s approach to DI relies on constructor functions and the idiomatic pattern of accepting interfaces while returning concrete structs. This keeps your code flexible for callers while maintaining implementation clarity.

// Repository interface - accept this in constructors
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

// Concrete implementation - return this from constructors
type PostgresUserRepository struct {
    db *sql.DB
}

func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository {
    return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    // Implementation details
}

Services accept interface dependencies, making them testable and decoupled from specific implementations:

type UserService struct {
    repo   UserRepository
    cache  Cache
    logger *slog.Logger
}

func NewUserService(repo UserRepository, cache Cache, logger *slog.Logger) *UserService {
    return &UserService{
        repo:   repo,
        cache:  cache,
        logger: logger,
    }
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    // Check cache first
    if user, err := s.cache.Get(ctx, "user:"+id); err == nil {
        return user.(*User), nil
    }
    
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, err
    }
    
    s.cache.Set(ctx, "user:"+id, user, time.Hour)
    return user, nil
}

Wiring happens explicitly in main():

func main() {
    // Infrastructure
    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    cache := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    
    // Repositories
    userRepo := NewPostgresUserRepository(db)
    
    // Services
    userService := NewUserService(userRepo, cache, logger)
    
    // HTTP handlers
    userHandler := NewUserHandler(userService, logger)
    
    // Router setup
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{id}", userHandler.GetUser)
    
    http.ListenAndServe(":8080", mux)
}

This approach is explicit, debuggable, and requires zero external dependencies. For applications with a handful of services, it’s the right choice.

Scaling Manual DI: When It Gets Painful

Manual wiring breaks down as applications grow. Here’s what a realistic main() looks like in a medium-sized application:

func main() {
    // Config
    cfg, err := config.Load()
    if err != nil {
        log.Fatal(err)
    }
    
    // Logger
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: cfg.LogLevel,
    }))
    
    // Database
    db, err := sql.Open("postgres", cfg.DatabaseURL)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    
    // Cache
    redisClient := redis.NewClient(&redis.Options{
        Addr:     cfg.RedisAddr,
        Password: cfg.RedisPassword,
    })
    defer redisClient.Close()
    
    // Message queue
    mqConn, err := amqp.Dial(cfg.RabbitMQURL)
    if err != nil {
        log.Fatal(err)
    }
    defer mqConn.Close()
    
    mqChannel, err := mqConn.Channel()
    if err != nil {
        log.Fatal(err)
    }
    
    // Repositories
    userRepo := repository.NewPostgresUserRepository(db)
    orderRepo := repository.NewPostgresOrderRepository(db)
    productRepo := repository.NewPostgresProductRepository(db)
    inventoryRepo := repository.NewPostgresInventoryRepository(db)
    paymentRepo := repository.NewPostgresPaymentRepository(db)
    
    // External clients
    stripeClient := stripe.NewClient(cfg.StripeKey)
    emailClient := sendgrid.NewClient(cfg.SendGridKey)
    
    // Services - ORDER MATTERS HERE
    inventoryService := service.NewInventoryService(inventoryRepo, logger)
    productService := service.NewProductService(productRepo, inventoryService, redisClient, logger)
    paymentService := service.NewPaymentService(paymentRepo, stripeClient, logger)
    notificationService := service.NewNotificationService(emailClient, mqChannel, logger)
    userService := service.NewUserService(userRepo, redisClient, notificationService, logger)
    orderService := service.NewOrderService(
        orderRepo, 
        userService, 
        productService, 
        inventoryService, 
        paymentService, 
        notificationService,
        mqChannel,
        logger,
    )
    
    // Handlers
    userHandler := handler.NewUserHandler(userService, logger)
    orderHandler := handler.NewOrderHandler(orderService, userService, logger)
    productHandler := handler.NewProductHandler(productService, logger)
    adminHandler := handler.NewAdminHandler(userService, orderService, inventoryService, logger)
    
    // Background workers
    orderProcessor := worker.NewOrderProcessor(orderService, mqChannel, logger)
    go orderProcessor.Start()
    
    // Router setup...
}

The problems are clear: constructor ordering is fragile, adding a new dependency requires updating multiple locations, and the cognitive load for understanding initialization grows with each component.

Introduction to Google Wire

Wire solves these problems through compile-time code generation. You define providers (constructors) and injectors (the dependency graph entry points), and Wire generates the wiring code.

Install Wire:

go install github.com/google/wire/cmd/wire@latest

Define providers—these are just your existing constructor functions:

// providers.go
package main

import (
    "database/sql"
    "your/app/repository"
    "your/app/service"
)

func NewDatabase(cfg *Config) (*sql.DB, error) {
    return sql.Open("postgres", cfg.DatabaseURL)
}

func NewUserRepository(db *sql.DB) *repository.PostgresUserRepository {
    return repository.NewPostgresUserRepository(db)
}

func NewUserService(repo repository.UserRepository, logger *slog.Logger) *service.UserService {
    return service.NewUserService(repo, logger)
}

Create an injector definition in wire.go:

//go:build wireinject

package main

import (
    "github.com/google/wire"
)

func InitializeApp(cfg *Config) (*App, error) {
    wire.Build(
        NewDatabase,
        NewLogger,
        NewRedisCache,
        NewUserRepository,
        NewOrderRepository,
        NewUserService,
        NewOrderService,
        NewUserHandler,
        NewOrderHandler,
        NewApp,
    )
    return nil, nil
}

Run wire to generate wire_gen.go:

// Code generated by Wire. DO NOT EDIT.

package main

func InitializeApp(cfg *Config) (*App, error) {
    db, err := NewDatabase(cfg)
    if err != nil {
        return nil, err
    }
    logger := NewLogger(cfg)
    redisCache := NewRedisCache(cfg)
    postgresUserRepository := NewUserRepository(db)
    postgresOrderRepository := NewOrderRepository(db)
    userService := NewUserService(postgresUserRepository, redisCache, logger)
    orderService := NewOrderService(postgresOrderRepository, userService, logger)
    userHandler := NewUserHandler(userService, logger)
    orderHandler := NewOrderHandler(orderService, logger)
    app := NewApp(userHandler, orderHandler)
    return app, nil
}

Wire determines the correct initialization order automatically. Your main() becomes trivial:

func main() {
    cfg := config.MustLoad()
    app, err := InitializeApp(cfg)
    if err != nil {
        log.Fatal(err)
    }
    app.Run()
}

Advanced Wire Patterns

Real applications need interface bindings, grouped providers, and cleanup functions.

Bind concrete types to interfaces:

var RepositorySet = wire.NewSet(
    repository.NewPostgresUserRepository,
    wire.Bind(new(repository.UserRepository), new(*repository.PostgresUserRepository)),
    
    repository.NewPostgresOrderRepository,
    wire.Bind(new(repository.OrderRepository), new(*repository.PostgresOrderRepository)),
)

Organize providers into logical sets:

var InfrastructureSet = wire.NewSet(
    NewDatabase,
    NewRedisCache,
    NewLogger,
    NewMessageQueue,
)

var ServiceSet = wire.NewSet(
    service.NewUserService,
    service.NewOrderService,
    service.NewPaymentService,
)

var HandlerSet = wire.NewSet(
    handler.NewUserHandler,
    handler.NewOrderHandler,
)

// In wire.go
func InitializeApp(cfg *Config) (*App, error) {
    wire.Build(
        InfrastructureSet,
        RepositorySet,
        ServiceSet,
        HandlerSet,
        NewApp,
    )
    return nil, nil
}

Handle cleanup for resources that need closing:

func NewDatabase(cfg *Config) (*sql.DB, func(), error) {
    db, err := sql.Open("postgres", cfg.DatabaseURL)
    if err != nil {
        return nil, nil, err
    }
    
    cleanup := func() {
        db.Close()
    }
    
    return db, cleanup, nil
}

Wire chains cleanup functions automatically in the generated code.

Manual vs Wire: Decision Framework

Choose manual DI when:

  • Your application has fewer than 15-20 dependencies
  • The team is small and everyone understands the initialization flow
  • You want zero external tooling dependencies
  • Compile times are already slow and you want to avoid code generation

Choose Wire when:

  • Dependency ordering becomes error-prone
  • New team members struggle to understand initialization
  • You’re adding or refactoring dependencies frequently
  • You want compile-time verification of your dependency graph

Wire adds roughly 100-200ms to builds for the code generation step. The generated code is readable and debuggable—you can step through it like any other Go code.

Testing Considerations

Both approaches support testability through interface substitution.

Manual injection in tests:

func TestUserService_GetUser(t *testing.T) {
    mockRepo := &MockUserRepository{
        users: map[string]*User{
            "123": {ID: "123", Name: "Test User"},
        },
    }
    mockCache := &MockCache{}
    logger := slog.New(slog.NewTextHandler(io.Discard, nil))
    
    svc := NewUserService(mockRepo, mockCache, logger)
    
    user, err := svc.GetUser(context.Background(), "123")
    require.NoError(t, err)
    assert.Equal(t, "Test User", user.Name)
}

With Wire, create test-specific provider sets:

var TestRepositorySet = wire.NewSet(
    NewMockUserRepository,
    wire.Bind(new(repository.UserRepository), new(*MockUserRepository)),
)

// wire.go for tests
func InitializeTestApp() (*App, error) {
    wire.Build(
        TestRepositorySet,
        ServiceSet,
        HandlerSet,
        NewApp,
    )
    return nil, nil
}

For unit tests, manual injection remains simpler. Wire’s value emerges in integration tests where you want the real dependency graph with selective mocking.

The bottom line: start with manual DI. It’s explicit, requires no tooling, and works well for most applications. When your main() becomes a maintenance burden—when you’re spending real time debugging initialization order or explaining the wiring to new developers—that’s when Wire earns its place in your toolchain.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.