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 accept interfaces, return structs):
// Interface defined in the package that needs it
type Mailer interface {
Send(to, body string) error
}
type OrderService struct {
db *sql.DB
mailer Mailer // injected ✅
}
func NewOrderService(db *sql.DB, mailer Mailer) *OrderService {
return &OrderService{db: db, mailer: mailer}
}
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
// ... business logic ...
return s.mailer.Send(order.CustomerEmail, "Your order is confirmed.")
}
OrderService doesn't know if it's SMTP, SendGrid, or a fake.
It just knows it has something that can send an email. That's decoupling.
Testing without a mock framework
You don't need testify/mock for this. An inline fake is enough
in 90% of cases:
type fakeMailer struct {
sent []string
err error
}
func (f *fakeMailer) Send(to, body string) error {
f.sent = append(f.sent, to)
return f.err
}
func TestPlaceOrder_SendsConfirmation(t *testing.T) {
db := setupTestDB(t)
mailer := &fakeMailer{}
svc := NewOrderService(db, mailer)
err := svc.PlaceOrder(context.Background(), Order{
CustomerEmail: "alice@example.com",
Items: []string{"item-123"},
})
if err != nil {
t.Fatalf("PlaceOrder: %v", err)
}
if len(mailer.sent) != 1 || mailer.sent[0] != "alice@example.com" {
t.Errorf("expected 1 email to alice, got %v", mailer.sent)
}
}
func TestPlaceOrder_MailerError_DoesNotSave(t *testing.T) {
db := setupTestDB(t)
mailer := &fakeMailer{err: errors.New("SMTP down")}
svc := NewOrderService(db, mailer)
err := svc.PlaceOrder(context.Background(), Order{CustomerEmail: "bob@example.com"})
if err == nil {
t.Fatal("expected error when mailer fails")
}
}
The test is fast, deterministic, no network side effects. And to test the behavior
when SMTP is down, set err: errors.New("SMTP down") — no mock framework
needed.
Wiring in main.go
Someone has to create the concrete dependencies. That someone is main.go.
It's the only place in the program that knows the concrete implementations —
everything else only sees interfaces:
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
mailer := smtp.NewMailer(
smtp.WithHost("smtp.internal"),
smtp.WithPort(587),
smtp.WithTLS(true),
)
orderService := orders.NewOrderService(db, mailer)
paymentService := payments.NewPaymentService(db, stripe.NewClient(os.Getenv("STRIPE_KEY")))
server := api.NewServer(orderService, paymentService)
log.Fatal(server.ListenAndServe(":8080"))
}
This is the composition root. All the wiring complexity is concentrated
here, not scattered across packages. Switch from SMTP to SendGrid?
One line changes in main.go, not 12 files across the project.
Multiple dependencies: explicit constructor or functional options?
When a service has many dependencies, two approaches coexist in Go. If all dependencies are required and their number is fixed, the explicit constructor remains the most readable:
func NewCheckoutService(
orders OrderRepository,
inventory InventoryChecker,
payments PaymentGateway,
mailer Mailer,
logger *slog.Logger,
) *CheckoutService {
return &CheckoutService{
orders: orders, inventory: inventory,
payments: payments, mailer: mailer, logger: logger,
}
}
If some dependencies are optional or can have default implementations, functional options 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.