Structurer ses classes avec les principes SOLID
Bonnes pratiques de conception: Introduction à SOLID
Le principe
SOLID est essentiel car il agit comme une boussole pour les développeurs lorsqu’ils conçoivent des applications orientées objet.
Dans un projet logiciel, le code évolue constamment (nouvelles fonctionnalités, corrections de bugs, adaptations aux besoins des utilisateurs...). Sans règles de conception, chaque modification risque de casser d’autres parties du système ou de rendre le code illisible. SOLID apporte une structure qui favorise la clarté, la réutilisabilité et la flexibilité. En effet, chaque classe a un rôle précis, les extensions se font sans toucher au cœur existant, les sous‑classes restent cohérentes, les interfaces sont adaptées aux besoins réels et les dépendances reposent sur des abstractions plutôt que sur des implémentations figées.
En pratique, cela signifie moins de bugs, un code plus facile à tester, et une architecture capable de grandir sans s’effondrer sous son propre poids. Autrement dit, SOLID est la garantie que le code PHP ne sera pas seulement fonctionnel aujourd’hui, mais aussi durable demain.
Single Responsibility Principle (SRP)
Le
Single Responsibility Principle (SRP) stipule qu’une classe doit avoir une seule raison de changer. Autrement dit, elle doit se concentrer sur une seule responsabilité. Si une classe combine plusieurs rôles (par exemple gérer les données utilisateur et envoyer des e‑mails), toute modification dans l’un de ces rôles risque de perturber l’autre. Cela crée du couplage inutile et rend le code plus difficile à maintenir.
A titre d'exemple, ce code illustre une mauvaise pratique de développement:
class User {
public function __construct(public string $name, public string $email) {}
public function sendWelcomeEmail() {
// Code d'envoi d'email
}
}
Ce code, par contre, illustre bien le principe SRP (ce qui est recommandé):
class User {
public function __construct(public string $name, public string $email) { }
}
class UserMailer {
public function sendWelcomeEmail(User $user) {
// Code d'envoi d'email
}
}
Donc, appliquer le SRP permet de construire des classes simples, cohérentes et faciles à faire évoluer, ce qui est la base d’un code orienté objet bien conçu.
Open/Closed Principle (OCP)
Le principe Ouvert/Fermé, ou
Open/Closed Principle (OCP), stipule qu’une classe doit être ouverte à l’extension, c’est‑à‑dire qu’on peut lui ajouter de nouvelles fonctionnalités, mais fermée à la modification, ce qui signifie que son code interne ne doit plus être changé une fois qu’elle est validée et utilisée.
Cela permet d’éviter de casser du code existant lorsqu’on ajoute de nouvelles fonctionnalités. En pratique, on utilise l’héritage ou les interfaces pour étendre le comportement sans toucher au cœur du système:
interface PaymentMethod {
public function pay(float $amount);
}
class CreditCardPayment implements PaymentMethod {
public function pay(float $amount) { }
}
class PayPalPayment implements PaymentMethod {
public function pay(float $amount) { }
}
En résumé, le principe OCP permet de construire des systèmes PHP qui grandissent sans se fragiliser, en séparant les extensions des fondations.
Liskov Substitution Principle (LSP)
Le
Liskov Substitution Principle stipule qu’une sous‑classe doit pouvoir remplacer sa classe parente sans altérer le comportement attendu du programme. Autrement dit, si une classe B hérite de A, alors tout code qui utilise A doit fonctionner correctement avec B sans nécessiter de modification.
Ce principe garantit la cohérence et la prévisibilité du code et les sous‑classes doivent respecter le contrat défini par la classe parente.
Prenons l’exemple classique du Rectangle et du Carré:
class Rectangle {
protected int $width;
protected int $height;
public function setWidth(int $w) {
$this->width = $w;
}
public function setHeight(int $h) {
$this->height = $h;
}
public function getArea(): int {
return $this->width * $this->height;
}
}
Si on crée une sous‑classe Square qui hérite de Rectangle:
class Square extends Rectangle {
public function setWidth(int $w) {
$this->width = $w;
$this->height = $w;
}
public function setHeight(int $h) {
$this->width = $h;
$this->height = $h;
}
}
Le problème est que dans certains cas, le comportement attendu d’un Rectangle (largeur et hauteur indépendantes) est cassé par Square (largeur = hauteur). Le code qui suppose un rectangle générique peut produire des résultats inattendus.
Pour respecter LSP, il vaut mieux ne pas forcer une relation d’héritage qui n’est pas cohérente. On peut utiliser une abstraction commune :
interface Shape {
public function getArea(): int;
}
class Rectangle implements Shape {
public function __construct(private int $width, private int $height) { }
public function getArea(): int {
return $this->width * $this->height;
}
}
class Square implements Shape {
public function __construct(private int $side) { }
public function getArea(): int {
return $this->side * $this->side;
}
}
Ici, Rectangle et Square respectent chacun leur logique propre, mais ils partagent une interface commune Shape. On peut les utiliser de manière interchangeable dans du code qui attend une forme (Shape) sans casser le comportement.
En résumé, le principe LSP rappelle qu’hériter ne suffit pas car il faut que la sous‑classe reste cohérente avec la classe parente. Sinon, mieux vaut utiliser une interface ou une composition.
Interface Segregation Principle (ISP)
Le principe
Interface Segregation Principle (ISP) affirme qu’il vaut mieux créer plusieurs petites interfaces spécialisées plutôt qu’une seule interface générale et lourde.
Une interface trop large oblige les classes qui l’implémentent à définir des méthodes dont elles n’ont pas besoin. Mais, avec des interfaces ciblées, chaque classe implémente uniquement ce qui est pertinent pour elle, ce qui réduit le couplage inutile et améliore la clarté du code.
Exemple d'une interface trop géénrale (mauvaise pratique):
interface Machine {
public function printDocument(string $doc);
public function scanDocument();
public function faxDocument(string $doc);
}
Si une classe implémente Machine mais ne sait pas envoyer de fax, elle est forcée de définir une méthode inutile ou vide.
Version correcte de déclaration des interfaces:
interface Printer {
public function printDocument(string $doc);
}
interface Scanner {
public function scanDocument();
}
interface Fax {
public function faxDocument(string $doc);
}
Puis chaque classe implémente uniquement ce dont elle a besoin:
class MultiFunctionMachine implements Printer, Scanner, Fax {
public function printDocument(string $doc) { }
public function scanDocument() { }
public function faxDocument(string $doc) { }
}
class SimplePrinter implements Printer {
public function printDocument(string $doc) { }
}
Donc, ISP encourage la création d’interfaces ciblées et cohérentes, ce qui rend le code PHP plus simple à maintenir et plus proche des besoins réels.
Dependency Inversion Principle (DIP)
Le
Dependency Inversion Principle (DIP) stipule que les classes doivent dépendre d’abstractions (interfaces) et non de classes concrètes.
- Si une classe dépend directement d’une implémentation précise, elle devient rigide et difficile à adapter.
- En s’appuyant sur une interface, elle reste flexible : on peut changer l’implémentation sans modifier la classe cliente.
En pratique, cela signifie que le haut niveau (la logique métier) ne doit pas dépendre du bas niveau (les détails techniques), mais que les deux doivent dépendre d’une abstraction commune.
Exemple d'une mauvaise pratique:
class FileLogger {
public function log(string $message) { }
}
class UserService {
private FileLogger $logger;
public function __construct() {
$this->logger = new FileLogger();
}
public function createUser(string $name) {
// Création utilisateur
$this->logger->log("Utilisateur $name créé.");
}
}
Le problème est que UserService est couplée à FileLogger. Si demain on veut utiliser une base de données ou un logger distant, il faut modifier UserService.
Place à la bonne pratique maintenant: On introduit une interface abstraite Logger et on injecte l’implémentation via le constructeur:
interface Logger {
public function log(string $message);
}
class FileLogger implements Logger {
public function log(string $message) {
echo "Log dans fichier : $message";
}
}
class DatabaseLogger implements Logger {
public function log(string $message) {
echo "Log dans base de données : $message";
}
}
class UserService {
private Logger $logger;
public function __construct(Logger $logger) {
$this->logger = $logger;
}
public function createUser(string $name) {
// Création utilisateur
$this->logger->log("Utilisateur $name créé.");
}
}
Dans ce cas, UserService dépend uniquement de l’interface Logger et on peut injecter n’importe quel logger (FileLogger, DatabaseLogger, ConsoleLogger, etc.) sans modifier UserService.
En résumé, le DIP permet de découpler la logique métier des détails techniques, garantissant un code PHP plus adaptable, plus testable et plus maintenable.
Bien que PHP soit un langage faiblement typé, depuis PHP7, il est possible de préciser explicitement le type des paramètres et des valeurs de retour. Dans les exemples de cette leçon, nous avons choisi d’expliciter les types afin de rendre le code plus clair, plus sûr et plus facile à maintenir.