Aller au contenu principal

Concept #004

Design Patterns

L'Injection de Dépendance

#0045 min de lecture
  • architecture
  • design-patterns
  • programmation
L'Injection de Dépendance : commandez, on vous livre !

L'Injection de Dépendance (DI) est l'un de ces concepts qui, une fois compris, change la façon dont on écrit du code pour toujours. C'est la fondation sur laquelle reposent les architectures modulaires, testables et maintenables. Le principe tient en une phrase : ne créez pas vos dépendances, faites-les vous livrer.

Le problème : le couplage fort

Imaginez un service qui envoie des notifications. Instinctivement, on pourrait l'écrire ainsi :

class OrderService {
  private notifier: EmailNotifier;
 
  constructor() {
    // Le service crée lui-même sa dépendance
    this.notifier = new EmailNotifier();
  }
 
  confirmOrder(orderId: string) {
    // ... logique métier
    this.notifier.send(`Commande ${orderId} confirmée.`);
  }
}

Ce code a l'air simple, mais il souffre d'un problème majeur : le couplage fort. OrderService est soudé à EmailNotifier. Conséquences directes :

  • Impossible à tester : pour tester OrderService, on est forcé d'utiliser le vrai EmailNotifier, qui va tenter d'envoyer de vrais emails.
  • Impossible à faire évoluer : si demain on veut envoyer des SMS à la place, il faut modifier OrderService. Ce n'est pas son rôle.
  • Impossible à réutiliser : le service est lié à un contexte technique précis.

Le principe de l'Injection de Dépendance

La DI résout ce problème en inversant la responsabilité. Au lieu que la classe crée ses dépendances, elle les reçoit de l'extérieur. C'est le principe du "Commander et se faire livrer" : vous passez votre commande (déclarez ce dont vous avez besoin), et quelqu'un d'autre s'occupe de la livraison.

Cet "autre", c'est souvent un conteneur d'injection (IoC Container) ou simplement le code appelant.

Les trois types d'injection

1. L'injection par constructeur (Constructor Injection)

C'est la forme la plus courante et la plus recommandée. Les dépendances sont passées au moment de la création de l'objet.

interface Notifier {
  send(message: string): void;
}
 
class OrderService {
  // Le service déclare ce dont il a besoin, sans savoir ce qu'il recevra
  constructor(private readonly notifier: Notifier) {}
 
  confirmOrder(orderId: string) {
    // ... logique métier
    this.notifier.send(`Commande ${orderId} confirmée.`);
  }
}

Les dépendances sont explicites, obligatoires, et l'objet est toujours dans un état valide dès sa création.

2. L'injection par setter (Setter Injection)

Les dépendances sont fournies après la construction, via des méthodes dédiées.

class OrderService {
  private notifier: Notifier | null = null;
 
  setNotifier(notifier: Notifier) {
    this.notifier = notifier;
  }
 
  confirmOrder(orderId: string) {
    if (!this.notifier) throw new Error("Notifier non configuré !");
    this.notifier.send(`Commande ${orderId} confirmée.`);
  }
}

Utile pour les dépendances optionnelles, mais l'objet peut se retrouver dans un état invalide si le setter n'est pas appelé. A utiliser avec prudence.

3. L'injection par interface (Interface Injection)

La dépendance est fournie via l'implémentation d'une interface. Moins courant en TypeScript, mais le principe consiste à ce que la classe implémente une interface qui lui permet de recevoir sa dépendance via une méthode injectée.

Exemple concret : un service de notification flexible

Voici la puissance de la DI en action. On définit un contrat (l'interface), et autant d'implémentations que nécessaire :

// Le contrat : ce que tout notifier doit savoir faire
interface Notifier {
  send(to: string, message: string): Promise<void>;
}
 
// Implémentation pour la production : emails réels
class EmailNotifier implements Notifier {
  async send(to: string, message: string) {
    console.log(`[EMAIL] -> ${to} : ${message}`);
    // appel à un vrai service d'emailing...
  }
}
 
// Implémentation pour la production : SMS
class SmsNotifier implements Notifier {
  async send(to: string, message: string) {
    console.log(`[SMS] -> ${to} : ${message}`);
    // appel à Twilio ou autre...
  }
}
 
// Implémentation pour les tests : ne fait rien (ou log en mémoire)
class MockNotifier implements Notifier {
  public sentMessages: Array<{ to: string; message: string }> = [];
 
  async send(to: string, message: string) {
    this.sentMessages.push({ to, message });
  }
}
 
// Le service métier, complètement indépendant du canal de notification
class OrderService {
  constructor(private readonly notifier: Notifier) {}
 
  async confirmOrder(orderId: string, customerEmail: string) {
    // ... logique métier (vérification stock, mise à jour BDD, etc.)
    await this.notifier.send(customerEmail, `Commande #${orderId} confirmée !`);
  }
}
 
// --- En production ---
const emailService = new OrderService(new EmailNotifier());
emailService.confirmOrder("42", "client@example.com");
 
// --- En test ---
const mockNotifier = new MockNotifier();
const testService = new OrderService(mockNotifier);
await testService.confirmOrder("42", "client@example.com");
console.log(mockNotifier.sentMessages); // On peut vérifier sans envoyer de vrais emails

OrderService ne sait pas et ne veut pas savoir si c'est un email, un SMS ou un mock qui sera utilisé. Il fait confiance au contrat.

Le lien avec SOLID : le principe D

L'Injection de Dépendance est l'application concrète du "D" de SOLID : le Dependency Inversion Principle (Principe d'Inversion de Dépendance).

Ce principe stipule :

Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions.

En pratique :

  • Sans DI : OrderService (haut niveau) depend de EmailNotifier (bas niveau). Le concret dépend du concret.
  • Avec DI : OrderService dépend de Notifier (abstraction). EmailNotifier dépend aussi de Notifier. Les deux pointent vers l'abstraction.

C'est ce renversement de la flèche de dépendance qui donne son nom au principe, et c'est la DI qui le rend possible au quotidien.

Quand l'utiliser ?

La DI est pertinente dès qu'une classe a besoin d'un service externe : base de données, API, système de fichiers, envoi de messages, horloge système, logger... En résumé : tout ce qui n'est pas de la logique métier pure.

En revanche, inutile de sur-architecturer. Un simple objet Date ou une constante de configuration n'a pas besoin d'être injectée. L'objectif est de rendre testable et remplaçable ce qui doit l'être.

Les frameworks modernes (NestJS, Angular, Spring) embarquent des conteneurs d'injection qui automatisent la résolution et l'instanciation des dépendances. Mais comprendre le mécanisme sous-jacent reste indispensable pour ne pas subir le framework, mais le maîtriser.

L'Injection de Dépendance n'est pas une contrainte : c'est la clé qui ouvre la porte à des tests unitaires rapides, des architectures évolutives et un code qui obéit au principe de responsabilité unique.