Dive Into Design Patterns
Chapitre 3 / 9 · 13 min de lecture

Les principes d'une bonne conception

Avant les patrons, les principes : viser un code réutilisable et extensible via « encapsuler ce qui varie », « programmer vers une interface » et « préférer la composition ».

Avant de plonger dans les patrons eux-mêmes, il faut répondre à une question plus fondamentale : qu'est-ce qui distingue une bonne conception logicielle d'une mauvaise ? Les patrons ne sont pas des recettes magiques sorties de nulle part. Ce sont des réponses éprouvées à des problèmes récurrents, et ces réponses obéissent toutes à une poignée de principes universels. Comprendre ces principes, c'est comprendre pourquoi les patrons sont construits comme ils le sont — et savoir reconnaître les situations où ils s'appliquent.

Ce chapitre fait l'inventaire des objectifs d'une architecture saine (la réutilisation et l'extensibilité du code) puis détaille les trois principes que Shvets place à la racine de la plupart des patrons du livre : encapsuler ce qui varie, programmer pour une interface et préférer la composition à l'héritage. Les exemples sont en TypeScript, dans un univers e-commerce et de catalogue automobile, fidèles aux analogies du livre.

Deux objectifs : réutilisation et extensibilité

Réutiliser le code (sans se mentir sur son coût)

Le temps et le coût sont les deux métriques les plus précieuses du développement logiciel. Moins de temps de développement, c'est arriver sur le marché avant ses concurrents ; un coût plus faible, c'est plus de budget pour le marketing. La réutilisation du code (code reuse) est l'un des leviers les plus évidents pour réduire ces coûts : plutôt que de tout réécrire à partir de zéro, pourquoi ne pas réemployer du code existant dans un nouveau projet ?

L'idée est séduisante sur le papier, mais Shvets prévient honnêtement : faire fonctionner du code existant dans un nouveau contexte demande presque toujours un effort supplémentaire. Le couplage fort entre composants, la dépendance à des classes concrètes plutôt qu'à des interfaces, les opérations codées en dur — tout cela réduit la flexibilité du code et le rend plus difficile à réutiliser.

Le livre distingue trois niveaux de réutilisation, du plus concret au plus abstrait :

  • les classes (bibliothèques, conteneurs) — le niveau le plus bas ;
  • les patrons — un niveau intermédiaire, à la fois plus petits et plus abstraits que les frameworks ;
  • les frameworks — le niveau le plus élevé, mais le plus risqué et le plus coûteux à bâtir.

Note

Les patrons occupent le « niveau intermédiaire » heureux : ils décrivent comment quelques classes se relient et interagissent, sans imposer de code concret. On réutilise ainsi des idées de conception — bien moins risqué qu'investir dans un framework complet.

Concevoir pour le changement (extensibilité)

À retenir

Le changement est la seule constante de la vie d'un développeur. On livre un jeu pour Windows, et les joueurs réclament une version macOS. On dessine des boutons carrés, et la mode passe aux boutons ronds quelques mois plus tard. On architecture un site e-commerce brillant, et un mois après le client veut accepter les commandes par téléphone.

Pourquoi le changement est-il inévitable ? Shvets avance trois raisons. D'abord, on comprend mieux le problème une fois qu'on commence à le résoudre : la première version finie, on est souvent prêt à tout réécrire parce qu'on a enfin saisi les vrais enjeux. Ensuite, quelque chose d'extérieur change : un navigateur abandonne Flash, et tout le monde doit migrer. Enfin, les objectifs se déplacent : votre excellente première version a montré au client tout ce qui devient possible, et il revient avec onze « petites » demandes.

La conclusion est positive : si quelqu'un vous demande de changer quelque chose, c'est que quelqu'un tient encore à votre application. C'est pourquoi tout développeur aguerri conçoit son architecture en anticipant les changements futurs — c'est exactement ce que visent les principes qui suivent. La plupart des patrons du livre en découlent.

Principe 1 — Encapsuler ce qui varie

Astuce

Identifiez les aspects de votre application qui varient et séparez-les de ce qui reste stable.

L'objectif de ce principe est de minimiser l'effet provoqué par les changements. L'analogie du livre est limpide : imaginez que votre programme est un navire, et que les changements sont des mines tapies sous l'eau. Touché par une mine, le navire coule. Mais si vous divisez la coque en compartiments étanches indépendants, une mine n'inonde qu'un seul compartiment et le navire reste à flot. De la même façon, isoler les parties variables du programme dans des modules indépendants protège le reste du code des effets de bord. Moins de temps passé à réparer, c'est plus de temps pour les fonctionnalités.

Ce principe s'applique à deux échelles : la méthode et la classe.

Au niveau d'une méthode

Prenons un site e-commerce avec une méthode getOrderTotal qui calcule le total d'une commande, taxes comprises. On peut anticiper que le code lié aux taxes changera : le taux dépend du pays, de l'État, parfois de la ville, et la formule évolue avec les lois. Or le nom même de la méthode suggère qu'elle ne devrait pas se soucier de la façon dont la taxe est calculée.

// ❌ Avant : le calcul de taxe est mêlé au reste de la méthode.
function getOrderTotal(order: Order): number {
  let total = 0;
  for (const item of order.lineItems) {
    total += item.price * item.quantity;
  }

  if (order.country === "US") {
    total += total * 0.07; // taxe de vente US
  } else if (order.country === "EU") {
    total += total * 0.2; // TVA européenne
  }

  return total;
}

La parade : extraire la logique de taxe dans une méthode dédiée, en la cachant de la méthode d'origine.

// ✅ Après : le taux de taxe vient d'une méthode dédiée.
function getOrderTotal(order: Order): number {
  let total = 0;
  for (const item of order.lineItems) {
    total += item.price * item.quantity;
  }

  total += total * getTaxRate(order.country);
  return total;
}

function getTaxRate(country: string): number {
  if (country === "US") return 0.07; // taxe de vente US
  if (country === "EU") return 0.2; // TVA européenne
  return 0;
}

Les changements liés aux taxes sont désormais isolés dans une seule méthode. Mieux : si cette logique devient trop complexe, il sera facile de la déplacer dans une classe à part — ce qui nous amène au niveau supérieur.

Au niveau d'une classe

Avec le temps, une méthode qui faisait une chose simple accumule des responsabilités, chacune amenant ses propres champs et méthodes auxiliaires qui finissent par brouiller la responsabilité principale de la classe. Si le calcul de taxe gonfle, on l'extrait dans une classe TaxCalculator dédiée. Les objets Order délèguent alors tout le travail lié aux taxes à un objet spécialisé.

// ✅ Le calcul de taxe est déplacé dans une classe dédiée.
class TaxCalculator {
  getTaxRate(country: string, state: string): number {
    if (country === "US") return this.getUsTax(state);
    if (country === "EU") return 0.2;
    return 0;
  }

  private getUsTax(state: string): number {
    return state === "CA" ? 0.0725 : 0.07;
  }
}

class Order {
  lineItems: LineItem[] = [];
  country = "US";
  state = "NY";

  constructor(private taxCalc: TaxCalculator) {}

  getTotal(): number {
    let total = 0;
    for (const item of this.lineItems) {
      total += item.price * item.quantity;
    }
    const rate = this.taxCalc.getTaxRate(this.country, this.state);
    return total + total * rate;
  }
}

La classe Order redevient lisible : elle calcule un sous-total et délègue le reste. Toute la complexité fiscale, qui est la partie qui varie, vit désormais dans son propre compartiment étanche.

Piège courant

N'encapsulez pas par anticipation tout ce qui pourrait changer un jour. Sur-segmenter du code stable ajoute de l'indirection inutile et nuit à la lisibilité. Encapsulez ce qui varie vraiment — ce que l'expérience du domaine ou les demandes répétées vous désignent.

Principe 2 — Programmer pour une interface, pas une implémentation

Astuce

Programmez pour une interface, pas une implémentation. Dépendez d'abstractions, pas de classes concrètes.

On peut affirmer qu'une conception est suffisamment flexible si l'on peut l'étendre sans casser le code existant. L'analogie du livre : un chat qui peut manger n'importe quelle nourriture est plus flexible qu'un chat qui ne mange que des saucisses. On peut toujours nourrir le premier avec des saucisses (un sous-ensemble de « n'importe quelle nourriture »), mais on peut aussi étendre son menu à volonté.

Pour faire collaborer deux classes, le réflexe est de rendre l'une directement dépendante de l'autre. Shvets propose une façon plus souple, en quatre étapes :

  1. Déterminer ce dont un objet a besoin de l'autre — quelles méthodes appelle-t-il ?
  2. Décrire ces méthodes dans une nouvelle interface (ou classe abstraite).
  3. Faire en sorte que la classe-dépendance implémente cette interface.
  4. Rendre la seconde classe dépendante de l'interface, plus de la classe concrète.

Le bénéfice n'est pas immédiat — au contraire, le code devient un peu plus compliqué. Mais si cet endroit est un bon point d'extension, le jeu en vaut la chandelle.

Exemple : le simulateur d'entreprise

Imaginez un simulateur d'entreprise. Une classe Company orchestre le travail de différents types d'employés. Au départ, elle est fortement couplée aux classes concrètes : elle nomme Designer, Programmer, Tester directement, et un if/else énumère chaque type.

// ❌ Avant : Company connaît chaque classe concrète d'employé.
class Company {
  createSoftware(): void {
    const d = new Designer();
    d.designArchitecture();
    const p = new Programmer();
    p.writeCode();
    const t = new Tester();
    t.testSoftware();
  }
}

En généralisant les méthodes liées au travail, on extrait une interface commune Employee. La classe Company traite alors les employés par polymorphisme, via l'interface — c'est déjà un gros progrès.

// Étape intermédiaire : une interface commune + polymorphisme.
interface Employee {
  doWork(): void;
}

class Designer implements Employee {
  doWork(): void {
    /* conçoit l'architecture */
  }
}

class Programmer implements Employee {
  doWork(): void {
    /* écrit le code */
  }
}

Mais Shvets pointe le défaut restant : la méthode qui crée les employés reste couplée aux classes concrètes. Si l'on introduit un nouveau type d'entreprise travaillant avec d'autres employés, il faudrait réécrire Company. La solution consiste à rendre la méthode de création abstraite : chaque entreprise concrète crée uniquement les employés dont elle a besoin.

// ✅ Après : Company ne dépend que de l'abstraction Employee.
abstract class Company {
  abstract getEmployees(): Employee[];

  createSoftware(): void {
    for (const e of this.getEmployees()) {
      e.doWork();
    }
  }
}

class GameDevCompany extends Company {
  getEmployees(): Employee[] {
    return [new Designer(), new Programmer()];
  }
}

class OutsourcingCompany extends Company {
  getEmployees(): Employee[] {
    return [new Tester(), new Programmer()];
  }
}

La méthode principale de Company est désormais indépendante des classes concrètes d'employés. On peut introduire de nouveaux types d'entreprises et d'employés tout en réutilisant la classe de base, sans casser le code existant.

Note

Vous venez de voir un patron à l'œuvre. Cette structure — déléguer la création d'objets à des sous-classes — est la fabrique (Factory Method), que nous étudierons en détail. Les principes de conception ne sont pas théoriques : ils engendrent naturellement les patrons.

Principe 3 — Préférer la composition à l'héritage

L'héritage est la façon la plus évidente de réutiliser du code : deux classes partagent du code, on crée une classe de base commune, on y déplace le code commun. Un jeu d'enfant ! Mais Shvets dresse la liste des pièges qui n'apparaissent souvent qu'une fois le programme couvert de classes :

  • Une sous-classe ne peut pas réduire l'interface de sa super-classe : elle doit implémenter toutes les méthodes abstraites du parent, même celles dont elle ne se sert pas.
  • Le redéfinissement (override) doit rester compatible avec le comportement de base, car un objet de la sous-classe peut être passé partout où l'on attend la super-classe.
  • L'héritage brise l'encapsulation : les détails internes du parent deviennent accessibles à l'enfant — et parfois le parent doit être rendu conscient de détails de ses enfants pour faciliter l'extension.
  • Les sous-classes sont fortement couplées aux super-classes : tout changement dans le parent peut casser les enfants.
  • L'héritage ne fonctionne que sur une seule dimension. Dès qu'il y a deux dimensions de variation ou plus, on doit créer des combinaisons de classes, gonflant la hiérarchie jusqu'à l'absurde.

L'alternative est la composition. Là où l'héritage exprime une relation « est un » (une voiture est un moyen de transport), la composition exprime « a un » (une voiture a un moteur). Le principe vaut aussi pour l'agrégation, variante plus souple où un objet référence un autre sans gérer son cycle de vie (une voiture a un conducteur, mais le conducteur peut prendre une autre voiture ou marcher).

L'explosion combinatoire des sous-classes

L'exemple du livre : un catalogue pour un constructeur qui fait des voitures et des camions, en versions électrique ou essence, avec commandes manuelles ou pilote automatique. Trois dimensions indépendantes.

Avec l'héritage, chaque paramètre multiplie le nombre de sous-classes. Il faut une classe par combinaison, avec énormément de code dupliqué entre elles, puisqu'une classe ne peut pas hériter de deux parents à la fois.

// ❌ Héritage : 2 × 2 × 2 = 8 classes, et ça empire à chaque dimension.
class Transport {}
class Car extends Transport {}
class Truck extends Transport {}
class ElectricCar extends Car {}
class CombustionCar extends Car {}
class AutopilotElectricCar extends ElectricCar {}
class ManualElectricCar extends ElectricCar {}
// ... et ainsi de suite, jusqu'à l'absurde.

La composition à la rescousse

Avec la composition, au lieu d'implémenter chaque comportement par eux-mêmes, les objets Transport délèguent ce comportement à d'autres objets. Chaque « dimension » de variation devient sa propre hiérarchie d'interfaces, et le transport reçoit un objet par dimension.

// ✅ Chaque dimension dans sa propre hiérarchie d'abstraction.
interface Engine {
  move(): void;
}
class ElectricEngine implements Engine {
  move(): void {
    /* roule à l'électricité */
  }
}
class CombustionEngine implements Engine {
  move(): void {
    /* roule à l'essence */
  }
}

interface Driver {
  navigate(): void;
}
class Human implements Driver {
  navigate(): void {
    /* conduite manuelle */
  }
}
class Robot implements Driver {
  navigate(): void {
    /* pilote automatique */
  }
}

class Transport {
  constructor(
    private engine: Engine,
    private driver: Driver,
  ) {}

  deliver(): void {
    this.driver.navigate();
    this.engine.move();
  }

  // Bonus : on remplace un comportement à l'exécution.
  setEngine(engine: Engine): void {
    this.engine = engine;
  }
}

// Toute combinaison se compose, sans nouvelle classe.
const tesla = new Transport(new ElectricEngine(), new Robot());
const oldTruck = new Transport(new CombustionEngine(), new Human());

Plus d'explosion combinatoire : ici deux dimensions à deux variantes deviennent une poignée de petites classes réutilisables (le code n'en montre que deux, Engine et Driver), au lieu d'une hiérarchie qui explose de façon combinatoire (huit combinaisons-feuilles, et bien plus de classes au total dès qu'on ajoute des dimensions), chacune réutilisable indépendamment. Avantage supplémentaire souligné par Shvets : on peut remplacer un comportement à l'exécution, par exemple échanger l'objet Engine lié à un transport en lui assignant simplement un autre moteur.

