Dans le monde du développement, il y a un grand fossé entre écrire du code et écrire du bon code. Tu sais, ce code que tu peux modifier dans 6 mois sans avoir à tout réécrire, ce code qui ne t’oblige pas à passer des heures à le comprendre avant de le débugger.
Et si je te disais qu’il existe une méthode pour structurer ton code de manière à le rendre facilement maintenable, modulaire, et sans prise de tête ? Accueillons sans plus attendre les principes SOLID.
Ces cinq principes, introduits par l'incontournable Robert C. Martin, aka Uncle Bob, montrent comment organiser tes classes et tes méthodes pour qu’elles puissent évoluer sans s’effondrer.
Tu es prêt à devenir un développeur plus SOLID 💪 que jamais ? On y va. 🚀
1. Le * S * ➡️ Single Responsibility Principle
"Une classe ne doit avoir qu'une seule responsabilité."
L'idée est simple : chaque classe doit avoir une seule raison de changer. Cela signifie qu'une classe ne doit s'occuper que d'une seule tâche ou d'une seule fonctionnalité. Si tu mélanges plusieurs responsabilités dans une même classe, tu finiras par complexifier ton code et rendre les modifications plus risquées.
Exemple avec Symfony
Prenons l'exemple d'une classe qui gère à la fois la validation d'un formulaire d'utilisateur et l'envoi d'emails de confirmation. Voici un exemple de ce qu'il ne faut pas faire :
Ici, la classe UserService
viole le principe de responsabilité unique. Elle gère à la fois la création de l'utilisateur, la validation des données, et l'envoi d'un email. Chaque fois que l'une de ces responsabilités évolue, la classe doit être modifiée.
Solution : On divise les responsabilités !
Maintenant, chaque classe a une responsabilité distincte : UserValidator
pour la validation, EmailService
pour l'envoi d'emails et UserService
pour la logique de gestion des utilisateurs. Ton code devient beaucoup plus facile à maintenir !
2. Le * O * ➡️ Open/Closed Principle
"Les entités doivent être ouvertes à l'extension, mais fermées à la modification."
Cela signifie que tu dois pouvoir ajouter de nouvelles fonctionnalités à une classe sans avoir à modifier son code existant.
Pourquoi ? Parce que chaque fois que tu modifies du code existant, tu risques d'introduire des bugs. Mieux vaut ajouter des fonctionnalités que de retoucher le code déjà écrit et testé.
Exemple avec Symfony
Imaginons que tu développes un service de paiement dans une boutique Symfony. Ce service gère différentes méthodes de paiement : carte de crédit, PayPal, etc. Si tu veux ajouter un nouveau mode de paiement (Bitcoin, par exemple), le principe Open/Closed te dit qu'il ne faut pas toucher au code déjà existant pour éviter les régressions.
Voici une mauvaise approche, où l'ajout de nouvelles fonctionnalités nécessite des modifications dans le code existant :
Le problème ici, c'est qu'à chaque fois que tu veux ajouter un nouveau moyen de paiement, tu dois modifier la méthode processPayment
, ce qui viole le principe Open/Closed.
Solution : Utiliser l'héritage et les interfaces
Pour résoudre ce problème et respecter le principe Open/Closed, tu vas utiliser une interface et des classes spécifiques pour chaque type de paiement. Comme ça, tu pourras ajouter de nouvelles méthodes de paiement sans toucher au code existant.
Quelques explications
- Chaque méthode de paiement implémente l'interface
PaymentMethodInterface
, ce qui garantit qu'elles ont toutes la méthode pay(). - Dans chaque classe, on utilise l'attribut
#[AsTaggedItem('payment.method')]
pour dire à Symfony que ces classes représentent des méthodes de paiement. Elles sont toutes taguées sous le même nom,payment.method
, pour être facilement récupérables.
Le service principal PaymentService
Maintenant, notre PaymentService
doit savoir quelle méthode de paiement utiliser, celle qui a été sélectionnée par l'utilisateur par exemple. Voici comment créer le PaymentService
pour qu'il prenne en compte la méthode de paiement envoyée par le contrôleur.
Dans ton contrôleur, tu vas récupérer la méthode de paiement depuis la requête (via un query parameter par exemple) et la passer au PaymentService
. Ton controller pourrait ressembler à ça :
Il est important que chaque méthode soit taguée correctement pour être injectée dans le PaymentService
grâce au TaggedIterator
.
En résumé, l'itérable $paymentMethods
dans le PaymentService
permet de récupérer dynamiquement toutes les méthodes de paiement disponibles, et le contrôleur passe cette information en fonction de la requête. Le service choisit ensuite la bonne méthode en fonction de la requête et procède au paiement.
Cette approche respecte le principe Open/Closed tout en exploitant les fonctionnalités modernes de Symfony pour rendre le code plus flexible et extensible.
3. Le * L * ➡️ Liskov Substitution Principle
"Les objets d'une classe dérivée doivent pouvoir remplacer les objets de leur classe mère sans altérer le bon fonctionnement du programme."
Ce principe peut paraître un peu complexe à première vue, mais il est essentiel pour assurer la cohérence de ton code. En résumé, une classe fille doit pouvoir être utilisée partout où une classe mère est attendue, sans briser le comportement du programme.
Exemple avec Symfony
Prenons une classe Article
et une classe FeaturedArticle
, qui hérite de Article
. Si FeaturedArticle
ne respecte pas le contrat de la classe mère Article
, tu risques d’avoir des problèmes lors de son utilisation dans certaines parties de ton code.
Voici un cas qui viole le principe de Liskov :
À première vue, ça peut sembler correct, mais si tu passes un FeaturedArticle
dans une logique qui attend un Article
, tu risques d’avoir des résultats inattendus.
Par exemple, imagine un système où les titres sont mis à jour automatiquement :
Le titre sera "Featured: Nouveautés", même si ce n'était pas prévu.
Solution
Pour respecter le principe de Liskov, tu dois faire en sorte que les classes dérivées n'altèrent pas le comportement attendu de la classe mère. Par exemple avec Symfony, si tu as un formulaire de base pour un Article
, et que tu veux étendre son comportement pour un FeaturedArticle
, tu peux étendre le FormType
tout en conservant les propriétés de base.
1. Crée un FormType
pour un article de base :
2. Crée un FormType pour un FeaturedArticle qui hérite de ArticleType :
Dans le contrôleur, utilise ce FormType
pour un article à la une :
Avec cette approche, le principe de Liskov est respecté. Tu peux utiliser FeaturedArticleType
partout où ArticleType
est utilisé, tout en ajoutant des champs spécifiques sans casser le comportement de base.
4. Le * I * ➡️ Interface Segregation Principle
"Une classe ne doit pas être obligée d'implémenter des méthodes qu'elle n'utilisera jamais."
Si tu veux garder ton code flexible, ce principe est primordial. Il dit que si une classe implémente une interface, elle ne doit implémenter que ce dont elle a besoin. Si l'interface est trop générale et oblige une classe à implémenter des méthodes inutiles, elle deviendra plus complexe et difficile à maintenir.
Exemple avec Symfony
Imaginons que tu aies une interface pour un export de données, avec plusieurs types d’export (CSV, JSON, XML). Si tu crées une interface unique pour tout, tu ne respectes pas le principe de ségrégation des interfaces :
Si tu implémentes cette interface dans une classe qui n'a besoin que de JSON, tu te retrouves à devoir définir des méthodes inutiles, comme exportToCSV()
et exportToXML()
.
Solution : Créer des interfaces spécifiques
La solution est de diviser cette interface en plusieurs petites interfaces qui sont spécifiques à chaque besoin :
Maintenant, chaque classe implémente seulement les interfaces dont elle a besoin :
Ton code est plus léger et maintenable, sans méthodes superflues !
5. Le * D * ➡️ Dependency Inversion Principle
"Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Tous deux doivent dépendre d'abstractions."
🔔 Dernier principe ! 🔔
Si tu es arrivé jusque là, je te félicite car ce n'était pas simple !
Alors, qu'est-ce qu'il nous dit ce dernier principe ?
Il nous dit tout simplement de ne pas dépendre de classes concrètes, mais plutôt d’abstractions (des interfaces ou des classes abstraites).
Exemple avec Symfony
Imaginons un service qui envoie des notifications. Une mauvaise approche serait de faire dépendre directement ce service des implémentations concrètes :
Ici, on voit que ce service dépend directement des implémentations concrètes d'email et de SMS (les classes EmailService et SmsService). Si tu veux changer le système d'envoi d'email, il faudra que tu modifies cette classe.
Solution
Une meilleure approche est de faire en sorte que NotificationService
dépende d'une abstraction (une interface), plutôt que d'implémentations concrètes :
Interface pour les services de notification :
Implémentation pour les emails 📧 :
Implémentation pour les SMS 📱 :
Le service NotificationService
dépend maintenant de NotificationInterface
, ce qui te permet de changer facilement l'implémentation sans toucher au service principal.
Maintenant on va injecter dynamiquement ces services dans un NotificationManager
:
Utilisation dans le contrôleur
Dans le contrôleur, on peut choisir dynamiquement le service de notification à utiliser :
- On injecte toutes les implémentations de
NotificationServiceInterface
automatiquement grâce au tag#[AsTaggedItem('notification.service')]
sur chaque service. - Le contrôleur peut choisir dynamiquement quel service utiliser via une query string dans la requête.
Cette solution suit parfaitement le dernier principe SOLID car :
- Le
NotificationManager
ne dépend que de l'interfaceNotificationServiceInterface
, et non des implémentations spécifiques. - Le code est ouvert à l'ajout de nouveaux services (par exemple, une notification par push) sans modifier le
NotificationManager
.
Conclusion
Et voilà ! Tu as maintenant une solide 😉 compréhension des principes SOLID et de leur application dans un projet Symfony. Le but de ces principes est de rendre ton code plus flexible, facile à maintenir, et surtout testable.
Ces concepts peuvent parfois sembler abstraits, mais lorsqu'ils sont appliqués, ils t'offrent un énorme gain en termes de qualité et de durabilité du code !
Top comments (0)