Aller au contenu principal

Comprendre l'injection de dépendances avec des exemples concrets

L'injection de dépendances est souvent découverte par la mauvaise porte, celle de la configuration d'un framework. Cet article prend le chemin inverse : un code qui coince, le geste qui le débloque en un paramètre, ce que ça change sur les tests, le rôle réel des conteneurs, et les quatre erreurs d'usage qui donnent au principe sa réputation de complication.

Guides & tutoriels ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

L'injection de dépendances arrive rarement au bon moment dans un parcours d'apprentissage. Elle apparaît en général dans la documentation d'un framework, entre une histoire de conteneur et une histoire d'annotations, à un stade où le lecteur cherche surtout à afficher une page qui fonctionne.

Résultat, le terme reste associé à de la plomberie de framework. Alors qu'il désigne un geste de programmation qui tient en une ligne et qui existait bien avant Symfony ou Spring.

Ce décalage explique la plupart des blocages. On apprend le vocabulaire du conteneur avant d'avoir rencontré la douleur que l'injection soulage, et sans cette douleur, la solution ressemble à de la complication gratuite.

Cet article prend le chemin inverse : d'abord le code qui coince, ensuite le geste qui le débloque, puis les conteneurs, et enfin les erreurs qui font abandonner en route. Les exemples sont en JavaScript et en Python, mais le principe se transpose tel quel en PHP, en Java ou en C#.

Un code qui marche et qu'on ne peut pas faire évoluer

Voici une classe qui envoie un email de confirmation après une commande. Elle est écrite de la façon la plus directe qui soit, celle qu'on apprend en premier.

class ServiceCommande {
  validerCommande(commande) {
    const mailer = new MailerGmail()   // fabriqué à l'intérieur
    mailer.envoyer(commande.email, "Commande validée")
  }
}

Ce code fonctionne. Il passe en production. Il rend service pendant des mois. Les ennuis commencent le jour où quelque chose doit changer autour de lui, et ce jour arrive toujours plus tôt qu'on ne l'imagine.

Changer de fournisseur d'envoi impose de rouvrir la classe. Écrire un test impose d'envoyer un vrai email à chaque exécution, ce qui rend le test lent, dépendant du réseau, et franchement dangereux si une adresse de client traîne dans les données de test. Ajouter un mode "brouillon" qui n'envoie rien impose un drapeau conditionnel au milieu de la logique métier.

Le point commun de ces trois blocages tient dans un mot : couplage. La classe ne réclame pas un moyen d'envoyer un email, elle décide que ce sera Gmail. Elle tranche une question qui ne relève pas de sa responsabilité, et elle la tranche définitivement, à l'intérieur d'une méthode.

Multiplié par toutes les classes d'un projet, ce schéma produit une application où chaque fichier décide seul de ses outils. Le remplacement d'une brique transverse, une nouvelle façon d'accéder à la base par exemple, se transforme alors en chantier de plusieurs jours réparti sur des dizaines de fichiers.

Deux morceaux de code sont couplés quand modifier l'un oblige à toucher l'autre. Un couplage fort rend le code rigide : chaque évolution se propage. Un couplage faible laisse remplacer une pièce sans démonter les voisines.

Le geste tient en un paramètre

L'injection de dépendances se résume à une règle : une classe ne fabrique pas les objets dont elle a besoin, elle les reçoit. Le mailer n'est plus créé à l'intérieur, il entre par le constructeur.

class ServiceCommande {
  constructor(mailer) {          // la dépendance entre ici
    this.mailer = mailer
  }
 
  validerCommande(commande) {
    this.mailer.envoyer(commande.email, "Commande validée")
  }
}
 
// Le choix se fait à l'assemblage, hors de la classe
const service = new ServiceCommande(new MailerGmail())

Une ligne a bougé. La classe ignore désormais quel mailer elle manipule, et c'est l'objectif recherché plutôt qu'un effet secondaire. Elle demande un objet capable d'envoyer un email, sans se prononcer sur l'identité du fournisseur.

Le mot injection décrit le fait de faire entrer la dépendance depuis l'extérieur. Le mot dépendance désigne l'objet sans lequel la classe ne peut pas travailler. Une fois traduit, le vocabulaire savant recouvre une opération élémentaire : passer en paramètre au lieu de fabriquer sur place.

Une image qui aide en formation : un appareil photo dont les piles seraient soudées à l'intérieur deviendrait un presse-papier dès qu'elles tombent à plat. Le compartiment à piles, c'est l'injection de dépendances appliquée au matériel.

Le bénéfice qui rend le principe évident : les tests

Sur la première version, tester la validation d'une commande revenait à envoyer un email à chaque exécution du test. Inutilisable en pratique.

Avec la dépendance passée de l'extérieur, le test fournit un faux mailer, un objet qui enregistre ce qu'on lui demande sans rien envoyer nulle part.

