Les principes SOLID

Les principes SOLID



En 2000, Robert C. Martin — surnommé Uncle Bob — formalisait cinq principes de conception orientée objet qui allaient devenir l’un des piliers de l’ingénierie logicielle moderne. Regroupés sous l’acronyme SOLID, ces principes ne sont pas des règles arbitraires : ils sont la réponse directe aux pathologies les plus courantes du code mal conçu — rigidité, fragilité, immobilité, viscosité.

Un code qui viole SOLID est un code qui résiste au changement. Il se brise quand on le modifie, cascade ses effets dans des endroits inattendus, et devient progressivement impossible à tester ou à réutiliser. SOLID est l’antidote.

« La seule façon d’aller vite est d’aller bien. »

— Robert C. Martin, Clean Code

Ces principes s’appliquent principalement à la programmation orientée objet, mais leur esprit transcende les paradigmes. Que vous écriviez en PHP, Python, Java, TypeScript ou C#, SOLID vous offrira un cadre de réflexion pour des décisions d’architecture saines.

Lettre Principe En une phrase
S Single Responsibility Une classe = une raison de changer
O Open / Closed Ouvert à l’extension, fermé à la modification
L Liskov Substitution Une sous-classe doit se substituer à sa classe parente
I Interface Segregation Mieux vaut plusieurs petites interfaces qu’une grande
D Dependency Inversion Dépendre des abstractions, pas des implémentations

S
Premier principe
Single Responsibility Principle
« Une classe ne devrait avoir qu’une seule raison de changer. »

Définition

Le principe de responsabilité unique stipule qu’une classe — ou plus largement, un module, une fonction — doit être responsable d’une seule chose, et ne doit changer que pour une seule raison. Cette « raison de changer » est généralement liée à un acteur spécifique : un utilisateur, un département, un système externe dont les exigences peuvent évoluer.

L’erreur classique est de confondre SRP avec « une classe ne fait qu’une chose ». Une classe peut avoir plusieurs méthodes — l’important est que toutes ces méthodes servent la même responsabilité cohérente. Un UserRepository peut avoir findById(), findByEmail(), save() — toutes ces méthodes servent la même responsabilité : la persistence des utilisateurs.

Pourquoi c’est important

Une classe qui fait trop de choses devient un point de couplage. Quand la logique métier, la persistence, la validation et la notification cohabitent dans la même classe, toute modification touche potentiellement des fonctionnalités sans rapport. Les tests deviennent difficiles, les bugs se propagent, la réutilisation devient impossible.


Anti-pattern : une classe User qui gère à la fois les données utilisateur, l’envoi d’emails ET la génération de rapports PDF. Ces trois responsabilités appartiennent à trois acteurs différents (équipe métier, équipe communication, équipe reporting) et changeront pour des raisons différentes.

Exemple — Violation du SRP

❌ Violation

// Une classe qui fait TROP de choses
class User
{
    private string $name;
    private string $email;

    // Responsabilité 1 : données utilisateur ✓
    public function getName(): string { return $this->name; }
    public function getEmail(): string { return $this->email; }

    // Responsabilité 2 : persistence en base — VIOLATION
    public function save(): void
    {
        $pdo = new PDO('mysql:...');
        $pdo->query("INSERT INTO users ...");
    }

    // Responsabilité 3 : envoi d'email — VIOLATION
    public function sendWelcomeEmail(): void
    {
        mail($this->email, 'Bienvenue', '...');
    }

    // Responsabilité 4 : rapport PDF — VIOLATION
    public function generateReport(): string
    {
        return "<pdf>" . $this->name . "</pdf>";
    }
}

Exemple — Respect du SRP

✓ Conforme

// Responsabilité 1 : entité utilisateur (données uniquement)
class User
{
    public function __construct(
        private readonly string $name,
        private readonly string $email
    ) {}

    public function getName(): string { return $this->name; }
    public function getEmail(): string { return $this->email; }
}

// Responsabilité 2 : persistence
class UserRepository
{
    public function save(User $user): void
    {
        // Toute la logique d'accès à la base de données ici
    }

    public function findByEmail(string $email): ?User
    {
        // ...
    }
}

