Go Dependency Injection Done Right
Stop reaching for frameworks before you need them — here’s how to wire Go applications cleanly, from main.go to production scale
Go Dependency Injection Done Right

Stop reaching for frameworks before you need them — here’s how to wire Go applications cleanly, from
main.goto production scale
Your Go service just got a new requirement: swap the in-memory cache for Redis. You open the codebase and find redis.NewClient(...) scattered across six different packages, each hardcoding its own connection string. One change means six hunts, six edits, and six potential bugs slipping into production.
This is what life looks like without dependency injection — and it’s more common than you’d think. Not because Go developers don’t care about clean architecture, but because DI in Go carries an unfair reputation for being “Java-style bloat.” The truth is, done right, dependency injection in Go is idiomatic, lightweight, and one of the most powerful tools for writing maintainable services.
Let’s fix that codebase — without importing a framework.
What Dependency Injection Actually Means in Go
Dependency injection (DI) is a design pattern where components receive their dependencies from outside rather than creating them internally. That’s it. No magic, no XML, no annotation soup.
In Go, this maps directly to one familiar pattern: constructor functions.
// ❌ Without DI — tight coupling
type UserService struct{}
func (s *UserService) GetUser(id int) (*User, error) {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL")) // hidden dependency
// ...
}
// ✅ With DI - explicit dependencies
type UserRepository interface {
FindByID(id int) (*User, error)
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
The difference is subtle but profound. In the first version, UserService secretly depends on a database connection and an environment variable — invisible at the call site and impossible to mock in tests. In the second, every dependency is explicit, visible, and replaceable.
Go’s implicit interface satisfaction makes this especially clean. You don’t declare implements UserRepository anywhere. If a type has the right methods, it satisfies the interface automatically. This is DI at its most idiomatic.
The Composition Root: Wire Everything in One Place
The biggest mistake developers make with DI is not using it — it’s spreading the wiring logic throughout the application. Instead, follow the Composition Root pattern: assemble all dependencies exactly once, in main.go
// main.go — the only place you call constructors
func main() {
cfg := config.Load()
db, err := postgres.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
// Build the dependency graph from bottom up
userRepo := repository.NewUserRepository(db)
emailSvc := email.NewSMTPService(cfg.SMTPHost)
userSvc := service.NewUserService(userRepo, emailSvc)
authSvc := service.NewAuthService(userRepo, cfg.JWTSecret)
server := api.NewServer(userSvc, authSvc)
server.Run(cfg.Port)
}
This approach keeps your internal packages completely unaware of how they’re assembled. Swap postgres.Connect for testcontainers in tests, or replace email.NewSMTPService with a mock — the rest of the tree doesn't care.
Rule of thumb: if a constructor calls another constructor, you have a layering violation. Dependencies should flow in one direction — inward toward domain logic, outward toward infrastructure.
The entire wiring is visible in one file, readable top-to-bottom. When a new developer joins your team, this is the file that tells the story of your application.
Go 1.26 Makes Constructor Injection Cleaner
Released in February 2026, Go 1.26 brings a small but welcome change that directly reduces DI boilerplate. The new() built-in now accepts an expression as its operand, so you can initialize pointer-typed optional fields inline:
// Before Go 1.26 — awkward temp variable
timeout := 30 * time.Second
svc := NewService(Options{Timeout: &timeout})
// Go 1.26 - inline, no temp variable needed
svc := NewService(Options{Timeout: new(30 * time.Second)})
This pattern appears constantly in DI-heavy code where structs use pointer fields to distinguish “not set” from zero values — common with config structs, optional service parameters, and protobuf-generated types.
Go 1.26 also enables self-referential generic types, opening the door for type-safe generic provider patterns — useful if you’re building your own lightweight DI layer. And with the new Green Tea GC now on by default, applications that create many small dependency structs at startup see improved allocation performance out of the box.
When to Reach for Wire or Fx
Manual constructor injection scales surprisingly far. For services with under roughly 20 components, a well-organized main.go is readable, compile-time safe, and has zero runtime overhead.
But dependency graphs grow. At some point you’re playing human topological sort across 50+ constructors, and that’s where tooling earns its keep.
Google Wire — Compile-Time Code Generation
📦 Library: github.com/google/wire
📥 go install github.com/google/wire/cmd/wire@latest
Wire generates the wiring boilerplate at compile time. There’s no reflection at runtime — the output is plain Go code you can read and review.
The pattern has two parts: providers (your regular constructors) and a wire injector function (a file Wire reads and replaces with generated code).
// === domain/user.go ===
// Your normal interfaces and structs — no Wire imports here
type UserRepository interface {
FindByID(id int) (*User, error)
Save(user *User) error
}
type UserService struct {
repo UserRepository
email EmailSender
}
func NewUserService(repo UserRepository, email EmailSender) *UserService {
return &UserService{repo: repo, email: email}
}
// === infra/postgres.go ===
import "database/sql"
type PostgresUserRepo struct{ db *sql.DB }
func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
return &PostgresUserRepo{db: db}
}
func (r *PostgresUserRepo) FindByID(id int) (*User, error) { /* ... */ }
func (r *PostgresUserRepo) Save(u *User) error { /* ... */ }
// === wire.go (build tag keeps this out of normal builds) ===
//go:build wireinject
package main
import (
"github.com/google/wire"
"myapp/infra"
"myapp/domain"
)
// ProviderSet groups related providers - reusable across injectors
var AppSet = wire.NewSet(
infra.NewDB, // func(cfg *Config) (*sql.DB, error)
infra.NewPostgresUserRepo, // func(*sql.DB) *PostgresUserRepo
wire.Bind(new(domain.UserRepository), new(*infra.PostgresUserRepo)),
infra.NewSMTPEmailSender, // func(cfg *Config) *SMTPEmailSender
wire.Bind(new(domain.EmailSender), new(*infra.SMTPEmailSender)),
domain.NewUserService,
NewServer,
)
// Wire reads this function and REPLACES it with generated code in wire_gen.go
func InitApp(cfg *Config) (*Server, error) {
wire.Build(AppSet)
return nil, nil // Wire fills this in
}
// === wire_gen.go (auto-generated — do not edit) ===
// This is what Wire produces after running `wire ./...`
func InitApp(cfg *Config) (*Server, error) {
db, err := infra.NewDB(cfg)
if err != nil {
return nil, err
}
postgresUserRepo := infra.NewPostgresUserRepo(db)
smtpEmailSender := infra.NewSMTPEmailSender(cfg)
userService := domain.NewUserService(postgresUserRepo, smtpEmailSender)
server := NewServer(userService)
return server, nil
}
// === main.go ===
func main() {
cfg := config.Load()
server, err := InitApp(cfg) // calls the generated function above
if err != nil {
log.Fatal(err)
}
server.Run(cfg.Port)
}
Run wire ./... whenever you add or change a constructor. The generated wire_gen.go is committed to your repo — reviewable, greppable, and debuggable like any other Go code. If Wire can't resolve the dependency graph (a missing provider, type mismatch, or cycle), it fails at generation time, not at runtime.
Uber Fx — Runtime Dependency Injection with Lifecycle Hooks
📦 Library: github.com/uber-go/fx
📥 go get go.uber.org/fx
Fx uses reflection at runtime to resolve the dependency graph. The real power isn’t just the wiring — it’s the lifecycle management: OnStart and OnStop hooks that give you clean graceful shutdown across all components automatically.
// === infra/db.go ===
import (
"database/sql"
"go.uber.org/fx"
)
// NewDB is a regular constructor - Fx discovers params via reflection
func NewDB(lc fx.Lifecycle, cfg *Config) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return nil, err
}
// Register lifecycle hooks - Fx calls these automatically
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return db.PingContext(ctx)
},
OnStop: func(ctx context.Context) error {
return db.Close() // guaranteed cleanup on shutdown
},
})
return db, nil
}
// === infra/server.go ===
func NewHTTPServer(lc fx.Lifecycle, svc *domain.UserService) *http.Server {
srv := &http.Server{
Addr: ":8080",
Handler: buildRouter(svc),
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go srv.ListenAndServe()
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx) // waits for in-flight requests
},
})
return srv
}
// === module/user.go ===
// fx.Module groups related providers — the "bounded context" unit in Fx apps
import "go.uber.org/fx"
var UserModule = fx.Module("user",
fx.Provide(
infra.NewPostgresUserRepo,
infra.NewSMTPEmailSender,
domain.NewUserService,
),
)
// === main.go ===
import "go.uber.org/fx"
func main() {
app := fx.New(
// Provide global singletons
fx.Provide(config.Load),
fx.Provide(infra.NewDB),
fx.Provide(infra.NewHTTPServer),
// Plug in feature modules
UserModule,
AuthModule,
NotificationModule,
// Fx instantiates *http.Server because it has a lifecycle hook;
// fx.Invoke forces it to be eagerly constructed
fx.Invoke(func(*http.Server) {}),
)
// Blocks until SIGINT/SIGTERM, then runs all OnStop hooks in reverse order
app.Run()
}
The key difference from Wire: you don’t write or commit any generated file. Fx resolves the graph when main() runs. If there's a missing dependency, you find out at startup — not at compile time. This is the trade-off: more runtime flexibility, less compile-time safety.
Fx is the better choice when you need dynamic module registration, plugin-style architecture, or when your team wants lifecycle management (db connections, HTTP servers, message consumers) to “just work” without coordinating shutdown logic manually.
Choosing the Right Tool

