Agile Software Development
Chapitre 8 / 15 · 16 min de lecture

Façade, Mediator, Singleton, Monostate & Null Object

Simplifier l'accès à un sous-système (Façade, Mediator), garantir une instance unique (Singleton, Monostate), et supprimer les null (Null Object).

Plutôt que de présenter les patrons en catalogue, Martin les fait émerger dans du vrai code, au fil de problèmes concrets. Ce chapitre rassemble cinq patrons que l'auteur traite ensemble parce qu'ils répondent à des besoins voisins : imposer une politique à un groupe d'objets (Façade et Mediator), garantir l'unicité d'une instance (Singleton et Monostate), et bannir les retours null (Null Object). Tous partagent une vertu : ils déplacent une décision ou une vérification hors du code client, qui s'en trouve allégé et plus expressif.

Façade et Mediator : imposer une politique

Façade et Mediator poursuivent un but commun : imposer une politique (policy) à un groupe d'objets. La différence tient à la direction et à la visibilité. La façade (Facade) impose sa politique par le haut, de façon visible et contraignante ; le médiateur (Mediator) l'impose par le bas, de façon invisible et permissive. L'une est un point de passage obligé que tout le monde connaît ; l'autre agit en coulisses, à l'insu des objets qu'il coordonne.

Façade : une interface simple sur un sous-système complexe

On recourt au patron façade (Facade) lorsqu'on veut offrir une interface simple et spécifique par-dessus un groupe d'objets à l'interface complexe et générale. L'exemple canonique de Martin est une classe DB qui impose une interface réduite, spécifique aux données produit, par-dessus les classes complexes et générales du paquet java.sql. En TypeScript, imaginons un sous-système d'accès à une base de données — connexion, requêtes préparées, curseurs de résultats — que l'on enveloppe derrière une poignée de méthodes métier.

// Le sous-système : complexe, général, verbeux.
interface Connexion {
  ouvrir(url: string): void;
  fermer(): void;
  preparer(sql: string): RequetePreparee;
}
interface RequetePreparee {
  lier(index: number, valeur: unknown): void;
  executer(): Resultat;
}
interface Resultat {
  suivant(): boolean;
  champ(nom: string): unknown;
}

type DonneesProduit = { sku: string; nom: string; prix: number };

// La FAÇADE : simple, spécifique, contraignante.
class DB {
  constructor(private readonly connexion: Connexion) {}

  enregistrer(produit: DonneesProduit): void {
    const requete = this.connexion.preparer(
      "INSERT INTO produits (sku, nom, prix) VALUES (?, ?, ?)",
    );
    requete.lier(0, produit.sku);
    requete.lier(1, produit.nom);
    requete.lier(2, produit.prix);
    requete.executer();
  }

  lireProduit(sku: string): DonneesProduit | null {
    const requete = this.connexion.preparer(
      "SELECT * FROM produits WHERE sku = ?",
    );
    requete.lier(0, sku);
    const resultat = requete.executer();
    if (!resultat.suivant()) return null;
    return {
      sku: resultat.champ("sku") as string,
      nom: resultat.champ("nom") as string,
      prix: resultat.champ("prix") as number,
    };
  }
}

La classe DB protège l'application de l'intimité du sous-système. Elle sait initialiser et fermer la connexion, traduire un DonneesProduit en champs de table et inversement, construire les bonnes requêtes — et elle cache toute cette complexité à ses utilisateurs. Du point de vue de l'application, java.sql (ou son équivalent) n'existe pas : il est dissimulé derrière la façade.

À retenir

Le recours à une façade implique une convention : tous les appels à la base doivent passer par DB. Si une partie du code attaque directement le sous-système plutôt que la façade, la convention est rompue. La façade impose donc sa politique en devenant, par accord collectif, le courtier unique des services du sous-système. C'est une contrainte visible et assumée.

Mediator : la politique en coulisses

