The unit test took 4 seconds. Not because the logic was complex — because the service sent a real email on every call. The constructor instantiated the SMTP client internally. There was no way to replace it in tests without modifying production code.
That's the hidden dependency. It's invisible in the function signature, it doesn't crash at runtime — but it makes code impossible to test cleanly and impossible to evolve without side effects.
The hidden dependency
Here's what it looks like:
type OrderService struct {
db *sql.DB
}
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
// ... business logic ...
// Dependency instantiated internally ❌
mailer := &SMTPMailer{Host: "smtp.internal", Port: 587}
return mailer.Send(order.CustomerEmail, "Your order is confirmed.")
}
To test PlaceOrder, you send an email. On every CI run, every local test, every refactor. And if you want to switch to SendGrid? You modify PlaceOrder — a function that has nothing to do with the choice of email provider.
The core principle: inject, don't create
The rule is simple: a function or struct shouldn't create its dependencies — it should receive them. That's dependency injection. Not a complicated pattern, not a framework: just passing dependencies as parameters instead of instantiating them internally.
In Go, this almost always goes through an interface, defined on the consumer side (see the article on fit naturally:
type CheckoutOption func(*CheckoutService)
func WithLogger(l *slog.Logger) CheckoutOption {
return func(s *CheckoutService) { s.logger = l }
}
func WithMetrics(m MetricsRecorder) CheckoutOption {
return func(s *CheckoutService) { s.metrics = m }
}
func NewCheckoutService(
orders OrderRepository,
payments PaymentGateway,
opts ...CheckoutOption,
) *CheckoutService {
s := &CheckoutService{
orders: orders,
payments: payments,
logger: slog.Default(),
metrics: &noopMetrics{},
}
for _, o := range opts {
o(s)
}
return s
}
What a DI framework won't do for you
Wire and Dig are valid tools for projects with hundreds of dependencies. But they don't solve the real problem — they automate it. If your UserService depends on 12 things, a DI framework won't tell you that those 12 dependencies might mean UserService is doing too many things.
DI frameworks generate code or use reflection. Both have a cost: reduced readability when debugging, errors caught at init time rather than compile time, and one more abstraction layer between you and what's actually happening.
In Go, for the vast majority of projects, manual wiring in main.go is simpler, more readable, and more maintainable than a framework. When manual wiring becomes painful, it's usually a design signal to address — not a signal to add a tool.
Conclusion
DI in Go isn't a complicated pattern. It's just: pass dependencies as parameters, define interfaces on the consumer side, and concentrate wiring in main.go. Three lines of principle, a few hours of practice for it to become instinctive.
What actually changes when you apply it: tests become fast and side-effect-free, refactors become local, and interfaces start revealing the real structure of the code — which responsibilities are separated, and which are still tangled together.
In the last article of this series, we push reusability one step further with Go generics — for the cases where neither interfaces nor functional options are enough.
SOCIAL SHARE CARD GENERATOR