📖 Cet article fait partie de la série sur le thème SOLID
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.
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.
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.