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
- Depend on Interfaces: Use interfaces for dependencies
- Constructor Injection: Pass dependencies through constructors
- Single Responsibility: Each type has one job
- Testability: Design for easy testing
- Avoid Service Locator: Use explicit injection
- Document Dependencies: Make dependencies clear
- Keep It Simple: Don’t over-engineer
- 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