Aller au contenu principal

Concept #013

Design Patterns

Le Design Pattern Builder

#0138 min de lecture
  • design-patterns
  • architecture
  • programmation
Le Builder : construire étape par étape

Le Design Pattern Builder en une phrase

Builder est un patron de conception qui sépare la construction d'un objet complexe de sa représentation. On assemble l'objet étape par étape, en nommant explicitement chaque paramètre, plutôt que de passer dix arguments dans un constructeur dans le bon ordre.

Si vous avez déjà lu un appel de constructeur et été incapable de savoir ce que signifiait le quatrième argument sans aller lire la définition de la classe, c'est le signal que vous avez besoin du Builder.


Le problème : les constructeurs téléscopiques

Imaginez une classe qui modélise une requête HTTP. Au départ, c'est simple : une URL, une méthode. Puis les besoins s'accumulent.

// Version initiale — encore lisible
const req = new HttpRequest('GET', 'https://api.example.com/users');
 
// Quelques semaines plus tard...
const req = new HttpRequest(
  'POST',
  'https://api.example.com/users',
  { 'Content-Type': 'application/json', 'Authorization': 'Bearer xyz' },
  JSON.stringify({ name: 'Alice' }),
  5000,
  true,
  3,
  null
);

Ce constructeur à huit paramètres pose plusieurs problèmes concrets.

La lisibilité s'effondre. Que signifie 5000 ? C'est un timeout ? En millisecondes ? Et true — c'est quoi ? Le mode debug ? Le suivi des redirections ? Et null en dernière position ? Impossible de le savoir sans lire la signature complète.

L'ordre est une source de bugs silencieux. Inverser timeout et maxRetries — deux number — ne provoque aucune erreur TypeScript. Le bug n'apparaît qu'à l'exécution, souvent en production.

Les valeurs par défaut deviennent un cauchemar. Si vous voulez juste changer le timeout mais garder toutes les autres valeurs par défaut, vous êtes forcé de répéter ces valeurs par défaut explicitement. Les paramètres optionnels en TypeScript n'aident qu'à la marge : undefined, undefined, undefined, 5000 n'est pas une amélioration.

C'est ce qu'on appelle le problème du constructeur téléscopique — quand un constructeur accumule tellement de paramètres qu'il devient illisible et fragile.


Le principe Builder : construire par étapes nommées

Le Builder décompose la construction en une séquence d'étapes explicites. Chaque étape a un nom qui décrit ce qu'elle configure. À la fin, un appel à build() produit l'objet final.

HttpRequestBuilder
  ├── setMethod(method)       ← étape 1 : la méthode HTTP
  ├── setUrl(url)             ← étape 2 : l'URL cible
  ├── setHeaders(headers)     ← étape 3 : les en-têtes
  ├── setBody(body)           ← étape 4 : le corps de la requête
  ├── setTimeout(ms)          ← étape 5 : le timeout
  ├── setRetries(n)           ← étape 6 : les tentatives en cas d'échec
  └── build()                 ← production de l'objet final

Le Builder expose généralement une API fluente : chaque méthode retourne this, ce qui permet de chaîner les appels. Le résultat est un code qui se lit presque comme une phrase en langage naturel.


Exemple concret : un builder de requête HTTP

Voici comment implémenter ce pattern en TypeScript.

1. La classe produit — l'objet final qu'on veut construire

// HttpRequest est l'objet final. Son constructeur est privé :
// on ne peut l'instancier qu'à travers le Builder.
class HttpRequest {
  private constructor(
    public readonly method: string,
    public readonly url: string,
    public readonly headers: Record<string, string>,
    public readonly body: string | null,
    public readonly timeoutMs: number,
    public readonly maxRetries: number,
  ) {}
 
  // Point d'entrée unique : passer par le Builder
  static builder(): HttpRequestBuilder {
    return new HttpRequestBuilder();
  }
}

2. Le Builder — l'assembleur étape par étape

class HttpRequestBuilder {
  private method: string = 'GET';
  private url: string = '';
  private headers: Record<string, string> = {};
  private body: string | null = null;
  private timeoutMs: number = 3000;
  private maxRetries: number = 0;
 
  setMethod(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'): this {
    this.method = method;
    return this; // ← retourne this pour le chaînage
  }
 
  setUrl(url: string): this {
    this.url = url;
    return this;
  }
 
  setHeader(key: string, value: string): this {
    this.headers[key] = value;
    return this;
  }
 
  setJsonBody(data: unknown): this {
    this.body = JSON.stringify(data);
    this.headers['Content-Type'] = 'application/json';
    return this;
  }
 
  setTimeout(ms: number): this {
    this.timeoutMs = ms;
    return this;
  }
 
  setRetries(n: number): this {
    this.maxRetries = n;
    return this;
  }
 
  build(): HttpRequest {
    if (!this.url) {
      throw new Error('HttpRequest : une URL est requise');
    }
    return new HttpRequest(
      this.method,
      this.url,
      this.headers,
      this.body,
      this.timeoutMs,
      this.maxRetries,
    );
  }
}