class MailerFactice {
  constructor() { this.envois = [] }
  envoyer(dest, sujet) { this.envois.push({ dest, sujet }) }
}
 
const faux = new MailerFactice()
const service = new ServiceCommande(faux)
 
service.validerCommande({ email: "client@exemple.fr" })
 
console.assert(faux.envois.length === 1)
console.assert(faux.envois[0].sujet === "Commande validée")

Le test s'exécute en quelques millisecondes, sans réseau, sans compte de messagerie, sans risque d'écrire à un client réel. Il vérifie ce qui compte : que le service demande bien un envoi, avec le bon destinataire et le bon sujet.

C'est là que le déclic se produit chez la plupart des personnes qui apprennent. Tant que l'injection reste une histoire de propreté du code, l'argument glisse. Quand elle devient la condition pour écrire un test qui tourne en local sans rien casser, la règle s'ancre. Le sujet des tests est développé dans l'article sur les tests et le TDD pour sortir de la peur de toucher au code qui marche.

Trois portes d'entrée pour une dépendance

Une dépendance peut entrer dans une classe de trois manières, et les trois se croisent dans du code réel. Le choix n'est pas cosmétique : il détermine si une dépendance est obligatoire ou facultative, et à quel moment de la vie de l'objet elle devient disponible.

  • Par le constructeur : la dépendance est fournie à la création de l'objet. La plus répandue, parce qu'elle rend la dépendance obligatoire, visible dans la signature, et impossible à oublier.
  • Par un setter : la dépendance arrive après la création, via une méthode dédiée. Adaptée aux dépendances optionnelles ou remplacées en cours de vie de l'objet.
  • Par la méthode : la dépendance est passée directement à la fonction qui l'utilise, pour ce seul appel. Utile quand elle ne sert nulle part ailleurs dans la classe.

Mon avis, et il n'est pas neutre : le constructeur devrait être le choix par défaut, et les deux autres des exceptions justifiées. Un objet dont les dépendances arrivent après coup peut exister dans un état incomplet, et ce genre de fenêtre finit toujours par produire un bug difficile à reproduire. Spring a d'ailleurs fait ce virage officiel en recommandant l'injection par constructeur plutôt que par champ, après des années de tolérance sur les annotations posées directement sur les attributs.

Dépendre d'un contrat plutôt que d'une classe

Le geste peut aller un cran plus loin. Plutôt que d'attendre une classe précise, la classe attend un contrat : une liste de méthodes qu'un objet promet de fournir, sans préciser comment il s'y prend. En programmation orientée objet, ce contrat s'appelle une interface.

from abc import ABC, abstractmethod
 
class Mailer(ABC):
    @abstractmethod
    def envoyer(self, dest: str, sujet: str) -> None: ...
 
class MailerSmtp(Mailer):
    def envoyer(self, dest: str, sujet: str) -> None:
        ...  # envoi réel
 
class ServiceCommande:
    def __init__(self, mailer: Mailer):    # le contrat, pas l'implémentation
        self.mailer = mailer
 
    def valider(self, email: str) -> None:
        self.mailer.envoyer(email, "Commande validée")

Un envoi SMTP, un service transactionnel, un faux mailer de test : tous remplissent le contrat, tous sont acceptés, et la classe ne fait aucune différence entre eux. Cette orientation porte un nom, l'inversion de dépendance, le D des principes SOLID.

Attention à ne pas fusionner les deux notions. L'injection décrit un mode de passage, l'inversion décrit vers quoi on pointe. On peut injecter une classe concrète sans aucune interface, et le code sera déjà plus testable. On peut aussi définir des interfaces partout et continuer à instancier en dur à l'intérieur des classes, ce qui annule le bénéfice.

Pour situer cette règle parmi les autres, LaPolaris publie un hub qui rassemble vingt principes de code maintenable, avec un article dédié par principe.

Et qui fabrique les objets, alors ?

Si chaque classe reçoit ses dépendances, quelqu'un doit bien les créer au sommet. Sur un petit projet, l'assemblage se fait à la main dans le fichier de démarrage : on instancie le mailer, on le passe au service, on branche le service à la route.

La méthode manuelle atteint sa limite quand les dépendances s'emboîtent sur plusieurs niveaux. Le service a besoin d'un mailer, qui a besoin d'une configuration, qui a besoin de lire des variables d'environnement, et il faut remonter cette chaîne à chaque point d'entrée de l'application.

Le conteneur d'injection de dépendances automatise ce câblage. On lui déclare une fois comment fabriquer chaque pièce, il se charge de l'ordre et des branchements. Symfony repose entièrement sur le sien et pratique l'autowiring par défaut depuis la version 3.3, ce qui lui permet de déduire les dépendances à partir des types déclarés dans le constructeur. FastAPI propose son système via Depends, avec la même logique appliquée aux fonctions de route.

