SOLID est un acronyme qui regroupe cinq principes de conception orientée objet. Robert C. Martin (« Uncle Bob ») les a rassemblés au début des années 2000, et Michael Feathers leur a trouvé ce nom facile à retenir. Leur objectif est très concret : écrire du code qu'on peut modifier sans peur, tester facilement et comprendre rapidement.
Cette série en cinq articles les passe en revue, une lettre à la fois, avec des exemples en PHP. On commence par le S : Single Responsibility Principle, le principe de responsabilité unique.
La définition
Une classe ne devrait avoir qu'une seule raison de changer.
Attention au contresens le plus fréquent : ce principe ne dit pas qu'une classe ne doit faire qu'une seule chose, ni qu'elle ne doit avoir qu'une seule méthode. Il parle de raisons de changer. Dans Clean Architecture (2017), Robert C. Martin le reformule d'ailleurs ainsi : un module ne devrait être responsable que devant un seul acteur, c'est-à-dire une seule personne ou un seul groupe susceptible de demander une modification.
Une classe qui en fait trop
Prenons un cas très courant : un rapport mensuel des ventes, envoyé par e-mail au service comptable. Dans une première version, tout tient dans une classe :
<?php
final class RapportVentes
{
public function __construct(private PDO $pdo) {}
public function envoyerRapportMensuel(DateTimeImmutable $mois, string $destinataire): void
{
// 1. Lire les ventes du mois
$debut = $mois->modify('first day of this month midnight');
$requete = $this->pdo->prepare(
'SELECT quantite, prix_unitaire_centimes FROM ventes WHERE vendu_le >= ? AND vendu_le < ?'
);
$requete->execute([$debut->format('Y-m-d'), $debut->modify('+1 month')->format('Y-m-d')]);
// 2. Calculer les totaux
$totalHt = 0;
foreach ($requete->fetchAll(PDO::FETCH_ASSOC) as $vente) {
$totalHt += $vente['quantite'] * $vente['prix_unitaire_centimes'];
}
$totalTtc = (int) round($totalHt * 1.2);
// 3. Mettre en forme
$html = '<h1>Ventes de ' . $mois->format('m/Y') . '</h1>'
. '<p>Total HT : ' . number_format($totalHt / 100, 2, ',', ' ') . ' €</p>'
. '<p>Total TTC : ' . number_format($totalTtc / 100, 2, ',', ' ') . ' €</p>';
// 4. Envoyer
mail($destinataire, 'Rapport des ventes', $html, ['Content-Type' => 'text/html; charset=UTF-8']);
}
}
Ce code fonctionne. Mais demandons-nous qui pourrait vouloir le modifier :
- l'équipe technique, si la table
venteschange de structure ou si l'on change de base de données ; - la comptabilité, si le calcul évolue (un taux de TVA réduit sur certains produits, des remises…) ;
- la direction, qui voudra un jour un graphique, un PDF ou un autre format ;
- l'exploitation, si les e-mails doivent passer par un service d'envoi plutôt que par la fonction
mail().
Quatre acteurs, quatre raisons de changer, une seule classe. Conséquences : modifier la mise en forme oblige à relire le calcul, au risque de le casser ; deux développeurs qui travaillent sur deux demandes sans rapport modifient le même fichier ; et surtout, impossible de tester le calcul sans une base de données remplie et un serveur de mail.
Découper par raison de changer
La solution consiste à créer une classe par responsabilité. On commence par représenter une vente avec un petit objet immuable. Les montants sont stockés en centimes, sous forme d'entiers : les nombres à virgule flottante ne sont pas faits pour compter de l'argent.
<?php
final readonly class Vente
{
public function __construct(
public int $quantite,
public int $prixUnitaireCentimes,
) {}
}
// Responsabilité : lire les ventes en base.
final class VentesRepository
{
public function __construct(private PDO $pdo) {}
/** @return list<Vente> */
public function ventesDuMois(DateTimeImmutable $mois): array
{
$debut = $mois->modify('first day of this month midnight');
$requete = $this->pdo->prepare(
'SELECT quantite, prix_unitaire_centimes FROM ventes WHERE vendu_le >= ? AND vendu_le < ?'
);
$requete->execute([$debut->format('Y-m-d'), $debut->modify('+1 month')->format('Y-m-d')]);
return array_map(
fn (array $ligne) => new Vente((int) $ligne['quantite'], (int) $ligne['prix_unitaire_centimes']),
$requete->fetchAll(PDO::FETCH_ASSOC),
);
}
}
final readonly class TotauxVentes
{
public function __construct(
public int $totalHtCentimes,
public int $totalTtcCentimes,
) {}
}
// Responsabilité : les règles de calcul.
final class CalculateurVentes
{
public function __construct(private float $tauxTva = 0.20) {}
/** @param list<Vente> $ventes */
public function totaux(array $ventes): TotauxVentes
{
$totalHt = array_sum(array_map(
fn (Vente $vente) => $vente->quantite * $vente->prixUnitaireCentimes,
$ventes,
));
return new TotauxVentes($totalHt, (int) round($totalHt * (1 + $this->tauxTva)));
}
}
La présentation et l'envoi ont chacun leur classe :
<?php
// Responsabilité : la mise en forme.
final class RapportVentesHtml
{
public function rendre(DateTimeImmutable $mois, TotauxVentes $totaux): string
{
return '<h1>Ventes de ' . $mois->format('m/Y') . '</h1>'
. '<p>Total HT : ' . $this->euros($totaux->totalHtCentimes) . '</p>'
. '<p>Total TTC : ' . $this->euros($totaux->totalTtcCentimes) . '</p>';
}
private function euros(int $centimes): string
{
return number_format($centimes / 100, 2, ',', ' ') . ' €';
}
}
// Responsabilité : l'envoi des e-mails.
final class Messagerie
{
public function envoyerHtml(string $destinataire, string $sujet, string $html): void
{
mail($destinataire, $sujet, $html, ['Content-Type' => 'text/html; charset=UTF-8']);
}
}
Enfin, une dernière classe orchestre l'ensemble. Elle aussi n'a qu'une responsabilité : enchaîner les étapes du rapport mensuel.
<?php
final class RapportMensuel
{
public function __construct(
private VentesRepository $ventes,
private CalculateurVentes $calculateur,
private RapportVentesHtml $presentation,
private Messagerie $messagerie,
) {}
public function envoyer(DateTimeImmutable $mois, string $destinataire): void
{
$totaux = $this->calculateur->totaux($this->ventes->ventesDuMois($mois));
$html = $this->presentation->rendre($mois, $totaux);
$this->messagerie->envoyerHtml($destinataire, 'Rapport des ventes', $html);
}
}
Le bénéfice immédiat : des tests simples
Le calcul, qui est la partie la plus sensible, se teste désormais en quelques lignes, sans base de données ni serveur de mail :
<?php
use PHPUnit\Framework\TestCase;
final class CalculateurVentesTest extends TestCase
{
public function testCalculeLesTotauxHtEtTtc(): void
{
$ventes = [
new Vente(quantite: 2, prixUnitaireCentimes: 4_990), // 2 × 49,90 €
new Vente(quantite: 1, prixUnitaireCentimes: 2_500), // 1 × 25,00 €
];
$totaux = (new CalculateurVentes(tauxTva: 0.20))->totaux($ventes);
$this->assertSame(12_480, $totaux->totalHtCentimes); // 124,80 €
$this->assertSame(14_976, $totaux->totalTtcCentimes); // 149,76 €
}
}
Et chaque demande future touche une seule classe : un changement de TVA concerne CalculateurVentes, un nouveau format de rapport concerne RapportVentesHtml, un nouveau fournisseur d'e-mails concerne Messagerie.
Comment repérer une classe qui en fait trop ?
- Le test du « et » : si vous décrivez la classe en disant « elle lit les ventes et calcule les totaux et envoie un e-mail », chaque « et » est un indice.
- Des dépendances de natures très différentes : une même classe qui manipule PDO, du HTML et un client e-mail mélange plusieurs couches.
- Des méthodes qui ne partagent rien : si deux groupes de méthodes utilisent des propriétés différentes, ce sont souvent deux classes qui cohabitent.
- Un nom vague :
Manager,Helper,UtilsouServicetout court cachent souvent une classe fourre-tout. - Des conflits Git à répétition sur le même fichier, pour des fonctionnalités qui n'ont rien à voir entre elles.
Attention à l'excès inverse
Le SRP ne demande pas de découper le code en dizaines de micro-classes d'une méthode. L'idée est de regrouper ce qui change pour les mêmes raisons et de séparer ce qui change pour des raisons différentes. Si deux morceaux de code évoluent toujours ensemble, les séparer ne fait qu'éparpiller la logique. Commencez simple, et découpez quand une deuxième raison de changer apparaît vraiment.
Dans Drupal, c'est toute la logique des services : un contrôleur ne lance pas de requêtes SQL et ne construit pas d'e-mails. Il reçoit des services spécialisés (accès aux entités, envoi de mails, formatage des dates…) et se contente de les coordonner.
À retenir
- Une classe = une raison de changer, pas une seule méthode.
- Pour trouver les responsabilités, demandez-vous qui peut demander une modification.
- Séparer accès aux données, règles métier, présentation et communication rend chaque partie testable seule.
- Le bon découpage regroupe ce qui évolue ensemble : ni classe fourre-tout, ni poussière de micro-classes.
Dans le prochain article, nous verrons comment ajouter des fonctionnalités sans modifier le code existant : c'est le principe ouvert/fermé.
La série SOLID
- S : responsabilité unique (vous êtes ici)
- O : ouvert/fermé
- L : substitution de Liskov
- I : ségrégation des interfaces
- D : inversion des dépendances