// Responsabilité 3 : notification
class UserMailer
{
    public function sendWelcome(User $user): void
    {
        // Envoi d'email de bienvenue
    }
}

// Responsabilité 4 : reporting
class UserReportGenerator
{
    public function generate(User $user): string
    {
        // Génération du rapport PDF
    }
}

💡
Bénéfices concrets : chaque classe peut maintenant être testée indépendamment. Changer l’ORM ne touche que UserRepository. Changer le fournisseur d’email ne touche que UserMailer. Zéro effet de bord.


O
Deuxième principe
Open / Closed Principle
« Les entités logicielles doivent être ouvertes à l’extension, mais fermées à la modification. »

Définition

Formulé par Bertrand Meyer en 1988 puis repris par Robert Martin, l’OCP signifie qu’une classe doit pouvoir être étendue sans être modifiée. En pratique : lorsqu’une nouvelle fonctionnalité est requise, on ajoute du code — on n’en modifie pas. La classe existante reste stable, testée, fiable.

Ce principe s’applique via l’héritage, la composition et les interfaces/abstractions. Le code qui dépend de l’abstraction n’a pas à changer quand on ajoute une nouvelle implémentation.

Le problème du switch géant

La violation la plus classique de l’OCP est le switch ou la chaîne de if/elseif qui doit être étendue à chaque nouveau type. Chaque ajout oblige à modifier la classe existante — risque de régression, obligation de re-tester tout.

❌ Violation — switch à étendre indéfiniment

class DiscountCalculator
{
    public function calculate(string $customerType, float $price): float
    {
        // Chaque nouveau type de client = modifier cette méthode
        switch ($customerType) {
            case 'regular':
                return $price;
            case 'vip':
                return $price * 0.85;
            case 'employee':
                return $price * 0.50;
            // Demain : 'partner', 'reseller', 'influencer'... ?
            default:
                return $price;
        }
    }
}

✓ Conforme — abstraction + extension

// Interface fermée à la modification
interface DiscountStrategy
{
    public function apply(float $price): float;
}

// Implémentations ouvertes à l'extension
class RegularDiscount implements DiscountStrategy
{
    public function apply(float $price): float { return $price; }
}

class VipDiscount implements DiscountStrategy
{
    public function apply(float $price): float { return $price * 0.85; }
}

class EmployeeDiscount implements DiscountStrategy
{
    public function apply(float $price): float { return $price * 0.50; }
}

// Demain : nouveau type → AUCUNE modification existante
class PartnerDiscount implements DiscountStrategy
{
    public function apply(float $price): float { return $price * 0.70; }
}

// Le calculateur ne change jamais
class DiscountCalculator
{
    public function __construct(
        private readonly DiscountStrategy $strategy
    ) {}

    public function calculate(float $price): float
    {
        return $this->strategy->apply($price);
    }
}


Ajouter un nouveau type de remise ne touche aucune ligne de code existante. On crée une nouvelle classe, on l’injecte — et le reste du système l’accepte sans modification. C’est le pattern Strategy, l’une des implémentations les plus courantes de l’OCP.

En Python

✓ Python — OCP via ABC

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        ...

class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width, self.height = width, height

    def area(self) -> float:
        return self.width * self.height

# Ajout de Triangle → aucune modification de Circle ou Rectangle
class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base, self.height = base, height

    def area(self) -> float:
        return 0.5 * self.base * self.height

def total_area(shapes: list[Shape]) -> float:
    return sum(s.area() for s in shapes)


L
Troisième principe
Liskov Substitution Principle
« Les objets d’une sous-classe doivent pouvoir remplacer les objets de la classe parente sans altérer le comportement du programme. »

Définition

Énoncé par Barbara Liskov en 1987, ce principe précise les conditions d’un héritage correct. Si B hérite de A, tout code qui utilise A doit pouvoir utiliser B sans rien savoir de B et sans que le comportement attendu ne soit violé.

En d’autres termes : une sous-classe ne doit pas renforcer les préconditions, affaiblir les postconditions, ni lancer des exceptions inattendues. Si la sous-classe oblige l’appelant à « faire attention », le principe est violé.

