Cours JavaScript et TypeScript · Module 9: Design patterns et architecture

Les principes SOLID en JavaScript/TypeScript

25 min de lecture
Dans cette leçon7

Bon retour à Jurassic Park ! Imagine un centre de commandement où une seule classe pilote les clôtures, vend les billets et envoie les e-mails. Une correction dans la billetterie peut alors couper le courant de l'enclos du T-Rex. Le remède s'appelle SOLID : l'acronyme de cinq principes de conception orientée objet popularisés par Robert C. Martin. Bien qu'ils soient nés pour les langages orientés objet, ils fonctionnent très bien en JavaScript et en TypeScript.

S - Single Responsibility Principle (principe de responsabilité unique)

Chaque classe ou module ne devrait avoir qu'une seule raison de changer, autrement dit une seule responsabilité. Le laboratoire ADN ne vend pas de billets, et la billetterie ne clone pas de dinosaures.

Mauvaise approche - violation du SRP

Voici l'employé qui fait tout. Combien de raisons de changer a UserManager ?

1// La classe UserManager fait trop de choses
2class UserManager {
3  constructor() {
4    this.users = [];
5  }
6
7  // Gestion des utilisateurs
8  addUser(userData) {
9    this.users.push(userData);
10  }
11
12  removeUser(userId) {
13    this.users = this.users.filter(user => user.id !== userId);
14  }
15
16  // Validation - une autre responsabilité
17  validateEmail(email) {
18    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
19  }
20
21  validatePassword(password) {
22    return password.length >= 8 && /[A-Z]/.test(password);
23  }
24
25  // Journalisation - encore une responsabilité
26  logUserAction(action, userId) {
27    console.log(`[${new Date().toISOString()}] User ${userId}: ${action}`);
28  }
29
30  // Envoi d'e-mails - encore une autre responsabilité
31  sendWelcomeEmail(userEmail) {
32    console.log(`Sending welcome email to ${userEmail}`);
33  }
34
35  // Formatage des données - encore une responsabilité
36  formatUserData(user) {
37    return `${user.firstName} ${user.lastName} (${user.email})`;
38  }
39}

On en compte cinq : le stockage, la validation, la journalisation, les e-mails et le formatage. Changer le format des logs oblige à modifier une classe qui surveille aussi les mots de passe.

Bonne approche - conforme au SRP

Désormais, chaque responsabilité a sa propre classe, et UserService les coordonne :

1// Chaque classe a une seule responsabilité
2
3// Gestion des utilisateurs
4interface User {
5  id: string;
6  firstName: string;
7  lastName: string;
8  email: string;
9  password: string;
10}
11
12class UserRepository {
13  private users: User[] = [];
14
15  add(user: User): void {
16    this.users.push(user);
17  }
18
19  remove(userId: string): boolean {
20    const initialLength = this.users.length;
21    this.users = this.users.filter(user => user.id !== userId);
22    return this.users.length < initialLength;
23  }
24
25  findById(userId: string): User | null {
26    return this.users.find(user => user.id === userId) || null;
27  }
28
29  findByEmail(email: string): User | null {
30    return this.users.find(user => user.email === email) || null;
31  }
32
33  getAll(): User[] {
34    return [...this.users];
35  }
36}
37
38// Validation
39class UserValidator {
40  validateEmail(email: string): boolean {
41    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
42    return emailRegex.test(email);
43  }
44
45  validatePassword(password: string): boolean {
46    return password.length >= 8 &&
47           /[A-Z]/.test(password) &&
48           /[a-z]/.test(password) &&
49           /[0-9]/.test(password);
50  }
51
52  validateUser(userData: Partial<User>): string[] {
53    const errors: string[] = [];
54
55    if (!userData.firstName || userData.firstName.length < 2) {
56      errors.push('Le prénom doit contenir au moins 2 caractères');
57    }
58
59    if (!userData.lastName || userData.lastName.length < 2) {
60      errors.push('Le nom doit contenir au moins 2 caractères');
61    }
62
63    if (!userData.email || !this.validateEmail(userData.email)) {
64      errors.push("Format d'e-mail invalide");
65    }
66
67    if (!userData.password || !this.validatePassword(userData.password)) {
68      errors.push('Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule et un chiffre');
69    }
70
71    return errors;
72  }
73}
74
75// Journalisation
76class UserLogger {
77  private logs: string[] = [];
78
79  logAction(action: string, userId: string): void {
80    const logEntry = `[${new Date().toISOString()}] User ${userId}: ${action}`;
81    this.logs.push(logEntry);
82    console.log(logEntry);
83  }
84
85  getLogs(): string[] {
86    return [...this.logs];
87  }
88
89  clearLogs(): void {
90    this.logs = [];
91  }
92}
93
94// Envoi d'e-mails
95class EmailService {
96  sendWelcomeEmail(userEmail: string, userName: string): Promise<boolean> {
97    // Simulation de l'envoi d'un e-mail
98    console.log(`Envoi de l'e-mail de bienvenue à ${userEmail}`);
99    return new Promise(resolve => {
100      setTimeout(() => {
101        console.log(`E-mail de bienvenue envoyé à ${userName}`);
102        resolve(true);
103      }, 1000);
104    });
105  }
106
107  sendPasswordResetEmail(userEmail: string): Promise<boolean> {
108    console.log(`Envoi de l'e-mail de réinitialisation du mot de passe à ${userEmail}`);
109    return Promise.resolve(true);
110  }
111}
112
113// Formatage des données
114class UserFormatter {
115  formatFullName(user: User): string {
116    return `${user.firstName} ${user.lastName}`;
117  }
118
119  formatUserDisplay(user: User): string {
120    return `${this.formatFullName(user)} (${user.email})`;
121  }
122
123  formatUserSummary(user: User): object {
124    return {
125      id: user.id,
126      fullName: this.formatFullName(user),
127      email: user.email
128    };
129  }
130}
131
132// Service layer - coordonne le travail des différents composants
133class UserService {
134  constructor(
135    private userRepository: UserRepository,
136    private userValidator: UserValidator,
137    private userLogger: UserLogger,
138    private emailService: EmailService,
139    private userFormatter: UserFormatter
140  ) {}
141
142  async createUser(userData: Omit<User, 'id'>): Promise<User | null> {
143    // Validation
144    const validationErrors = this.userValidator.validateUser(userData);
145    if (validationErrors.length > 0) {
146      throw new Error(`Erreurs de validation : ${validationErrors.join(', ')}`);
147    }
148
149    // Vérifier que l'e-mail n'est pas déjà pris
150    if (this.userRepository.findByEmail(userData.email)) {
151      throw new Error('Un utilisateur avec cet e-mail existe déjà');
152    }
153
154    // Création de l'utilisateur
155    const user: User = {
156      id: 'user_' + Math.random().toString(36).slice(2, 11),
157      ...userData
158    };
159
160    this.userRepository.add(user);
161
162    // Journalisation
163    this.userLogger.logAction('USER_CREATED', user.id);
164
165    // Envoi de l'e-mail de bienvenue
166    await this.emailService.sendWelcomeEmail(
167      user.email,
168      this.userFormatter.formatFullName(user)
169    );
170
171    return user;
172  }
173
174  deleteUser(userId: string): boolean {
175    const user = this.userRepository.findById(userId);
176    if (!user) {
177      return false;
178    }
179
180    const deleted = this.userRepository.remove(userId);
181    if (deleted) {
182      this.userLogger.logAction('USER_DELETED', userId);
183    }
184
185    return deleted;
186  }
187}
188
189// Utilisation
190const userRepository = new UserRepository();
191const userValidator = new UserValidator();
192const userLogger = new UserLogger();
193const emailService = new EmailService();
194const userFormatter = new UserFormatter();
195
196const userService = new UserService(
197  userRepository,
198  userValidator,
199  userLogger,
200  emailService,
201  userFormatter
202);
203
204// Test
205userService.createUser({
206  firstName: 'Jan',
207  lastName: 'Kowalski',
208  email: 'jan@example.com',
209  password: 'SecurePass123'
210}).then(user => {
211  if (user) {
212    console.log('Utilisateur créé :', userFormatter.formatUserDisplay(user));
213  }
214});

Le comportement de l'application n'a pas changé, mais tu peux tester et remplacer chaque partie séparément. UserService reçoit ses assistants dans le constructeur, une idée que la lettre D va développer.

O - Open/Closed Principle (principe ouvert/fermé)

Les classes devraient être ouvertes à l'extension, mais fermées à la modification. Une nouvelle espèce signifie un nouvel enclos, pas la démolition de clôtures qui fonctionnent.

Exemple avec un système de paiement

D'abord le problème : cette fonction doit être modifiée à chaque nouveau moyen de paiement.

1// Violation de l'OCP - chaque nouveau type de paiement impose de modifier cette fonction
2function processPayment(type: string, amount: number): void {
3  if (type === 'credit-card') {
4    console.log(`Carte : ${amount} PLN`);
5  } else if (type === 'paypal') {
6    console.log(`PayPal : ${amount} PLN`);
7  }
8  // les cryptomonnaies ? Encore un else if dans du code déjà testé
9}

La version conforme à l'OCP s'appuie sur l'interface PaymentProcessor, et PaymentService range les processeurs dans une map :

1// Bonne approche - conforme à l'OCP
2
3interface PaymentProcessor {
4  processPayment(amount: number, details: any): Promise<PaymentResult>;
5  validatePayment(details: any): boolean;
6}
7
8interface PaymentResult {
9  success: boolean;
10  transactionId?: string;
11  error?: string;
12}
13
14// Implémentation de base pour les cartes de crédit
15class CreditCardProcessor implements PaymentProcessor {
16  async processPayment(amount: number, details: CreditCardDetails): Promise<PaymentResult> {
17    if (!this.validatePayment(details)) {
18      return { success: false, error: 'Données de carte invalides' };
19    }
20
21    // Simulation du traitement d'un paiement par carte
22    console.log(`Traitement du paiement par carte : ${amount} EUR`);
23
24    return {
25      success: true,
26      transactionId: 'cc_' + Math.random().toString(36).slice(2, 11)
27    };
28  }
29
30  validatePayment(details: CreditCardDetails): boolean {
31    return !!(details.cardNumber &&
32             details.expiryDate &&
33             details.cvv &&
34             details.cardNumber.length === 16);
35  }
36}
37
38interface CreditCardDetails {
39  cardNumber: string;
40  expiryDate: string;
41  cvv: string;
42  holderName: string;
43}
44
45// Extension - un nouveau processeur sans modifier le code existant
46class PayPalProcessor implements PaymentProcessor {
47  async processPayment(amount: number, details: PayPalDetails): Promise<PaymentResult> {
48    if (!this.validatePayment(details)) {
49      return { success: false, error: 'Données PayPal invalides' };
50    }
51
52    console.log(`Traitement du paiement PayPal : ${amount} EUR`);
53
54    return {
55      success: true,
56      transactionId: 'pp_' + Math.random().toString(36).slice(2, 11)
57    };
58  }
59
60  validatePayment(details: PayPalDetails): boolean {
61    return !!(details.email && details.password);
62  }
63}
64
65interface PayPalDetails {
66  email: string;
67  password: string;
68}
69
70// Une autre extension
71class BankTransferProcessor implements PaymentProcessor {
72  async processPayment(amount: number, details: BankTransferDetails): Promise<PaymentResult> {
73    if (!this.validatePayment(details)) {
74      return { success: false, error: 'Données de virement invalides' };
75    }
76
77    console.log(`Traitement du virement bancaire : ${amount} EUR`);
78
79    return {
80      success: true,
81      transactionId: 'bt_' + Math.random().toString(36).slice(2, 11)
82    };
83  }
84
85  validatePayment(details: BankTransferDetails): boolean {
86    return !!(details.accountNumber &&
87             details.bankCode &&
88             details.accountNumber.length >= 20);
89  }
90}
91
92interface BankTransferDetails {
93  accountNumber: string;
94  bankCode: string;
95  recipientName: string;
96}
97
98// Nouveau processeur pour les cryptomonnaies - sans modifier le code existant
99class CryptocurrencyProcessor implements PaymentProcessor {
100  async processPayment(amount: number, details: CryptoDetails): Promise<PaymentResult> {
101    if (!this.validatePayment(details)) {
102      return { success: false, error: 'Adresse de portefeuille invalide' };
103    }
104
105    console.log(`Traitement du paiement ${details.currency} : ${amount} EUR`);
106
107    return {
108      success: true,
109      transactionId: 'crypto_' + Math.random().toString(36).slice(2, 11)
110    };
111  }
112
113  validatePayment(details: CryptoDetails): boolean {
114    return !!(details.walletAddress &&
115             details.currency &&
116             details.walletAddress.length >= 26);
117  }
118}
119
120interface CryptoDetails {
121  walletAddress: string;
122  currency: 'BTC' | 'ETH' | 'LTC';
123}
124
125// Contexte qui utilise les processeurs - il n'a pas besoin d'être modifié
126class PaymentService {
127  private processors = new Map<string, PaymentProcessor>();
128
129  registerProcessor(type: string, processor: PaymentProcessor): void {
130    this.processors.set(type, processor);
131  }
132
133  async processPayment(type: string, amount: number, details: any): Promise<PaymentResult> {
134    const processor = this.processors.get(type);
135
136    if (!processor) {
137      return { success: false, error: `Type de paiement non pris en charge : ${type}` };
138    }
139
140    return processor.processPayment(amount, details);
141  }
142
143  getSupportedPaymentTypes(): string[] {
144    return Array.from(this.processors.keys());
145  }
146}
147
148// Utilisation - ajouter de nouveaux types de paiement est facile
149const paymentService = new PaymentService();
150
151paymentService.registerProcessor('credit-card', new CreditCardProcessor());
152paymentService.registerProcessor('paypal', new PayPalProcessor());
153paymentService.registerProcessor('bank-transfer', new BankTransferProcessor());
154paymentService.registerProcessor('crypto', new CryptocurrencyProcessor());
155
156// Test de différents types de paiement
157async function testPayments() {
158  // Paiement par carte
159  const creditCardResult = await paymentService.processPayment('credit-card', 100, {
160    cardNumber: '1234567890123456',
161    expiryDate: '12/25',
162    cvv: '123',
163    holderName: 'Jan Kowalski'
164  });
165
166  // Paiement PayPal
167  const paypalResult = await paymentService.processPayment('paypal', 50, {
168    email: 'user@example.com',
169    password: 'password123'
170  });
171
172  // Paiement en cryptomonnaie
173  const cryptoResult = await paymentService.processPayment('crypto', 200, {
174    walletAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
175    currency: 'BTC'
176  });
177
178  console.log('Résultats des paiements :', {
179    creditCard: creditCardResult,
180    paypal: paypalResult,
181    crypto: cryptoResult
182  });
183}
184
185testPayments();

Les cryptomonnaies n'ont pas modifié une seule ligne de PaymentService : il a suffi d'ajouter une classe et d'appeler registerProcessor.

L - Liskov Substitution Principle (principe de substitution de Liskov)

Les objets des classes dérivées doivent pouvoir remplacer les objets de la classe de base sans nuire au bon fonctionnement du programme. Ce principe a été formulé par Barbara Liskov, et notre exemple porte sur les oiseaux, les descendants vivants des dinosaures.

Exemple avec des oiseaux - violation du LSP

La classe Bird promet que tout oiseau sait voler. Que se passe-t-il quand un manchot rejoint la volière ?

1// Violation du LSP
2abstract class Bird {
3  abstract fly(): void;
4  abstract makeSound(): void;
5}
6
7class Sparrow extends Bird {
8  fly(): void {
9    console.log('Le moineau vole !');
10  }
11
12  makeSound(): void {
13    console.log('Cui-cui !');
14  }
15}
16
17class Eagle extends Bird {
18  fly(): void {
19    console.log("L'aigle plane !");
20  }
21
22  makeSound(): void {
23    console.log('Kriii !');
24  }
25}
26
27// Problème : le manchot ne sait pas voler !
28class Penguin extends Bird {
29  fly(): void {
30    throw new Error('Les manchots ne savent pas voler !'); // Violation du LSP
31  }
32
33  makeSound(): void {
34    console.log('Coin-coin !');
35  }
36}
37
38// Fonction qui suppose que chaque oiseau sait voler
39function makeBirdFly(bird: Bird): void {
40  bird.fly(); // Peut lever une erreur pour un manchot !
41}

TypeScript ne dit rien, mais makeBirdFly(new Penguin()) lèvera une exception pendant l'exécution. La sous-classe a rompu la promesse de la classe de base.

Approche corrigée conforme au LSP

Les capacités passent dans des interfaces séparées, Flyable, Swimmable et Walkable, et la classe de base ne promet que ce que sait faire chaque oiseau :

1// Conforme au LSP - une meilleure modélisation de la hiérarchie
2
3interface Animal {
4  makeSound(): void;
5  move(): void;
6}
7
8interface Flyable {
9  fly(): void;
10}
11
12interface Swimmable {
13  swim(): void;
14}
15
16interface Walkable {
17  walk(): void;
18}
19
20// Classe de base pour les oiseaux
21abstract class Bird implements Animal {
22  abstract makeSound(): void;
23  abstract move(): void;
24
25  eat(): void {
26    console.log("L'oiseau mange");
27  }
28
29  sleep(): void {
30    console.log("L'oiseau dort");
31  }
32}
33
34// Oiseaux volants
35class FlyingBird extends Bird implements Flyable {
36  makeSound(): void {
37    console.log("L'oiseau émet un son");
38  }
39
40  move(): void {
41    this.fly();
42  }
43
44  fly(): void {
45    console.log("L'oiseau vole");
46  }
47}
48
49// Oiseaux nageurs
50class SwimmingBird extends Bird implements Swimmable {
51  makeSound(): void {
52    console.log("L'oiseau émet un son");
53  }
54
55  move(): void {
56    this.swim();
57  }
58
59  swim(): void {
60    console.log("L'oiseau nage");
61  }
62}
63
64// Implémentations concrètes
65class Sparrow extends FlyingBird {
66  makeSound(): void {
67    console.log('Cui-cui !');
68  }
69
70  fly(): void {
71    console.log('Le moineau vole vite');
72  }
73}
74
75class Eagle extends FlyingBird {
76  makeSound(): void {
77    console.log('Kriii !');
78  }
79
80  fly(): void {
81    console.log("L'aigle plane majestueusement");
82  }
83}
84
85class Penguin extends SwimmingBird implements Walkable {
86  makeSound(): void {
87    console.log('Coin-coin !');
88  }
89
90  swim(): void {
91    console.log('Le manchot nage avec agilité');
92  }
93
94  walk(): void {
95    console.log('Le manchot marche sur la glace');
96  }
97
98  move(): void {
99    // Le manchot peut se déplacer de plusieurs façons
100    if (Math.random() > 0.5) {
101      this.swim();
102    } else {
103      this.walk();
104    }
105  }
106}
107
108// Fonctions qui respectent le LSP
109function makeAnimalMove(animal: Animal): void {
110  animal.move(); // Fonctionnera toujours correctement
111}
112
113function makeFlyableObjectFly(flyable: Flyable): void {
114  flyable.fly(); // Uniquement pour les objets qui savent vraiment voler
115}
116
117function makeSwimmableObjectSwim(swimmable: Swimmable): void {
118  swimmable.swim(); // Uniquement pour les objets qui savent vraiment nager
119}
120
121// Test
122const birds: Bird[] = [
123  new Sparrow(),
124  new Eagle(),
125  new Penguin()
126];
127
128birds.forEach(bird => {
129  bird.makeSound();
130  makeAnimalMove(bird); // Chaque oiseau peut se déplacer à sa façon
131});
132
133// Test de la capacité de vol, uniquement pour les oiseaux volants
134const flyingBirds: Flyable[] = [
135  new Sparrow(),
136  new Eagle()
137  // Le manchot n'est pas ajouté, car il n'implémente pas Flyable
138];
139
140flyingBirds.forEach(bird => {
141  makeFlyableObjectFly(bird);
142});

Désormais, makeAnimalMove fonctionne pour n'importe quel oiseau, et tu ne pourras pas ajouter le manchot au tableau Flyable, car le compilateur signalera une erreur.

I - Interface Segregation Principle (principe de ségrégation des interfaces)

Les classes ne devraient pas être obligées d'implémenter des interfaces dont elles ne se servent pas. Le gardien d'un enclos n'a pas besoin de savoir piloter un hélicoptère.

Violation de l'ISP

Une seule interface géante oblige une simple imprimante à faire semblant de tout savoir faire :

1// Violation de l'ISP - une interface trop grande
2interface MultiFunctionDevice {
3  print(document: string): void;
4  scan(document: string): string;
5  fax(document: string, number: string): void;
6  copy(document: string): string;
7  email(document: string, address: string): void;
8}
9
10// Problème : une simple imprimante doit implémenter toutes les méthodes
11class SimplePrinter implements MultiFunctionDevice {
12  print(document: string): void {
13    console.log(`Impression : ${document}`);
14  }
15
16  // Implémentation forcée de méthodes inutilisées
17  scan(document: string): string {
18    throw new Error("L'imprimante ne prend pas en charge la numérisation");
19  }
20
21  fax(document: string, number: string): void {
22    throw new Error("L'imprimante ne prend pas en charge le fax");
23  }
24
25  copy(document: string): string {
26    throw new Error("L'imprimante ne prend pas en charge la copie");
27  }
28
29  email(document: string, address: string): void {
30    throw new Error("L'imprimante ne prend pas en charge les e-mails");
31  }
32}

Quatre méthodes sur cinq lèvent une exception, et on ne le découvrira qu'à l'exécution.

Conforme à l'ISP

On découpe l'interface en petits morceaux, et chaque classe n'implémente que ce dont elle a besoin :

1// Conforme à l'ISP - des interfaces découpées
2
3interface Printer {
4  print(document: string): void;
5}
6
7interface Scanner {
8  scan(document: string): string;
9}
10
11interface FaxMachine {
12  fax(document: string, number: string): void;
13}
14
15interface Copier {
16  copy(document: string): string;
17}
18
19interface EmailSender {
20  email(document: string, address: string): void;
21}
22
23// La simple imprimante n'implémente que ce dont elle a besoin
24class SimplePrinter implements Printer {
25  print(document: string): void {
26    console.log(`Impression : ${document}`);
27  }
28}
29
30// Le scanner n'implémente que la numérisation
31class SimpleScanner implements Scanner {
32  scan(document: string): string {
33    console.log(`Numérisation : ${document}`);
34    return `Numérisé : ${document}`;
35  }
36}
37
38// L'appareil multifonction implémente les interfaces nécessaires
39class MultiFunctionPrinter implements Printer, Scanner, Copier {
40  print(document: string): void {
41    console.log(`[MFP] Impression : ${document}`);
42  }
43
44  scan(document: string): string {
45    console.log(`[MFP] Numérisation : ${document}`);
46    return `Numérisé : ${document}`;
47  }
48
49  copy(document: string): string {
50    console.log(`[MFP] Copie : ${document}`);
51    const scanned = this.scan(document);
52    this.print(scanned);
53    return scanned;
54  }
55}
56
57// Appareil de bureau professionnel
58class OfficeMachine implements Printer, Scanner, FaxMachine, Copier, EmailSender {
59  print(document: string): void {
60    console.log(`[Office] Impression : ${document}`);
61  }
62
63  scan(document: string): string {
64    console.log(`[Office] Numérisation : ${document}`);
65    return `Numérisé : ${document}`;
66  }
67
68  fax(document: string, number: string): void {
69    console.log(`[Office] Envoi du fax ${document} au numéro ${number}`);
70  }
71
72  copy(document: string): string {
73    console.log(`[Office] Copie : ${document}`);
74    return this.scan(document);
75  }
76
77  email(document: string, address: string): void {
78    console.log(`[Office] Envoi de l'e-mail ${document} à l'adresse ${address}`);
79  }
80}
81
82// Fonctions qui n'utilisent que les interfaces nécessaires
83class DocumentProcessor {
84  static printDocument(printer: Printer, document: string): void {
85    printer.print(document);
86  }
87
88  static scanDocument(scanner: Scanner, document: string): string {
89    return scanner.scan(document);
90  }
91
92  static copyDocument(copier: Copier, document: string): string {
93    return copier.copy(document);
94  }
95}
96
97// Utilisation
98const simplePrinter = new SimplePrinter();
99const simpleScanner = new SimpleScanner();
100const mfp = new MultiFunctionPrinter();
101const officeMachine = new OfficeMachine();
102
103// Chaque appareil n'utilise que ses propres capacités
104DocumentProcessor.printDocument(simplePrinter, 'Document 1');
105DocumentProcessor.scanDocument(simpleScanner, 'Document 2');
106DocumentProcessor.copyDocument(mfp, 'Document 3');
107DocumentProcessor.printDocument(officeMachine, 'Document 4');

DocumentProcessor demande exactement ce qu'il utilise, et OfficeMachine montre qu'une classe peut implémenter plusieurs interfaces à la fois.

D - Dependency Inversion Principle (principe d'inversion des dépendances)

Les modules de haut niveau ne devraient pas dépendre des modules de bas niveau. Les deux devraient dépendre d'abstractions. Le centre de commandement se branche sur une prise, pas sur un générateur précis.

Violation du DIP

Ici, UserService crée lui-même une base MySQL concrète et un service d'e-mails concret :

1// Violation du DIP - dépendance envers des implémentations concrètes
2
3class MySQLDatabase {
4  save(data: any): void {
5    console.log('Enregistrement dans MySQL :', data);
6  }
7
8  find(id: string): any {
9    console.log('Recherche dans MySQL :', id);
10    return { id, data: 'mysql_data' };
11  }
12}
13
14class EmailNotification {
15  send(message: string, recipient: string): void {
16    console.log(`Envoi d'un e-mail à ${recipient} : ${message}`);
17  }
18}
19
20// Problème : UserService dépend d'implémentations concrètes
21class UserService {
22  private database: MySQLDatabase; // Dépendance concrète
23  private notification: EmailNotification; // Dépendance concrète
24
25  constructor() {
26    this.database = new MySQLDatabase(); // Création des dépendances à l'intérieur de la classe
27    this.notification = new EmailNotification();
28  }
29
30  createUser(userData: any): void {
31    this.database.save(userData);
32    this.notification.send('Bienvenue !', userData.email);
33  }
34
35  getUser(id: string): any {
36    return this.database.find(id);
37  }
38}

Changer de base implique de modifier le service, et dans les tests tu ne pourras pas la remplacer par un mock.

Conforme au DIP

Désormais, le service ne connaît que les interfaces Database, NotificationService et Logger, et il reçoit les classes concrètes de l'extérieur, par exemple d'un simple conteneur DI :

1// Conforme au DIP - dépendance envers des abstractions
2
3// Abstractions (interfaces)
4interface Database {
5  save(data: any): void;
6  find(id: string): any;
7  update(id: string, data: any): void;
8  delete(id: string): boolean;
9}
10
11interface NotificationService {
12  send(message: string, recipient: string): Promise<boolean>;
13}
14
15interface Logger {
16  log(level: 'info' | 'warn' | 'error', message: string): void;
17}
18
19// Implémentations concrètes - bas niveau
20class MySQLDatabase implements Database {
21  save(data: any): void {
22    console.log('Enregistrement dans MySQL :', data);
23  }
24
25  find(id: string): any {
26    console.log('Recherche dans MySQL :', id);
27    return { id, data: 'mysql_data' };
28  }
29
30  update(id: string, data: any): void {
31    console.log(`Mise à jour dans MySQL ${id} :`, data);
32  }
33
34  delete(id: string): boolean {
35    console.log('Suppression dans MySQL :', id);
36    return true;
37  }
38}
39
40class PostgreSQLDatabase implements Database {
41  save(data: any): void {
42    console.log('Enregistrement dans PostgreSQL :', data);
43  }
44
45  find(id: string): any {
46    console.log('Recherche dans PostgreSQL :', id);
47    return { id, data: 'postgres_data' };
48  }
49
50  update(id: string, data: any): void {
51    console.log(`Mise à jour dans PostgreSQL ${id} :`, data);
52  }
53
54  delete(id: string): boolean {
55    console.log('Suppression dans PostgreSQL :', id);
56    return true;
57  }
58}
59
60class MongoDatabase implements Database {
61  save(data: any): void {
62    console.log('Enregistrement dans MongoDB :', data);
63  }
64
65  find(id: string): any {
66    console.log('Recherche dans MongoDB :', id);
67    return { id, data: 'mongo_data' };
68  }
69
70  update(id: string, data: any): void {
71    console.log(`Mise à jour dans MongoDB ${id} :`, data);
72  }
73
74  delete(id: string): boolean {
75    console.log('Suppression dans MongoDB :', id);
76    return true;
77  }
78}
79
80class EmailNotificationService implements NotificationService {
81  async send(message: string, recipient: string): Promise<boolean> {
82    console.log(`Envoi d'un e-mail à ${recipient} : ${message}`);
83    return true;
84  }
85}
86
87class SMSNotificationService implements NotificationService {
88  async send(message: string, recipient: string): Promise<boolean> {
89    console.log(`Envoi d'un SMS à ${recipient} : ${message}`);
90    return true;
91  }
92}
93
94class SlackNotificationService implements NotificationService {
95  async send(message: string, recipient: string): Promise<boolean> {
96    console.log(`Envoi d'un message Slack à ${recipient} : ${message}`);
97    return true;
98  }
99}
100
101class ConsoleLogger implements Logger {
102  log(level: 'info' | 'warn' | 'error', message: string): void {
103    console.log(`[${level.toUpperCase()}] ${message}`);
104  }
105}
106
107class FileLogger implements Logger {
108  log(level: 'info' | 'warn' | 'error', message: string): void {
109    console.log(`Écriture dans le fichier [${level.toUpperCase()}] : ${message}`);
110  }
111}
112
113// Module de haut niveau - il ne dépend que d'abstractions
114class UserService {
115  constructor(
116    private database: Database,
117    private notificationService: NotificationService,
118    private logger: Logger
119  ) {}
120
121  async createUser(userData: any): Promise<void> {
122    try {
123      this.logger.log('info', `Création de l'utilisateur : ${userData.email}`);
124
125      this.database.save(userData);
126
127      await this.notificationService.send(
128        'Bienvenue dans notre application !',
129        userData.email
130      );
131
132      this.logger.log('info', `L'utilisateur ${userData.email} a été créé`);
133    } catch (error) {
134      this.logger.log('error', `Erreur lors de la création de l'utilisateur : ${error}`);
135      throw error;
136    }
137  }
138
139  getUser(id: string): any {
140    this.logger.log('info', `Récupération de l'utilisateur : ${id}`);
141    return this.database.find(id);
142  }
143
144  async updateUser(id: string, userData: any): Promise<void> {
145    try {
146      this.logger.log('info', `Mise à jour de l'utilisateur : ${id}`);
147
148      this.database.update(id, userData);
149
150      await this.notificationService.send(
151        'Vos données ont été mises à jour',
152        userData.email
153      );
154
155      this.logger.log('info', `L'utilisateur ${id} a été mis à jour`);
156    } catch (error) {
157      this.logger.log('error', `Erreur lors de la mise à jour de l'utilisateur : ${error}`);
158      throw error;
159    }
160  }
161
162  deleteUser(id: string): boolean {
163    this.logger.log('info', `Suppression de l'utilisateur : ${id}`);
164    return this.database.delete(id);
165  }
166}
167
168// Dependency Injection Container (simplifié)
169class DIContainer {
170  private services = new Map<string, any>();
171
172  register<T>(name: string, instance: T): void {
173    this.services.set(name, instance);
174  }
175
176  get<T>(name: string): T {
177    const service = this.services.get(name);
178    if (!service) {
179      throw new Error(`Service ${name} not found`);
180    }
181    return service;
182  }
183}
184
185// Configuration des dépendances
186const container = new DIContainer();
187
188// Enregistrement de différentes implémentations
189container.register<Database>('database', new PostgreSQLDatabase());
190container.register<NotificationService>('notification', new EmailNotificationService());
191container.register<Logger>('logger', new ConsoleLogger());
192
193// Création du service avec les dépendances injectées
194const userService = new UserService(
195  container.get<Database>('database'),
196  container.get<NotificationService>('notification'),
197  container.get<Logger>('logger')
198);
199
200// Test
201userService.createUser({
202  id: '1',
203  name: 'Jan Kowalski',
204  email: 'jan@example.com'
205});
206
207// Changer d'implémentation en toute simplicité
208const mobileUserService = new UserService(
209  new MongoDatabase(),              // Autre base de données
210  new SMSNotificationService(),     // Autres notifications
211  new FileLogger()                  // Autre journalisation
212);
213
214mobileUserService.createUser({
215  id: '2',
216  name: 'Anna Nowak',
217  email: '+48123456789'
218});

Le code de UserService n'a pas changé d'un caractère, et pourtant il tourne une fois avec PostgreSQL et l'e-mail, une autre fois avec MongoDB et les SMS. Je te recommande l'injection par le constructeur comme style par défaut : dans les tests, il te suffit de passer des mocks.

Event Bus

Un Event Bus est un canal d'événements partagé par lequel les modules communiquent sans dépendances directes, comme les gardiens qui se parlent par la radio du parc. L'émetteur appelle publish, et les intéressés écoutent grâce à subscribe :

1class EventBus {
2  constructor() {
3    this.listeners = new Map(); // nom de l'événement -> tableau de callbacks
4  }
5
6  subscribe(event, callback) {
7    if (!this.listeners.has(event)) this.listeners.set(event, []);
8    this.listeners.get(event).push(callback);
9  }
10
11  unsubscribe(event, callback) {
12    const callbacks = this.listeners.get(event) || [];
13    this.listeners.set(event, callbacks.filter(cb => cb !== callback));
14  }
15
16  publish(event, data) {
17    (this.listeners.get(event) || []).forEach(cb => cb(data));
18  }
19}
20
21const bus = new EventBus();
22bus.subscribe('fence:breach', data => console.log('Alarme dans le secteur', data.sector));
23bus.publish('fence:breach', { sector: 'B6' });

Le module des clôtures ne sait pas qui recevra l'alarme, et le système d'alarme ne l'importe pas : tous deux ne connaissent que le bus d'événements, c'est-à-dire l'abstraction.

Singleton et registre de modules

Un Singleton garantit une instance unique, comme un seul centre de commandement : il la mémorise dans un champ statique, et chaque new suivant renvoie justement celle-là :

1class ParkCore {
2  constructor() {
3    if (ParkCore.instance) return ParkCore.instance;
4    ParkCore.instance = this;
5    this.modules = new Map();
6  }
7
8  registerModule(name, module) {
9    this.modules.set(name, module);
10  }
11
12  getModule(name) {
13    return this.modules.get(name);
14  }
15
16  init() {
17    this.modules.forEach(module => module.init?.());
18  }
19}
20
21console.log(new ParkCore() === new ParkCore()); // true

Tu enregistres un nouveau module sans toucher au noyau (principe ouvert/fermé). Un Singleton reste toutefois une variable globale déguisée, alors transmets-le plutôt par le constructeur. Protège tes modules avec le pattern Module : il apporte l'encapsulation et ne pollue pas la portée globale.

Exemple pratique - un système e-commerce conforme à SOLID

Pour finir, voici les cinq principes réunis dans un seul système de commandes, et les commentaires marqués S, O, L, I et D montrent où chacun agit :

1// Exemple complet qui applique tous les principes SOLID
2
3// Abstractions
4interface Product {
5  id: string;
6  name: string;
7  price: number;
8  category: string;
9  inStock: boolean;
10}
11
12interface ProductRepository {
13  findById(id: string): Promise<Product | null>;
14  findByCategory(category: string): Promise<Product[]>;
15  save(product: Product): Promise<void>;
16  update(id: string, updates: Partial<Product>): Promise<void>;
17  delete(id: string): Promise<boolean>;
18}
19
20interface PricingStrategy {
21  calculatePrice(product: Product, quantity: number): number;
22}
23
24interface DiscountCalculator {
25  calculateDiscount(product: Product, quantity: number): number;
26}
27
28interface InventoryService {
29  checkAvailability(productId: string, quantity: number): Promise<boolean>;
30  reserveItems(productId: string, quantity: number): Promise<boolean>;
31  releaseReservation(productId: string, quantity: number): Promise<void>;
32}
33
34interface OrderRepository {
35  save(order: Order): Promise<void>;
36  findById(orderId: string): Promise<Order | null>;
37}
38
39interface NotificationService {
40  sendOrderConfirmation(order: Order): Promise<void>;
41  sendInventoryAlert(productId: string): Promise<void>;
42}
43
44// S - Single Responsibility
45class RegularPricingStrategy implements PricingStrategy {
46  calculatePrice(product: Product, quantity: number): number {
47    return product.price * quantity;
48  }
49}
50
51class BulkDiscountStrategy implements PricingStrategy {
52  constructor(private discountCalculator: DiscountCalculator) {}
53
54  calculatePrice(product: Product, quantity: number): number {
55    const basePrice = product.price * quantity;
56    const discount = this.discountCalculator.calculateDiscount(product, quantity);
57    return basePrice - discount;
58  }
59}
60
61class QuantityDiscountCalculator implements DiscountCalculator {
62  calculateDiscount(product: Product, quantity: number): number {
63    if (quantity >= 10) return product.price * quantity * 0.1;
64    if (quantity >= 5) return product.price * quantity * 0.05;
65    return 0;
66  }
67}
68
69class CategoryDiscountCalculator implements DiscountCalculator {
70  private discountRates = new Map([
71    ['electronics', 0.15],
72    ['books', 0.1],
73    ['clothing', 0.2]
74  ]);
75
76  calculateDiscount(product: Product, quantity: number): number {
77    const rate = this.discountRates.get(product.category) || 0;
78    return product.price * quantity * rate;
79  }
80}
81
82// O - Open/Closed - ajouter de nouvelles stratégies est facile
83class SeasonalPricingStrategy implements PricingStrategy {
84  constructor(
85    private basePricingStrategy: PricingStrategy,
86    private seasonalMultiplier: number
87  ) {}
88
89  calculatePrice(product: Product, quantity: number): number {
90    const basePrice = this.basePricingStrategy.calculatePrice(product, quantity);
91    return basePrice * this.seasonalMultiplier;
92  }
93}
94
95// L - Liskov Substitution - toutes les stratégies sont interchangeables
96class PremiumPricingStrategy implements PricingStrategy {
97  calculatePrice(product: Product, quantity: number): number {
98    return product.price * quantity * 1.2; // 20% premium
99  }
100}
101
102// I - Interface Segregation - des interfaces petites et dédiées
103interface OrderItem {
104  productId: string;
105  quantity: number;
106  unitPrice: number;
107  totalPrice: number;
108}
109
110interface Order {
111  id: string;
112  customerId: string;
113  items: OrderItem[];
114  totalAmount: number;
115  status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
116  createdAt: Date;
117}
118
119// D - Dependency Inversion
120class OrderService {
121  constructor(
122    private productRepository: ProductRepository,
123    private orderRepository: OrderRepository,
124    private inventoryService: InventoryService,
125    private pricingStrategy: PricingStrategy,
126    private notificationService: NotificationService
127  ) {}
128
129  async createOrder(customerId: string, items: Array<{productId: string, quantity: number}>): Promise<Order> {
130    const orderItems: OrderItem[] = [];
131    let totalAmount = 0;
132
133    // Vérification de la disponibilité et calcul des prix
134    for (const item of items) {
135      const product = await this.productRepository.findById(item.productId);
136      if (!product) {
137        throw new Error(`Produit ${item.productId} introuvable`);
138      }
139
140      const isAvailable = await this.inventoryService.checkAvailability(item.productId, item.quantity);
141      if (!isAvailable) {
142        throw new Error(`Quantité insuffisante du produit ${product.name}`);
143      }
144
145      const totalPrice = this.pricingStrategy.calculatePrice(product, item.quantity);
146
147      orderItems.push({
148        productId: item.productId,
149        quantity: item.quantity,
150        unitPrice: product.price,
151        totalPrice
152      });
153
154      totalAmount += totalPrice;
155    }
156
157    // Réservation des produits
158    for (const item of orderItems) {
159      await this.inventoryService.reserveItems(item.productId, item.quantity);
160    }
161
162    // Création de la commande
163    const order: Order = {
164      id: 'order_' + Math.random().toString(36).slice(2, 11),
165      customerId,
166      items: orderItems,
167      totalAmount,
168      status: 'pending',
169      createdAt: new Date()
170    };
171
172    await this.orderRepository.save(order);
173    await this.notificationService.sendOrderConfirmation(order);
174
175    return order;
176  }
177
178  async confirmOrder(orderId: string): Promise<void> {
179    const order = await this.orderRepository.findById(orderId);
180    if (!order) {
181      throw new Error('Commande introuvable');
182    }
183
184    order.status = 'confirmed';
185    await this.orderRepository.save(order);
186  }
187}
188
189// Exemple d'utilisation avec différentes stratégies de prix
190function createOrderServiceWithRegularPricing(): OrderService {
191  return new OrderService(
192    new InMemoryProductRepository(),
193    new InMemoryOrderRepository(),
194    new SimpleInventoryService(),
195    new RegularPricingStrategy(),
196    new EmailNotificationService()
197  );
198}
199
200function createOrderServiceWithBulkDiscount(): OrderService {
201  return new OrderService(
202    new InMemoryProductRepository(),
203    new InMemoryOrderRepository(),
204    new SimpleInventoryService(),
205    new BulkDiscountStrategy(new QuantityDiscountCalculator()),
206    new EmailNotificationService()
207  );
208}
209
210function createOrderServiceWithSeasonalPricing(): OrderService {
211  const basePricing = new BulkDiscountStrategy(new CategoryDiscountCalculator());
212  const seasonalPricing = new SeasonalPricingStrategy(basePricing, 0.8); // 20 % de réduction
213
214  return new OrderService(
215    new InMemoryProductRepository(),
216    new InMemoryOrderRepository(),
217    new SimpleInventoryService(),
218    seasonalPricing,
219    new SlackNotificationService()
220  );
221}
222
223// Implémentations auxiliaires (simplifiées)
224class InMemoryProductRepository implements ProductRepository {
225  private products = new Map<string, Product>();
226
227  async findById(id: string): Promise<Product | null> {
228    return this.products.get(id) || null;
229  }
230
231  async findByCategory(category: string): Promise<Product[]> {
232    return Array.from(this.products.values()).filter(p => p.category === category);
233  }
234
235  async save(product: Product): Promise<void> {
236    this.products.set(product.id, product);
237  }
238
239  async update(id: string, updates: Partial<Product>): Promise<void> {
240    const product = this.products.get(id);
241    if (product) {
242      this.products.set(id, { ...product, ...updates });
243    }
244  }
245
246  async delete(id: string): Promise<boolean> {
247    return this.products.delete(id);
248  }
249}
250
251class InMemoryOrderRepository implements OrderRepository {
252  private orders = new Map<string, Order>();
253
254  async save(order: Order): Promise<void> {
255    this.orders.set(order.id, order);
256  }
257
258  async findById(orderId: string): Promise<Order | null> {
259    return this.orders.get(orderId) || null;
260  }
261}
262
263class SimpleInventoryService implements InventoryService {
264  private inventory = new Map<string, number>();
265
266  async checkAvailability(productId: string, quantity: number): Promise<boolean> {
267    const available = this.inventory.get(productId) || 0;
268    return available >= quantity;
269  }
270
271  async reserveItems(productId: string, quantity: number): Promise<boolean> {
272    const available = this.inventory.get(productId) || 0;
273    if (available >= quantity) {
274      this.inventory.set(productId, available - quantity);
275      return true;
276    }
277    return false;
278  }
279
280  async releaseReservation(productId: string, quantity: number): Promise<void> {
281    const current = this.inventory.get(productId) || 0;
282    this.inventory.set(productId, current + quantity);
283  }
284}
285
286class EmailNotificationService implements NotificationService {
287  async sendOrderConfirmation(order: Order): Promise<void> {
288    console.log(`E-mail : confirmation de la commande ${order.id} envoyée`);
289  }
290
291  async sendInventoryAlert(productId: string): Promise<void> {
292    console.log(`E-mail : alerte de stock faible pour ${productId}`);
293  }
294}
295
296class SlackNotificationService implements NotificationService {
297  async sendOrderConfirmation(order: Order): Promise<void> {
298    console.log(`Slack : confirmation de la commande ${order.id} envoyée`);
299  }
300
301  async sendInventoryAlert(productId: string): Promise<void> {
302    console.log(`Slack : alerte de stock faible pour ${productId}`);
303  }
304}

OrderService ne sait pas s'il calcule des prix normaux, de gros ou saisonniers, car il reçoit la stratégie de l'extérieur, et son code reste intact.

Récapitulatif des principes SOLID

  1. Single Responsibility - une classe, une responsabilité
  2. Open/Closed - ouvert à l'extension, fermé à la modification
  3. Liskov Substitution - les objets dérivés peuvent remplacer les objets de base
  4. Interface Segregation - des interfaces petites et dédiées
  5. Dependency Inversion - dépendre des abstractions, pas des implémentations

Un code conforme à SOLID est plus facile à maintenir, testable, évolutif et lisible. N'applique pas pour autant ces principes à tout prix : trois lignes de code n'ont pas besoin de cinq interfaces. Dans l'éditeur ci-dessous, tu compléteras ton propre EventBus, et dans la leçon suivante, la programmation fonctionnelle atteindra des objectifs similaires avec des fonctions pures.

Retiens : un bon parc, ce sont de nombreux petits systèmes spécialisés reliés par des abstractions, et non un gigantesque panneau de commande unique.

Code de cette leçon : index.js
1// Pattern Observer - Event Bus
2// Exercice : crée un système d'événements pour le parc
3
4class EventBus {
5  constructor() {
6    this.listeners = {};
7  }
8
9  // Implémente les méthodes :
10  // subscribe(event, callback) - abonnement
11  // unsubscribe(event, callback) - désabonnement
12  // publish(event, data) - publication d'un événement
13
14  subscribe(event, callback) {
15    // Ton code ici
16  }
17
18  publish(event, data) {
19    // Ton code ici
20  }
21}
22
23const bus = new EventBus();
24bus.subscribe("alert", (data) => console.log("Alert:", data));
25bus.publish("alert", { level: "HIGH", message: "Clôture endommagée !" });

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel est le rôle d'un Event Bus dans l'architecture d'une application ?

  2. 2. Qu'apporte le Module Pattern en JavaScript ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Le fichier index.js contient une classe ParkCore qui garde les modules du parc dans une Map (this.modules). Complète les blancs avec des méthodes de Map : ___BLANK1___ vérifie si un nom de module est déjà pris (registerModule retourne alors false et n'écrase pas le module), ___BLANK2___ retourne le module enregistré sous un nom, et ___BLANK3___ parcourt tous les modules pour que init() appelle init() de chaque module qui la possède et retourne le nombre de modules initialisés.

  • Clique dans l'ordre

    Range les éléments d'une implémentation du pattern Singleton.

  • Éditeur de code

    Le fichier index.js contient la classe EventBus de la leçon sur SOLID. Complète les blancs : ___BLANK1___ est la méthode de tableau qui ajoute le callback à la fin de la liste de l'événement, ___BLANK2___ est la méthode de tableau qui retourne un nouveau tableau sans le callback retiré, et ___BLANK3___ est l'appel du callback cb avec les données de l'événement. publish doit transmettre les données à chaque abonné de l'événement, et après unsubscribe le callback retiré ne doit plus rien recevoir.

Articles utiles