How to Use Dependency Injection in Go
Dependency injection in Go looks different from what you might expect coming from Java or C#. There's no framework magic, no annotations, and no runtime reflection required. Go's simplicity actually...
Key Insights
- Constructor injection with interfaces is the most idiomatic dependency injection pattern in Go, eliminating the need for heavyweight DI frameworks
- Designing dependencies as interfaces rather than concrete types enables effortless testing with mocks and makes your code more flexible and maintainable
- Google’s Wire provides compile-time dependency injection for complex applications, catching wiring errors before runtime without reflection overhead
Introduction to Dependency Injection
Dependency injection in Go looks different from what you might expect coming from Java or C#. There’s no framework magic, no annotations, and no runtime reflection required. Go’s simplicity actually makes dependency injection more straightforward—you’re just passing dependencies as function parameters instead of hardcoding them.
The core principle remains the same: instead of a component creating its own dependencies, those dependencies are provided from the outside. This inverts the control flow and makes your code testable, flexible, and easier to reason about. In Go, we achieve this primarily through constructor functions and interfaces, leveraging the language’s structural typing to our advantage.
The Problem: Tight Coupling
Let’s start with what not to do. Here’s a typical tightly coupled implementation where dependencies are hardcoded:
package user
import (
"database/sql"
"log"
_ "github.com/lib/pq"
)
type UserService struct{}
func (s *UserService) GetUser(id int) (*User, error) {
// Creating dependencies directly inside the method
db, err := sql.Open("postgres", "postgres://localhost/mydb")
if err != nil {
log.Printf("database error: %v", err)
return nil, err
}
defer db.Close()
var user User
err = db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id).
Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
log.Printf("query error: %v", err)
return nil, err
}
return &user, nil
}
This code has several problems. The database connection is created on every call, making it inefficient. The connection string is hardcoded, making it inflexible. The logger uses the standard library’s global logger, making it impossible to control in tests. Most critically, you cannot test this code without a real PostgreSQL database running.
Constructor Injection Pattern
The idiomatic Go solution is constructor injection. Create a constructor function that accepts dependencies as parameters and returns a fully initialized struct:
package user
import (
"database/sql"
)
type Logger interface {
Printf(format string, v ...interface{})
}
type UserService struct {
db *sql.DB
logger Logger
}
func NewUserService(db *sql.DB, logger Logger) *UserService {
return &UserService{
db: db,
logger: logger,
}
}
func (s *UserService) GetUser(id int) (*User, error) {
var user User
err := s.db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id).
Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
s.logger.Printf("failed to get user %d: %v", id, err)
return nil, err
}
return &user, nil
}
Now the dependencies are injected through the constructor. The service doesn’t care where the database connection comes from or how the logger is implemented. This simple change makes the code testable and reusable in different contexts.
Interface-Based Dependency Injection
The real power comes from designing around interfaces. Instead of depending on concrete types, depend on behavior contracts:
package user
type Database interface {
QueryRow(query string, args ...interface{}) Row
}
type Row interface {
Scan(dest ...interface{}) error
}
type Logger interface {
Printf(format string, v ...interface{})
}
type Cache interface {
Get(key string) (interface{}, bool)
Set(key string, value interface{})
}
type UserService struct {
db Database
logger Logger
cache Cache
}
func NewUserService(db Database, logger Logger, cache Cache) *UserService {
return &UserService{
db: db,
logger: logger,
cache: cache,
}
}
Now you can provide different implementations for different contexts. For production, use the real database:
type PostgresDB struct {
*sql.DB
}
func (p *PostgresDB) QueryRow(query string, args ...interface{}) Row {
return p.DB.QueryRow(query, args...)
}
For testing, use a mock:
type MockDB struct {
QueryRowFunc func(query string, args ...interface{}) Row
}
func (m *MockDB) QueryRow(query string, args ...interface{}) Row {
return m.QueryRowFunc(query, args...)
}
The UserService doesn’t know or care which implementation it receives. This is Go’s structural typing at its best—any type that implements the required methods satisfies the interface.
Wire: Google’s Code Generation Tool
For larger applications with many dependencies, manually wiring everything becomes tedious and error-prone. Google’s Wire generates the wiring code at compile time:
// wire.go
//go:build wireinject
package main
import (
"github.com/google/wire"
)
func InitializeUserService() (*user.UserService, error) {
wire.Build(
ProvideDatabase,
ProvideLogger,
ProvideCache,
user.NewUserService,
)
return nil, nil
}
Define provider functions that create your dependencies:
// providers.go
package main
import (
"database/sql"
"log"
"os"
)
func ProvideDatabase() (*sql.DB, error) {
return sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
func ProvideLogger() Logger {
return log.New(os.Stdout, "[USER] ", log.LstdFlags)
}
func ProvideCache() Cache {
return NewRedisCache(os.Getenv("REDIS_URL"))
}
Run wire to generate the injector code. Wire analyzes your provider functions, determines the dependency graph, and generates type-safe initialization code. Unlike runtime DI frameworks, Wire catches wiring errors at compile time and produces zero-overhead code.
Practical Patterns and Best Practices
For optional dependencies, use the functional options pattern:
type UserServiceOption func(*UserService)
func WithCache(cache Cache) UserServiceOption {
return func(s *UserService) {
s.cache = cache
}
}
func NewUserService(db Database, logger Logger, opts ...UserServiceOption) *UserService {
s := &UserService{
db: db,
logger: logger,
cache: nil, // optional, may be nil
}
for _, opt := range opts {
opt(s)
}
return s
}
// Usage
service := NewUserService(db, logger, WithCache(redisCache))
Avoid global state. Instead of package-level variables, pass configuration through constructors:
type Config struct {
MaxRetries int
Timeout time.Duration
}
func NewUserService(db Database, logger Logger, cfg Config) *UserService {
return &UserService{
db: db,
logger: logger,
maxRetries: cfg.MaxRetries,
timeout: cfg.Timeout,
}
}
Don’t overuse dependency injection. Not everything needs to be injected. Simple value objects, pure functions, and stateless utilities don’t benefit from DI. Use it when you need flexibility, testability, or when dealing with external resources like databases, HTTP clients, or file systems.
Testing with Dependency Injection
Dependency injection makes testing straightforward. Here’s a complete unit test using mocks:
package user_test
import (
"errors"
"testing"
)
type MockLogger struct {
messages []string
}
func (m *MockLogger) Printf(format string, v ...interface{}) {
m.messages = append(m.messages, fmt.Sprintf(format, v...))
}
type MockRow struct {
scanFunc func(dest ...interface{}) error
}
func (m *MockRow) Scan(dest ...interface{}) error {
return m.scanFunc(dest...)
}
type MockDB struct {
queryRowFunc func(query string, args ...interface{}) Row
}
func (m *MockDB) QueryRow(query string, args ...interface{}) Row {
return m.queryRowFunc(query, args...)
}
func TestUserService_GetUser(t *testing.T) {
logger := &MockLogger{}
db := &MockDB{
queryRowFunc: func(query string, args ...interface{}) Row {
return &MockRow{
scanFunc: func(dest ...interface{}) error {
// Simulate successful database response
id := dest[0].(*int)
name := dest[1].(*string)
email := dest[2].(*string)
*id = 1
*name = "John Doe"
*email = "john@example.com"
return nil
},
}
},
}
service := user.NewUserService(db, logger, nil)
user, err := service.GetUser(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "John Doe" {
t.Errorf("expected name 'John Doe', got '%s'", user.Name)
}
}
func TestUserService_GetUser_Error(t *testing.T) {
logger := &MockLogger{}
db := &MockDB{
queryRowFunc: func(query string, args ...interface{}) Row {
return &MockRow{
scanFunc: func(dest ...interface{}) error {
return errors.New("connection failed")
},
}
},
}
service := user.NewUserService(db, logger, nil)
_, err := service.GetUser(1)
if err == nil {
t.Fatal("expected error, got nil")
}
if len(logger.messages) == 0 {
t.Error("expected error to be logged")
}
}
No database required, no complex setup, just pure unit tests that run in milliseconds. The mocks give you complete control over the behavior, letting you test error conditions, edge cases, and success paths with equal ease.
Dependency injection in Go is about simplicity and pragmatism. Use constructor injection with interfaces as your default approach, reach for Wire when manual wiring becomes unwieldy, and always design with testing in mind. Your future self will thank you.