Aller au contenu

POO : principes SOLID et conception

SOLID regroupe cinq principes qui guident la conception pour obtenir un code facile à modifier et à tester. Ce sont des repères, pas des règles absolues : appliquer un principe quand le code gêne réellement (modifications difficiles, tests pénibles), pas par avance.

Une classe doit avoir une seule raison de changer.

// Avant : mélange règle métier, persistance, présentation
class Facture {
constructor(public lignes: { prixCentimes: number; quantite: number }[]) {}
total() {
return this.lignes.reduce((t, l) => t + l.prixCentimes * l.quantite, 0);
}
enregistrer() {
/* écrit en base de données */
}
versHtml() {
return `<p>Total : ${this.total() / 100} €</p>`;
}
}
// Après : chaque classe a un rôle
class Facture {
constructor(readonly lignes: { prixCentimes: number; quantite: number }[]) {}
get totalCentimes() {
return this.lignes.reduce((t, l) => t + l.prixCentimes * l.quantite, 0);
}
}
interface DepotFactures {
enregistrer(facture: Facture): Promise<void>;
}
class RendeurHtml {
rendre(facture: Facture): string {
return `<p>Total : ${(facture.totalCentimes / 100).toFixed(2)} €</p>`;
}
}

Modifier la présentation ne touche plus la règle métier ni la base de données.

Une classe est ouverte à l’extension, fermée à la modification : ajouter un comportement sans réécrire le code existant.

// Fermé à l'extension : chaque nouveau tarif modifie la fonction
function prix(type: 'normal' | 'reduit' | 'enfant', base: number) {
if (type === 'reduit') return base * 0.8;
if (type === 'enfant') return base * 0.5;
return base;
}
// Ouvert : un nouveau tarif = une nouvelle classe
interface Tarif {
appliquer(base: number): number;
}
class TarifNormal implements Tarif {
appliquer(b: number) {
return b;
}
}
class TarifReduit implements Tarif {
appliquer(b: number) {
return b * 0.8;
}
}
class TarifEnfant implements Tarif {
appliquer(b: number) {
return b * 0.5;
}
}
const prix = (tarif: Tarif, base: number) => tarif.appliquer(base);

C’est le patron « stratégie » (Composition et patrons de conception). Ne pas l’appliquer à un if simple qui ne changera pas.

Une sous-classe doit pouvoir remplacer sa classe parente sans casser le programme.

class Rectangle {
constructor(
protected largeur: number,
protected hauteur: number,
) {}
definirLargeur(l: number) {
this.largeur = l;
}
definirHauteur(h: number) {
this.hauteur = h;
}
aire() {
return this.largeur * this.hauteur;
}
}
class Carre extends Rectangle {
// viole Liskov
override definirLargeur(l: number) {
this.largeur = l;
this.hauteur = l;
}
override definirHauteur(h: number) {
this.largeur = h;
this.hauteur = h;
}
}
function redimensionner(r: Rectangle) {
r.definirLargeur(4);
r.definirHauteur(5);
return r.aire(); // 20 attendu : un Carre renvoie 25
}

Un carré « est un » rectangle en géométrie, pas en programmation avec des objets modifiables : le comportement attendu du parent n’est pas respecté. Solutions : objets immuables, ou deux types distincts qui partagent une interface Forme.

Règles : une sous-classe ne doit pas renforcer les conditions d’entrée, affaiblir les garanties de sortie ni lever des erreurs que le parent ne lève pas. Si une sous-classe doit désactiver une méthode (throw new Error('non supporté')), l’héritage est mal choisi.

Ne pas obliger une classe à dépendre de méthodes qu’elle n’utilise pas. Préférer plusieurs petites interfaces à une grosse.

// Interface trop large
interface Machine {
imprimer(): void;
scanner(): void;
faxer(): void;
}
class ImprimanteSimple implements Machine {
imprimer() {}
scanner() {
throw new Error('non supporté');
}
faxer() {
throw new Error('non supporté');
}
}
// Interfaces ciblées
interface Imprimante {
imprimer(): void;
}
interface Scanneur {
scanner(): void;
}
class ImprimanteSimple implements Imprimante {
imprimer() {}
}
class Multifonction implements Imprimante, Scanneur {
imprimer() {}
scanner() {}
}
function imprimerRapport(imprimante: Imprimante) {
imprimante.imprimer();
}

Une fonction ne demande que ce dont elle a besoin : elle fonctionne avec toute classe qui fournit ce rôle, et se teste avec un faux minimal.

