Agile Software Development
Chapitre 12 / 15 · 19 min de lecture

Composite, Observer, Adapter & Bridge

Traiter uniformément objets et compositions (Composite), réagir aux changements (Observer), et adapter ou découpler des interfaces (Adapter, Bridge).

Ces chapitres ouvrent l'étude de cas de la station météo (weather station case study), un système de surveillance fictif mais résolument réaliste, avec ses pressions calendaires, son code hérité, ses spécifications fluctuantes et ses technologies neuves. Avant de plonger dans le code de la station, Martin présente les patrons (patterns) qui y serviront. Mais sa manière de les présenter le distingue radicalement d'un catalogue : ici, les patrons ne sont pas exposés sous forme achevée, ils émergent du code sous la pression des principes SOLID. Le chapitre sur Observer en est l'illustration la plus marquante — Martin y « rentre à reculons » (backing into a pattern) dans le patron, étape par étape, à partir d'un problème d'horloge. Cinq patrons défilent ici : Composite, Observer, Abstract Server, Adapter et Bridge.

Composite : un objet ou une multitude, sans que personne le sache

Le patron Composite est d'une grande simplicité de structure, mais ses implications sont considérables. Sa forme canonique repose sur une hiérarchie de formes (Shape). Une classe de base Shape possède deux dérivées feuilles, Circle et Square. La troisième dérivée est le composite lui-même : CompositeShape conserve une liste d'instances de Shape et, lorsqu'on appelle draw() sur lui, il délègue cet appel à toutes les formes de sa liste.

        «interface»
          Shape  ◇─────────┐ 0..*
         draw()            │
           ▲               │
     ┌─────┼──────┐        │
  Circle  Square  CompositeShape (délègue)
                  + add(Shape)
                    draw()

Le point essentiel : pour le reste du système, une instance de CompositeShape se comporte comme une simple Shape. On peut la passer à n'importe quelle fonction ou objet qui attend une forme, et elle se conduira comme telle. En réalité, ce n'est qu'un mandataire (proxy) pour un groupe de formes — d'où la parenté structurelle évidente avec le patron Proxy.

interface Shape {
  draw(): void;
}

class CompositeShape implements Shape {
  private shapes: Shape[] = [];

  add(shape: Shape): void {
    this.shapes.push(shape);
  }

  draw(): void {
    for (const shape of this.shapes) {
      shape.draw();
    }
  }
}

Composite en situation : les commandes multiples d'un capteur

Là où le livre se démarque d'un catalogue, c'est qu'il montre le patron surgir d'un vrai problème déjà rencontré dans le système de tri de documents. Un capteur (Sensor) utilisait un objet commande (Command) : quand le capteur détectait son stimulus, il appelait do() sur la commande. La relation était strictement un-à-un.

Mais il existait des cas où un capteur devait exécuter plusieurs commandes : lorsque le papier atteignait un certain point du chemin, il déclenchait un capteur optique, qui arrêtait un moteur, en démarrait un autre, et engageait un embrayage. La première idée fut que chaque Sensor devrait maintenir une liste de commandes. Puis on s'aperçut que, chaque fois qu'un capteur exécutait plusieurs commandes, il les traitait toutes identiquement : il itérait sur la liste et appelait do() sur chacune. Cas d'école pour Composite.

interface Command {
  do(): void;
}

class CompositeCommand implements Command {
  private commands: Command[] = [];

  add(command: Command): void {
    this.commands.push(command);
  }

  do(): void {
    for (const command of this.commands) {
      command.do();
    }
  }
}

On a donc laissé Sensor et Command intacts et créé un CompositeCommand. On a ajouté la pluralité des commandes à un capteur sans rien modifier de l'existant : c'est une application directe du principe ouvert/fermé (Open-Closed Principle, OCP).

Multiplicité ou non-multiplicité

Cette anecdote ouvre une question féconde. On a réussi à faire en sorte que les capteurs se comportent comme s'ils contenaient plusieurs commandes, sans transformer la relation un-à-un en relation un-à-plusieurs. Or une relation un-à-un est nettement plus facile à comprendre, à coder et à maintenir qu'une relation un-à-plusieurs. Le compromis de conception était clairement le bon.

