Le test unitaire prenait 4 secondes. Pas parce que la logique était complexe — parce que le service envoyait un vrai email à chaque appel. Le constructeur instanciait le client SMTP en interne. Il n'y avait aucun moyen de le remplacer dans les tests sans modifier le code de production.
C'est la dépendance cachée. Elle ne se voit pas dans la signature de la fonction, elle ne casse rien au runtime — mais elle rend le code impossible à tester proprement et impossible à faire évoluer sans effets de bord.
La dépendance cachée
Voilà à quoi ça ressemble :
type OrderService struct {
db *sql.DB
}
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
// ... logique métier ...
// Dépendance instanciée en interne ❌
mailer := &SMTPMailer{Host: "smtp.internal", Port: 587}
return mailer.Send(order.CustomerEmail, "Votre commande est confirmée.")
}
Pour tester PlaceOrder, on envoie un email. À chaque run de CI,
à chaque test local, à chaque refacto. Et si on veut passer à SendGrid ?
On modifie PlaceOrder — une fonction qui n'a rien à voir avec
le choix du provider email.
Le fond du principe : injecter, ne pas créer
La règle est simple : une fonction ou une struct ne devrait pas créer ses dépendances — elle devrait les recevoir. C'est ça, la DI. Pas un pattern compliqué, pas un framework : juste passer les dépendances en paramètre plutôt que de les instancier en interne.
En Go, ça passe presque toujours par une interface, définie du côté consommateur (voir l'article sur accept interfaces, return structs) :
// Interface définie dans le package qui en a besoin
type Mailer interface {
Send(to, body string) error
}
type OrderService struct {
db *sql.DB
mailer Mailer // injectée ✅
}
func NewOrderService(db *sql.DB, mailer Mailer) *OrderService {
return &OrderService{db: db, mailer: mailer}
}
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
// ... logique métier ...
return s.mailer.Send(order.CustomerEmail, "Votre commande est confirmée.")
}
OrderService ne sait pas si c'est SMTP, SendGrid ou un fake.
Il sait juste qu'il a quelque chose qui peut envoyer un email.
C'est ça, le découplage.
Tester sans mock framework
On n'a pas besoin de testify/mock pour ça. Un fake inline suffit
dans 90% des cas :
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{"article-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")
}
}
Le test est rapide, déterministe, sans effet de bord réseau. Et si on veut tester
le comportement en cas d'erreur SMTP, on set err: errors.New("SMTP down")
— pas besoin d'un mock framework pour ça.
Wiring dans main.go
Quelqu'un doit bien créer les dépendances concrètes. Ce quelqu'un, c'est
main.go. C'est le seul endroit du programme qui connaît les
implémentations concrètes — tout le reste ne voit que des 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"))
}
C'est le composition root. Toute la complexité du wiring est concentrée
ici, pas dispersée dans chaque package. Si on remplace SMTP par SendGrid,
on change une ligne dans main.go, pas 12 fichiers dans le projet.
Plusieurs dépendances : functional options ou constructeur direct ?
Quand un service a de nombreuses dépendances, deux approches coexistent en Go. Si les dépendances sont toutes obligatoires et en nombre fixe, le constructeur explicite reste la solution la plus lisible :
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,
}
}
Si certaines dépendances sont optionnelles ou peuvent avoir des implémentations par défaut, les functional options s'intègrent naturellement :
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(), // défaut raisonnable
metrics: &noopMetrics{},
}
for _, o := range opts {
o(s)
}
return s
}
Ce qu'un framework de DI ne fait pas à votre place
Wire et Dig sont des outils valides pour des projets avec des centaines de
dépendances. Mais ils ne résolvent pas le vrai problème — ils l'automatisent.
Si votre UserService dépend de 12 choses, un framework de DI
ne vous dira pas que ces 12 dépendances sont peut-être un signal que
UserService fait trop de choses.
Les frameworks de DI génèrent du code ou font de la réflexion. Les deux ont un coût : lisibilité réduite à la débogage, erreurs détectées à l'init plutôt qu'à la compilation, et une couche d'abstraction de plus entre vous et ce qui se passe vraiment.
En Go, pour la majorité des projets, le wiring manuel dans main.go
est plus simple, plus lisible et plus maintenable qu'un framework.
Quand le wiring manuel devient pénible, c'est souvent un signal de design
à résoudre — pas un signal pour ajouter un outil.
Conclusion
La DI en Go n'est pas un pattern compliqué. C'est juste : passer les dépendances
en paramètre, définir des interfaces du côté consommateur, et concentrer
le wiring dans main.go. Trois lignes de principe, quelques heures
de pratique pour que ça devienne un réflexe.
Ce qui change réellement quand on l'applique : les tests deviennent rapides et sans effets de bord, les refactorisations deviennent locales, et les interfaces commencent à révéler la vraie structure du code — quelles responsabilités sont séparées, lesquelles sont encore enchevêtrées.
Dans le dernier article de cette série, on pousse la réutilisabilité un cran plus loin avec les generics Go — pour les cas où ni les interfaces ni les functional options ne suffisent.