Practical Takeaways
1. Start with interfaces, not concrete types. Even if you only have one implementation today, define an interface. It costs nothing and makes testing and future swaps trivial.
2. Keep constructors dumb. A NewXxx function should only assign fields — no validation logic, no network calls, no goroutines. Initialization side effects belong in a separate Start() or Open() method.
3. Accept interfaces, return concrete types. This is idiomatic Go: func NewUserService(repo UserRepository) *UserService. Callers get the full concrete type; internals depend only on the abstraction.
4. Don’t inject what you don’t need. Over-applying DI adds noise. Simple value types, utility structs, and internal helpers don’t need injection. Reserve it for components that cross architectural boundaries — repositories, external services, loggers, caches.
5. Test your wiring. Write a smoke test that calls your composition root with real (or test-double) dependencies. It catches wiring errors before your CI pipeline does — and before your users do.
Conclusion
Dependency injection in Go doesn’t require a framework, a container, or a “Java mindset.” It requires one thing: explicit dependencies flowing through constructors, assembled once in main.go.
Start manual. Grow intentionally. Reach for Wire when your dependency graph becomes genuinely unwieldy — not before. Your future self, debugging a production incident at 2am, will thank you for a codebase where every dependency is visible, swappable, and testable.
Go 1.26’s improvements to new() expressions and generic type semantics make clean DI even more ergonomic in 2026. There's never been a better time to get this right from the start.
What’s your preferred DI approach in Go? Have you found a pattern that works better than constructor injection for your use case? Drop a comment — the discussion is always worth having.
메타데이터
- post_id
- 0b430ed39d0d
- slug
- go-dependency-injection-done-right-0b430ed39d0d
- url
- https://medium.com/@feildrixliemdra/go-dependency-injection-done-right-0b430ed39d0d
- canonical_url
- https://medium.com/@feildrixliemdra/go-dependency-injection-done-right-0b430ed39d0d
- author_url
- https://medium.com/@feildrixliemdra
- status
- ok
- fetched_at
- 2026-06-09 15:37:30