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.