Cours JavaScript et TypeScript · Module 9: Design patterns et architecture
Les principes SOLID en JavaScript/TypeScript
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()); // trueTu 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
- Single Responsibility - une classe, une responsabilité
- Open/Closed - ouvert à l'extension, fermé à la modification
- Liskov Substitution - les objets dérivés peuvent remplacer les objets de base
- Interface Segregation - des interfaces petites et dédiées
- 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. Quel est le rôle d'un Event Bus dans l'architecture d'une application ?
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.