Cet article clôt la série sur les principes SOLID, avec le dernier d'entre eux, et non des moindres, le DIP : Dependency Inversion Principle.

C'est possiblement le plus important des 5 principes et forme, avec le le Single Responsibility Principle et le Liskov substitution principle, les bases de notre pattern architectural préféré à Gojob : la Clean Architecture.

📚 Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau, mais plutôt des abstractions. En d'autres termes, les détails de l'implémentation doivent dépendre des abstractions, et non l'inverse.

Modules de haut niveau et de bas niveau 🤔 ?

Modules de haut niveau : Ces modules sont généralement ceux qui contiennent la logique métier ou l'orchestration de l'application. Ils sont souvent responsables de la coordination des tâches complexes et de l'application des règles métier.

Modules de bas niveau : Ces modules sont ceux qui fournissent des fonctionnalités spécifiques et des détails d'implémentation. Ils peuvent inclure des classes pour accéder aux bases de données, des services externes, des frameworks, etc.

En appliquant le DIP, on évite que les modules de haut niveau ne dépendent directement des modules de bas niveau: au lieu de cela, ils dépendent d'abstractions (interfaces ou classes abstraites) représentant les fonctionnalités nécessaires fournies par les modules de bas niveau.

Un schema représentant le dip

Cela signifie que les détails spécifiques d'implémentation sont relégués aux modules de bas niveau, tandis que les modules de haut niveau interagissent uniquement avec des abstractions.

Comme d'habitude avec les principes SOLID, le but ici est de réduire le couplage entre les différentes parties de notre application, tout en rendant le système plus flexible, car les détails d'implémentation peuvent être changés sans affecter les modules de haut niveau, tant que les abstractions restent les mêmes.

bob wants you to invert your deps

Un exemple de violation du DIP

Supposons que nous ayons une classe NotificationService qui envoie des notifications par e-mail :

class EmailService {
  sendEmail(to: string, subject: string, message: string) {
    // intense black magic 🧙🪄
  }
}
 
class NotificationService {
  private emailService: EmailService;
 
  constructor() {
    this.emailService = new EmailService();
  }
 
  sendNotification(user: User, message: string) {
    const userEmail = user.getEmail();
    this.emailService.sendEmail(userEmail, 'Notification', message);
  }
}

Dans cet exemple, la classe NotificationService dépend directement de la classe EmailService.

Les deux classes sont fortement couplées, et le NotificationService (notre module de haut niveau) dépend directement du module de bas niveau qu'est l'EmailService.

Autrement dit, nous sommes donc dans le cas d'une violation flagrante (🚨👮✋) du principe DIP car NotificationService dépend d'une implémentation concrète plutôt que d'une abstraction.

Quel est le problème avec cet exemple ? 🤔

Cet exemple de code pose plusieurs problèmes, les 3 principaux étant :

Rigidité : En dépendant directement de la classe concrète EmailService, la classe NotificationService est fortement couplée à cette implémentation spécifique. Cela signifie que si vous souhaitez changer la manière dont les e-mails sont envoyés (par exemple, en utilisant un provider d'e-mail différent ou en ajoutant un mécanisme de journalisation), vous devez forcément modifier la classe NotificationService, ce qui peut entraîner des répercussions imprévues dans tout le système.

Difficulté de test : Puisque la classe NotificationService dépend directement de EmailService, il devient difficile de la tester de manière isolée. Vous ne pouvez pas facilement remplacer EmailService par une doublure de test pour simuler le comportement d'envoi d'e-mail.

Encapsulation des dépendances : L'instanciation directe des dépendances à l'intérieur d'une classe rompt le principe d'encapsulation. Une classe devrait normalement être responsable de son propre comportement et ne pas avoir à se soucier de la manière dont ses dépendances sont créées. En injectant les dépendances, vous séparez la création des dépendances de leur utilisation, ce qui améliore la modularité et la maintenabilité du code.

Comment mieux faire ?

Application du DIP

Pour remédier aux problèmes évoqués précedemment en respectant le DIP, nous pouvons introduire une abstraction pour EmailService (ou toute autre implémentation):

interface NotificationSender {
  send(to: string, subject: string, message: string): void;
}
 
class EmailService implements NotificationSender {
  send(to: string, subject: string, message: string) {
    // intense black magic 🧙🪄
  }
}
 
class NotificationService {
  private readonly notificationSender: NotificationSender;
 
  constructor(sender: NotificationSender) {
    // la dépendance est injectée
    this.notificationSender = sender;
  }
 
  sendNotification(user: User, message: string) {
    const userEmail = user.getEmail();
    this.notificationSender.send(userEmail, 'Notification', message);
  }
}

Maintenant, NotificationService dépend de l'abstraction NotificationSender plutôt que de l'implémentation concrète EmailService.

Notre code est plus flexible et cela facilite le remplacement de EmailService par une autre implémentation, par exemple, une implémentation qui envoie des notifications par SMS ou par push notification.

Conclusion

Le Dependency Inversion Principle est un élément essentiel de conception logicielle. En favorisant la dépendance vers des abstractions plutôt que des implémentations concrètes, il offre une flexibilité et une extensibilité accrues à travers le découplage des composants. Cette approche permet de réduire la complexité, facilite la maintenance et favorise la réutilisation du code.