L’exemple classique : Rectangle et Carré

Intuitivement, un carré est un rectangle (en mathématiques). En POO, cet héritage est piégeux — et c’est l’exemple canonique du LSP violé.

❌ Violation — Square hérite de Rectangle

class Rectangle
{
    protected int $width;
    protected int $height;

    public function setWidth(int $w): void  { $this->width  = $w; }
    public function setHeight(int $h): void { $this->height = $h; }
    public function area(): int { return $this->width * $this->height; }
}

class Square extends Rectangle
{
    // Le carré force les deux dimensions à être égales
    public function setWidth(int $w): void
    {
        $this->width  = $w;
        $this->height = $w; // ← surprise !
    }

    public function setHeight(int $h): void
    {
        $this->width  = $h; // ← surprise !
        $this->height = $h;
    }
}

// Comportement qui "marchait" avec Rectangle mais échoue avec Square
function resizeAndCheck(Rectangle $r): void
{
    $r->setWidth(4);
    $r->setHeight(5);
    // Attendu : 20 | Avec Square : 25 → VIOLATION
    assert($r->area() === 20);
}

✓ Conforme — abstraction commune

// Abstraction commune sans héritage problématique
interface Shape
{
    public function area(): float;
}

class Rectangle implements Shape
{
    public function __construct(
        private readonly float $width,
        private readonly float $height
    ) {}

    public function area(): float
    {
        return $this->width * $this->height;
    }
}

class Square implements Shape
{
    public function __construct(
        private readonly float $side
    ) {}

    public function area(): float
    {
        return $this->side ** 2;
    }
}

// Fonctionne correctement avec les deux
function printArea(Shape $shape): void
{
    echo "Aire : " . $shape->area();
}

Règles pratiques pour respecter le LSP

📋

Préconditions : une méthode de sous-classe ne peut pas exiger plus que la méthode parente.
Postconditions : une méthode de sous-classe ne peut pas garantir moins que la méthode parente.
Invariants : les propriétés fondamentales de la classe parente doivent être préservées.
Exceptions : une sous-classe ne peut pas lever de nouveaux types d’exceptions non déclarés dans le parent.

⚠️
Le LSP est le principe le plus subtil à diagnostiquer. Un signe révélateur : si vous avez des instanceof dans votre code pour « savoir quel type réel on manipule », c’est presque toujours une violation du LSP.


I
Quatrième principe
Interface Segregation Principle
« Un client ne devrait pas être forcé d’implémenter des interfaces qu’il n’utilise pas. »

Définition

L’ISP préconise de préférer plusieurs interfaces petites et spécialisées à une seule interface large et généraliste — parfois appelée « fat interface ». L’objectif est d’éviter qu’une classe soit forcée d’implémenter des méthodes qu’elle n’utilise pas, créant des dépendances parasites.

Quand une interface est trop large, toute modification la touchant propage des impacts à toutes les classes qui l’implémentent — même celles qui n’utilisent pas la méthode modifiée.

L’exemple de l’imprimante multifontions

❌ Violation — fat interface

// Interface trop large : pas toutes les imprimantes scannent ou faxent
interface Machine
{
    public function print(Document $doc): void;
    public function scan(Document $doc): void;
    public function fax(Document $doc): void;
    public function staple(Document $doc): void;
}

// Une simple imprimante est forcée d'implémenter scan, fax, staple
class SimplePrinter implements Machine
{
    public function print(Document $doc): void { /* implémentation réelle */ }

    // Ces méthodes ne font rien — ou pire, lancent une exception
    public function scan(Document $doc): void
    {
        throw new BadMethodCallException('Non supporté');
    }
    public function fax(Document $doc): void
    {
        throw new BadMethodCallException('Non supporté');
    }
    public function staple(Document $doc): void
    {
        throw new BadMethodCallException('Non supporté');
    }
}

✓ Conforme — interfaces segmentées

// Interfaces séparées, chacune avec une seule responsabilité
interface Printable
{
    public function print(Document $doc): void;
}

interface Scannable
{
    public function scan(Document $doc): void;
}