Le médiateur (Mediator) impose lui aussi une politique, mais de manière cachée et non contraignante. L'exemple de Martin est un QuickEntryMediator qui se tient discrètement en retrait pour lier un champ de saisie à une liste : à mesure que l'on tape, le premier élément de la liste qui correspond au préfixe saisi se trouve sélectionné. Cela permet de taper une abréviation et de sélectionner rapidement un élément. Transposons l'idée en TypeScript, en faisant abstraction du framework d'interface :

interface ChampTexte {
  texte(): string;
  surChangement(rappel: () => void): void;
}
interface Liste {
  elements(): string[];
  selectionner(index: number): void;
  effacerSelection(): void;
}

// Le MEDIATOR : personne ne sait qu'il existe.
class MediateurSaisieRapide {
  constructor(
    private readonly champ: ChampTexte,
    private readonly liste: Liste,
  ) {
    // Il s'abonne lui-même aux changements du champ.
    this.champ.surChangement(() => this.champChange());
  }

  private champChange(): void {
    const prefixe = this.champ.texte();
    if (prefixe.length === 0) {
      this.liste.effacerSelection();
      return;
    }
    const index = this.liste
      .elements()
      .findIndex((e) => e.startsWith(prefixe));
    if (index >= 0) this.liste.selectionner(index);
    else this.liste.effacerSelection();
  }
}

// Côté client : on le crée, et on l'oublie.
declare const champ: ChampTexte;
declare const liste: Liste;
new MediateurSaisieRapide(champ, liste);
// C'est tout. (Mais ne le laissez pas filer au ramasse-miettes.)

Le détail savoureux que souligne Martin : il n'y a aucune méthode à appeler sur ce médiateur. On le crée, et on l'oublie — en prenant garde de ne pas le laisser collecter par le ramasse-miettes. Les utilisateurs du champ et de la liste n'ont aucune idée que ce médiateur existe. Il reste là, silencieux, imposant sa politique à ces objets sans leur permission ni leur connaissance.

Note

Le contraste se résume ainsi : une façade est le point focal d'une convention — tout le monde s'accorde à l'utiliser plutôt que les objets sous-jacents. Un médiateur, lui, est caché de ces objets ; sa politique est un fait accompli, non une affaire de convention. Choisissez la façade quand la politique doit être grande et visible ; le médiateur quand subtilité et discrétion sont de mise.

Singleton et Monostate : garantir l'unicité

La relation entre classes et instances est d'ordinaire de un-à-plusieurs : on crée autant d'instances qu'on veut, elles naissent quand on en a besoin et disparaissent ensuite. Mais certaines classes ne devraient avoir qu'une seule instance, qui semble exister dès le démarrage du programme et n'être détruite qu'à sa fin. Ces objets sont souvent des racines (à partir desquelles on rejoint tous les autres objets du système), des fabriques, ou des gestionnaires.

C'est une grave erreur de logique d'en créer plusieurs. Deux racines, et l'accès aux objets dépend de la racine choisie : un programmeur ignorant l'existence de la seconde croira voir tout le système alors qu'il n'en voit qu'un sous-ensemble. Deux fabriques, et le contrôle des objets créés est compromis. Deux gestionnaires, et des activités censées être sérielles deviennent concurrentes.

Astuce

Les mécanismes d'unicité peuvent sembler excessifs : après tout, on peut simplement créer un exemplaire de chacun à l'initialisation et en rester là — et c'est souvent la meilleure approche. Mais on veut aussi que le code communique notre intention. Si le mécanisme qui garantit l'unicité est trivial, le bénéfice d'expressivité l'emporte sur son coût. Ce chapitre présente deux mécanismes aux compromis coût/bénéfice très différents.

Singleton : l'unicité par la structure

Le singleton (Singleton) repose sur un mécanisme de classe : constructeur privé, variable statique stockant l'unique instance, et méthode statique d'accès. Le cas de test que Martin écrit en premier exprime exactement la spécification : appeler instance() plusieurs fois renvoie toujours la même référence, et il n'existe aucun constructeur public permettant de créer une instance autrement.

class Singleton {
  // L'unique instance, stockée dans un champ statique.
  private static instanceUnique: Singleton | null = null;

