Dependency Injection in Go: Patterns & Best Practices

Master DI patterns for testable Go code

Page content

Dependency injection (DI) is a fundamental design pattern that promotes clean, testable, and maintainable code in Go applications.

Whether you’re building REST APIs, implementing multi-tenant database patterns, or working with ORM libraries, understanding dependency injection will significantly improve your code quality.

go-dependency-injection

What is Dependency Injection?

Dependency injection is a design pattern where components receive their dependencies from external sources rather than creating them internally. This approach decouples components, making your code more modular, testable, and maintainable.

In Go, dependency injection is particularly powerful because of the language’s interface-based design philosophy. Go’s implicit interface satisfaction means you can easily swap implementations without modifying existing code.

Why Use Dependency Injection in Go?

Improved Testability: By injecting dependencies, you can easily replace real implementations with mocks or test doubles. This allows you to write unit tests that are fast, isolated, and don’t require external services like databases or APIs.

Better Maintainability: Dependencies become explicit in your code. When you look at a constructor function, you immediately see what a component requires. This makes the codebase easier to understand and modify.

Loose Coupling: Components depend on abstractions (interfaces) rather than concrete implementations. This means you can change implementations without affecting dependent code.

Flexibility: You can configure different implementations for different environments (development, testing, production) without changing your business logic.

Constructor Injection: The Go Way

The most common and idiomatic way to implement dependency injection in Go is through constructor functions. These are typically named NewXxx and accept dependencies as parameters.

Basic Example

Here’s a simple example demonstrating constructor injection:

// Define an interface for the repository
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// Service depends on the repository interface
type UserService struct {
    repo UserRepository
}

// Constructor injects the dependency
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Methods use the injected dependency
func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

This pattern makes it clear that UserService requires a UserRepository. You cannot create a UserService without providing a repository, which prevents runtime errors from missing dependencies.

Multiple Dependencies

When a component has multiple dependencies, simply add them as constructor parameters:

type EmailService interface {
    Send(to, subject, body string) error
}

type Logger interface {
    Info(msg string)
    Error(msg string, err error)
}

type OrderService struct {
    repo        OrderRepository
    emailSvc    EmailService
    logger      Logger
    paymentSvc  PaymentService
}

func NewOrderService(
    repo OrderRepository,
    emailSvc EmailService,
    logger Logger,
    paymentSvc PaymentService,
) *OrderService {
    return &OrderService{
        repo:       repo,
        emailSvc:   emailSvc,
        logger:     logger,
        paymentSvc: paymentSvc,
    }
}

Interface Design for Dependency Injection

One of the key principles when implementing dependency injection is the Dependency Inversion Principle (DIP): high-level modules should not depend on low-level modules; both should depend on abstractions.

In Go, this means defining small, focused interfaces that represent only what your component needs:

// Good: Small, focused interface
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// Bad: Large interface with unnecessary methods
type PaymentService interface {
    ProcessPayment(amount float64) error
    RefundPayment(id string) error
    GetPaymentHistory(userID int) ([]Payment, error)
    UpdatePaymentMethod(userID int, method PaymentMethod) error
    // ... many more methods
}

The smaller interface follows the Interface Segregation Principle—clients shouldn’t depend on methods they don’t use. This makes your code more flexible and easier to test.

Real-World Example: Database Abstraction

When working with databases in Go applications, you’ll often need to abstract database operations. Here’s how dependency injection helps:

// Database interface - high level abstraction
type DB interface {
    Query(ctx context.Context, query string, args ...interface{}) (Rows, error)
    Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
    BeginTx(ctx context.Context) (Tx, error)
}

// Repository depends on the abstraction
type UserRepository struct {
    db DB
}

func NewUserRepository(db DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    rows, err := r.db.Query(ctx, query, id)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    // ... parse rows
}

This pattern is especially useful when implementing multi-tenant database patterns, where you might need to switch between different database implementations or connection strategies.

The Composition Root Pattern

The Composition Root is where you assemble all your dependencies at the application’s entry point (typically main). This centralizes dependency configuration and makes the dependency graph explicit.

func main() {
    // Initialize infrastructure dependencies
    db := initDatabase()
    logger := initLogger()
    
    // Initialize repositories
    userRepo := NewUserRepository(db)
    orderRepo := NewOrderRepository(db)
    
    // Initialize services with dependencies
    emailSvc := NewEmailService(logger)
    paymentSvc := NewPaymentService(logger)
    userSvc := NewUserService(userRepo, logger)
    orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
    
    // Initialize HTTP handlers
    userHandler := NewUserHandler(userSvc)
    orderHandler := NewOrderHandler(orderSvc)
    
    // Wire up routes
    router := setupRouter(userHandler, orderHandler)
    
    // Start server
    log.Fatal(http.ListenAndServe(":8080", router))
}

This approach makes it clear how your application is structured and where dependencies come from. It’s particularly valuable when building REST APIs in Go, where you need to coordinate multiple layers of dependencies.

Dependency Injection Frameworks

For larger applications with complex dependency graphs, managing dependencies manually can become cumbersome. Go has several DI frameworks that can help:

Google Wire (Compile-Time DI)