À retenir

Cette structure — extraire une famille de comportements interchangeables derrière une interface et la déléguer — ressemble fortement au patron stratégie (Strategy), que nous verrons plus loin. « Préférer la composition à l'héritage » (favor composition over inheritance) n'interdit pas l'héritage ; il invite à ne l'employer que pour de vraies relations « est un », stables et à une seule dimension.

Tableau récapitulatif

PrincipeIntentionQuand l'appliquer
Encapsuler ce qui varieIsoler les aspects variables dans leur propre méthode ou classeDès qu'une partie du code change pour des raisons distinctes du reste (taxes, formats, règles métier mouvantes)
Programmer pour une interfaceDépendre d'abstractions, pas de classes concrètesQuand deux classes collaborent et qu'un point d'extension est probable (collaborateurs interchangeables)
Préférer la composition à l'héritageDéléguer un comportement à un objet plutôt que d'en hériterQuand plusieurs dimensions varient indépendamment, ou qu'on veut changer un comportement à l'exécution

À retenir

  • Une bonne conception poursuit deux objectifs : la réutilisation du code (utile mais coûteuse — le couplage la sabote) et l'extensibilité, car le changement est la seule constante.
  • Encapsuler ce qui varie : repérez les parties qui changent et isolez-les, au niveau méthode (extraire la logique variable) puis classe (la déléguer à un objet dédié), pour éviter les effets de bord en cascade.
  • Programmer pour une interface : dépendez d'abstractions, pas de classes concrètes ; cela découple les collaborateurs et les rend interchangeables — au prix d'une légère complexité supplémentaire.
  • Préférer la composition à l'héritage : l'héritage est rigide (couplage fort, encapsulation brisée, explosion combinatoire des sous-classes) ; la composition par délégation est plus souple et permet de changer un comportement à l'exécution.
  • Ces principes ne sont pas des contraintes gratuites : ils engendrent les patrons. La fabrique naît de « programmer pour une interface », la stratégie de « préférer la composition ».
  • Gardez la mesure : n'abstrayez et ne décomposez que ce qui le mérite. Appliqués sans discernement, ces principes mènent à la sur-ingénierie.