Astuce

Combien des relations un-à-plusieurs de votre projet actuel pourraient être ramenées à du un-à-un grâce à Composite ? Seules celles où chaque objet de la liste est traité de façon strictement identique sont éligibles. Une liste d'employés que l'on parcourt pour trouver ceux dont la paie tombe aujourd'hui n'est pas un candidat : on n'y traite pas tous les employés de la même manière.

L'avantage est tangible : plutôt que de dupliquer dans chaque client le code de gestion de liste et d'itération, ce code n'apparaît qu'une seule fois, dans la classe composite.

Observer : rentrer à reculons dans un patron

Ce chapitre a un objectif singulier. Décrire le patron Observer n'en est que le but mineur. Le but premier est de montrer comment une conception et un code peuvent évoluer pour adopter un patron. Martin refuse l'idée qu'un patron soit un bloc tout fait qu'on insère dans le code. Il préfère faire évoluer son code dans la direction de ses besoins ; en le remaniant pour résoudre des problèmes de couplage, de simplicité et d'expressivité, il constate parfois que le code s'est rapproché d'un patron connu. Il renomme alors classes et variables au nom du patron, et régularise la structure. Le code « rentre à reculons » dans le patron.

À retenir

La leçon centrale d'Observer ici n'est pas le diagramme final, c'est la méthode incrémentale. À chaque étape, Martin nomme le problème qu'il cherche à résoudre, applique la plus petite transformation possible, garde ses tests au vert, puis examine ce qui le gêne encore. Le patron n'est jamais le but : c'est le point d'arrivée d'une suite de décisions locales.

L'horloge numérique

Le point de départ est un objet Clock qui capte les interruptions à la milliseconde émises par le système d'exploitation (les « tics ») et les convertit en heure du jour : il sait calculer secondes, minutes, heures, jours, mois, années bissextiles. Bref, il sait tout du temps. On veut maintenant une horloge numérique (DigitalClock) posée sur le bureau, qui affiche l'heure en continu. La solution la plus simple ?

function displayTime(clock: Clock): void {
  while (true) {
    const sec = clock.getSeconds();
    const min = clock.getMinutes();
    const hour = clock.getHours();
    showTime(hour, min, sec);
  }
}

C'est manifestement sous-optimal : cette boucle consomme tous les cycles CPU disponibles pour réafficher l'heure sans relâche, alors que la plupart de ces affichages sont gaspillés puisque l'heure n'a pas changé. Acceptable peut-être dans une montre, pas sur un bureau. Le vrai problème est : comment acheminer efficacement la donnée de Clock vers DigitalClock ?

Les tests d'abord, et les interfaces qui en découlent

Martin suppose que Clock et DigitalClock existent ; seule la connexion l'intéresse. Pour la tester, il invente deux interfaces : l'une qui prétend être l'horloge (TimeSource), l'autre qui prétend être l'afficheur (TimeSink). Il pourra alors écrire des objets factices (mocks) implémentant ces interfaces et vérifier que la donnée passe bien de la source au puits.

Note

Détail crucial : ces interfaces n'ont été ajoutées à la conception que pour la rendre testable. Pour tester un module, il faut l'isoler des autres. Penser aux tests en premier nous pousse mécaniquement à minimiser le couplage. La testabilité est un révélateur de bonne conception, pas une contrainte qu'on lui ajoute après coup.

Comment le pilote (ClockDriver) sait-il que l'heure a changé ? Le sonder (polling) recréerait le problème de surconsommation. Le plus simple est que Clock le lui dise. On passe donc le pilote à la source via setDriver, et la source rappelle le pilote quand l'heure change.

interface TimeSink {
  setTime(hours: number, minutes: number, seconds: number): void;
}

interface TimeSource {
  setDriver(driver: ClockDriver): void;
}

class ClockDriver {
  constructor(source: TimeSource, private sink: TimeSink) {
    source.setDriver(this);
  }

  update(hours: number, minutes: number, seconds: number): void {
    this.sink.setTime(hours, minutes, seconds);
  }
}

