Refactorer vers les patrons orientés objet
Reconnaître quand un design pattern résout vraiment un problème — et refactorer vers lui sans sur-ingénierie.
Les patrons de conception (design patterns) traînent une réputation à double tranchant. Vénérés depuis le livre du Gang of Four, ils sont aussi devenus le symbole de la sur-ingénierie : ces bases de code où chaque classe finit en AbstractStrategyFactoryProxy, où l'on cherche pendant dix minutes l'endroit où le travail est réellement fait. Burchard prend le contre-pied de cette dérive. Un patron, dans son optique, n'est jamais un point de départ qu'on plaque sur un problème vierge. C'est une destination vers laquelle on refactore, par petits pas, quand — et seulement quand — le code lui-même réclame ce changement.
Ce chapitre parcourt quelques patrons utiles en JavaScript : Template Method, Strategy, State, Null Object, et la famille des « emballages » (Decorator, Adapter, Facade). À chaque fois, l'enjeu n'est pas tant la mécanique du patron — souvent triviale — que le chemin qui y mène. Quel est le code smell de départ ? Quelle douleur le patron soulage-t-il ? Et où s'arrêter avant de tomber dans la soupe de patrons ? L'objectif déclaré de Burchard pour tout le chapitre n'est d'ailleurs pas l'élégance académique mais une seule chose : du code dont on peut être confiant, c'est-à-dire bien testé et doté d'interfaces sensées.
À retenir
La méthode est invariable, quel que soit le patron. 1) Sauvegarder et committer le code. 2) Faire un petit changement. 3) Recommencer jusqu'à ce que ce soit fini. Écrivez des tests comme bon vous semble — TDD, tests de caractérisation, tests bout-en-bout, tests unitaires. Chaque transformation se fait sous la protection des tests, jamais à l'aveugle.
Template Method : remonter le commun dans la classe parente
Le patron méthode-patron (Template Method) sert quand deux algorithmes poursuivent le même but avec des variations mineures. Sa mécanique est simple : déplacer la partie commune de deux sous-classes vers leur classe parente. Le plus instructif n'est pas le patron lui-même, mais la manière dont on en arrive à le vouloir.
Burchard part d'une classe Person qui gère sa connaissance du binaire via un booléen. Le défaut saute aux yeux : le conditionnel vit dans le code client.
// ❌ Avant : le if est imposé à tout code qui utilise Person.
class Person {
constructor(binaryKnower) {
this.binaryKnower = binaryKnower;
}
whatIs(number) { return number; }
whatIsInBinary(number) { return Number('0b' + number); }
}
const personOne = new Person(true);
const personTwo = new Person(false);
[personOne, personTwo].forEach(person => {
if (person.binaryKnower) {
console.log(person.whatIsInBinary(10));
} else {
console.log(person.whatIs(10));
}
}); Le personOne connaît le binaire : on obtient 2. Le personTwo croit que 10 vaut dix : on l'affiche tel quel. Cela fonctionne, mais l'interrogation sur la connaissance du binaire est laissée à l'appelant. Conséquence : un if resurgit chaque fois qu'on veut faire interpréter un nombre. La règle générale est d'éliminer les conditionnels là où c'est sensé et de repousser la complexité loin de l'API. Mieux vaut que les couches profondes du code absorbent la complexité.
Premier pas : faire entrer le conditionnel dans Person, et remplacer le booléen par une chaîne plus expressive.
// Étape intermédiaire : le if est enfoui dans log().
class Person {
constructor(typeOfPerson) {
this.typeOfPerson = typeOfPerson;
}
whatIs(number) { return number; }
whatIsInBinary(number) { return Number('0b' + number); }
log(number) {
if (this.typeOfPerson === "binary knower") {
console.log(this.whatIsInBinary(number));
} else {
console.log(this.whatIs(number));
}
}
} Deuxième pas : supprimer le conditionnel complètement grâce au sous-classement. Chaque type de personne implémente son propre log.
class Person {}
class BinaryKnower extends Person {
log(number) { console.log(this.whatIsInBinary(number)); }
whatIsInBinary(number) { return Number('0b' + number); }
}
class BinaryOblivious extends Person {
log(number) { console.log(this.whatIs(number)); }
whatIs(number) { return number; }
} La fonction log se répète maintenant dans les deux sous-classes. Dernier pas — et c'est précisément là qu'on applique le Template Method : on remonte log dans Person, en uniformisant le nom de la fonction variable à whatIs.
// ✅ Après : log() vit dans le parent ; seul whatIs() varie.
class Person {
log(number) { console.log(this.whatIs(number)); }
}
class BinaryKnower extends Person {
whatIs(number) { return Number('0b' + number); }
}
class BinaryOblivious extends Person {
whatIs(number) { return number; }
}
const personOne = new BinaryKnower();
const personTwo = new BinaryOblivious();
[personOne, personTwo].forEach(person => person.log(10)); Note
Quand le patron a-t-il été appliqué ? À cette dernière étape, en remontant log dans Person. Le Template Method est une forme spécialisée du refactoring « remonter une méthode » (pull-up method). Ce qui en fait le patron méthode-patron, c'est qu'une partie de l'implémentation — ici whatIs — crée la variation depuis les sous-classes. On peut donc « tomber » dedans par accident, simplement en déplaçant des fonctions entre objets.
Burchard note au passage une variante fonctionnelle : si BinaryKnower et BinaryOblivious ne contiennent qu'une fonction, les classes ne se justifient peut-être pas. On peut tout réduire à des fonctions libres. L'important est de faire un choix et de tenir un style dominant dans la base de code : orientée objet ou fonctionnelle.
Strategy : injecter le comportement au lieu de sous-classer
Là où le Template Method retirait un conditionnel par le sous-classement, le patron stratégie (Strategy) retire les sous-classes en attachant une stratégie — une fonction — à l'objet parent. C'est l'étape critique suivante : avons-nous vraiment besoin de BinaryKnower et BinaryOblivious ?
La tentation serait de revenir en arrière, de poser un type dans le constructeur et de reconstruire le if dans whatIs. Ce serait régresser. La bonne voie consiste à fournir la fonction à la construction.
// ❌ Avant : deux sous-classes pour une seule fonction qui change.
class Person {
log(number) { console.log(this.whatIs(number)); }
}
class BinaryKnower extends Person {
whatIs(number) { return Number('0b' + number); }
}
class BinaryOblivious extends Person {
whatIs(number) { return number; }
} // ✅ Après : la stratégie est passée au constructeur.
class Person {
constructor(whatIs) { this.whatIs = whatIs; }
log(number) { console.log(this.whatIs(number)); }
}
const personOne = new Person(number => Number('0b' + number));
const personTwo = new Person(number => number);
[personOne, personTwo].forEach(person => person.log(10)); On peut extraire et nommer ces fonctions pour alléger le constructeur — et, avantage majeur, les tester indépendamment, sans jamais créer de Person. On pourrait être tenté de les rapatrier comme méthodes statiques de Person, mais ce serait introduire un couplage entre le contexte (la personne) et la stratégie (la conscience du binaire) que l'on cherche justement à éviter. La conscience du binaire pourrait servir demain à d'autres objets — dauphins, androïdes, extraterrestres. Mieux vaut la garder à part, dans un objet dédié.
// La stratégie vit dans son propre objet, réutilisable.
class Person {
constructor(whatIs) { this.whatIs = whatIs; }
log(number) { console.log(this.whatIs(number)); }
}
const binary = {
aware(number) { return Number('0b' + number); },
oblivious(number) { return number; }
};
const personOne = new Person(binary.aware);
const personTwo = new Person(binary.oblivious);
[personOne, personTwo].forEach(person => person.log(10)); Les stratégies sont désormais soigneusement rangées dans un objet qu'on passe au constructeur. L'intérêt est tangible : si l'on veut une nouvelle interprétation d'un nombre — octal, hexadécimal —, il suffit d'ajouter une fonction, pas une sous-classe entière.
Astuce
L'odeur de code qui appelle un Strategy est un gros conditionnel qui sélectionne un comportement (switch ou cascade de if sur un « type »), surtout s'il est dupliqué à plusieurs endroits. Chaque branche devient une stratégie interchangeable. Et chaque nouvelle variante n'ajoute qu'une fonction, sans toucher au reste — l'esprit du principe ouvert/fermé.
State : des stratégies qui savent passer d'un état à l'autre
Le patron état (State) est un cran au-dessus de Strategy, mais il en découle naturellement. Supposons que « connaître le binaire » signifie en réalité maîtriser plusieurs opérations : read, and, xor. Avec un Strategy pur, il faudrait gonfler le constructeur d'un paramètre par opération — ça devient vite ingérable. Le remède simple : regrouper toute la « conscience » et toute l'« ignorance » dans deux objets de connaissance.
class Person {
constructor(binaryKnowledge) {
this.binaryKnowledge = binaryKnowledge;
}
}
const binaryAwareness = {
read(number) { return Number('0b' + number); },
and(a, b) { return a & b; },
xor(a, b) { return a ^ b; }
};
const binaryObliviousness = {
read(number) { return number; },
and(a, b) { return "unknown"; },
xor(a, b) { return "unknown"; }
}; Ce qui distingue ce code du véritable patron State, c'est qu'il manque les transitions entre objets. Le State ajoute justement la capacité de changer d'état. On ajoute une méthode change sur Person, une fonction forget sur l'objet « conscient » et une fonction learn sur l'objet « ignorant ».
// ✅ Après : les états savent transiter de l'un vers l'autre.
class Person {
constructor(binaryKnowledge) {
this.binaryKnowledge = binaryKnowledge;
}
change(binaryKnowledge) {
this.binaryKnowledge = binaryKnowledge;
}
}
const binaryAwareness = {
read(number) { return Number('0b' + number); },
and(a, b) { return a & b; },
xor(a, b) { return a ^ b; },
forget(person) { person.change(binaryObliviousness); }
};
const binaryObliviousness = {
read(number) { return number; },
and(a, b) { return "unknown"; },
xor(a, b) { return "unknown"; },
learn(person) { person.change(binaryAwareness); }
};
personOne.binaryKnowledge.forget(personOne);
personTwo.binaryKnowledge.learn(personTwo); Deux choses sont suspectes ici. D'abord, passer un objet person à forget et learn est maladroit. Ensuite, les objets de connaissance peuvent être redéfinis : const protège la liaison, pas les fonctions qu'elle contient. Redéfinir une fonction sur un état partagé la redéfinit pour tous les objets qui le référencent. Burchard corrige les deux points avec Object.create (pour créer un nouvel objet qui masque le state partagé sans le modifier) et Object.assign (pour établir un lien bidirectionnel this.person).
// Constructeur sûr : nouvel objet dérivé + lien retour vers la personne.
class Person {
constructor(binaryKnowledge) {
this.binaryKnowledge = Object.create(
Object.assign({ person: this }, binaryKnowledge));
}
change(binaryKnowledge) {
this.binaryKnowledge = Object.create(
Object.assign({ person: this }, binaryKnowledge));
}
}
// forget() et learn() utilisent alors this.person, sans argument. Attention
Le State passe très bien à l'échelle, mais y refactorer juste pour supprimer des conditionnels est agressif : ajouter une classe ou un objet par état peut déplaire à l'équipe et exploser si les états se ramifient. Faut-il un état pour chaque combinaison de « connaît le binaire », « parle anglais » et « a un animal » ? Non. La simplicité doit l'emporter sur la réalisation complète du patron. Si vos états n'ont rien à voir entre eux, ils n'ont rien à faire ensemble.
Au cœur du State se cache une idée plus générale et sous-estimée : la puissance du « a-un » (has-a). L'orienté objet tend à privilégier les hiérarchies (est-un) au détriment de la délégation. Le State est une forme relativement complexe de délégation — mais les objets délégués simples ne devraient pas être négligés pour autant.
Null Object : remplacer les vérifications de null par un objet poli
Combien de vos if testent null, undefined ou l'existence d'une variable ? Probablement une large part. Et l'absence de ces tests explique sans doute une large part de vos erreurs. Tony Hoare a inventé la référence nulle en 1965 et l'a qualifiée, en 2009, de son « erreur à un milliard de dollars ». Le patron objet nul (Null Object) — absent des 23 patrons du Gang of Four et probablement le plus sous-utilisé — y apporte une réponse.
Le problème démarre dès qu'une valeur peut être null. Une AnonymousPerson dont le name vaut null fait exploser la première fonction qui la touche.
// ❌ Avant : un null se propage et contamine chaque fonction.
function capitalize(string) {
if (string === null) { return null; }
return string[0].toUpperCase() + string.substring(1);
}
function tigerify(string) {
if (string === null) { return null; }
return `${string}, the tiger`;
}
function display(string) {
if (string === null) { return ''; }
return string;
}
console.log(display(tigerify(capitalize(personTwo.name)))); L'anti-patron est clair : soit on teste null partout, soit on s'expose aux erreurs et aux affichages absurdes (null, the tiger). Un seul null déclenche une cascade de vérifications conditionnelles — et de tristesse. La solution : traiter les noms comme des objets, et non plus comme des chaînes ou des null. On crée deux classes aux interfaces miroir : NameString (le vrai nom) et NullString (l'objet nul). Chacune implémente les mêmes méthodes, mais NullString retourne des valeurs neutres.
// ✅ Après : NullString offre la même interface, sans null.
class Person {
constructor(name) { this.name = new NameString(name); }
}
class AnonymousPerson extends Person {
constructor() { super(); this.name = new NullString(); }
}
class NameString extends String {
capitalize() {
return new NameString(this[0].toUpperCase() + this.substring(1));
}
tigerify() { return new NameString(`${this}, the tiger`); }
display() { return this.toString(); }
}
class NullString {
capitalize() { return this; }
tigerify() { return this; }
display() { return ''; }
}
const personOne = new Person("tony");
const personTwo = new AnonymousPerson("tony");
console.log(personOne.name.capitalize().tigerify().display());
console.log(personTwo.name.capitalize().tigerify().display()); Le changement le plus net est l'apparition de ces deux classes et le passage de l'imbrication au chaînage : name.capitalize().tigerify().display() se lit de gauche à droite. Plus aucun test de null n'encombre la logique métier : NullString absorbe l'absence et la transforme en valeurs neutres cohérentes.
Piège courant
Le Null Object n'est pas gratuit. Vous devez implémenter une méthode sur l'objet nul chaque fois que vous ajoutez un appel susceptible de l'atteindre. Comme null et undefined sont omniprésents — y compris dans le cœur du langage et les API tierces —, votre base ne sera jamais totalement débarrassée d'eux : attendez-vous à des incohérences. C'est surtout en écrivant une bibliothèque, un framework ou un module que ce patron prend tout son sens : il évite d'« empoisonner au null » le code de ceux qui vous utiliseront.
Un détail JavaScript important : on n'écrase pas String.prototype. Modifier les objets de base est presque toujours une mauvaise idée, car les autres programmeurs — vous compris, dans six mois — supposeront que String est resté lui-même. Le sous-classement, généralement déconseillé pour extraire un objet, est en revanche très utile pour créer des copies de types natifs comme String et Array.
Wrapper : Decorator et Adapter, ou comment emballer sans toucher
Le patron décorateur (Decorator) ajoute du comportement à un objet sans le modifier. Il brille dans deux cas : quand la classe vient d'un module qu'on ne possède pas (ou qu'on ne veut pas toucher), et quand on veut éviter une prolifération de sous-classes. Imaginez un prix de chien dépendant de cinq traits non exclusifs — mignon, dressé, robotique, amical, de concours. Le sous-classement exhaustif accoucherait de monstres comme FriendlyNotCuteTrainedNonRoboticNonShowDog. Le Decorator les remplace par de simples fonctions fabriques (factory functions) qu'on emboîte.
class Dog {
constructor() { this.cost = 50; }
displayPrice() { return `The dog costs $${this.cost}.`; }
}
// ✅ Chaque trait est une fabrique qui emballe le chien.
function Cute(dog) {
const cuteDog = Object.create(dog);
cuteDog.cost = dog.cost + 20;
return cuteDog;
}
function Trained(dog) {
const trainedDog = Object.create(dog);
trainedDog.cost = dog.cost + 60;
return trainedDog;
}
Cute(new Dog).displayPrice(); // "The dog costs $70."
Trained(Cute(new Dog)).displayPrice(); // "The dog costs $130." Ajouter un trait revient à ajouter une fabrique : aucune classe à toucher. On pourrait viser une interface (new Dog).Cute().Trained(), mais cela exigerait d'ajouter des méthodes à la classe d'origine — exactement ce qu'on refuse de faire. Burchard préfère la fonction fabrique à la classe ici : un constructeur retournerait implicitement un objet de type Cute ou WithoutNull, ce qui n'a aucun sens. Qu'est-ce qu'« un nouveau Cute » ? Un trait ne s'instancie pas.
L'adaptateur (Adapter) est le cousin du Decorator : au lieu d'ajouter des traits à une interface, il remappe une interface vers une autre. Burchard l'illustre en réutilisant le Null Object : une fabrique WithoutNull enveloppe une personne pour garantir que son nom n'est jamais null.
// ✅ Adapter : on enveloppe sans toucher AnonymousPerson.
function WithoutNull(person) {
const personWithoutNull = Object.create(person);
if (personWithoutNull.name === null) {
personWithoutNull.name = new NullString();
}
return personWithoutNull;
}
// On peut emballer n'importe quelle personne, anonyme ou non.
WithoutNull(personTwo).name.capitalize().tigerify().display(); // '' C'est précieux quand on ne maîtrise pas le type des objets reçus — par exemple ceux d'une base de données : WithoutNull(db.get({ person: { id: 17 } })) fournit une interface cohérente sans modifier l'API de la base. Voici l'autre forme, l'Adapter classique qui réaligne des noms de méthodes différents.
class Target { hello() { console.log('hello'); } }
class Adaptee { hi() { console.log('hi'); } }
// ✅ Adapter : remappe hi() sur hello().
class Adapter {
constructor(adaptee) {
this.hello = adaptee.hi;
}
}
new Adapter(new Adaptee).hello(); // "hi" Quelle est, au fond, la différence entre un Decorator et un Adapter ? Surtout une question d'accent. Le Decorator empile plusieurs emballages pour ajouter des traits disparates — WithoutNullName(WithoutNullPhone(person)). L'Adapter applique une transformation d'interface en une seule étape. Mais dans tous les cas, on utilise des fonctions d'emballage pour ajouter des propriétés à des objets — et la distinction importe peu.
Astuce
Le conseil le plus durable du Gang of Four reste : « programmer vers une interface, pas vers une implémentation ». En JavaScript — sans étape de compilation distincte, sans certains mots-clés hérités de Java — ce n'est pas seulement un bon conseil, c'est souvent la seule approche possible. Plutôt que de partir d'un diagramme UML, partez de l'interface que vous voulez écrire (le code de test ou le code client), puis trouvez comment l'implémenter.
Quand éviter Decorator et Adapter ? Si vous contrôlez toute l'implémentation — c'est votre bibliothèque —, vous obtiendrez la même utilité en modifiant le code de base plutôt qu'en compliquant vos interfaces avec des emballages. Et, comme pour tout patron : assurez-vous que le résultat est réellement plus simple que le code d'origine. Avant d'appliquer un Adapter, essayez d'abord d'extraire des objets et des fonctions ; si vous voulez ensuite le patron, l'extraction l'aura facilité.
Facade : exposer un sous-ensemble choisi d'une API
Après les emballages, la façade (Facade) est d'une simplicité désarmante. Quand une API est complexe, au lieu de l'utiliser directement, on passe par une interface dédiée. Vous ne poussez pas les marteaux sur les cordes en jouant du piano ; vous ne pensez pas aux composants internes en conduisant. Une façade est une interface qui rassemble un sous-ensemble choisi d'une ou plusieurs API, pour fluidifier et simplifier le code à écrire.
L'exemple de Burchard regroupe les manipulations courantes du navigateur — pléthore de propriétés sur document et window — derrière un objet page aux noms parlants.
// Façade : un sous-ensemble lisible des API du navigateur.
const page = {
say(string) { console.log(string); },
yell(string) { alert(string); },
addButton(text) {
const button = document.createElement("button");
button.appendChild(document.createTextNode(text));
document.body.appendChild(button);
},
changeBackground(color) {
document.body.style.background = color;
},
now(asNumber = false) {
return asNumber ? new Date().getTime()
: new Date().toLocaleTimeString();
}
}; Tout l'intérêt tient dans le choix : exposer un noyau de fonctionnalités populaires, indépendamment d'une grande API, rend la documentation et le code plus accessibles aux débutants comme aux « oublieux » — c'est-à-dire, vu l'immensité du paysage JavaScript, à peu près tout le monde. jQuery est, en somme, une vaste façade pour le JavaScript frontend ; un ORM en est une pour la base de données.
Note
Quand ne pas créer de façade ? Chaque fois que l'interaction directe avec l'API est déjà assez simple ou bien comprise. Une façade superflue sera soit ignorée, soit utilisée par intermittence — et vous vous retrouverez à apprendre, supporter et maintenir deux interfaces disparates au lieu d'une.
Reconnaître l'odeur, choisir la destination
Tous ces patrons répondent à des problèmes d'architecture probables, pas à un goût pour l'abstraction. Le tableau ci-dessous résume l'odeur de départ et la destination correspondante.
| Code smell de départ | Patron destination | Ce qu'il soulage |
|---|---|---|
| Conditionnel dupliqué dans le code client | Template Method | Remonte le commun, varie un détail |
switch/if sélectionnant un comportement | Strategy | Comportements interchangeables, injectés |
| Strategy + transitions entre modes | State | États avec passages explicites |
Vérifications de null partout | Null Object | Interface miroir, valeurs neutres |
| Sous-classes combinatoires / classe intouchable | Decorator / Adapter | Emballage sans modification |
| API trop vaste pour les usages courants | Facade | Sous-ensemble choisi et lisible |
Burchard a volontairement laissé de côté la majorité des patrons du Gang of Four — c'est un livre de refactoring, pas un catalogue. Certains patrons n'ont d'ailleurs pas de « cas avant » plausible, d'autres servent l'optimisation plutôt que l'interface, d'autres encore sont déjà intégrés à JavaScript (Prototype, Iterator via les générateurs). Quelques-uns valent l'exploration selon le contexte : Composite (parcourir des arbres de données), Builder (générer des données de test), Observer (publish/subscribe, événements, observables), Proxy (le Proxy natif peut d'ailleurs servir à emballer un objet withoutNull).
À retenir
Même si l'on pouvait reproduire fidèlement en JavaScript les diagrammes UML d'autres langages, ce ne serait pas le but. Réécrire tout un programme dans un autre langage et alourdir le build pour réaliser pleinement un patron est déconseillé. Le critère reste pragmatique : le patron rend-il le code plus simple et plus compréhensible que la version sans patron ? Sinon, ne l'appliquez pas.
À retenir
- Un patron est une destination, pas un point de départ. On refactore vers lui par petits pas, sous tests, quand le code le réclame — jamais en le plaquant d'emblée (gare à la soupe de patrons).
- Repoussez la complexité loin de l'API. Template Method enfouit un conditionnel dans une hiérarchie ; Strategy le remplace par des comportements interchangeables injectés ; State y ajoute des transitions explicites.
- Le Null Object remplace les vérifications de
nullpar un objet à interface miroir qui retourne des valeurs neutres — particulièrement précieux dans une bibliothèque, pour ne pas « empoisonner au null » vos utilisateurs. - Decorator et Adapter emballent sans modifier : privilégiez la fonction fabrique à la classe ; un trait ne s'instancie pas. La distinction entre les deux est une affaire d'accent, pas de nature.
- La Facade expose un sous-ensemble choisi d'une API trop vaste — mais inutile si l'API directe est déjà simple, sous peine de maintenir deux interfaces.
- Programmez vers une interface, pas une implémentation. Partez du code client que vous voulez écrire, et n'appliquez un patron que s'il rend réellement le résultat plus simple.