Concept #017
Design PatternsLe Design Pattern Decorator
- design-patterns
- architecture
- programmation
Le Design Pattern Decorator en une phrase
Decorator est un patron de conception structurel qui ajoute des comportements à un objet de manière dynamique, en l'enveloppant dans d'autres objets qui partagent la même interface. Chaque couche ajoute sa propre logique sans jamais toucher à la classe d'origine.
Si vous vous retrouvez à créer des dizaines de sous-classes pour couvrir toutes les combinaisons possibles de comportements, ou à accumuler des boolean en paramètres de constructeur pour activer des fonctionnalités optionnelles, c'est le signe que vous avez besoin du Decorator.
Le problème : l'héritage rigide et l'explosion de sous-classes
Imaginez un système de notifications. Au départ, il n'envoie que des e-mails. Simple.
class EmailNotifier {
send(message: string): void {
console.log(`[Email] ${message}`);
}
}Puis les demandes arrivent.
"On veut logger toutes les notifications envoyées." "On a besoin de chiffrer les messages sensibles avant envoi." "Certains messages doivent être loggés ET chiffrés." "Pour les alertes critiques, on veut aussi retry automatiquement en cas d'échec."
La réaction instinctive est de créer des sous-classes :
EmailNotifier
├── LoggingEmailNotifier
├── EncryptedEmailNotifier
├── LoggingEncryptedEmailNotifier
├── RetryEmailNotifier
├── LoggingRetryEmailNotifier
├── EncryptedRetryEmailNotifier
└── LoggingEncryptedRetryEmailNotifier
Avec seulement trois comportements optionnels, on arrive à sept sous-classes. Ajoutez un quatrième comportement (RateLimit, Compression, Audit...) et vous doublez à nouveau le nombre de sous-classes. C'est ce qu'on appelle l'explosion combinatoire.
Le problème fondamental : l'héritage est statique. La combinaison de comportements est figée à la compilation. On ne peut pas, à l'exécution, décider qu'un notifier va logger mais pas chiffrer, ou chiffrer mais pas retry. La hiérarchie de classes doit anticiper toutes les combinaisons possibles.
Le principe Decorator : envelopper sans modifier
Le Decorator repose sur une idée simple mais puissante : plutôt que d'hériter, envelopper.
Tous les objets — l'objet d'origine et ses decorateurs — implémentent la même interface. Chaque decorateur reçoit un objet de cette interface dans son constructeur, ajoute sa propre logique, puis délègue à l'objet qu'il enveloppe. Les couches s'accumulent comme des poupées gigognes.
Interface Notifier
└── send(message): void
EmailNotifier ← implémente Notifier (la vraie logique)
LoggingDecorator ← implémente Notifier, enveloppe un Notifier
EncryptionDecorator ← implémente Notifier, enveloppe un Notifier
RetryDecorator ← implémente Notifier, enveloppe un Notifier
Pour composer des comportements, on imbrique des decorateurs :
RetryDecorator(
LoggingDecorator(
EncryptionDecorator(
EmailNotifier()
)
)
)
Chaque appel à send() traverse les couches dans l'ordre, et chaque couche peut agir avant, après, ou autour de l'appel suivant.
Exemple concret : un système de notifications
Voici comment implémenter ce système en TypeScript.
1. L'interface commune
// Le contrat partagé par l'objet original et tous ses décorateurs
interface Notifier {
send(message: string): void;
}2. La classe concrète d'origine
// L'implémentation réelle — envoie un e-mail
class EmailNotifier implements Notifier {
private readonly recipient: string;
constructor(recipient: string) {
this.recipient = recipient;
}
send(message: string): void {
console.log(`[Email → ${this.recipient}] ${message}`);
}
}3. La base des décorateurs
// Classe abstraite optionnelle pour éviter la répétition
// Chaque décorateur reçoit un Notifier et implémente Notifier
abstract class NotifierDecorator implements Notifier {
constructor(protected readonly wrapped: Notifier) {}
send(message: string): void {
this.wrapped.send(message);
}
}4. Les décorateurs concrets
// Ajoute du logging avant chaque envoi
class LoggingDecorator extends NotifierDecorator {
send(message: string): void {
console.log(`[Log] Envoi d'une notification : "${message}"`);
this.wrapped.send(message);
console.log(`[Log] Notification envoyée avec succès.`);
}
}
// Chiffre le message avant de le transmettre
class EncryptionDecorator extends NotifierDecorator {
private encrypt(text: string): string {
// Simulation d'un chiffrement (Base64 pour l'exemple)
return Buffer.from(text).toString('base64');
}
send(message: string): void {
const encrypted = this.encrypt(message);
console.log(`[Encryption] Message chiffré.`);
this.wrapped.send(encrypted);
}
}
// Réessaie automatiquement en cas d'échec
class RetryDecorator extends NotifierDecorator {
constructor(
wrapped: Notifier,
private readonly maxAttempts: number = 3,
) {
super(wrapped);
}
send(message: string): void {
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
this.wrapped.send(message);
return; // succès, on s'arrête
} catch (error) {
console.warn(`[Retry] Tentative ${attempt}/${this.maxAttempts} échouée.`);
if (attempt === this.maxAttempts) throw error;
}
}
}
}5. Composition à la volée
// Notifier simple : juste un e-mail
const simpleNotifier: Notifier = new EmailNotifier('alice@example.com');
// Notifier avec logging
const loggingNotifier: Notifier = new LoggingDecorator(
new EmailNotifier('bob@example.com'),
);
// Notifier avec chiffrement + logging + retry
// L'ordre des couches compte : le message est d'abord loggé, puis chiffré, puis envoyé avec retry
const secureNotifier: Notifier = new LoggingDecorator(
new RetryDecorator(
new EncryptionDecorator(
new EmailNotifier('charlie@example.com'),
),
3,
),
);
secureNotifier.send('Votre code de vérification est 482910');
// [Log] Envoi d'une notification : "Votre code de vérification est 482910"
// [Encryption] Message chiffré.
// [Email → charlie@example.com] Vm90cmUgY29kZSBkZSB2w...
// [Log] Notification envoyée avec succès.Remarquez que secureNotifier est de type Notifier. Le code qui l'utilise ne sait pas combien de couches existent derrière cette interface. Il appelle send(), le reste est invisible.
Avantages du Decorator sur l'héritage
Combinaisons sans explosion de classes
Là où l'héritage nécessitait sept sous-classes pour trois comportements, le Decorator n'en nécessite que trois. Les combinaisons se font à l'assemblage, pas dans la hiérarchie.
Composition dynamique à l'exécution
Les comportements peuvent être ajoutés ou retirés au moment de l'exécution, pas seulement à la compilation. Selon le contexte (environnement de prod vs dev, niveau de criticité du message, préférences utilisateur), vous assemblez la pile de décorateurs appropriée sans modifier une seule classe.
Principe de responsabilité unique
Chaque décorateur n'a qu'une seule préoccupation : logger, chiffrer, ou gérer les retries. Si la logique de chiffrement change, seul EncryptionDecorator est modifié. Les autres couches ne sont pas touchées.
Principe Open/Closed respecté
Ajouter un comportement de compression ou de rate-limiting ? Vous créez un nouveau décorateur sans modifier ni EmailNotifier, ni les décorateurs existants, ni le code client.
| Héritage | Decorator | |
|---|---|---|
| 3 comportements optionnels | 7 sous-classes | 3 décorateurs |
| Ajout d'un 4e comportement | +8 sous-classes | +1 décorateur |
| Composition à l'exécution | Impossible | Natif |
| Modification d'un comportement | Risque de régression | Isolé dans sa classe |
Quand l'utiliser ?
Le Decorator est pertinent dans ces situations :
-
Des comportements optionnels à combiner librement : logging, caching, validation, compression, chiffrement, retry, rate-limiting. Chaque comportement mérite sa propre couche plutôt qu'un paramètre booléen dans le constructeur.
-
Des responsabilités transverses (cross-cutting concerns) : les fonctionnalités qui s'appliquent à de nombreux endroits de façon uniforme — observabilité, sécurité, métriques — sont des candidats idéaux pour des décorateurs.
-
Quand étendre une classe n'est pas possible : si la classe est
final, si elle vient d'une librairie tierce que vous ne contrôlez pas, le Decorator est souvent la seule option propre. -
Quand l'héritage génère trop de sous-classes : c'est le signal le plus fiable. Si vous commencez à nommer vos classes
LoggingRetryEncryptedXxx, passez aux décorateurs.
Quand éviter le Decorator ?
Comme tout pattern, le Decorator a un coût. Il introduit de l'indirection : pour comprendre ce que fait secureNotifier.send(), il faut dérouler mentalement trois couches d'imbrication. Ce surcoût ne se justifie pas quand :
-
Vous n'avez qu'un seul comportement additionnel fixe. Si le logging est toujours présent, intégrez-le directement dans la classe plutôt que d'ajouter une couche.
-
L'ordre des couches est critique et non-évident. Si l'imbrication des décorateurs produit des résultats différents selon l'ordre, documentez-le explicitement ou optez pour un objet de configuration plus explicite.
-
Vous êtes en phase d'exploration. Comme pour Strategy, attendez de voir le troisième comportement optionnel apparaître avant d'extraire des décorateurs. Le pattern émerge du code, il ne le précède pas.
Decorator vs Adapter : la confusion fréquente
Les deux patterns enveloppent un objet, mais leurs objectifs sont opposés :
| Adapter | Decorator | |
|---|---|---|
| Objectif | Convertir une interface incompatible | Ajouter des comportements |
| Interface de sortie | Différente de l'entrée | Identique à l'entrée |
| Question posée | "Comment faire parler deux interfaces ?" | "Comment enrichir un objet ?" |
L'Adapter change ce que l'objet expose. Le Decorator conserve exactement la même interface en enrichissant ce qui se passe à l'intérieur.
Résumé
Le Decorator résout l'un des problèmes les plus courants de l'héritage : l'impossibilité de composer des comportements librement. En partageant la même interface entre l'objet original et ses enveloppes, il permet d'empiler des responsabilités sans explosion de sous-classes, sans modification de l'existant, et sans couplage rigide.
La règle empirique : si vous comptez vos sous-classes et que le chiffre monte vite, vos comportements sont probablement des décorateurs qui s'ignorent.