Une dépendance gêne déjà Martin : TimeSource dépend de ClockDriver, parce que l'argument de setDriver est un ClockDriver. Cela implique que toute source de temps devrait connaître les pilotes. Il diffère pourtant la correction jusqu'à ce que le programme fonctionne.

Première amélioration : casser la dépendance par une interface

Une fois le code vert, on nettoie. Pour que TimeSource soit utilisable par n'importe qui, on introduit une interface que la source utilise et que le pilote implémente : ClockObserver. La source ne dépend plus du pilote concret, mais d'une abstraction.

interface ClockObserver {
  update(hours: number, minutes: number, seconds: number): void;
}

interface TimeSource {
  setObserver(observer: ClockObserver): void;
}

class ClockDriver implements ClockObserver {
  constructor(source: TimeSource, private sink: TimeSink) {
    source.setObserver(this);
  }

  update(hours: number, minutes: number, seconds: number): void {
    this.sink.setTime(hours, minutes, seconds);
  }
}

Désormais, quiconque veut être notifié implémente ClockObserver et appelle setObserver en se passant lui-même.

Deuxième amélioration : supprimer le pilote superflu

Martin veut maintenant que plusieurs puits reçoivent l'heure : une horloge numérique, un service de rappels, un lancement de sauvegarde nocturne. Mais une double indirection le gêne : il faut dire à la source qui est l'observateur, et dire au pilote qui sont les puits. En regardant ClockObserver et TimeSink, il remarque qu'ils ont tous deux une méthode de mise à jour. Le puits pourrait donc implémenter directement ClockObserver — et le ClockDriver disparaît purement et simplement.

class MockTimeSink implements ClockObserver {
  private hours = 0;
  private minutes = 0;
  private seconds = 0;

  update(hours: number, minutes: number, seconds: number): void {
    this.hours = hours;
    this.minutes = minutes;
    this.seconds = seconds;
  }
  // getHours / getMinutes / getSeconds...
}

« Pourquoi ai-je cru avoir besoin d'un ClockDriver au départ ? » C'est nettement plus simple. La source enregistre les observateurs dans une liste et les notifie tous lors d'un changement d'heure.

class MockTimeSource implements TimeSource {
  private observers: ClockObserver[] = [];

  registerObserver(observer: ClockObserver): void {
    this.observers.push(observer);
  }

  setTime(hours: number, minutes: number, seconds: number): void {
    for (const observer of this.observers) {
      observer.update(hours, minutes, seconds);
    }
  }
}

Troisième amélioration : remonter l'inscription dans une classe de base

Martin n'aime pas que MockTimeSource gère lui-même l'inscription et la notification : chaque dérivée de TimeSource — dont Clock — devrait dupliquer ce code. Il déplace donc cette mécanique dans une classe de base. TimeSource passe d'interface à classe, et MockTimeSource se réduit à presque rien.

class TimeSource {
  private observers: ClockObserver[] = [];

  registerObserver(observer: ClockObserver): void {
    this.observers.push(observer);
  }

  protected notify(hours: number, minutes: number, seconds: number): void {
    for (const observer of this.observers) {
      observer.update(hours, minutes, seconds);
    }
  }
}

class MockTimeSource extends TimeSource {
  setTime(hours: number, minutes: number, seconds: number): void {
    this.notify(hours, minutes, seconds);
  }
}

C'est élégant, mais un dernier point dérange : si MockTimeSource hérite de TimeSource, alors Clock aussi devra en hériter. Pourquoi Clock, qui ne sait que le temps, devrait-il dépendre de la mécanique d'inscription et de notification ? En C++, Martin résoudrait cela par héritage multiple (un ObservableClock héritant de Clock et de TimeSource) ; en Java, faute d'héritage multiple de classes, il faudrait un « bricolage » par délégation, fonctionnel mais coûteux. Conclusion pragmatique : on revient en arrière et on accepte que Clock dépende de cette mécanique. Toutes les structures ne sont qu'un compromis.

Quatrième amélioration : du modèle push au modèle pull, et la forme canonique