  // Constructeur privé : aucun `new` depuis l'extérieur.
  private constructor() {}

  static instance(): Singleton {
    if (Singleton.instanceUnique === null) {
      Singleton.instanceUnique = new Singleton();
    }
    return Singleton.instanceUnique;
  }
}

const s = Singleton.instance();
const s2 = Singleton.instance();
console.log(s === s2); // true : exactement le même objet

L'inspection du code suffit à se convaincre qu'il ne pourra jamais exister plus d'une instance dans la portée de la variable statique. Martin recense les bénéfices du singleton : il est multiplateforme (cross platform) — moyennant un intergiciel approprié (du type RMI), il peut s'étendre à travers plusieurs JVM et plusieurs machines ; il s'applique à n'importe quelle classe (rendre les constructeurs privés et ajouter le couple statique suffit) ; il peut être créé par dérivation ; et il est paresseux (lazy) — s'il n'est jamais utilisé, il n'est jamais créé. Côté coûts : la destruction est indéfinie (nuller l'instance pendant que d'autres modules en détiennent une référence conduit à recréer une seconde instance concurrente) ; il n'est pas hérité (une sous-classe d'un singleton n'est pas un singleton) ; l'if de garde est inutile à la quasi-totalité des appels ; et il est non transparent — les utilisateurs savent qu'ils manipulent un singleton, puisqu'ils doivent invoquer instance().

Singleton en action, via une façade

Martin montre le singleton à l'œuvre sur un système web d'authentification. Plutôt que d'éparpiller l'API tierce de la base dans chaque module, on crée — par le patron façade — une classe BaseUtilisateurs qui lit et écrit des objets User, et au sein de laquelle on peut imposer des conventions : refuser tout User au nom vide, sérialiser les accès pour éviter qu'un même enregistrement soit lu et écrit simultanément. On en fait ensuite un singleton pour garantir que tout passe par une instance unique. Notez que l'implémentation tire parti de l'initialisation statique du langage, ce qui dispense de l'if de garde :

interface BaseUtilisateurs {
  lireUtilisateur(nom: string): User;
  ecrireUtilisateur(utilisateur: User): void;
}

class SourceBaseUtilisateurs implements BaseUtilisateurs {
  // Initialisé une fois, à la définition de la classe.
  private static readonly instanceUnique: BaseUtilisateurs =
    new SourceBaseUtilisateurs();

  static instance(): BaseUtilisateurs {
    return SourceBaseUtilisateurs.instanceUnique;
  }

  private constructor() {}

  lireUtilisateur(nom: string): User {
    // ... accès à l'API tierce, application des conventions.
    return User.NULL; // placeholder
  }

  ecrireUtilisateur(utilisateur: User): void {
    // ... écriture, vérification du nom non vide, verrou.
  }
}

C'est un usage extrêmement courant : on s'assure que tout accès à la base passe par une instance unique, ce qui rend trivial l'ajout de vérifications, de compteurs et de verrous au seul endroit qui compte.

Monostate : l'unicité par le comportement

Le monostate (Monostate) atteint l'unicité par un mécanisme radicalement différent. Son cas de test est troublant : deux instances de la même classe se comportent comme si elles n'en faisaient qu'une. Si l'on fixe la variable x sur une instance, on la retrouve en interrogeant x sur une autre instance — comme si les deux n'étaient que deux noms du même objet.

class Monostate {
  // L'état est statique : partagé par TOUTES les instances.
  private static itsX = 0;

  // Constructeur public, instances ordinaires.
  // Méthodes NON statiques : c'est essentiel.
  setX(x: number): void {
    Monostate.itsX = x;
  }

  getX(): number {
    return Monostate.itsX;
  }
}

const m1 = new Monostate();
const m2 = new Monostate();
m1.setX(42);
console.log(m2.getX()); // 42 : deux instances, un seul état