interface Faxable
{
    public function fax(Document $doc): void;
}

// Simple imprimante : implémente seulement ce qu'elle sait faire
class SimplePrinter implements Printable
{
    public function print(Document $doc): void
    {
        // Impression réelle
    }
}

// Multifonction : combine les interfaces dont elle a besoin
class AllInOnePrinter implements Printable, Scannable, Faxable
{
    public function print(Document $doc): void { /* ... */ }
    public function scan(Document $doc): void  { /* ... */ }
    public function fax(Document $doc): void   { /* ... */ }
}

// Le code client ne dépend que de ce dont il a besoin
function printReport(Printable $printer, Document $doc): void
{
    $printer->print($doc);
}

ISP et frameworks modernes

L’ISP est particulièrement visible dans les frameworks modernes. Laravel, par exemple, propose des dizaines de contrats (contracts) comme Illuminate\Contracts\Auth\Authenticatable, Illuminate\Contracts\Queue\ShouldQueue — chacun ne demande que les méthodes strictement nécessaires à son rôle.

💡
Règle heuristique : si une interface a plus de 5-7 méthodes, posez-vous la question de sa cohérence. Des méthodes qui « vont ensemble » forment une interface. Des méthodes qui « servent des besoins différents » devraient être séparées. L’ISP est aussi une question de couplage à la compilation : une petite interface minimise le nombre de modules qui doivent recompiler lorsqu’elle change.


D
Cinquième principe
Dependency Inversion Principle
« Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d’abstractions. Les abstractions ne doivent pas dépendre des détails. Ce sont les détails qui doivent dépendre des abstractions. »

Définition

Le DIP est souvent le moins intuitif des cinq principes, mais c’est celui qui a le plus d’impact sur l’architecture globale. Il dit en substance : ne programmez pas contre des implémentations concrètes, programmez contre des interfaces. Les détails d’implémentation (bases de données, services externes, librairies) doivent dépendre des abstractions définies par la logique métier — et non l’inverse.

C’est le fondement de l’injection de dépendances (DI) et des conteneurs IoC (Inversion of Control) utilisés dans Symfony, Laravel, Spring, .NET Core, etc.

Le problème du couplage fort

❌ Violation — dépendance directe sur une implémentation concrète

// Module de haut niveau (logique métier)
class OrderService
{
    private MySQLOrderRepository $repo;
    private SmtpMailer $mailer;
    private StripePaymentGateway $gateway;

    public function __construct()
    {
        // Dépendances créées en dur — impossible à tester ou remplacer
        $this->repo    = new MySQLOrderRepository();
        $this->mailer  = new SmtpMailer('smtp.mailtrap.io');
        $this->gateway = new StripePaymentGateway('sk_live_...');
    }

    public function placeOrder(Order $order): void
    {
        $this->gateway->charge($order);
        $this->repo->save($order);
        $this->mailer->send($order);
    }
    // Problèmes :
    // • Changer MySQL → PostgreSQL = modifier OrderService
    // • Changer Stripe → PayPal    = modifier OrderService
    // • Tests unitaires impossibles (appels réels à SMTP, Stripe, MySQL)
}

✓ Conforme — dépendance sur les abstractions

// Abstractions définies par la logique métier
interface OrderRepositoryInterface
{
    public function save(Order $order): void;
    public function findById(int $id): ?Order;
}

interface MailerInterface
{
    public function send(string $to, string $subject, string $body): void;
}

interface PaymentGatewayInterface
{
    public function charge(Order $order): PaymentResult;
}

// Module de haut niveau — dépend uniquement des interfaces
class OrderService
{
    public function __construct(
        private readonly OrderRepositoryInterface  $repository,
        private readonly MailerInterface           $mailer,
        private readonly PaymentGatewayInterface   $gateway
    ) {}

    public function placeOrder(Order $order): void
    {
        $result = $this->gateway->charge($order);
        $this->repository->save($order);
        $this->mailer->send(
            $order->getCustomerEmail(),
            'Commande confirmée',
            '...'
        );
    }
}

// Implémentations concrètes (modules de bas niveau)
class MySQLOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): void  { /* ... */ }
    public function findById(int $id): ?Order { /* ... */ }
}