TimeSource est devenu un mauvais nom pour ce que fait la classe. Le patron Observer appelle cette classe Subject (sujet). Mais la spécificité du modèle « push » (où la donnée est poussée dans notify et update) interdit de généraliser. En passant à un modèle « pull », on rend la classe générique : plutôt que de passer l'heure dans la notification, on laisse le puits demander l'heure au sujet via une interface dédiée. Et l'on atteint la forme canonique.

interface Observer {
  update(): void;
}

class Subject {
  private observers: Observer[] = [];

  registerObserver(observer: Observer): void {
    this.observers.push(observer);
  }

  protected notifyObservers(): void {
    for (const observer of this.observers) {
      observer.update();
    }
  }
}

interface TimeSource {
  getHours(): number;
  getMinutes(): number;
  getSeconds(): number;
}

class MockTimeSource extends Subject implements TimeSource {
  private hours = 0;
  private minutes = 0;
  private seconds = 0;

  setTime(hours: number, minutes: number, seconds: number): void {
    this.hours = hours;
    this.minutes = minutes;
    this.seconds = seconds;
    this.notifyObservers();
  }

  getHours(): number { return this.hours; }
  getMinutes(): number { return this.minutes; }
  getSeconds(): number { return this.seconds; }
}

class MockTimeSink implements Observer {
  private hours = 0;
  private minutes = 0;
  private seconds = 0;

  constructor(private source: TimeSource) {}

  update(): void {
    this.hours = this.source.getHours();
    this.minutes = this.source.getMinutes();
    this.seconds = this.source.getSeconds();
  }
}

Push contre pull : choisir selon la complexité

La forme canonique tient en peu de chose : Clock (un Subject) est observé par DigitalClock (un Observer). DigitalClock s'inscrit, Clock appelle notify à chaque changement d'heure, notify appelle update sur chaque observateur, et l'observateur en profite pour redemander l'heure à l'horloge.

       Subject  ◇──────────► «interface» Observer
  + registerObserver(...)         + update()
  # notify()                          ▲
       ▲                              │
     Clock                       DigitalClock

Il existe deux modèles d'Observer. Le modèle pull (celui ci-dessus) tire son nom du fait que l'observateur doit aller chercher l'information dans le sujet après réception d'update. Avantage : simplicité, et possibilité de faire de Subject et Observer des éléments réutilisables d'une bibliothèque. Inconvénient : imaginez observer une fiche employé à mille champs et recevoir updatelequel des mille champs a changé ? On ne sait pas quoi faire.

Le modèle push répond à ce besoin : notify et update prennent alors un argument — un indice (hint) poussé du sujet vers l'observateur, qui précise la nature du changement. Cet indice peut être une énumération, une chaîne, ou une structure contenant l'ancienne et la nouvelle valeur.

type EmployeeHint = "salaire" | "nom" | "patron" | "compteBancaire";

interface EmployeeObserver {
  update(hint: EmployeeHint): void;
}

Le choix se résume à la complexité de l'objet observé : objet complexe et observateur qui a besoin d'aide → push ; objet simple → pull suffit.

Piège courant

Observer est de ces patrons qu'on voit partout une fois compris, et l'indirection qu'il offre est séduisante : on inscrit des observateurs sur toutes sortes d'objets au lieu d'écrire ces objets pour qu'ils nous rappellent explicitement. Mais l'abus d'Observer rend les systèmes très difficiles à comprendre et à tracer. À doser.

Observer est avant tout piloté par l'OCP : on ajoute de nouveaux observateurs sans modifier l'objet observé, qui reste fermé. Le principe de substitution de Liskov (Liskov Substitution Principle, LSP) y est présent dans les deux sens : Clock est substituable à Subject et DigitalClock est substituable à Observer. Vient ensuite le principe d'inversion des dépendances (Dependency-Inversion Principle, DIP), car les classes concrètes dépendent de l'abstraction Observer. Subject, bien que sans méthode abstraite, est logiquement abstraite : elle ne prend sens que dérivée, et l'on peut forcer cette abstraction en rendant ses constructeurs protégés. Enfin, le principe de ségrégation des interfaces (Interface-Segregation Principle, ISP) affleure dans la forme finale : Subject et TimeSource ségrèguent les clients de MockTimeSource, en offrant à chacun l'interface spécialisée dont il a besoin — l'inscription d'un côté, la lecture de l'heure de l'autre.

