Why Dependency Injection?
Let’s be honest: “Dependency Injection” sounds complicated. It’s not. It’s just a tool to solve real problems you face every day.
The Problem You’re Having Right Now
Your Go app started simple:
func main() {
db := NewDatabase()
service := NewUserService(db)
handler := NewHandler(service)
// Easy!
}
Then reality hit:
func main() {
config := LoadConfig()
logger := NewLogger(config.LogLevel)
db := NewDatabase(config.DBUrl, logger)
cache := NewCache(config.RedisUrl, logger)
emailClient := NewEmailClient(config.SMTPHost, logger)
smsClient := NewSMSClient(config.TwilioKey, logger)
userRepo := NewUserRepository(db, cache, logger)
authService := NewAuthService(userRepo, emailClient, logger)
userService := NewUserService(userRepo, authService, logger)
notificationService := NewNotificationService(emailClient, smsClient, logger)
orderRepo := NewOrderRepository(db, cache, logger)
orderService := NewOrderService(orderRepo, userService, notificationService, logger)
handler := NewHandler(userService, orderService, authService, logger)
// 😱 And this is just the beginning...
}
Problem #1: The Constructor Cascade
You need to add a rate limiter to your auth service:
// Before: Update the constructor
func NewAuthService(repo UserRepository, email EmailClient, logger Logger) *AuthService
// After: Now with rate limiter
func NewAuthService(repo UserRepository, email EmailClient, logger Logger, limiter RateLimiter) *AuthService
Without DI: Update every single place that creates AuthService (main.go, tests, integration tests…)
With DI: Update just the constructor. Done.
// Just change the constructor
func NewAuthService(repo UserRepository, email EmailClient, logger Logger, limiter RateLimiter) *AuthService {
return &AuthService{repo, email, logger, limiter}
}
// godi handles injecting the rate limiter everywhere!
Problem #2: Testing Nightmare
Want to test your user service?
Without DI:
func TestUserService(t *testing.T) {
// Set up real database 😱
db := NewDatabase("postgres://test...")
// Set up real cache 😱
cache := NewCache("redis://test...")
// Set up real logger
logger := NewLogger("test.log")
// Finally create service
service := NewUserService(db, cache, logger)
// Test... if the database is running... and Redis... and...
}
With DI:
func TestUserService(t *testing.T) {
// Use test module with mocks
testModule := godi.NewModule("test",
godi.AddSingleton(func() Database { return &MockDB{} }),
godi.AddSingleton(func() Cache { return &MockCache{} }),
godi.AddScoped(NewUserService),
)
provider := BuildProvider(testModule)
service, _ := godi.Resolve[*UserService](provider)
// Test with fast, reliable mocks!
}
Problem #3: Request Isolation
In web apps, each request needs its own context:
Without DI: Pass request context through every function
func (h *Handler) CreateUser(ctx context.Context, w http.ResponseWriter, r *http.Request) {
tx := h.db.BeginTx(ctx)
userID := GetUserID(ctx)
// Pass tx and userID to EVERYTHING
user, err := h.userService.Create(ctx, tx, userID, userData)
h.auditService.Log(ctx, tx, userID, "created user")
h.emailService.SendWelcome(ctx, tx, userID, user.Email)
// Don't forget to commit!
tx.Commit()
}
With DI: Use scopes
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
scope := h.provider.CreateScope(r.Context())
defer scope.Close()
// Services automatically share the same transaction!
userService, _ := godi.Resolve[*UserService](scope)
user, _ := userService.Create(userData)
// Transaction commits when scope closes
}
Problem #4: Environment Differences
Different services for different environments:
Without DI: If/else everywhere
var emailClient EmailClient
if env == "production" {
emailClient = NewSendGridClient(apiKey)
} else if env == "staging" {
emailClient = NewSMTPClient(smtpHost)
} else {
emailClient = NewMockEmailClient()
}
// Repeat for every service... 😭
With DI: Clean modules
// Choose module based on environment
var appModule godi.ModuleOption
switch env {
case "production":
appModule = ProductionModule
case "staging":
appModule = StagingModule
default:
appModule = DevelopmentModule
}
// That's it!
provider := BuildProvider(appModule)
The Real Magic: Examples
Adding Multi-Tenancy
Without DI: Rewrite half your app to pass tenant ID everywhere.
With DI: Add one scoped service:
var TenantModule = godi.NewModule("tenant",
godi.AddScoped(NewTenantContext),
)
// Now every service in that scope has access to the tenant!
Adding Request Tracing
Without DI: Add traceID parameter to 50 functions.
With DI: Add one scoped service:
var TracingModule = godi.NewModule("tracing",
godi.AddScoped(NewTraceContext),
)
// Every log automatically includes trace IDs!
Switching Databases
Without DI: Find and update every place that creates connections.
With DI: Change one module:
// From
var DBModule = godi.NewModule("db",
godi.AddSingleton(NewMySQLDatabase),
)
// To
var DBModule = godi.NewModule("db",
godi.AddSingleton(NewPostgresDatabase),
)
Common Concerns Addressed
“But I like Go’s simplicity!”
godi IS simple:
// 1. Create a module
var AppModule = godi.NewModule("app",
godi.AddSingleton(NewLogger),
godi.AddScoped(NewUserService),
)
// 2. Use it
provider := BuildProvider(AppModule)
service, _ := godi.Resolve[*UserService](provider)
// No magic, no annotations, no reflection abuse
“I don’t want a framework!”
godi isn’t a framework. Your services don’t know about godi:
// This is just a normal Go function
func NewUserService(repo UserRepository, logger Logger) *UserService {
return &UserService{repo: repo, logger: logger}
}
// No imports from godi, no base classes, nothing
“It’s overkill for small apps!”
True! If your app is 200 lines, you don’t need DI. But when you have:
Multiple services (5+)
Any tests
HTTP handlers
Different environments
…DI saves time immediately.
When You Need DI
✅ You need DI when:
Adding a dependency means updating 10+ files
Testing requires complex setup
You have request-scoped data (user context, transactions)
Different environments need different implementations
You’re tired of writing boilerplate
❌ You don’t need DI when:
Your app is a single file
You have no tests
Dependencies never change
It’s a simple CLI tool
The Bottom Line
Dependency injection solves real problems:
Change Management - Update constructors, not callers
Testing - Swap implementations instantly
Request Isolation - Each request gets its own world
Environment Flexibility - Dev/staging/prod made easy
It’s not about being “enterprise” or “sophisticated”. It’s about writing less boilerplate and focusing on your actual business logic.
Ready to try it? Check out the Quick Start - you’ll be productive in 5 minutes.