class StripePaymentGateway implements PaymentGatewayInterface
{
    public function charge(Order $order): PaymentResult { /* ... */ }
}

// Pour les tests : implémentations mock — SANS modifier OrderService
class InMemoryOrderRepository implements OrderRepositoryInterface
{
    private array $store = [];
    public function save(Order $order): void  { $this->store[] = $order; }
    public function findById(int $id): ?Order { return $this->store[$id] ?? null; }
}

Injection de dépendances et conteneurs IoC

En pratique, l’injection des dépendances est gérée par un conteneur IoC qui lit la configuration et instancie les bonnes implémentations automatiquement. Dans Symfony, par exemple :

Symfony — services.yaml

# Le conteneur lit ce fichier et injecte les bonnes implémentations
services:
    App\Repository\OrderRepositoryInterface:
        alias: App\Repository\MySQLOrderRepository

    # En environnement de test, on utilise une autre implémentation
    # App\Repository\OrderRepositoryInterface:
    #     alias: App\Repository\InMemoryOrderRepository


Bénéfices concrets du DIP : tests unitaires faciles (on injecte des mocks), changement de base de données sans toucher à la logique métier, changement de provider email sans modification, possibilité de tester OrderService complètement isolé de toute dépendance externe.

DIP vs Dependency Injection

À ne pas confondre : le DIP est un principe (dépendre des abstractions), l’injection de dépendances est un pattern (mécanisme pour satisfaire ces dépendances de l’extérieur). L’un n’implique pas forcément l’autre, mais ils sont complémentaires dans 99% des cas.


Récapitulatif — Les 5 principes SOLID

S
Single Responsibility
Une classe = une seule raison de changer. Séparez les responsabilités selon les acteurs qui les font évoluer.

O
Open / Closed
Étendez par ajout de code, jamais par modification. Utilisez interfaces et polymorphisme pour absorber les nouveaux besoins.

L
Liskov Substitution
Toute sous-classe doit fonctionner à la place de sa classe parente. L’héritage doit refléter une vraie relation de substitution.

I
Interface Segregation
Préférez plusieurs interfaces petites et précises à une seule grande. Un client ne devrait dépendre que de ce qu’il utilise.

D
Dependency Inversion
Dépendez des abstractions (interfaces), jamais des implémentations concrètes. Les détails suivent les abstractions, pas l’inverse.

SOLID n’est pas une checklist à cocher mécaniquement. C’est un cadre de réflexion pour poser les bonnes questions au bon moment. Appliquer SOLID systématiquement à des petits scripts ou des prototypes peut être contre-productif. Mais dans tout système destiné à croître et à être maintenu par une équipe, ces principes font la différence entre un code qui résiste au temps et un code qui s’effondre à la première extension.

mestutosguitare.fr — Article technique · Principes SOLID · Robert C. Martin



Photo : Daniil Komov sur Pexels.

Pour aller plus loin

Liens affiliés Amazon : en tant que Partenaire Amazon, ce site perçoit une commission sur les achats éligibles réalisés via ces liens, sans coût supplémentaire pour vous.

Couverture : Coder proprement : Le manuel de l'artisanat logiciel agileCoder proprement : Le manuel de l’artisanat logiciel agile, Robert C. Martin

Rédigé par l’auteur qui a popularisé les principes SOLID, cet ouvrage incontournable détaille comment les appliquer concrètement au quotidien pour produire un code lisible, testable et facile à maintenir.

Couverture : Clean ArchitectureClean Architecture, Robert C. Martin

Ce livre va plus loin en appliquant les principes SOLID à la structure globale de l’application, montrant comment découper les modules et gérer leurs dépendances pour bâtir des architectures durables.

Couverture : Design Patterns : Catalogue de modèles de conception réutilisablesDesign Patterns : Catalogue de modèles de conception réutilisables, Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides

Ouvrage de référence sur les patrons de conception orientée objet, il illustre parfaitement la mise en pratique des principes SOLID pour résoudre des problématiques d’architecture récurrentes.

Publications similaires

Laisser un commentaire