Abstract Server, Adapter & Bridge : la lampe et le modem

Pour ces trois patrons, Martin part d'un problème volontairement trivial débattu pendant des mois sur le newsgroup comp.object : le logiciel d'une lampe de table. Il y a un interrupteur (Switch) et une lumière (Light). On peut demander à l'interrupteur son état, et dire à la lumière de s'allumer ou de s'éteindre.

   Switch ──────► Light
                  + turnOn()
                  + turnOff()

Ce design fonctionne, mais viole deux principes. Le DIP : la dépendance de Switch vers Light est une dépendance sur une classe concrète, alors que le DIP recommande de dépendre d'abstractions. L'OCP, plus subtilement mais plus gravement : ce design nous force à traîner une Light partout où l'on a besoin d'un Switch. L'interrupteur ne peut pas être étendu pour piloter autre chose qu'une lumière. Hériter un FanSwitch de Switch n'y change rien, car il hériterait aussi de la dépendance sur Light.

Abstract Server : interposer une interface

Le plus simple des patrons résout cela : Abstract Server. On interpose une interface entre Switch et Light. L'interrupteur peut désormais piloter tout ce qui implémente cette interface, satisfaisant d'un coup le DIP et l'OCP.

interface Switchable {
  turnOn(): void;
  turnOff(): void;
}

class Light implements Switchable {
  turnOn(): void { /* allumer */ }
  turnOff(): void { /* éteindre */ }
}

class Switch {
  constructor(private device: Switchable) {}

  poll(state: boolean): void {
    if (state) this.device.turnOn();
    else this.device.turnOff();
  }
}

Astuce

Remarquez le nom : Switchable, et non iLight. L'interface appartient au client, pas à la dérivée. Le lien logique entre le client et l'interface est plus fort qu'entre l'interface et ses implémentations : déployer Switch sans Switchable n'a aucun sens, mais déployer Switchable sans Light en a parfaitement un. C'est pourquoi on ne regroupe pas une hiérarchie d'héritage dans un même paquet : on regroupe plutôt les clients avec les interfaces qu'ils pilotent.

Adapter : adapter une interface qu'on ne peut pas modifier

Le design Abstract Server a un défaut potentiel : il viole le principe de responsabilité unique (Single-Responsibility Principle, SRP) en liant Light et Switchable, qui pourraient changer pour des raisons différentes. Et que faire si l'on ne peut pas ajouter l'héritage à Light — par exemple parce qu'on l'a achetée à un tiers et qu'on n'a pas le code source ? Entre l'Adapter.

L'adaptateur dérive de Switchable et délègue à Light. On peut alors piloter par un Switch n'importe quel objet allumable, même si ses méthodes ne s'appellent pas turnOn / turnOff : l'adaptateur traduit l'interface.

// Objet tiers, interface incompatible, non modifiable.
class Light {
  illuminate(): void { /* ... */ }
  extinguish(): void { /* ... */ }
}

// L'adaptateur (forme « objet ») : implémente la cible, délègue à l'adapté.
class LightAdapter implements Switchable {
  constructor(private light: Light) {}

  turnOn(): void { this.light.illuminate(); }
  turnOff(): void { this.light.extinguish(); }
}

Attention

Rien n'est gratuit (TANSTAAFL). Un adaptateur exige d'écrire une classe, de l'instancier, de lier l'objet adapté, et de payer le coût de la délégation à chaque appel. N'en abusez donc pas : Abstract Server convient à la plupart des cas, et même le tout premier design (couplage direct) est correct tant que vous ne savez pas qu'il y aura d'autres objets à piloter.

La version montrée ci-dessus est l'adaptateur de forme objet. Il existe une forme classe où l'adaptateur hérite à la fois de l'interface cible et de la classe adaptée : un brin plus efficace et plus simple d'usage, mais au prix du fort couplage de l'héritage.

Le problème du modem : Adapter contre violations de LSP et OCP