Ce confort a un revers pédagogique : quand le framework câble tout seul, on peut écrire du code injecté pendant des mois sans avoir compris ce qui se passe. Écrire l'assemblage à la main sur un petit projet reste le meilleur moyen de voir le mécanisme. Ces conteneurs sont manipulés dans nos parcours backend, en PHP avec la formation Symfony 7, en Java avec la formation Spring Boot pour les API REST, ou en Python avec la formation FastAPI.

Les erreurs qui donnent une mauvaise réputation au principe

Les critiques adressées à l'injection de dépendances visent le plus souvent des usages abusifs plutôt que le principe. Quatre reviennent constamment.

  • Injecter par réflexe, y compris l'inutile
    Une fonction de calcul sans contact avec l'extérieur n'a aucune dépendance à recevoir. Ce qui mérite d'être injecté touche le monde réel : base de données, réseau, système de fichiers, horloge, générateur aléatoire.
  • Un constructeur à dix paramètres
    L'injection ne crée pas ce symptôme, elle le rend visible. Une classe qui réclame huit collaborateurs assume trop de responsabilités et demande à être découpée.
  • Injecter le conteneur lui-même
    Passer le conteneur entier à une classe pour qu'elle pioche dedans la recouple à toute l'application et masque ses vraies dépendances. Ce contre-modèle porte un nom, le service locator, et il annule le bénéfice recherché.
  • Créer une interface pour chaque classe
    Une interface avec une seule implémentation qui n'a aucune chance d'en avoir une deuxième ajoute un fichier et zéro souplesse. L'abstraction se justifie quand une variation existe ou se profile.

Ces erreurs partagent une racine commune : traiter l'injection comme une case à cocher d'architecture au lieu d'un moyen de réduire le couplage. La question à se poser devant chaque dépendance reste la même, va-t-elle changer ou devoir être simulée un jour. La répartition des responsabilités entre objets est aussi le sujet de l'article sur Tell Don't Ask et les objets actifs.

Par où commencer demain

Prends une classe existante de ton projet qui instancie quelque chose à l'intérieur d'une méthode. Sors cette instanciation, passe l'objet par le constructeur, et écris un test avec un remplaçant factice. Une classe, une heure, et le principe devient une habitude plutôt qu'une définition.

Le reste, interfaces, conteneurs, autowiring, viendra quand un besoin le réclamera. Ce sera peut-être dans six mois, ou dès la prochaine fois qu'un test refusera de tourner en local.

Questions fréquentes

Faut-il un framework pour faire de l'injection de dépendances ?

Non. Passer un objet en paramètre d'un constructeur au lieu de l'instancier dans la classe suffit à appliquer le principe. Les frameworks apportent un conteneur qui automatise l'assemblage, utile quand les dépendances s'emboîtent sur plusieurs niveaux, mais le principe fonctionne sans aucune bibliothèque.

Quelle différence entre injection et inversion de dépendance ?

L'injection décrit un mode de passage : la dépendance entre depuis l'extérieur. L'inversion décrit une orientation de conception : la classe pointe vers un contrat abstrait plutôt que vers une implémentation précise. Les deux se combinent souvent, mais chacune s'applique indépendamment de l'autre.

Constructeur, setter ou méthode : lequel choisir ?

Le constructeur en premier choix : la dépendance devient obligatoire, visible dans la signature, et l'objet ne peut jamais exister à moitié construit. Le setter se réserve aux dépendances optionnelles ou remplaçables en cours de route. L'injection par la méthode convient quand une dépendance ne sert qu'à un appel isolé.

Pourquoi l'injection rend-elle les tests plus simples ?

Parce qu'elle permet de substituer une dépendance réelle par un objet factice pendant le test. Au lieu d'envoyer un email ou d'interroger une base, le test fournit un remplaçant qui enregistre les appels reçus. L'exécution devient rapide, isolée du réseau et reproductible. Sans injection, la classe fabrique ses propres dépendances et rien ne peut être substitué.

Faut-il injecter toutes les dépendances d'une application ?

Non, et injecter systématiquement alourdit le code sans contrepartie. Ce qui mérite l'injection touche le monde extérieur : base de données, envois réseau, fichiers, horloge, générateur aléatoire. Une fonction de calcul pure se teste très bien sans rien recevoir de particulier.

Un constructeur avec beaucoup de dépendances, faut-il s'en inquiéter ?

Oui, c'est un signal utile. Une classe qui réclame six à dix collaborateurs assume trop de rôles à elle seule. La réponse consiste à la découper en unités plus ciblées, pas à masquer le symptôme en lui passant le conteneur entier pour qu'elle se serve elle-même.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous