Le pattern Builder : construire des objets complexes étape par étape #
Introduction #
Le pattern Builder est un design pattern créationnel qui permet de construire des objets complexes étape par étape. Il sépare la construction d’un objet de sa représentation, permettant ainsi de créer différentes représentations avec le même processus de construction. Dans cet article, nous explorerons ce pattern à travers un exemple concret : la construction d’une promotion étudiante avec validation des données.
Qu’est-ce que le Builder ? #
Définition #
Le pattern Builder permet de :
- Construire des objets complexes étape par étape
- Produire différents types d’objets avec le même processus
- Isoler le code de construction de la logique métier
- Valider les données avant la création de l’objet final
Structure UML #
classDiagram
class BuilderInterface~T~ {
<>
+build() T
}
class ConcreteBuilder {
-field1
-field2
+method1() ConcreteBuilder
+method2() ConcreteBuilder
+build() Product
}
class Product {
+property1
+property2
}
BuilderInterface <|.. ConcreteBuilder : implements
ConcreteBuilder ..> Product : creates
Caractéristiques clés #
| Caractéristique | Description |
|---|---|
| Fluent Interface | Chaînage des méthodes (method chaining) |
| Validation | Vérification des données avant construction |
| Immutabilité | L’objet final peut être immutable |
| Séparation | Construction séparée de la représentation |
Problématique sans Builder #
Le problème du constructeur télescopique #
// Sans Builder : Constructeurs multiples (anti-pattern)
class Promo {
constructor(name: string) { /* ... */ }
}
class Promo {
constructor(name: string, beginAt: Date) { /* ... */ }
}
class Promo {
constructor(name: string, beginAt: Date, endAt: Date) { /* ... */ }
}
// Utilisation confuse
const promo1 = new Promo("Dev 2025");
const promo2 = new Promo("Dev 2025", new Date());
const promo3 = new Promo("Dev 2025", new Date(), new Date());
Le problème de l’objet partiellement construit #
// Sans Builder : Objet invalide temporairement
const promo = new Promo();
// Promo existe mais n'est pas valide
promo.setName("Dev 2025");
// Toujours pas valide
promo.setBeginAt(new Date());
// Enfin valide
promo.setEndAt(new Date());
Conséquences :
- Objets dans un état invalide pendant la construction
- Pas de validation centralisée
- Code verbeux et répétitif
- Difficile de maintenir la cohérence
Implémentation TypeScript #
Architecture complète #
classDiagram
class BuilderInterface~T~ {
<>
+build() T
}
class PromoBuilder {
-_name: string
-_beginAt: Date
-_endAt: Date
+name(name: string) PromoBuilder
+beginAt(date: Date) PromoBuilder
+endAt(date: Date) PromoBuilder
+build() Promo
}
class Promo {
-name: string
-beginAt: Date
-endAt: Date
-students: CollectionInterface~Student~
+getName() string
+setName(name: string) void
+getBeginAt() Date
+setBeginAt(beginAt: Date) void
+getEndAt() Date
+setEndAt(endAt: Date) void
+addStudent(student: Student) void
+removeStudent(student: Student) void
+getStudentsNumber() number
}
class CollectionInterface~T~ {
<>
+add(item: T) void
+remove(item: T) void
+getLength() number
}
class StudentCollection {
+add(student: Student) void
+remove(student: Student) void
+getLength() number
}
BuilderInterface <|.. PromoBuilder : implements
PromoBuilder ..> Promo : creates
Promo --> CollectionInterface : uses
CollectionInterface <|.. StudentCollection : implements
Interface Builder générique #
export interface BuilderInterface<T> {
build(): T
}
Cette interface générique définit le contrat de base :
- Paramètre de type T : le type d’objet à construire
- Méthode build() : retourne l’instance finale construite
Classe Promo #
import type { CollectionInterface } from "../core/collection/collection-interface";
import type { Student } from "./student";
import { StudentCollection } from "./student-collection";
export class Promo {
private name: string = ''
private beginAt: Date = new Date()
private endAt: Date = new Date()
private students: CollectionInterface<Student> = new StudentCollection()
public getName(): string {
return this.name;
}
public setName(name: string): void {
if (this.name === '')
this.name = name;
}
public getBeginAt(): Date {
return this.beginAt;
}
public setBeginAt(beginAt: Date): void {
this.beginAt = beginAt;
}
public getEndAt(): Date {
return this.endAt;
}
public setEndAt(endAt: Date): void {
this.endAt = endAt;
}
public addStudent(student: Student): void {
this.students.add(student)
}
public removeStudent(student: Student): void {
this.students.remove(student)
}
public getStudentsNumber(): number {
return this.students.getLength()
}
}
Points clés :
- Attributs privés avec valeurs par défaut
- Protection contre la modification du nom (immutabilité partielle)
- Gestion d’une collection d’étudiants
- Interface simple et claire
PromoBuilder #
import type { BuilderInterface } from "../core/builder/builder.interface";
import { Promo } from "./promo";
export class PromoBuilder implements BuilderInterface<Promo> {
private _name: string = ''
private _beginAt: Date = new Date()
private _endAt: Date = new Date()
public name(name: string): PromoBuilder {
this._name = name
return this
}
public beginAt(date: Date): PromoBuilder {
this._beginAt = date
return this
}
public endAt(date: Date): PromoBuilder {
this._endAt = date
return this
}
build(): Promo {
if (this._name === '') {
throw new Error('Promo name is required')
}
if (this._beginAt > this._endAt) {
throw new Error("Incorrect dates");
}
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
return promo
}
}
Caractéristiques du builder :
- Attributs privés préfixés par _ (convention)
- Chaque méthode retourne this (fluent interface)
- Validation dans build() avant création
- Séparation claire construction/validation
Analyse du code #
Diagramme de séquence : construction d’une promo #
sequenceDiagram
participant Client
participant Builder as PromoBuilder
participant Promo
Client->>Builder: new PromoBuilder()
Client->>Builder: name("Dev 2025")
Builder-->>Client: this
Client->>Builder: beginAt(2025-01-15)
Builder-->>Client: this
Client->>Builder: endAt(2025-12-20)
Builder-->>Client: this
Client->>Builder: build()
alt Validation OK
Builder->>Builder: Vérifier name
Builder->>Builder: Vérifier dates
Builder->>Promo: new Promo()
Builder->>Promo: setName()
Builder->>Promo: setBeginAt()
Builder->>Promo: setEndAt()
Promo-->>Builder: promo
Builder-->>Client: promo
else Validation échouée
Builder-->>Client: throw Error
end
Fluent Interface : le chaînage de méthodes #
public name(name: string): PromoBuilder {
this._name = name
return this // Retourne l'instance courante
}
Ce pattern permet le chaînage :
const promo = new PromoBuilder()
.name("Dev 2025")
.beginAt(new Date("2025-01-15"))
.endAt(new Date("2025-12-20"))
.build();
Avantages :
- Code lisible et expressif
- Ordre des appels flexible
- Construction en une seule expression
Validation Centralisée #
build(): Promo {
// Validation 1 : Nom obligatoire
if (this._name === '') {
throw new Error('Promo name is required')
}
// Validation 2 : Dates cohérentes
if (this._beginAt > this._endAt) {
throw new Error("Incorrect dates");
}
// Construction uniquement si validations OK
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
return promo
}
Bénéfices :
- Toutes les validations en un seul endroit
- Objet final toujours valide
- Erreurs explicites avant création
Séparation des responsabilités #
| Classe | Responsabilité |
|---|---|
| Promo | Représenter une promotion (logique métier) |
| PromoBuilder | Construire et valider une promotion |
| BuilderInterface | Définir le contrat de construction |
Utilisation pratique #
Exemple complet #
// main.ts
import { PromoBuilder } from './promo-builder';
import { Student } from './student';
// Construction simple
const promo = new PromoBuilder()
.name("Développement Web 2025")
.beginAt(new Date("2025-01-15"))
.endAt(new Date("2025-12-20"))
.build();
console.log(promo.getName()); // "Développement Web 2025"
console.log(promo.getStudentsNumber()); // 0
// Ajout d'étudiants
const alice = new Student("Alice", "Martin");
const bob = new Student("Bob", "Dupont");
promo.addStudent(alice);
promo.addStudent(bob);
console.log(promo.getStudentsNumber()); // 2
Gestion des erreurs #
// Erreur : Nom manquant
try {
const promo = new PromoBuilder()
.beginAt(new Date("2025-01-15"))
.endAt(new Date("2025-12-20"))
.build();
} catch (error) {
console.error(error.message); // "Promo name is required"
}
// Erreur : Dates incohérentes
try {
const promo = new PromoBuilder()
.name("Dev 2025")
.beginAt(new Date("2025-12-20"))
.endAt(new Date("2025-01-15"))
.build();
} catch (error) {
console.error(error.message); // "Incorrect dates"
}
Tests unitaires #
// promo-builder.test.ts
import { describe, it, expect } from 'vitest';
import { PromoBuilder } from './promo-builder';
describe('PromoBuilder', () => {
it('devrait construire une promo valide', () => {
const promo = new PromoBuilder()
.name("Dev 2025")
.beginAt(new Date("2025-01-15"))
.endAt(new Date("2025-12-20"))
.build();
expect(promo.getName()).toBe("Dev 2025");
expect(promo.getBeginAt()).toEqual(new Date("2025-01-15"));
expect(promo.getEndAt()).toEqual(new Date("2025-12-20"));
});
it('devrait rejeter un nom vide', () => {
expect(() => {
new PromoBuilder()
.beginAt(new Date("2025-01-15"))
.endAt(new Date("2025-12-20"))
.build();
}).toThrow("Promo name is required");
});
it('devrait rejeter des dates incohérentes', () => {
expect(() => {
new PromoBuilder()
.name("Dev 2025")
.beginAt(new Date("2025-12-20"))
.endAt(new Date("2025-01-15"))
.build();
}).toThrow("Incorrect dates");
});
it('devrait permettre le chaînage de méthodes', () => {
const builder = new PromoBuilder();
const result1 = builder.name("Dev 2025");
const result2 = result1.beginAt(new Date());
expect(result1).toBe(builder);
expect(result2).toBe(builder);
});
});
Avantages et inconvénients #
Avantages #
| Avantage | Description | Exemple |
|---|---|---|
| Code lisible | Syntaxe fluide et expressive | .name("Dev").beginAt(date) |
| Validation centralisée | Toutes les vérifications en un lieu | Méthode build() |
| Construction atomique | Erreur avant création | - |
| Flexibilité | Ordre des paramètres libre | Commence par n’importe quel setter |
| Réutilisabilité | Builder réutilisable | Créer plusieurs objets similaires |
| Immutabilité possible | Objet final peut être immutable | Pas de setters publics |
Inconvénients #
| Inconvénient | Description | Impact |
|---|---|---|
| Code verbeux | Classe builder supplémentaire | Plus de fichiers |
| Duplication | Attributs répétés (classe + builder) | Maintenance accrue |
| Complexité | Pattern lourd pour objets simples | Overhead inutile |
| Mémoire | Instance builder temporaire | Garbage collection |
Quand utiliser le builder ? #
Utilisez le Builder pour :
// Objet avec nombreux paramètres
class Configuration {
// 15+ paramètres
}
const config = new ConfigurationBuilder()
.host("localhost")
.port(3000)
.timeout(5000)
.retries(3)
// ...
.build();
// Objets avec validations complexes
class User {
// Validations multiples interdépendantes
}
const user = new UserBuilder()
.email("test@example.com")
.password("secure123")
.confirmPassword("secure123")
.age(25)
.build();
// Création d'objets immutables
class Invoice {
// Readonly après construction
}
const invoice = new InvoiceBuilder()
.customer(customer)
.items(items)
.build(); // Objet immutable
N’utilisez PAS le Builder pour :
// Objets simples
class Point {
constructor(public x: number, public y: number) {}
}
// new Point(10, 20) suffit
// Objets avec peu de paramètres
class Color {
constructor(public r: number, public g: number, public b: number) {}
}
// Pas besoin de ColorBuilder
// Objets sans validation
class Label {
constructor(public text: string) {}
}
// Trop simple pour un builder
Variantes du pattern #
Builder avec valeurs par défaut #
export class PromoBuilderWithDefaults implements BuilderInterface<Promo> {
private _name: string = 'Promo Sans Nom'
private _beginAt: Date = new Date()
private _endAt: Date = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // +1 an
public name(name: string): PromoBuilderWithDefaults {
this._name = name
return this
}
public beginAt(date: Date): PromoBuilderWithDefaults {
this._beginAt = date
return this
}
public endAt(date: Date): PromoBuilderWithDefaults {
this._endAt = date
return this
}
build(): Promo {
// Pas d'erreur si nom manquant (valeur par défaut)
if (this._beginAt > this._endAt) {
throw new Error("Incorrect dates");
}
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
return promo
}
}
// Utilisation
const promo = new PromoBuilderWithDefaults().build(); // Utilise les défauts
Builder avec étapes obligatoires (Step Builder) #
// Étape 1 : Nom obligatoire
interface INameStep {
name(name: string): IBeginDateStep;
}
// Étape 2 : Date de début obligatoire
interface IBeginDateStep {
beginAt(date: Date): IEndDateStep;
}
// Étape 3 : Date de fin obligatoire
interface IEndDateStep {
endAt(date: Date): IBuildStep;
}
// Étape 4 : Construction finale
interface IBuildStep {
build(): Promo;
}
export class StepPromoBuilder implements INameStep, IBeginDateStep, IEndDateStep, IBuildStep {
private _name: string = ''
private _beginAt: Date = new Date()
private _endAt: Date = new Date()
public name(name: string): IBeginDateStep {
this._name = name
return this
}
public beginAt(date: Date): IEndDateStep {
this._beginAt = date
return this
}
public endAt(date: Date): IBuildStep {
this._endAt = date
return this
}
build(): Promo {
if (this._beginAt > this._endAt) {
throw new Error("Incorrect dates");
}
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
return promo
}
}
// Utilisation : Ordre forcé par TypeScript
const promo = new StepPromoBuilder()
.name("Dev 2025") // Doit être appelé en premier
.beginAt(new Date()) // Puis celui-ci
.endAt(new Date()) // Puis celui-ci
.build(); // Enfin build()
Builder avec reset #
export class ReusablePromoBuilder implements BuilderInterface<Promo> {
private _name: string = ''
private _beginAt: Date = new Date()
private _endAt: Date = new Date()
public name(name: string): ReusablePromoBuilder {
this._name = name
return this
}
public beginAt(date: Date): ReusablePromoBuilder {
this._beginAt = date
return this
}
public endAt(date: Date): ReusablePromoBuilder {
this._endAt = date
return this
}
// Méthode pour réinitialiser le builder
public reset(): ReusablePromoBuilder {
this._name = ''
this._beginAt = new Date()
this._endAt = new Date()
return this
}
build(): Promo {
if (this._name === '') {
throw new Error('Promo name is required')
}
if (this._beginAt > this._endAt) {
throw new Error("Incorrect dates");
}
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
return promo
}
}
// Utilisation : Réutilisation du même builder
const builder = new ReusablePromoBuilder();
const promo1 = builder
.name("Promo A")
.beginAt(new Date("2025-01-01"))
.endAt(new Date("2025-06-30"))
.build();
const promo2 = builder
.reset() // Réinitialisation
.name("Promo B")
.beginAt(new Date("2025-07-01"))
.endAt(new Date("2025-12-31"))
.build();
Builder avec director (Pattern Complet) #
// Director : encapsule des constructions prédéfinies
export class PromoDirector {
constructor(private builder: PromoBuilder) {}
// Construction standard
public buildStandardPromo(name: string, year: number): Promo {
return this.builder
.name(name)
.beginAt(new Date(`${year}-01-15`))
.endAt(new Date(`${year}-12-20`))
.build();
}
// Construction pour stage d'été
public buildSummerPromo(name: string, year: number): Promo {
return this.builder
.name(`${name} - Été`)
.beginAt(new Date(`${year}-06-01`))
.endAt(new Date(`${year}-08-31`))
.build();
}
// Construction intensive (bootcamp)
public buildBootcamp(name: string): Promo {
const start = new Date();
const end = new Date();
end.setDate(end.getDate() + 90); // 3 mois
return this.builder
.name(`Bootcamp ${name}`)
.beginAt(start)
.endAt(end)
.build();
}
}
// Utilisation
const builder = new PromoBuilder();
const director = new PromoDirector(builder);
const promo1 = director.buildStandardPromo("Dev Web", 2025);
const promo2 = director.buildSummerPromo("Dev Mobile", 2025);
const promo3 = director.buildBootcamp("Full Stack");
Bonnes pratiques #
Nommage cohérent #
// BON : Noms clairs et cohérents
class PromoBuilder {
name(name: string): PromoBuilder { /* ... */ }
beginAt(date: Date): PromoBuilder { /* ... */ }
endAt(date: Date): PromoBuilder { /* ... */ }
}
// MAUVAIS : Noms incohérents
class PromoBuilder {
setName(name: string): PromoBuilder { /* ... */ }
withBeginDate(date: Date): PromoBuilder { /* ... */ }
endingAt(date: Date): PromoBuilder { /* ... */ }
}
Validation complète #
build(): Promo {
// Toutes les validations en un bloc
const errors: string[] = [];
if (this._name === '') {
errors.push('Promo name is required');
}
if (this._name.length < 3) {
errors.push('Promo name must be at least 3 characters');
}
if (this._beginAt > this._endAt) {
errors.push('Begin date must be before end date');
}
if (this._endAt < new Date()) {
errors.push('End date must be in the future');
}
// Lever toutes les erreurs d'un coup
if (errors.length > 0) {
throw new Error(`Validation failed:\n- ${errors.join('\n- ')}`);
}
// Construction
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
return promo
}
Documentation claire #
/**
* Builder pour la création de promotions étudiantes
*
* @example
* ```typescript
* const promo = new PromoBuilder()
* .name("Dev Web 2025")
* .beginAt(new Date("2025-01-15"))
* .endAt(new Date("2025-12-20"))
* .build();
* ```
*
* @throws {Error} Si le nom est vide
* @throws {Error} Si les dates sont incohérentes
*/
export class PromoBuilder implements BuilderInterface<Promo> {
/**
* Définit le nom de la promotion
*
* @param name - Nom de la promotion (minimum 3 caractères)
* @returns L'instance du builder pour chaînage
*/
public name(name: string): PromoBuilder {
this._name = name
return this
}
// ...
}
Types de retour explicites #
// BON : Type de retour explicite
public name(name: string): PromoBuilder {
this._name = name
return this
}
// MAUVAIS : Type inféré (moins clair)
public name(name: string) {
this._name = name
return this
}
Méthodes optionnelles #
export class PromoBuilder implements BuilderInterface<Promo> {
private _name: string = ''
private _beginAt: Date = new Date()
private _endAt: Date = new Date()
private _maxStudents?: number // Optionnel
private _description?: string // Optionnel
// Méthodes obligatoires
public name(name: string): PromoBuilder {
this._name = name
return this
}
// Méthodes optionnelles
public maxStudents(max: number): PromoBuilder {
this._maxStudents = max
return this
}
public description(desc: string): PromoBuilder {
this._description = desc
return this
}
build(): Promo {
// Validations sur champs obligatoires uniquement
if (this._name === '') {
throw new Error('Promo name is required')
}
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
// Champs optionnels
if (this._maxStudents !== undefined) {
promo.setMaxStudents(this._maxStudents)
}
if (this._description !== undefined) {
promo.setDescription(this._description)
}
return promo
}
}
Pattern Builder avec TypeScript avancé #
Builder générique réutilisable #
// Builder générique pour n'importe quelle classe
export abstract class GenericBuilder<T> implements BuilderInterface<T> {
protected properties: Partial<T> = {}
protected set<K extends keyof T>(key: K, value: T[K]): this {
this.properties[key] = value
return this
}
protected validate(): void {
// À surcharger dans les sous-classes
}
abstract build(): T
}
// Utilisation spécifique
class PromoGenericBuilder extends GenericBuilder<Promo> {
name(name: string): this {
return this.set('name' as any, name)
}
beginAt(date: Date): this {
return this.set('beginAt' as any, date)
}
endAt(date: Date): this {
return this.set('endAt' as any, date)
}
protected validate(): void {
if (!this.properties.name) {
throw new Error('Name is required')
}
}
build(): Promo {
this.validate()
const promo = new Promo()
promo.setName(this.properties.name as string)
promo.setBeginAt(this.properties.beginAt as Date)
promo.setEndAt(this.properties.endAt as Date)
return promo
}
}
Builder avec types conditionnels #
// Types pour forcer les étapes obligatoires
type RequiredFields<T> = {
[K in keyof T]-?: T[K]
}
type OptionalFields<T> = {
[K in keyof T]?: T[K]
}
// Builder typé strictement
class TypedPromoBuilder {
private data: Partial<{
name: string
beginAt: Date
endAt: Date
maxStudents: number
}> = {}
name(name: string): Omit<TypedPromoBuilder, 'name'> {
this.data.name = name
return this
}
beginAt(date: Date): Omit<TypedPromoBuilder, 'beginAt'> {
this.data.beginAt = date
return this
}
endAt(date: Date): Omit<TypedPromoBuilder, 'endAt'> {
this.data.endAt = date
return this
}
maxStudents(max: number): this {
this.data.maxStudents = max
return this
}
build(): Promo {
// TypeScript garantit que toutes les méthodes ont été appelées
const promo = new Promo()
promo.setName(this.data.name!)
promo.setBeginAt(this.data.beginAt!)
promo.setEndAt(this.data.endAt!)
return promo
}
}
Builder avec validation progressive #
class ValidatingPromoBuilder {
private _name: string = ''
private _beginAt: Date = new Date()
private _endAt: Date = new Date()
private errors: Map<string, string> = new Map()
name(name: string): ValidatingPromoBuilder {
this._name = name
// Validation immédiate
if (name.length < 3) {
this.errors.set('name', 'Name must be at least 3 characters')
} else {
this.errors.delete('name')
}
return this
}
beginAt(date: Date): ValidatingPromoBuilder {
this._beginAt = date
// Validation de cohérence avec endAt
if (this._endAt && date > this._endAt) {
this.errors.set('dates', 'Begin date must be before end date')
} else {
this.errors.delete('dates')
}
return this
}
endAt(date: Date): ValidatingPromoBuilder {
this._endAt = date
// Validation de cohérence avec beginAt
if (this._beginAt && this._beginAt > date) {
this.errors.set('dates', 'End date must be after begin date')
} else {
this.errors.delete('dates')
}
return this
}
// Vérifier les erreurs à tout moment
hasErrors(): boolean {
return this.errors.size > 0
}
getErrors(): string[] {
return Array.from(this.errors.values())
}
build(): Promo {
if (this.hasErrors()) {
throw new Error(`Validation errors:\n- ${this.getErrors().join('\n- ')}`)
}
const promo = new Promo()
promo.setName(this._name)
promo.setBeginAt(this._beginAt)
promo.setEndAt(this._endAt)
return promo
}
}
// Utilisation avec feedback progressif
const builder = new ValidatingPromoBuilder()
.name('Ab') // Trop court
.beginAt(new Date('2025-12-31'))
.endAt(new Date('2025-01-01')); // Incohérent
console.log(builder.hasErrors()); // true
console.log(builder.getErrors());
// ['Name must be at least 3 characters', 'End date must be after begin date']
Anti-Patterns à éviter #
Builder trop complexe #
// MAUVAIS : Builder qui fait trop de choses
class GodPromoBuilder {
private promo: Promo
constructor() {
this.promo = new Promo()
}
name(name: string): this {
this.promo.setName(name)
// Logique métier dans le builder (MAUVAIS)
this.notifyAdmins(`New promo: ${name}`)
this.createDatabaseEntry()
this.generateReports()
return this
}
// Trop de responsabilités
private notifyAdmins(message: string): void { /* ... */ }
private createDatabaseEntry(): void { /* ... */ }
private generateReports(): void { /* ... */ }
build(): Promo {
return this.promo
}
}
// BON : Builder simple, logique séparée
class SimplePromoBuilder {
private _name: string = ''
name(name: string): this {
this._name = name
return this
}
build(): Promo {
const promo = new Promo()
promo.setName(this._name)
return promo
}
}
// Logique métier ailleurs
class PromoService {
create(promo: Promo): void {
this.notifyAdmins(promo)
this.saveToDB(promo)
this.generateReports(promo)
}
}
Modification de l’objet après construction #
// MAUVAIS : Builder qui modifie l'objet construit
class MutablePromoBuilder {
private promo: Promo = new Promo()
name(name: string): this {
this.promo.setName(name)
return this
}
build(): Promo {
// Retourne la référence directe (MAUVAIS)
return this.promo
}
}
const builder = new MutablePromoBuilder()
const promo1 = builder.name('Promo A').build()
const promo2 = builder.name('Promo B').build() // Modifie aussi promo1 !
console.log(promo1.getName()) // "Promo B" (PROBLÈME)
// BON : Créer une nouvelle instance à chaque build()
class ImmutablePromoBuilder {
private _name: string = ''
name(name: string): this {
this._name = name
return this
}
build(): Promo {
// Nouvelle instance à chaque fois
const promo = new Promo()
promo.setName(this._name)
return promo
}
}
Validation insuffisante #
// MAUVAIS : Pas de validation
class UnsafePromoBuilder {
private _name: string = ''
name(name: string): this {
this._name = name
return this
}
build(): Promo {
// Aucune validation (MAUVAIS)
const promo = new Promo()
promo.setName(this._name)
return promo
}
}
// Permet de créer des objets invalides
const promo = new UnsafePromoBuilder().build() // Nom vide !
// BON : Validation complète
class SafePromoBuilder {
private _name: string = ''
name(name: string): this {
this._name = name
return this
}
build(): Promo {
if (this._name === '') {
throw new Error('Name is required')
}
if (this._name.length < 3) {
throw new Error('Name must be at least 3 characters')
}
const promo = new Promo()
promo.setName(this._name)
return promo
}
}
Tests avancés #
Tests de validation #
describe('PromoBuilder - Validation', () => {
it('devrait valider le nom minimum', () => {
expect(() => {
new PromoBuilder()
.name('AB') // Trop court
.beginAt(new Date())
.endAt(new Date())
.build()
}).toThrow('at least 3 characters')
})
it('devrait valider les dates futures', () => {
const pastDate = new Date('2020-01-01')
expect(() => {
new PromoBuilder()
.name('Dev 2025')
.beginAt(pastDate)
.endAt(pastDate)
.build()
}).toThrow('must be in the future')
})
it('devrait valider la durée minimale', () => {
const start = new Date('2025-01-01')
const end = new Date('2025-01-02') // Trop court
expect(() => {
new PromoBuilder()
.name('Dev 2025')
.beginAt(start)
.endAt(end)
.build()
}).toThrow('minimum duration')
})
})
Tests d’immutabilité #
describe('PromoBuilder - Immutabilité', () => {
it('ne devrait pas affecter les builds précédents', () => {
const builder = new PromoBuilder()
const promo1 = builder
.name('Promo A')
.beginAt(new Date('2025-01-01'))
.endAt(new Date('2025-06-30'))
.build()
const promo2 = builder
.name('Promo B')
.beginAt(new Date('2025-07-01'))
.endAt(new Date('2025-12-31'))
.build()
expect(promo1.getName()).toBe('Promo A')
expect(promo2.getName()).toBe('Promo B')
expect(promo1).not.toBe(promo2)
})
})
Tests de performance #
describe('PromoBuilder - Performance', () => {
it('devrait construire rapidement 1000 promos', () => {
const start = Date.now()
for (let i = 0; i < 1000; i++) {
new PromoBuilder()
.name(`Promo ${i}`)
.beginAt(new Date())
.endAt(new Date())
.build()
}
const duration = Date.now() - start
expect(duration).toBeLessThan(1000) // < 1 seconde
})
})
Conclusion #
Récapitulatif #
Le pattern Builder est idéal pour construire des objets complexes avec validation :
| Avantages | Inconvénients |
|---|---|
| Code lisible et expressif | Code plus verbeux |
| Validation centralisée | Classe supplémentaire |
| Objets toujours valides | Duplication des attributs |
| Flexibilité totale | Complexité accrue |
| Immutabilité possible | Overhead mémoire |
Quand l’utiliser ? #
Utilisez le Builder pour :
- Objets avec nombreux paramètres (plus de 4-5)
- Validation complexe avant construction
- Objets immutables après création
- Construction en plusieurs étapes logiques
- API publiques nécessitant une bonne ergonomie
Évitez le Builder pour :
- Objets simples avec peu de paramètres
- Objets sans validation
- Performance critique (overhead du builder)
- Objets internes non exposés