L'exemple canonique est celui du modem. Une interface Modem (dial, hangup, send, receive) est implémentée par HayesModem, USRoboticsModem, ErniesModem, et des centaines de clients l'utilisent. Conforme à l'OCP, au LSP, au DIP. Puis arrive une exigence : des modems dédiés (dedicated) qui ne composent pas de numéro, utilisés par de nouvelles applications (les DedUsers). Mais le client interdit de modifier les centaines de clients existants.

La solution idéale — scinder les interfaces Dialler et Modem avec l'ISP — est exclue, car elle obligerait à toucher tous les clients. Tentation suivante : faire dériver DedicatedModem de Modem et implémenter dial/hangup à vide.

// ❌ Fonctions dégénérées : signe d'une violation du LSP.
class DedicatedModem implements Modem {
  dial(phoneNumber: string): void { /* ne fait rien */ }
  hangup(): void { /* ne fait rien */ }
  send(c: string): void { /* ... */ }
  receive(): string { /* ... */ return ""; }
}

Les fonctions dégénérées trahissent une violation du LSP. Les clients s'attendent à ce qu'un modem reste dormant tant que dial n'est pas appelé, et le redevienne après hangup. Or DedicatedModem émet des caractères avant tout dial et après hangup : il peut faire planter certains clients. On pourrait simuler l'état de connexion (dial/hangup activent un drapeau interne) et demander aux DedUsers d'appeler dial et hangup — un bricolage (kludge).

Ce bricolage engendre une cascade de dépendances. Des mois plus tard, le client veut des numéros de longueur arbitraire (appels internationaux). Il faut changer tous les clients (passage de char[10] à chaîne)… et les DedUsers, qui n'appelaient dial que parce qu'on le leur avait imposé. Un bricolage local a propagé un fil de dépendance vénéneux jusqu'à une partie du système qui aurait dû rester indépendante.

L'Adapter aurait évité tout cela. DedicatedModem n'hérite pas de Modem. Les clients l'utilisent indirectement via un DedicatedModemAdapter qui implémente dial/hangup pour simuler l'état de connexion et délègue send/receive au modem dédié.

class DedicatedModem {
  send(c: string): void { /* ... */ }
  receive(): string { return ""; }
}

class DedicatedModemAdapter implements Modem {
  constructor(private modem: DedicatedModem) {}

  // Simule l'état de connexion attendu par les clients.
  dial(phoneNumber: string): void { /* simule la connexion */ }
  hangup(): void { /* simule la déconnexion */ }

  send(c: string): void { this.modem.send(c); }
  receive(): string { return this.modem.receive(); }
}

Les clients voient le comportement de connexion qu'ils attendent ; les DedUsers n'ont plus à toucher dial/hangup et seront indemnes lors du changement de format de numéro. On a corrigé les violations du LSP et de l'OCP. Le bricolage existe toujours — l'adaptateur simule encore l'état — mais toutes les dépendances pointent loin de l'adaptateur : la verrue est isolée, tapie là où presque personne ne la connaît.

Bridge : deux axes de variation indépendants

Il existe une autre lecture du problème. Le besoin de modem dédié a introduit un deuxième degré de liberté dans la hiérarchie Modem. Au départ, Modem ne variait que selon le matériel (Hayes, USR, Ernie). Voici qu'apparaît un second axe : le type de connexion (composé ou dédié). Fusionner les deux hiérarchies donne une explosion combinatoire : HayesDialModem, HayesDedicatedModem, USRDialModem… Chaque nouveau matériel impose deux classes, chaque nouveau type de connexion en impose autant que de matériels.

                 Modem
        ┌──────────┴──────────┐
   DialModem            DedicatedModem
   ┌───┼───┐              ┌───┼───┐
 Hayes USR Ernie       Hayes USR Ernie   (explosion combinatoire)
 Dial  Dial Dial       Ded   Ded  Ded

Le patron Bridge aide quand une hiérarchie possède plus d'un degré de liberté. Au lieu de fusionner, on sépare les deux hiérarchies et on les relie par un pont. D'un côté, la hiérarchie de la politique de connexion ; de l'autre, celle de l'implémentation matérielle.

  «interface»                         «interface»
    Modem                           ModemImplementation
      ▲                                 + dial/hangup/send/receive
      │                                          ▲
  ModemConnectionController  ◇────délègue───────┤
  {abstract}                                     ├── HayesModem
    # dialImp/hangImp/sendImp/receiveImp         ├── ErniesModem
    + dial/hangup/send/receive                   └── USRoboticsModem

   ┌──┴───────────┐
 DedModemController  DialModemController