Wire is a compile-time dependency injection tool that generates code. It’s type-safe and has no runtime overhead.

Installation:

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

Example:

// wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewUserRepository,
        NewUserService,
        NewUserHandler,
        NewApp,
    )
    return &App{}, nil
}

Wire generates the dependency injection code at compile time, ensuring type safety and eliminating runtime reflection overhead.

Uber Dig (Runtime DI)

Dig is a runtime dependency injection framework that uses reflection. It’s more flexible but has some runtime cost.

Example:

import "go.uber.org/dig"

func main() {
    container := dig.New()
    
    // Register providers
    container.Provide(NewDB)
    container.Provide(NewUserRepository)
    container.Provide(NewUserService)
    container.Provide(NewUserHandler)
    
    // Invoke function that needs dependencies
    err := container.Invoke(func(handler *UserHandler) {
        // Use handler
    })
    if err != nil {
        log.Fatal(err)
    }
}

When to Use Frameworks

Use a framework when:

  • Your dependency graph is complex with many interdependent components
  • You have multiple implementations of the same interface that need to be selected based on configuration
  • You want automatic dependency resolution
  • You’re building a large application where manual wiring becomes error-prone

Stick with manual DI when:

  • Your application is small to medium-sized
  • The dependency graph is simple and easy to follow
  • You want to keep dependencies minimal and explicit
  • You prefer explicit code over generated code

Testing with Dependency Injection

One of the primary benefits of dependency injection is improved testability. Here’s how DI makes testing easier:

Unit Testing Example

// Mock implementation for testing
type mockUserRepository struct {
    users map[int]*User
    err   error
}

func (m *mockUserRepository) FindByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[user.ID] = user
    return nil
}

// Test using the mock
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "John", Email: "john@example.com"},
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "John", user.Name)
}

This test runs quickly, doesn’t require a database, and tests your business logic in isolation. When working with ORM libraries in Go, you can inject mock repositories to test service logic without database setup.

Common Patterns and Best Practices

1. Use Interface Segregation

Keep interfaces small and focused on what the client actually needs:

// Good: Client only needs to read users
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// Separate interface for writing
type UserWriter interface {
    Save(user *User) error
    Delete(id int) error
}

2. Return Errors from Constructors

Constructors should validate dependencies and return errors if initialization fails:

func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("user repository cannot be nil")
    }
    return &UserService{repo: repo}, nil
}

3. Use Context for Request-Scoped Dependencies

For dependencies that are request-specific (like database transactions), pass them via context:

type ctxKey string

const dbKey ctxKey = "db"

func WithDB(ctx context.Context, db DB) context.Context {
    return context.WithValue(ctx, dbKey, db)
}

func DBFromContext(ctx context.Context) (DB, bool) {
    db, ok := ctx.Value(dbKey).(DB)
    return db, ok
}

4. Avoid Over-Injection

Don’t inject dependencies that are truly internal implementation details. If a component creates and manages its own helper objects, that’s fine:

// Good: Internal helper doesn't need injection
type UserService struct {
    repo UserRepository
    // Internal cache - doesn't need injection
    cache map[int]*User
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo:  repo,
        cache: make(map[int]*User),
    }
}

5. Document Dependencies

Use comments to document why dependencies are needed and any constraints:

// UserService handles user-related business logic.
// It requires a UserRepository for data access and a Logger for
// error tracking. The repository must be thread-safe if used
// in concurrent contexts.
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

When NOT to Use Dependency Injection

Dependency injection is a powerful tool, but it’s not always necessary:

Skip DI for:

  • Simple value objects or data structures
  • Internal helper functions or utilities
  • One-off scripts or small utilities
  • When direct instantiation is clearer and simpler

Example of when NOT to use DI:

// Simple struct - no need for DI
type Point struct {
    X, Y float64
}

func NewPoint(x, y float64) Point {
    return Point{X: x, Y: y}
}

// Simple utility - no need for DI
func FormatCurrency(amount float64) string {
    return fmt.Sprintf("$%.2f", amount)
}

Integration with Go Ecosystem

Dependency injection works seamlessly with other Go patterns and tools. When building applications that use Go’s standard library for web scraping or generating PDF reports, you can inject these services into your business logic:

type PDFGenerator interface {
    GenerateReport(data ReportData) ([]byte, error)
}

type ReportService struct {
    pdfGen PDFGenerator
    repo   ReportRepository
}

func NewReportService(pdfGen PDFGenerator, repo ReportRepository) *ReportService {
    return &ReportService{
        pdfGen: pdfGen,
        repo:   repo,
    }
}

This allows you to swap PDF generation implementations or use mocks during testing.

Conclusion

Dependency injection is a cornerstone of writing maintainable, testable Go code. By following the patterns outlined in this article—constructor injection, interface-based design, and the composition root pattern—you’ll create applications that are easier to understand, test, and modify.

Start with manual constructor injection for small to medium applications, and consider frameworks like Wire or Dig as your dependency graph grows. Remember that the goal is clarity and testability, not complexity for its own sake.

For more Go development resources, check out our Go Cheatsheet for quick reference on Go syntax and common patterns.

External Resources