Deuxième épisode de notre série sur SOLID. Après la responsabilité unique, place au O : Open/Closed Principle, le principe ouvert/fermé.
La définition
Les entités logicielles (classes, modules, fonctions) doivent être ouvertes à l'extension, mais fermées à la modification.
La formule vient de Bertrand Meyer, en 1988, dans son livre Object-Oriented Software Construction. Elle semble contradictoire : comment ajouter un comportement sans modifier le code ? L'idée est de concevoir les parties susceptibles de varier pour qu'on puisse leur ajouter du code (une nouvelle classe) plutôt que réécrire le code existant, déjà testé et en production.
Le problème : le match qui grossit
Une boutique en ligne propose plusieurs modes de livraison. Le calcul des frais de port ressemble souvent à ceci :
<?php
final readonly class Commande
{
public function __construct(
public int $poidsGrammes,
public int $totalCentimes,
) {}
}
final class CalculateurFraisDePort
{
public function frais(Commande $commande, string $transporteur): int
{
return match ($transporteur) {
'colissimo' => $commande->poidsGrammes <= 1_000 ? 590 : 890,
'chronopost' => 1_290 + intdiv($commande->poidsGrammes, 1_000) * 150,
'retrait' => 0,
default => throw new InvalidArgumentException("Transporteur inconnu : $transporteur"),
};
}
}
Chaque nouveau transporteur oblige à rouvrir cette classe. Et ce n'est jamais la seule : ailleurs, un autre match affiche le nom du transporteur, un troisième calcule le délai de livraison… Ajouter Mondial Relay revient à chercher tous ces match dans le projet, à les modifier un par un et à espérer n'en oublier aucun. À chaque modification, on risque aussi de casser un transporteur qui fonctionnait très bien.
La solution : une abstraction et du polymorphisme
On commence par décrire ce qu'est un mode de livraison, sans dire comment chacun fonctionne. C'est le rôle d'une interface. Chaque transporteur l'implémente à sa façon :
<?php
interface ModeLivraison
{
public function code(): string;
public function libelle(): string;
/** Frais de port, en centimes. */
public function frais(Commande $commande): int;
}
final class Colissimo implements ModeLivraison
{
public function code(): string { return 'colissimo'; }
public function libelle(): string { return 'Colissimo (48 h)'; }
public function frais(Commande $commande): int
{
return $commande->poidsGrammes <= 1_000 ? 590 : 890;
}
}
final class Chronopost implements ModeLivraison
{
public function code(): string { return 'chronopost'; }
public function libelle(): string { return 'Chronopost (24 h)'; }
public function frais(Commande $commande): int
{
return 1_290 + intdiv($commande->poidsGrammes, 1_000) * 150;
}
}
final class RetraitEnMagasin implements ModeLivraison
{
public function code(): string { return 'retrait'; }
public function libelle(): string { return 'Retrait en magasin'; }
public function frais(Commande $commande): int
{
return 0;
}
}
Le calculateur ne connaît plus aucun transporteur. Il s'appuie sur un registre qui retrouve le bon mode à partir de son code :
<?php
final class ModesLivraison
{
/** @var array<string, ModeLivraison> */
private array $modes = [];
/** @param iterable<ModeLivraison> $modes */
public function __construct(iterable $modes)
{
foreach ($modes as $mode) {
$this->modes[$mode->code()] = $mode;
}
}
public function get(string $code): ModeLivraison
{
return $this->modes[$code] ?? throw new InvalidArgumentException("Mode de livraison inconnu : $code");
}
/** @return list<ModeLivraison> Pour proposer les choix pendant la commande. */
public function tous(): array
{
return array_values($this->modes);
}
}
final class CalculateurFraisDePort
{
public function __construct(private ModesLivraison $modes) {}
public function frais(Commande $commande, string $codeMode): int
{
return $this->modes->get($codeMode)->frais($commande);
}
}
Ajouter Mondial Relay
Voici maintenant tout ce qu'il faut écrire pour proposer un nouveau transporteur :
<?php
final class MondialRelay implements ModeLivraison
{
public function code(): string { return 'mondial_relay'; }
public function libelle(): string { return 'Point relais Mondial Relay'; }
public function frais(Commande $commande): int
{
// Offert dès 50 € d'achat.
return $commande->totalCentimes >= 5_000 ? 0 : 450;
}
}
Aucune ligne existante n'est modifiée. Le calculateur, Colissimo, Chronopost et leurs tests restent intacts : on ajoute une classe et ses propres tests.
$ git diff --stat main
src/Livraison/MondialRelay.php | 15 +++++++++++++++
tests/Livraison/MondialRelayTest.php | 24 ++++++++++++++++++++++++
2 files changed, 39 insertions(+)
Avec Symfony, il n'y a même pas de configuration à toucher. Un attribut sur l'interface étiquette automatiquement chaque classe qui l'implémente, et le registre les reçoit toutes :
<?php
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
#[AutoconfigureTag('app.mode_livraison')]
interface ModeLivraison
{
// …
}
final class ModesLivraison
{
/** @param iterable<ModeLivraison> $modes */
public function __construct(
#[AutowireIterator('app.mode_livraison')] iterable $modes,
) {
// …
}
}
Drupal, champion du principe ouvert/fermé
Le système de plugins de Drupal applique ce principe à grande échelle. Un module peut ajouter un bloc, un formateur de champ, un filtre de texte ou un type de champ sans modifier une seule ligne du cœur : il déclare une classe qui implémente l'interface attendue, et Drupal la découvre. C'est ainsi que des milliers de modules contribués étendent le même cœur sans jamais le modifier.
Ne pas en faire une religion
Le principe ouvert/fermé a un coût : une interface et plusieurs classes au lieu d'un match de dix lignes. Ce coût vaut la peine quand la liste va réellement évoluer (moyens de paiement, transporteurs, formats d'export…). Pour un ensemble fermé et stable, un enum avec un match reste la solution la plus lisible :
<?php
enum Jour: int
{
case Lundi = 1;
case Mardi = 2;
case Mercredi = 3;
case Jeudi = 4;
case Vendredi = 5;
case Samedi = 6;
case Dimanche = 7;
public function estOuvre(): bool
{
return match ($this) {
self::Samedi, self::Dimanche => false,
default => true,
};
}
}
Les jours de la semaine ne changeront pas demain. Un bon réflexe est la « règle de trois » : écrivez le match simple, et introduisez l'abstraction quand le troisième cas arrive ou quand le même match apparaît à plusieurs endroits.
À retenir
- Ouvert à l'extension : on peut ajouter un comportement. Fermé à la modification : sans réécrire le code existant.
- L'outil principal est l'abstraction (une interface) combinée au polymorphisme.
- Le symptôme typique d'une violation : le même
switchoumatchsur un type, recopié à plusieurs endroits. - On l'applique là où la variation est probable, pas partout par principe.
Mais toutes les implémentations d'une interface se valent-elles vraiment ? Le prochain article s'intéresse au L de SOLID : le principe de substitution de Liskov.
La série SOLID
- S : responsabilité unique
- O : ouvert/fermé (vous êtes ici)
- L : substitution de Liskov
- I : ségrégation des interfaces
- D : inversion des dépendances