Skip to main content
โšก Calmops

Dependency Injection in Go

Dependency Injection in Go

Dependency injection (DI) is a technique for achieving loose coupling and improving testability. Go’s simplicity makes DI straightforward to implement.

Constructor Injection

Pass dependencies through constructors.

Good: Constructor Injection

package main

import (
	"fmt"
)

type Logger interface {
	Log(message string)
}

type ConsoleLogger struct{}

func (cl *ConsoleLogger) Log(message string) {
	fmt.Println(message)
}

type UserService struct {
	logger Logger
}

// Inject dependency through constructor
func NewUserService(logger Logger) *UserService {
	return &UserService{logger: logger}
}

func (us *UserService) CreateUser(name string) {
	us.logger.Log(fmt.Sprintf("Creating user: %s", name))
}

func main() {
	logger := &ConsoleLogger{}
	service := NewUserService(logger)
	service.CreateUser("Alice")
}

Bad: Hard-Coded Dependencies

// โŒ AVOID: Hard-coded dependencies
package main

type UserService struct{}

func (us *UserService) CreateUser(name string) {
	// Hard-coded dependency - can't test or change
	fmt.Println(fmt.Sprintf("Creating user: %s", name))
}

func main() {
	service := &UserService{}
	service.CreateUser("Alice")
}

Interface-Based Design

Depend on interfaces, not concrete types.

Good: Interface-Based Design

package main

import (
	"fmt"
)

type Repository interface {
	Save(data string) error
	Get(id string) (string, error)
}

type InMemoryRepository struct {
	data map[string]string
}

func (imr *InMemoryRepository) Save(data string) error {
	imr.data["key"] = data
	return nil
}

func (imr *InMemoryRepository) Get(id string) (string, error) {
	return imr.data[id], nil
}

type Service struct {
	repo Repository
}

func NewService(repo Repository) *Service {
	return &Service{repo: repo}
}

func (s *Service) Process(data string) error {
	return s.repo.Save(data)
}

func main() {
	repo := &InMemoryRepository{data: make(map[string]string)}
	service := NewService(repo)
	service.Process("test data")
}

Dependency Container

Centralize dependency creation.

Good: Dependency Container

package main

import (
	"fmt"
)

type Logger interface {
	Log(message string)
}

type ConsoleLogger struct{}

func (cl *ConsoleLogger) Log(message string) {
	fmt.Println(message)
}

type Repository interface {
	Save(data string) error
}

type InMemoryRepository struct{}

func (imr *InMemoryRepository) Save(data string) error {
	fmt.Println("Saving:", data)
	return nil
}

type Service struct {
	logger Logger
	repo   Repository
}

type Container struct {
	logger Logger
	repo   Repository
	service *Service
}

func NewContainer() *Container {
	logger := &ConsoleLogger{}
	repo := &InMemoryRepository{}
	
	return &Container{
		logger: logger,
		repo:   repo,
		service: &Service{
			logger: logger,
			repo:   repo,
		},
	}
}

func (c *Container) GetService() *Service {
	return c.service
}

func main() {
	container := NewContainer()
	service := container.GetService()
	service.repo.Save("test")
}

Testing with Dependency Injection

Easy to test with injected dependencies.

Good: Testable Code

package main

import (
	"fmt"
	"testing"
)

type Logger interface {
	Log(message string)
}

type MockLogger struct {
	messages []string
}

func (ml *MockLogger) Log(message string) {
	ml.messages = append(ml.messages, message)
}

type UserService struct {
	logger Logger
}

func NewUserService(logger Logger) *UserService {
	return &UserService{logger: logger}
}

func (us *UserService) CreateUser(name string) {
	us.logger.Log(fmt.Sprintf("Creating user: %s", name))
}

func TestCreateUser(t *testing.T) {
	mockLogger := &MockLogger{}
	service := NewUserService(mockLogger)
	
	service.CreateUser("Alice")
	
	if len(mockLogger.messages) != 1 {
		t.Errorf("Expected 1 log message, got %d", len(mockLogger.messages))
	}
	
	if mockLogger.messages[0] != "Creating user: Alice" {
		t.Errorf("Unexpected log message: %s", mockLogger.messages[0])
	}
}

Factory Functions

Use factory functions for complex creation.

Good: Factory Functions

package main

import (
	"fmt"
)

type Config struct {
	DatabaseURL string
	LogLevel    string
}

type Logger interface {
	Log(message string)
}

type ConsoleLogger struct {
	level string
}

func (cl *ConsoleLogger) Log(message string) {
	fmt.Printf("[%s] %s\n", cl.level, message)
}

type Repository interface {
	Query(sql string) ([]string, error)
}

type DatabaseRepository struct {
	url string
}

func (dr *DatabaseRepository) Query(sql string) ([]string, error) {
	return []string{}, nil
}

type Service struct {
	logger Logger
	repo   Repository
}

// Factory function
func NewService(config Config) *Service {
	logger := &ConsoleLogger{level: config.LogLevel}
	repo := &DatabaseRepository{url: config.DatabaseURL}
	
	return &Service{
		logger: logger,
		repo:   repo,
	}
}

func main() {
	config := Config{
		DatabaseURL: "postgres://localhost",
		LogLevel:    "INFO",
	}
	
	service := NewService(config)
	service.logger.Log("Service created")
}

Wire-Up Pattern

Organize dependency creation.

Good: Wire-Up Pattern

package main

import (
	"fmt"
)

// Domain
type User struct {
	ID   int
	Name string
}

// Interfaces
type Logger interface {
	Log(message string)
}

type UserRepository interface {
	Save(user User) error
	GetByID(id int) (*User, error)
}

// Implementations
type ConsoleLogger struct{}

func (cl *ConsoleLogger) Log(message string) {
	fmt.Println(message)
}

type InMemoryUserRepository struct {
	users map[int]*User
}

func (iur *InMemoryUserRepository) Save(user User) error {
	iur.users[user.ID] = &user
	return nil
}

func (iur *InMemoryUserRepository) GetByID(id int) (*User, error) {
	return iur.users[id], nil
}

type UserService struct {
	logger Logger
	repo   UserRepository
}

func (us *UserService) CreateUser(name string) error {
	user := User{ID: 1, Name: name}
	us.logger.Log(fmt.Sprintf("Creating user: %s", name))
	return us.repo.Save(user)
}

// Wire-up function
func wireUp() *UserService {
	logger := &ConsoleLogger{}
	repo := &InMemoryUserRepository{users: make(map[int]*User)}
	
	return &UserService{
		logger: logger,
		repo:   repo,
	}
}

func main() {
	service := wireUp()
	service.CreateUser("Alice")
}

Best Practices

  1. Depend on Interfaces: Use interfaces for dependencies
  2. Constructor Injection: Pass dependencies through constructors
  3. Single Responsibility: Each type has one job
  4. Testability: Design for easy testing
  5. Avoid Service Locator: Use explicit injection
  6. Document Dependencies: Make dependencies clear
  7. Keep It Simple: Don’t over-engineer
  8. Use Containers Wisely: Containers can hide complexity

Common Pitfalls

  • Service Locator: Using global service locator
  • Circular Dependencies: A depends on B, B depends on A
  • Over-Injection: Injecting too many dependencies
  • Hidden Dependencies: Dependencies not obvious
  • Complexity: DI can add complexity

Resources

Summary

Dependency injection improves testability and loose coupling. Use constructor injection to pass dependencies. Depend on interfaces, not concrete types. Create factory functions for complex creation. Keep DI simple and explicit. Design for testability from the start.

Comments