Dependency Injection in Go Without a Framework
TL;DR — Go DI is just constructor functions returning structs with their dependencies. Pass interfaces, return concrete types. No reflection, no annotations, no container. For >50 components,
google/wirecodegens the wiring. Below that, manual main.go is fine.
After the Go layout, the question of how to wire it up. Go developers from Java/Spring or PHP/Laravel reach for a “DI container.” Don’t. Go doesn’t need one. This post is what to do instead.
Constructor injection — the whole pattern
A “constructor” in Go is just a function returning a struct. Dependencies are arguments:
type StartSubscription struct {
repo SubscriptionRepository
pub EventPublisher
now func() time.Time
}
func NewStartSubscription(repo SubscriptionRepository, pub EventPublisher, now func() time.Time) *StartSubscription {
return &StartSubscription{repo: repo, pub: pub, now: now}
}
The struct is unexported fields; the constructor is the only way to make a valid one. Callers can’t bypass it.
The arguments are interfaces (SubscriptionRepository) or function types (func() time.Time). Concrete implementations are passed in by main.go. The struct itself is concrete (*StartSubscription), not an interface — return concrete, accept interface.
Why interfaces only at consumers
Three reasons:
- Smaller interfaces. Each consumer defines what it actually uses. The Postgres struct might have 30 methods; the use case interface declares the 2 it needs.
- No tight coupling. Adapter packages don’t need to know about every consumer.
- Easier to test. Mocking a 2-method interface is trivial.
This is “interface segregation” in Go-native form. Consumers define ports; providers fulfill them by happenstance of structural typing.
Manual wiring in main.go
For 10-20 components, just write it:
func main() {
cfg := mustLoadConfig()
pool := mustOpenPostgres(cfg.DBURL)
redis := mustOpenRedis(cfg.RedisURL)
// adapters
subRepo := postgres.NewSubscriptionRepository(pool)
custRepo := postgres.NewCustomerRepository(pool)
pub := kafka.NewPublisher(cfg.KafkaBrokers)
stripe := stripe.NewClient(cfg.StripeKey)
cache := redisCache.New(redis)
// use cases
now := time.Now
startSub := usecase.NewStartSubscription(subRepo, custRepo, pub, now)
cancelSub := usecase.NewCancelSubscription(subRepo, pub, now)
refund := usecase.NewRefund(subRepo, stripe, pub, now)
listInvoices := usecase.NewListInvoices(subRepo, cache)
// HTTP
router := httpadapter.NewRouter(startSub, cancelSub, refund, listInvoices)
// run
srv := &http.Server{Addr: cfg.Addr, Handler: router}
runWithGracefulShutdown(srv)
}
100 lines max. The whole dependency graph is visible at a glance. New developer reads main.go top to bottom; understands the service in 5 minutes.
When manual wire breaks down
At ~50+ components, manual wiring becomes tedious and error-prone. You’ll forget to pass a dep; you’ll wire it in the wrong order. That’s when codegen helps.
google/wire generates the wiring at build time:
//go:build wireinject
func InitializeApp(cfg Config) (*App, error) {
wire.Build(
postgres.NewSubscriptionRepository,
postgres.NewCustomerRepository,
kafka.NewPublisher,
usecase.NewStartSubscription,
usecase.NewCancelSubscription,
httpadapter.NewRouter,
wire.Struct(new(App), "*"),
)
return nil, nil
}
Run wire ./.... Wire reads the constructors, figures out the dependency graph, generates a wire_gen.go with the manual wiring code. You commit both.
Benefits:
- Compile-time errors if a dep is missing
- No runtime reflection
- Generated code is readable; debugger steps through it
When to introduce wire:
- 50+ constructors
- You’ve already had a “forgot to pass
now” bug at runtime - New team members keep getting wiring wrong
Most services I write never hit this threshold.
What NOT to use
Reflection-based DI containers (uber/fx, dig). Add runtime overhead, hide the dependency graph, debugging is opaque. Avoid unless you have a specific reason.
Service locator patterns. Singleton registry that things look themselves up in. Defeats explicit dependencies; hides what’s needed.
Global variables. “Just put the DB pool in a package-level var” — works until you need a second DB pool, until you need to test with a fake, until you need to lazy-init. Pass explicitly.
Testing with constructor injection
The payoff:
func TestStartSubscription_HappyPath(t *testing.T) {
fakeRepo := &fakeRepo{}
fakePub := &fakePub{}
fixedNow := func() time.Time { return time.Date(2022, 6, 1, 0, 0, 0, 0, time.UTC) }
uc := usecase.NewStartSubscription(fakeRepo, fakePub, fixedNow)
sub, err := uc.Execute(context.Background(), usecase.StartSubscriptionInput{
CustomerID: uuid.New(),
PlanID: "pro_monthly",
})
require.NoError(t, err)
require.Equal(t, domain.StatusActive, sub.Status)
require.Equal(t, 1, fakeRepo.saveCount)
require.Equal(t, 1, fakePub.publishCount)
}
fakeRepo and fakePub are 30-line structs in _test.go. No mock framework needed. Fast, debuggable, clear.
For when you need behaviour-rich mocks, stretchr/testify/mock is fine. But hand-rolled fakes are usually clearer.
Common Pitfalls
Passing concrete types instead of interfaces. Use case takes *postgres.SubscriptionRepository; you can’t swap for tests. Always accept interface.
Returning interface from constructors. func New() Storage — caller can’t extend the type. Return concrete: func New() *PostgresStorage. Callers can hold as Storage if they want.
Initialization order bugs. “X needs Y but Y isn’t constructed yet.” Sequence main.go to build deps before consumers. Compiler can’t help here, but the linear top-to-bottom wiring makes it obvious.
Hiding wiring in init() functions. init() runs at import time, magic. Wiring should be explicit in main.
Mocking everything. Some real types are cheap to use directly: time.Now as a function, in-memory implementations of small interfaces. Don’t mock for the sake of mocking.
Wrapping Up
DI in Go is constructor functions + interfaces + main.go wiring. No framework. For very large services, codegen via wire. For everything else, manual is clearer. Friday: domain entities in Go — what goes inside the structs being wired up.