Dependency Injection in Go: Patterns & Best Practices
Source: Dev.to
Introduction
Dependency injection (DI) is a fundamental design pattern that promotes clean, testable, and maintainable code in Go applications. By receiving their dependencies from external sources rather than creating them internally, components become decoupled, modular, and easier to work with.
Benefits of Dependency Injection
- Improved Testability – Replace real implementations with mocks or test doubles, enabling fast, isolated unit tests without external services.
- Better Maintainability – Dependencies are explicit in constructor functions, making it clear what a component requires.
- Loose Coupling – Components depend on abstractions (interfaces) rather than concrete types, allowing implementations to change without affecting dependents.
- Flexibility – Configure different implementations for development, testing, and production without altering business logic.
Constructor Injection (Idiomatic Go)
The most common way to implement DI in Go is through constructor functions, typically named NewXxx, that accept dependencies as parameters.
// 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)
}
Multiple Dependencies
When a component requires several collaborators, list them all 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,
}
}
Dependency Inversion Principle (DIP) & Interface Segregation
High‑level modules should depend on abstractions, not concrete implementations. In Go, this translates to defining small, focused interfaces.
// 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
}
A narrow interface follows the Interface Segregation Principle, keeping clients from depending on methods they don’t use.
Database Abstraction Example
// 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
return nil, nil
}
This pattern is especially useful for multi‑tenant databases or when swapping between different drivers.
Composition Root
The composition root is the single place (usually main) where the entire object graph is assembled:
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 makes the dependency graph explicit and simplifies reasoning about the application’s structure, which is valuable for REST APIs and other layered systems.
DI Frameworks for Larger Projects
Wire (compile‑time)
Wire generates type‑safe code at compile time, eliminating runtime reflection overhead.
go install github.com/google/wire/cmd/wire@latest
// 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
}
Dig (runtime)
Dig uses reflection to resolve dependencies at runtime, offering more flexibility at the cost of some overhead.
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 a function that needs dependencies
err := container.Invoke(func(handler *UserHandler) {
// Use handler
})
if err != nil {
log.Fatal(err)
}
}
When to Use a Framework vs. Manual DI
-
Use a framework when:
- The dependency graph is complex with many interdependent components.
- Multiple implementations of the same interface must be selected based on configuration.
- Automatic resolution would reduce boilerplate and error‑prone wiring.
- You are building a large application where manual wiring becomes cumbersome.
-
Stick with manual DI when:
- The application is small to medium‑size and the graph remains simple.
- You prefer explicit wiring without additional dependencies.