Les modules de haut niveau (règles métier) ne dépendent pas des modules de bas niveau (base de données, courriel, fichiers) : les deux dépendent d’abstractions (interfaces).

// Haut niveau couplé au bas niveau
class ServiceCommande {
private base = new ClientPostgres(); // dépendance concrète
passer(c: Commande) {
return this.base.inserer(c);
}
}
// Inversion : le métier définit l'interface, l'infrastructure l'implémente
interface DepotCommandes {
enregistrer(commande: Commande): Promise<void>;
}
class ServiceCommande {
constructor(private readonly depot: DepotCommandes) {}
passer(c: Commande) {
return this.depot.enregistrer(c);
}
}
class DepotCommandesPostgres implements DepotCommandes {
/* ... */
}
class DepotCommandesMemoire implements DepotCommandes {
/* ... */
}

Le métier ne sait plus qu’une base existe ; on teste avec le dépôt en mémoire. Voir Polymorphisme et interfaces.

Notion Définition Objectif
Cohésion Les éléments d’une classe servent un même but Forte
Couplage Dépendances d’une classe envers les autres Faible

Signaux d’une mauvaise cohésion : une classe Utils ou Manager qui fait tout, des méthodes qui n’utilisent pas les attributs de la classe, des noms en « et » (ChargerEtEnvoyer). Signaux de couplage excessif : une modification dans une classe en casse plusieurs autres, des chaînes d’appels a.b().c().d().

Un objet ne devrait parler qu’à ses voisins immédiats.

// Couplé à la structure interne de plusieurs objets
const ville = commande.client.adresse.ville;
commande.client.adresse.changerVille('Lyon');
// Déléguer
const ville2 = commande.villeDeLivraison();
commande.changerVilleDeLivraison('Lyon');

Principe : demander à un objet de faire (« dites, ne demandez pas »), plutôt que d’extraire ses données pour décider à sa place.

Une classe qui n’a que des attributs et des accesseurs, avec la logique éparpillée dans des « services », perd l’intérêt de la POO :

// Anémique : la logique est ailleurs
class Compte {
solde = 0;
}
class ServiceCompte {
retirer(c: Compte, m: number) {
if (c.solde >= m) c.solde -= m;
}
}
// Riche : l'objet protège son invariant
class Compte {
#solde = 0;
retirer(m: number) {
if (m > this.#solde) throw new Error('solde insuffisant');
this.#solde -= m;
}
}

À l’inverse, des données transportées (réponse d’API, DTO) restent de simples objets : l’important est que les règles métier vivent là où se trouve l’état qu’elles protègent.

Situation Conseil
Script court, prototype Fonctions simples : pas d’abstractions
Une seule implémentation, aucun besoin de test isolé Pas d’interface « au cas où »
Code qui ne change pas Ne pas le restructurer
Équipe qui découvre la POO Introduire un principe à la fois, à partir d’un vrai problème

Une abstraction a un coût (indirections, fichiers supplémentaires). La conception sert à réduire le coût du changement, pas à ajouter des couches.

interface Remise {
appliquer(totalCentimes: number): number;
}
interface DepotCommandes {
enregistrer(c: Commande): Promise<void>;
}
interface Notificateur {
confirmer(c: Commande): Promise<void>;
}
class Commande {
constructor(
readonly numero: string,
readonly totalCentimes: number,
) {}
}
class ServiceCommande {
constructor(
private readonly depot: DepotCommandes,
private readonly notificateur: Notificateur,
private readonly remise: Remise,
) {}
async passer(numero: string, brutCentimes: number): Promise<Commande> {
const commande = new Commande(numero, this.remise.appliquer(brutCentimes));
await this.depot.enregistrer(commande);
await this.notificateur.confirmer(commande);
return commande;
}
}

Responsabilité unique (orchestration), ouvert (nouvelle remise sans modifier le service), dépendances inversées (interfaces), interfaces ciblées : le service se teste avec trois faux d’une ligne chacun.

Symptôme Piste
Classe de plusieurs centaines de lignes Responsabilités multiples : extraire
Modifier une classe oblige à modifier beaucoup d’autres Couplage : interfaces, injection
Méthode throw new Error('non supporté') dans une sous-classe Violation de Liskov : revoir la hiérarchie, interfaces plus petites
Interface à plus de 5 méthodes Ségrégation : la découper par rôle
Test qui demande une vraie base Dépendance concrète : inverser par une interface
Abstractions que personne n’utilise Trop de conception anticipée : les retirer

Ubuntu 24.04 et 26.04 : différences à connaître

Section intitulée « Ubuntu 24.04 et 26.04 : différences à connaître »

Aucune.