Les utilisateurs de modem continuent d'utiliser l'interface Modem. ModemConnectionController l'implémente, et ses dérivées pilotent le mécanisme de connexion. DialModemController transmet dial/hangup aux méthodes protégées dialImp/hangImp de la base, qui délèguent à la ModemImplementation matérielle. DedModemController, lui, implémente dial/hangup pour simuler l'état de connexion, et délègue send/receive à leurs imps respectives.

interface ModemImplementation {
  dialImp(phoneNumber: string): void;
  hangImp(): void;
  sendImp(c: string): void;
  receiveImp(): string;
}

abstract class ModemConnectionController implements Modem {
  // Protégées : réservées aux dérivées, personne d'autre ne les appelle.
  constructor(protected imp: ModemImplementation) {}

  abstract dial(phoneNumber: string): void;
  abstract hangup(): void;

  send(c: string): void { this.imp.sendImp(c); }
  receive(): string { return this.imp.receiveImp(); }
}

// Axe « connexion composée » : délègue dial/hangup au matériel.
class DialModemController extends ModemConnectionController {
  dial(phoneNumber: string): void { this.imp.dialImp(phoneNumber); }
  hangup(): void { this.imp.hangImp(); }
}

// Axe « connexion dédiée » : simule l'état de connexion.
class DedModemController extends ModemConnectionController {
  dial(phoneNumber: string): void { /* simule la connexion */ }
  hangup(): void { /* simule la déconnexion */ }
}

Ainsi, les deux axes varient indépendamment : chaque dérivée de ModemConnectionController est une nouvelle politique de connexion, chaque ModemImplementation un nouveau matériel. L'ISP pourrait même ajouter de nouvelles interfaces au contrôleur, ouvrant une voie de migration vers une API de plus haut niveau que dial/hangup.

À retenir

On pourrait blâmer les concepteurs initiaux : connexion et communication auraient dû être séparées dès l'origine. Balivernes. Il n'existe pas d'« analyse suffisante » : quel que soit le temps passé à chercher la structure parfaite, le client introduira toujours un changement qui la viole. Il n'y a pas de structure parfaite, seulement des structures qui équilibrent les coûts et bénéfices du moment et doivent évoluer ensuite.

La morale : l'Adapter est simple et direct, garde les dépendances dans le bon sens et s'implémente vite. Le Bridge est nettement plus complexe ; ne vous y engagez pas tant que vous n'avez pas la preuve solide qu'il faut séparer entièrement les deux politiques et en ajouter de nouvelles. Un patron a toujours un coût et un bénéfice : choisissez ceux qui collent au problème du moment.

À retenir

  • Composite fait passer un groupe d'objets pour un objet unique : il permet d'obtenir un comportement un-à-plusieurs sans la complexité d'une relation un-à-plusieurs, à condition que tous les éléments soient traités identiquement (exemples CompositeShape, CompositeCommand).
  • Observer se construit ici à reculons, par évolution incrémentale guidée par les tests et les principes : la leçon est la méthode (petits pas, couplage minimal) autant que le patron.
  • Choisir entre push (un indice est poussé pour dire quoi a changé) et pull (l'observateur redemande la donnée) dépend de la complexité de l'objet observé ; Observer est avant tout une application de l'OCP.
  • Abstract Server interpose une interface entre client et serveur pour satisfaire DIP et OCP — et l'interface appartient au client (Switchable, pas iLight).
  • Adapter adapte une interface incompatible ou non modifiable par délégation ; il isole les bricolages et empêche les violations de LSP/OCP de contaminer le système (problème du modem dédié).
  • Bridge sépare une abstraction de son implémentation sur deux axes de variation indépendants, évitant l'explosion combinatoire — mais à un coût de complexité élevé, à ne payer qu'en cas de besoin avéré.
  • Il n'existe pas d'analyse parfaite ni de structure définitive : on équilibre coûts et bénéfices du moment et on remanie quand les exigences changent.