Le secret tient en une ligne : toutes les variables sont statiques, mais aucune méthode ne l'est. Peu importe combien d'instances de Monostate vous créez, elles se comportent toutes comme un objet unique. Vous pouvez même détruire toutes les instances courantes sans perdre les données. Martin remarque qu'on pourrait brancher un singleton dans le cas de test du monostate et qu'il passerait : ce test décrit le comportement du singleton sans en imposer la contrainte d'instance unique. L'inverse n'est pas vrai — le test du singleton n'a aucun sens pour un monostate.

Note

Toute la différence est là : comportement contre structure. Le singleton impose la structure de l'unicité (il empêche la création d'une seconde instance). Le monostate impose le comportement de l'unicité sans aucune contrainte structurelle (on crée autant d'instances qu'on veut, elles partagent le même état).

Les bénéfices du monostate inversent point par point les coûts du singleton. Transparence : ses utilisateurs ne se comportent pas différemment des utilisateurs d'un objet ordinaire — ils ignorent qu'il s'agit d'un monostate. Dérivabilité : les dérivées d'un monostate sont elles-mêmes des monostates, partageant le même état statique. Polymorphisme : ses méthodes n'étant pas statiques, elles peuvent être redéfinies dans les sous-classes, qui offrent ainsi des comportements différents sur le même jeu de variables statiques. Création et destruction bien définies : les variables statiques ont des temps de vie nets. En face, ses coûts : pas de conversion (on ne transforme pas une classe normale en monostate par dérivation) ; efficacité moindre (c'est un vrai objet, soumis à de réelles créations/destructions, souvent coûteuses) ; présence (ses variables occupent de la place même s'il n'est jamais utilisé) ; et localité (il ne peut pas fonctionner à travers plusieurs machines).

Monostate en action : le tourniquet de métro

L'exemple en situation est une machine à états finis pour un tourniquet de métro (turnstile). Il démarre verrouillé (Locked). Une pièce le fait passer déverrouillé (Unlocked), déverrouille le portillon, coupe l'alarme et encaisse la pièce. Quand l'usager passe, il revient verrouillé. Deux conditions anormales : une seconde pièce avant le passage est remboursée (le portillon reste ouvert) ; un passage sans paiement déclenche l'alarme (le portillon reste fermé). Le test suppose que le tourniquet est un monostate : il envoie des événements et interroge l'état depuis des instances différentes — ce qui a du sens puisqu'il n'y aura jamais plus d'un tourniquet.

                          ┌───────────────┐
              Pass / Alarm │               │
                           ▼               │
                      ┌──────────┐         │
            ┌────────►│  Locked  │─────────┘
            │         └────┬─────┘
            │              │
            │ Pass / Lock  │ Coin / Unlock, AlarmOff, Deposit
            │              ▼
            │         ┌──────────┐
            └─────────┤ Unlocked │◄────────┐
                      └────┬─────┘         │
                           │               │
                           └───────────────┘
                            Coin / Refund

L'implémentation tire parti des deux propriétés clés : les dérivées d'un monostate sont polymorphes, et elles sont elles-mêmes des monostates (donc partagent l'état de la base). La classe Tourniquet délègue les deux événements (coin, pass) à deux dérivées (Verrouille, Deverrouille) qui représentent les états.

abstract class Tourniquet {
  // Tout l'état est statique : un seul tourniquet logique.
  protected static verrouille = true;
  protected static alarmeActive = false;
  protected static pieces = 0;
  protected static remboursements = 0;

  // Les états eux-mêmes sont des dérivées (donc monostates).
  protected static readonly VERROUILLE: Tourniquet = new Verrouille();
  protected static readonly DEVERROUILLE: Tourniquet = new Deverrouille();
  protected static etatCourant: Tourniquet = Tourniquet.VERROUILLE;

  reinitialiser(): void {
    this.poserVerrou(true);
    this.poserAlarme(false);
    Tourniquet.pieces = 0;
    Tourniquet.remboursements = 0;
    Tourniquet.etatCourant = Tourniquet.VERROUILLE;
  }

  // Délégation polymorphe à l'état courant.
  inserer(): void {
    Tourniquet.etatCourant.inserer();
  }
  passer(): void {
    Tourniquet.etatCourant.passer();
  }

  // Requêtes et opérations partagées.
  estVerrouille(): boolean {
    return Tourniquet.verrouille;
  }
  alarme(): boolean {
    return Tourniquet.alarmeActive;
  }
  nbPieces(): number {
    return Tourniquet.pieces;
  }
  nbRemboursements(): number {
    return Tourniquet.remboursements;
  }

  protected poserVerrou(v: boolean): void {
    Tourniquet.verrouille = v;
  }
  protected poserAlarme(a: boolean): void {
    Tourniquet.alarmeActive = a;
  }
  protected encaisser(): void {
    Tourniquet.pieces++;
  }
  protected rembourser(): void {
    Tourniquet.remboursements++;
  }
}

class Verrouille extends Tourniquet {
  inserer(): void {
    Tourniquet.etatCourant = Tourniquet.DEVERROUILLE;
    this.poserVerrou(false);
    this.poserAlarme(false);
    this.encaisser();
  }
  passer(): void {
    this.poserAlarme(true); // passage sans payer
  }
}

class Deverrouille extends Tourniquet {
  inserer(): void {
    this.rembourser(); // seconde pièce : remboursée
  }
  passer(): void {
    this.poserVerrou(true);
    Tourniquet.etatCourant = Tourniquet.VERROUILLE;
  }
}

L'exemple révèle aussi une fragilité du monostate. Cette solution dépend fortement de la nature monostate du tourniquet : Verrouille et Deverrouille ne sont pas de vrais objets séparés, mais des parties de l'abstraction Tourniquet, qui accèdent aux mêmes variables. Le jour où il faudrait piloter plusieurs tourniquets avec cette machine à états, le code exigerait un remaniement considérable. C'est l'écueil du monostate : transformer un monostate en classe normale est difficile.

Attention

Choisissez le singleton quand vous voulez contraindre une classe existante par dérivation et que vous acceptez que tout le monde doive appeler instance(). Choisissez le monostate quand vous voulez que la nature singulière soit transparente aux utilisateurs, ou quand vous voulez exploiter des dérivées polymorphes de l'objet unique. Mais ne créez de mécanisme que là où le besoin est immédiat et significatif : souvent, créer un seul exemplaire à l'initialisation suffit.

Null Object : en finir avec les null

Considérons le code que nous avons tous écrit un jour :

const e = DB.getEmployee("Bob");
if (e !== null && e.estJourDePaie(today)) {
  e.payer();
}

On demande à la base un employé nommé « Bob » ; elle renvoie null s'il n'existe pas, sinon l'instance demandée. L'idiome est répandu parce que, dans les langages à court-circuit, le second membre du && n'est évalué que si le premier est vrai. Mais nous avons tous été brûlés un jour pour avoir oublié de tester le null. Si courant soit-il, cet idiome est laid et propice aux erreurs.

On pourrait faire lever une exception à getEmployee au lieu de renvoyer null. Mais les blocs try/catch sont parfois plus laids encore que le test de null, et imposer des exceptions dans une application existante est ardu. Le patron objet nul (Null Object) règle ces deux problèmes : il élimine souvent le besoin de tester null et simplifie le code.

La structure

Employe devient une interface dotée de deux implémentations. EmployeReel est l'implémentation normale, avec toutes les méthodes et variables attendues. getEmployee la renvoie quand l'employé existe ; sinon, elle renvoie un objet nul. Ce dernier implémente toutes les méthodes de Employe pour « ne rien faire » — où « ne rien faire » dépend de la méthode. Par exemple, estJourDePaie retourne false, puisqu'il n'est jamais temps de payer un employé inexistant.

  ┌──────────┐  utilise   ╭──«interface»──╮
  │    DB    │───────────►│    Employe    │
  └────┬─────┘            ╰───────△───────╯
       │ «crée»                   │ réalise
       ├─────────────►  ┌─────────┴─────────┐
       │                │    EmployeReel     │
       │                │      (normal)      │
       │                └────────────────────┘
       │ «crée»                   △ réalise
       └─────────────►  ┌─────────┴─────────┐
                        │    Employe.NULL    │
                        │ (anonyme, ne fait  │
                        │       rien)        │
                        └────────────────────┘

Plutôt qu'une classe EmployeNul distincte, Martin range l'objet nul dans une instance unique exposée par l'interface. En TypeScript, on l'expose comme une constante Employe.NULL, implémentée par un objet anonyme :

interface Employe {
  estJourDePaie(date: Date): boolean;
  payer(): void;
}

// Espace de noms attaché à l'interface : l'unique objet nul.
namespace Employe {
  export const NULL: Employe = {
    estJourDePaie(_date: Date): boolean {
      return false; // jamais temps de payer un employé inexistant
    },
    payer(): void {
      // ne fait rien
    },
  };
}

class DB {
  static getEmployee(nom: string): Employe {
    // Renvoie un vrai employé, ou l'objet nul s'il est absent.
    // Pour ce test, on renvoie toujours l'objet nul.
    return Employe.NULL;
  }
}

Le code appelant devient limpide, ni laid ni risqué :

const e = DB.getEmployee("Bob");
if (e.estJourDePaie(new Date())) {
  e.payer();
}

Il y a là une belle cohérence : getEmployee renvoie toujours une instance d'Employe, garantie de se comporter convenablement, que l'employé ait été trouvé ou non. Bien sûr, on voudra parfois savoir si la recherche a échoué — d'où l'intérêt de tenir l'objet nul dans une constante unique : on peut alors écrire e === Employe.NULL. C'est précisément pour que ce test soit fiable qu'il ne doit exister qu'une seule instance de l'objet nul ; personne d'autre ne peut en fabriquer.

Piège courant

Les fonctions qui renvoient null ou 0 en cas d'échec, héritées de décennies de langages à la C, présument que la valeur de retour doit être testée. Oubliez un seul de ces tests et vous récoltez une erreur à l'exécution, souvent loin de sa cause. Le Null Object renverse la logique : les fonctions renvoient toujours des objets valides, même en cas d'échec. Les objets qui représentent l'échec « ne font rien » — sans jamais provoquer de déréférencement fautif.

Astuce

Le Null Object n'est pas une excuse pour masquer silencieusement tous les échecs. Là où l'absence est une vraie information métier, conservez un moyen explicite de la détecter (la comparaison à Employe.NULL, ou une méthode estNul()). Le but est de supprimer les vérifications défensives répétitives, pas de dissimuler les erreurs qui demandent une décision.

À retenir

  • Façade contre Mediator : tous deux imposent une politique à un groupe d'objets. La façade le fait par le haut, visible et contraignante, par convention ; le médiateur par le bas, caché et permissif, en fait accompli.
  • La façade offre une interface simple et spécifique sur un sous-système complexe (la classe DB par-dessus java.sql) et en devient le courtier unique ; le médiateur se crée puis s'oublie, et coordonne des objets qui ignorent jusqu'à son existence.
  • Le singleton garantit l'unicité par la structure : constructeur privé, instance statique, accesseur statique. Bénéfices (multiplateforme via intergiciel, paresseux, applicable à toute classe, dérivable) ; coûts (destruction indéfinie, non hérité, non transparent).
  • Le monostate garantit l'unicité par le comportement : variables toutes statiques, méthodes non statiques. Il est transparent, polymorphe et ses dérivées sont aussi des monostates — au prix d'une grande difficulté à le reconvertir en classe normale (cf. le tourniquet).
  • Singleton ou monostate : le premier pour contraindre par dérivation une classe existante, le second pour rendre l'unicité transparente ou exploiter des dérivées polymorphes — mais souvent, créer un exemplaire unique à l'initialisation suffit.
  • Le Null Object remplace les retours null et les if (x !== null) par un objet polymorphe au comportement neutre. Le code est plus sûr, plus lisible, et les fonctions renvoient toujours un objet valide.
  • Tenez l'objet nul dans une instance unique (Employe.NULL) pour pouvoir tester l'absence de façon fiable — sans rétablir la vérification défensive que le patron cherchait justement à supprimer.