3. Le code client — lisible, expressif, sans ambiguïté

// Construction d'une requête POST avec authentification
const createUserRequest = HttpRequest.builder()
  .setMethod('POST')
  .setUrl('https://api.example.com/users')
  .setHeader('Authorization', 'Bearer xyz')
  .setJsonBody({ name: 'Alice', role: 'admin' })
  .setTimeout(5000)
  .setRetries(3)
  .build();
 
// Construction d'une requête GET simple — seules les étapes utiles sont appelées
const getUserRequest = HttpRequest.builder()
  .setMethod('GET')
  .setUrl('https://api.example.com/users/42')
  .setHeader('Authorization', 'Bearer xyz')
  .build();

Chaque ligne dit exactement ce qu'elle fait. L'ordre n'est plus une contrainte : setTimeout avant ou après setHeader, le résultat est identique. Et passer de la version à zéro paramètre optionnel à la version complète n'oblige pas à modifier les appels existants.


Le rôle du Directeur : standardiser les constructions courantes

Quand certaines configurations reviennent souvent, on peut introduire un Directeur — une classe qui encapsule des séquences de construction pré-définies. Le Directeur ne produit pas l'objet lui-même ; il pilote le Builder pour assembler des variantes standards.

class HttpRequestDirector {
  // Requête GET authentifiée : configuration la plus courante
  static authenticatedGet(url: string, token: string): HttpRequest {
    return HttpRequest.builder()
      .setMethod('GET')
      .setUrl(url)
      .setHeader('Authorization', `Bearer ${token}`)
      .setTimeout(5000)
      .setRetries(2)
      .build();
  }
 
  // Requête POST JSON avec retry : pour les opérations critiques
  static criticalPost(url: string, data: unknown, token: string): HttpRequest {
    return HttpRequest.builder()
      .setMethod('POST')
      .setUrl(url)
      .setHeader('Authorization', `Bearer ${token}`)
      .setJsonBody(data)
      .setTimeout(10000)
      .setRetries(3)
      .build();
  }
}
 
// Utilisation — le Directeur fait gagner du temps sur les cas standards
const req = HttpRequestDirector.authenticatedGet(
  'https://api.example.com/users',
  userToken
);

Le Directeur répond à une règle simple : si vous construisez le même objet dans les mêmes configurations à plusieurs endroits du code, centralisez cette logique dans un Directeur. Les développeurs récupèrent une méthode nommée qui décrit l'intention, plutôt que d'avoir à se souvenir de la bonne séquence d'étapes.


Quand utiliser le Builder ?

Le Builder est pertinent dans ces situations :

  • Un constructeur avec de nombreux paramètres optionnels. Dès que vous avez plus de quatre ou cinq paramètres, en particulier du même type, le Builder devient plus sûr et plus lisible qu'un constructeur traditionnel.

  • Des objets qui nécessitent une validation à la construction. La méthode build() est le point idéal pour valider que l'objet est dans un état cohérent avant de l'instanciater — URL manquante, combinaison de paramètres incompatibles, valeurs hors limites.

  • Des variations fréquentes d'une même structure. Si vous construisez des requêtes HTTP, des emails, des rapports PDF, des pipelines de traitement — autant d'objets complexes qui partagent une structure mais varient dans leurs paramètres — le Builder donne une API claire pour chaque variante.

  • Des objets immuables. En rendant le constructeur de la classe produit privé et en ne passant par le Builder que pour l'instanciation, vous garantissez que l'objet ne peut pas être modifié après création.


Quand c'est de la sur-ingénierie ?

Le Builder a un coût : deux classes au lieu d'une, une API plus verbale pour les cas simples. Ce surcoût ne se justifie pas quand :

  • Votre objet n'a que deux ou trois paramètres obligatoires. Un constructeur classique est plus direct et tout aussi lisible.

  • Tous les paramètres sont requis. Si rien n'est optionnel, le Builder n'apporte pas de valeur — le compilateur vérifie déjà que tous les arguments sont fournis.

  • L'objet est construit en un seul endroit dans tout le code. Le Builder brille quand la construction est répétée en de nombreux endroits avec des variations. S'il n'y a qu'un seul site de construction, un simple objet de configuration littéral fait souvent l'affaire.

La règle : introduisez le Builder quand la lisibilité ou la sécurité de la construction devient un problème réel, pas par anticipation.


Résumé

Sans BuilderAvec Builder
Constructeur avec 8+ paramètres dans le bon ordreMéthodes nommées, ordre libre, intention claire
Inverser deux number ne provoque aucune erreurChaque étape est explicitement nommée
Les valeurs par défaut se répètent partoutValeurs par défaut centralisées dans le Builder
Validation éparpillée dans le code clientValidation centralisée dans build()
Configurations standards re-construites partoutDirecteur encapsule les recettes courantes

Le Builder, c'est l'art de transformer un constructeur incompréhensible en une séquence de décisions lisibles. Quand construire un objet devient aussi clair que composer une commande sur mesure — poser chaque ingrédient un à un, en le nommant — c'est que le pattern fait son travail.