Kurs JavaScript i TypeScript · Moduł 9: Wzorce projektowe i architektura

Zasady SOLID w JavaScript/TypeScript

23 min czytania
W tej lekcji7

Witaj z powrotem w Parku Jurajskim! Wyobraź sobie centrum dowodzenia, gdzie jedna klasa steruje ogrodzeniami, sprzedaje bilety i wysyła maile. Poprawka w kasie może wtedy wyłączyć prąd na wybiegu T-Rexa. Lekarstwem jest SOLID: akronim pięciu zasad projektowania obiektowego, które spopularyzował Robert C. Martin. Choć powstały dla języków obiektowych, świetnie sprawdzają się w JavaScript i TypeScript.

S - Single Responsibility Principle (Zasada jednej odpowiedzialności)

Każda klasa lub moduł powinien mieć tylko jeden powód do zmiany, czyli jedną odpowiedzialność. Laboratorium DNA nie sprzedaje biletów, a kasa nie klonuje dinozaurów.

Złe podejście - naruszenie SRP

Oto pracownik od wszystkiego. Ile powodów do zmiany ma UserManager?

1// Klasa UserManager robi za dużo rzeczy
2class UserManager {
3  constructor() {
4    this.users = [];
5  }
6
7  // Zarządzanie użytkownikami
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  // Walidacja - inna odpowiedzialność
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  // Logowanie - kolejna odpowiedzialność
26  logUserAction(action, userId) {
27    console.log(`[${new Date().toISOString()}] User ${userId}: ${action}`);
28  }
29
30  // Wysyłanie emaili - jeszcze inna odpowiedzialność
31  sendWelcomeEmail(userEmail) {
32    console.log(`Sending welcome email to ${userEmail}`);
33  }
34
35  // Formatowanie danych - kolejna odpowiedzialność
36  formatUserData(user) {
37    return `${user.firstName} ${user.lastName} (${user.email})`;
38  }
39}

Wychodzi pięć: przechowywanie, walidacja, logowanie, maile i formatowanie. Zmiana formatu logów wymusza edycję klasy, która pilnuje też haseł.

Dobre podejście - zgodne z SRP

Teraz każda odpowiedzialność dostaje własną klasę, a UserService je koordynuje:

1// Każda klasa ma jedną odpowiedzialność
2
3// Zarządzanie użytkownikami
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// Walidacja
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('Imię musi mieć co najmniej 2 znaki');
57    }
58
59    if (!userData.lastName || userData.lastName.length < 2) {
60      errors.push('Nazwisko musi mieć co najmniej 2 znaki');
61    }
62
63    if (!userData.email || !this.validateEmail(userData.email)) {
64      errors.push('Nieprawidłowy format email');
65    }
66
67    if (!userData.password || !this.validatePassword(userData.password)) {
68      errors.push('Hasło musi mieć co najmniej 8 znaków, wielką literę, małą literę i cyfrę');
69    }
70
71    return errors;
72  }
73}
74
75// Logowanie
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// Wysyłanie emaili
95class EmailService {
96  sendWelcomeEmail(userEmail: string, userName: string): Promise<boolean> {
97    // Symulacja wysyłania emaila
98    console.log(`Wysyłanie maila powitalnego do ${userEmail}`);
99    return new Promise(resolve => {
100      setTimeout(() => {
101        console.log(`Mail powitalny wysłany do ${userName}`);
102        resolve(true);
103      }, 1000);
104    });
105  }
106
107  sendPasswordResetEmail(userEmail: string): Promise<boolean> {
108    console.log(`Wysyłanie maila z resetem hasła do ${userEmail}`);
109    return Promise.resolve(true);
110  }
111}
112
113// Formatowanie danych
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 - koordynuje działanie różnych komponentów
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    // Walidacja
144    const validationErrors = this.userValidator.validateUser(userData);
145    if (validationErrors.length > 0) {
146      throw new Error(`Błędy walidacji: ${validationErrors.join(', ')}`);
147    }
148
149    // Sprawdzenie czy email nie jest zajęty
150    if (this.userRepository.findByEmail(userData.email)) {
151      throw new Error('Użytkownik z tym emailem już istnieje');
152    }
153
154    // Tworzenie użytkownika
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    // Logowanie
163    this.userLogger.logAction('USER_CREATED', user.id);
164
165    // Wysyłanie maila powitalnego
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// Użycie
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('Utworzono użytkownika:', userFormatter.formatUserDisplay(user));
213  }
214});

Zachowanie aplikacji się nie zmieniło, ale każdą część testujesz i wymieniasz osobno. Pomocników UserService dostaje w konstruktorze, co rozwinie litera D.

O - Open/Closed Principle (Zasada otwarte/zamknięte)

Klasy powinny być otwarte na rozszerzenie, ale zamknięte na modyfikację. Nowy gatunek oznacza nowy wybieg, a nie burzenie działających ogrodzeń.

Przykład z systemem płatności

Najpierw problem: ta funkcja wymaga edycji przy każdej nowej metodzie płatności.

1// Naruszenie OCP - każdy nowy typ płatności to zmiana tej funkcji
2function processPayment(type: string, amount: number): void {
3  if (type === 'credit-card') {
4    console.log(`Karta: ${amount} PLN`);
5  } else if (type === 'paypal') {
6    console.log(`PayPal: ${amount} PLN`);
7  }
8  // kryptowaluty? Kolejny else if w przetestowanym już kodzie
9}

Wersja zgodna z OCP opiera się na interfejsie PaymentProcessor, a PaymentService trzyma procesory w mapie:

1// Dobre podejście - zgodne z 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// Podstawowa implementacja dla kart kredytowych
15class CreditCardProcessor implements PaymentProcessor {
16  async processPayment(amount: number, details: CreditCardDetails): Promise<PaymentResult> {
17    if (!this.validatePayment(details)) {
18      return { success: false, error: 'Nieprawidłowe dane karty' };
19    }
20
21    // Symulacja przetwarzania płatności kartą
22    console.log(`Przetwarzanie płatności kartą: ${amount} PLN`);
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// Rozszerzenie - nowy procesor bez modyfikacji istniejącego kodu
46class PayPalProcessor implements PaymentProcessor {
47  async processPayment(amount: number, details: PayPalDetails): Promise<PaymentResult> {
48    if (!this.validatePayment(details)) {
49      return { success: false, error: 'Nieprawidłowe dane PayPal' };
50    }
51
52    console.log(`Przetwarzanie płatności PayPal: ${amount} PLN`);
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// Kolejne rozszerzenie
71class BankTransferProcessor implements PaymentProcessor {
72  async processPayment(amount: number, details: BankTransferDetails): Promise<PaymentResult> {
73    if (!this.validatePayment(details)) {
74      return { success: false, error: 'Nieprawidłowe dane przelewu' };
75    }
76
77    console.log(`Przetwarzanie przelewu bankowego: ${amount} PLN`);
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// Nowy procesor dla kryptowalut - bez modyfikacji istniejącego kodu
99class CryptocurrencyProcessor implements PaymentProcessor {
100  async processPayment(amount: number, details: CryptoDetails): Promise<PaymentResult> {
101    if (!this.validatePayment(details)) {
102      return { success: false, error: 'Nieprawidłowy adres portfela' };
103    }
104
105    console.log(`Przetwarzanie płatności ${details.currency}: ${amount} PLN`);
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// Kontekst używający procesorów - nie musi być modyfikowany
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: `Nieobsługiwany typ płatności: ${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// Użycie - łatwe dodawanie nowych typów płatności
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// Testowanie różnych typów płatności
157async function testPayments() {
158  // Płatność kartą
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  // Płatność PayPal
167  const paypalResult = await paymentService.processPayment('paypal', 50, {
168    email: 'user@example.com',
169    password: 'password123'
170  });
171
172  // Płatność kryptowalutą
173  const cryptoResult = await paymentService.processPayment('crypto', 200, {
174    walletAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
175    currency: 'BTC'
176  });
177
178  console.log('Wyniki płatności:', {
179    creditCard: creditCardResult,
180    paypal: paypalResult,
181    crypto: cryptoResult
182  });
183}
184
185testPayments();

Kryptowaluty nie zmieniły ani linijki w PaymentService, wystarczyło dopisać klasę i wywołać registerProcessor.

L - Liskov Substitution Principle (Zasada podstawienia Liskov)

Obiekty klas pochodnych powinny móc zastąpić obiekty klasy bazowej bez wpływu na poprawność programu. Zasadę sformułowała Barbara Liskov, a przykład dotyczy ptaków, żyjących potomków dinozaurów.

Przykład z ptakami - naruszenie LSP

Klasa Bird obiecuje, że każdy ptak lata. Co się stanie, gdy dołączy pingwin?

1// Naruszenie LSP
2abstract class Bird {
3  abstract fly(): void;
4  abstract makeSound(): void;
5}
6
7class Sparrow extends Bird {
8  fly(): void {
9    console.log('Wróbel leci!');
10  }
11
12  makeSound(): void {
13    console.log('Ćwir ćwir!');
14  }
15}
16
17class Eagle extends Bird {
18  fly(): void {
19    console.log('Orzeł szybuje!');
20  }
21
22  makeSound(): void {
23    console.log('Krra!');
24  }
25}
26
27// Problem: pingwin nie potrafi latać!
28class Penguin extends Bird {
29  fly(): void {
30    throw new Error('Pingwiny nie potrafią latać!'); // Naruszenie LSP
31  }
32
33  makeSound(): void {
34    console.log('Kwa kwa!');
35  }
36}
37
38// Funkcja oczekująca, że każdy ptak potrafi latać
39function makeBirdFly(bird: Bird): void {
40  bird.fly(); // Może rzucić błąd dla pingwina!
41}

TypeScript milczy, ale makeBirdFly(new Penguin()) rzuci wyjątek w trakcie działania. Podklasa złamała obietnicę klasy bazowej.

Poprawione podejście zgodne z LSP

Umiejętności trafiają do osobnych interfejsów Flyable, Swimmable i Walkable, a klasa bazowa obiecuje tylko to, co potrafi każdy ptak:

1// Zgodne z LSP - lepsze modelowanie hierarchii
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// Klasa bazowa dla ptaków
21abstract class Bird implements Animal {
22  abstract makeSound(): void;
23  abstract move(): void;
24
25  eat(): void {
26    console.log('Ptak je');
27  }
28
29  sleep(): void {
30    console.log('Ptak śpi');
31  }
32}
33
34// Latające ptaki
35class FlyingBird extends Bird implements Flyable {
36  makeSound(): void {
37    console.log('Ptak wydaje dźwięk');
38  }
39
40  move(): void {
41    this.fly();
42  }
43
44  fly(): void {
45    console.log('Ptak leci');
46  }
47}
48
49// Pływające ptaki
50class SwimmingBird extends Bird implements Swimmable {
51  makeSound(): void {
52    console.log('Ptak wydaje dźwięk');
53  }
54
55  move(): void {
56    this.swim();
57  }
58
59  swim(): void {
60    console.log('Ptak pływa');
61  }
62}
63
64// Konkretne implementacje
65class Sparrow extends FlyingBird {
66  makeSound(): void {
67    console.log('Ćwir ćwir!');
68  }
69
70  fly(): void {
71    console.log('Wróbel leci szybko');
72  }
73}
74
75class Eagle extends FlyingBird {
76  makeSound(): void {
77    console.log('Krra!');
78  }
79
80  fly(): void {
81    console.log('Orzeł szybuje majestatycznie');
82  }
83}
84
85class Penguin extends SwimmingBird implements Walkable {
86  makeSound(): void {
87    console.log('Kwa kwa!');
88  }
89
90  swim(): void {
91    console.log('Pingwin pływa zwinnie');
92  }
93
94  walk(): void {
95    console.log('Pingwin chodzi po lodzie');
96  }
97
98  move(): void {
99    // Pingwin może się poruszać na różne sposoby
100    if (Math.random() > 0.5) {
101      this.swim();
102    } else {
103      this.walk();
104    }
105  }
106}
107
108// Funkcje przestrzegające LSP
109function makeAnimalMove(animal: Animal): void {
110  animal.move(); // Zawsze zadziała poprawnie
111}
112
113function makeFlyableObjectFly(flyable: Flyable): void {
114  flyable.fly(); // Tylko dla obiektów, które rzeczywiście potrafią latać
115}
116
117function makeSwimmableObjectSwim(swimmable: Swimmable): void {
118  swimmable.swim(); // Tylko dla obiektów, które rzeczywiście potrafią pływać
119}
120
121// Testowanie
122const birds: Bird[] = [
123  new Sparrow(),
124  new Eagle(),
125  new Penguin()
126];
127
128birds.forEach(bird => {
129  bird.makeSound();
130  makeAnimalMove(bird); // Każdy ptak może się poruszać po swojemu
131});
132
133// Testowanie zdolności latania tylko dla latających ptaków
134const flyingBirds: Flyable[] = [
135  new Sparrow(),
136  new Eagle()
137  // Pingwin nie jest dodany, bo nie implementuje Flyable
138];
139
140flyingBirds.forEach(bird => {
141  makeFlyableObjectFly(bird);
142});

Teraz makeAnimalMove zadziała dla każdego ptaka, a pingwina nie dopiszesz do tablicy Flyable, bo kompilator zgłosi błąd.

I - Interface Segregation Principle (Zasada segregacji interfejsów)

Klasy nie powinny być zmuszone do implementowania interfejsów, z których nie korzystają. Strażnik wybiegu nie musi umieć pilotować helikoptera.

Naruszenie ISP

Jeden wielki interfejs zmusza prostą drukarkę do udawania, że umie wszystko:

1// Naruszenie ISP - zbyt duży interfejs
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// Problem: prosta drukarka musi implementować wszystkie metody
11class SimplePrinter implements MultiFunctionDevice {
12  print(document: string): void {
13    console.log(`Drukowanie: ${document}`);
14  }
15
16  // Wymuszenie implementacji nieużywanych metod
17  scan(document: string): string {
18    throw new Error('Drukarka nie obsługuje skanowania');
19  }
20
21  fax(document: string, number: string): void {
22    throw new Error('Drukarka nie obsługuje faksów');
23  }
24
25  copy(document: string): string {
26    throw new Error('Drukarka nie obsługuje kopiowania');
27  }
28
29  email(document: string, address: string): void {
30    throw new Error('Drukarka nie obsługuje emaili');
31  }
32}

Cztery z pięciu metod rzucają wyjątek, co wyjdzie na jaw dopiero w trakcie działania.

Zgodne z ISP

Dzielimy interfejs na małe części, a każda klasa implementuje tylko to, czego potrzebuje:

1// Zgodne z ISP - podzielone interfejsy
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// Prosta drukarka implementuje tylko to, czego potrzebuje
24class SimplePrinter implements Printer {
25  print(document: string): void {
26    console.log(`Drukowanie: ${document}`);
27  }
28}
29
30// Skaner implementuje tylko skanowanie
31class SimpleScanner implements Scanner {
32  scan(document: string): string {
33    console.log(`Skanowanie: ${document}`);
34    return `Zeskanowany: ${document}`;
35  }
36}
37
38// Urządzenie wielofunkcyjne implementuje potrzebne interfejsy
39class MultiFunctionPrinter implements Printer, Scanner, Copier {
40  print(document: string): void {
41    console.log(`[MFP] Drukowanie: ${document}`);
42  }
43
44  scan(document: string): string {
45    console.log(`[MFP] Skanowanie: ${document}`);
46    return `Zeskanowany: ${document}`;
47  }
48
49  copy(document: string): string {
50    console.log(`[MFP] Kopiowanie: ${document}`);
51    const scanned = this.scan(document);
52    this.print(scanned);
53    return scanned;
54  }
55}
56
57// Profesjonalne urządzenie biurowe
58class OfficeMachine implements Printer, Scanner, FaxMachine, Copier, EmailSender {
59  print(document: string): void {
60    console.log(`[Office] Drukowanie: ${document}`);
61  }
62
63  scan(document: string): string {
64    console.log(`[Office] Skanowanie: ${document}`);
65    return `Zeskanowany: ${document}`;
66  }
67
68  fax(document: string, number: string): void {
69    console.log(`[Office] Wysyłanie faksu ${document} na numer ${number}`);
70  }
71
72  copy(document: string): string {
73    console.log(`[Office] Kopiowanie: ${document}`);
74    return this.scan(document);
75  }
76
77  email(document: string, address: string): void {
78    console.log(`[Office] Wysyłanie emaila ${document} na adres ${address}`);
79  }
80}
81
82// Funkcje używające tylko potrzebnych interfejsów
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// Użycie
98const simplePrinter = new SimplePrinter();
99const simpleScanner = new SimpleScanner();
100const mfp = new MultiFunctionPrinter();
101const officeMachine = new OfficeMachine();
102
103// Każde urządzenie używa tylko swoich możliwości
104DocumentProcessor.printDocument(simplePrinter, 'Dokument 1');
105DocumentProcessor.scanDocument(simpleScanner, 'Dokument 2');
106DocumentProcessor.copyDocument(mfp, 'Dokument 3');
107DocumentProcessor.printDocument(officeMachine, 'Dokument 4');

DocumentProcessor prosi dokładnie o to, czego używa, a OfficeMachine pokazuje, że klasa może implementować kilka interfejsów naraz.

D - Dependency Inversion Principle (Zasada odwrócenia zależności)

Moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu. Oba powinny zależeć od abstrakcji. Centrum dowodzenia korzysta z gniazdka, a nie z konkretnego generatora.

Naruszenie DIP

Tutaj UserService sam tworzy konkretną bazę MySQL i konkretną usługę maili:

1// Naruszenie DIP - zależność od konkretnych implementacji
2
3class MySQLDatabase {
4  save(data: any): void {
5    console.log('Zapisywanie do MySQL:', data);
6  }
7
8  find(id: string): any {
9    console.log('Szukanie w MySQL:', id);
10    return { id, data: 'mysql_data' };
11  }
12}
13
14class EmailNotification {
15  send(message: string, recipient: string): void {
16    console.log(`Wysyłanie emaila do ${recipient}: ${message}`);
17  }
18}
19
20// Problem: UserService zależy od konkretnych implementacji
21class UserService {
22  private database: MySQLDatabase; // Konkretna zależność
23  private notification: EmailNotification; // Konkretna zależność
24
25  constructor() {
26    this.database = new MySQLDatabase(); // Tworzenie zależności wewnątrz klasy
27    this.notification = new EmailNotification();
28  }
29
30  createUser(userData: any): void {
31    this.database.save(userData);
32    this.notification.send('Witaj!', userData.email);
33  }
34
35  getUser(id: string): any {
36    return this.database.find(id);
37  }
38}

Zmiana bazy oznacza edycję serwisu, a w testach nie podmienisz jej na atrapę.

Zgodne z DIP

Teraz serwis zna tylko interfejsy Database, NotificationService i Logger, a konkretne klasy dostaje z zewnątrz, choćby z prostego kontenera DI:

1// Zgodne z DIP - zależność od abstrakcji
2
3// Abstrakcje (interfejsy)
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// Konkretne implementacje - niski poziom
20class MySQLDatabase implements Database {
21  save(data: any): void {
22    console.log('Zapisywanie do MySQL:', data);
23  }
24
25  find(id: string): any {
26    console.log('Szukanie w MySQL:', id);
27    return { id, data: 'mysql_data' };
28  }
29
30  update(id: string, data: any): void {
31    console.log(`Aktualizacja w MySQL ${id}:`, data);
32  }
33
34  delete(id: string): boolean {
35    console.log('Usuwanie z MySQL:', id);
36    return true;
37  }
38}
39
40class PostgreSQLDatabase implements Database {
41  save(data: any): void {
42    console.log('Zapisywanie do PostgreSQL:', data);
43  }
44
45  find(id: string): any {
46    console.log('Szukanie w PostgreSQL:', id);
47    return { id, data: 'postgres_data' };
48  }
49
50  update(id: string, data: any): void {
51    console.log(`Aktualizacja w PostgreSQL ${id}:`, data);
52  }
53
54  delete(id: string): boolean {
55    console.log('Usuwanie z PostgreSQL:', id);
56    return true;
57  }
58}
59
60class MongoDatabase implements Database {
61  save(data: any): void {
62    console.log('Zapisywanie do MongoDB:', data);
63  }
64
65  find(id: string): any {
66    console.log('Szukanie w MongoDB:', id);
67    return { id, data: 'mongo_data' };
68  }
69
70  update(id: string, data: any): void {
71    console.log(`Aktualizacja w MongoDB ${id}:`, data);
72  }
73
74  delete(id: string): boolean {
75    console.log('Usuwanie z MongoDB:', id);
76    return true;
77  }
78}
79
80class EmailNotificationService implements NotificationService {
81  async send(message: string, recipient: string): Promise<boolean> {
82    console.log(`Wysyłanie emaila do ${recipient}: ${message}`);
83    return true;
84  }
85}
86
87class SMSNotificationService implements NotificationService {
88  async send(message: string, recipient: string): Promise<boolean> {
89    console.log(`Wysyłanie SMS do ${recipient}: ${message}`);
90    return true;
91  }
92}
93
94class SlackNotificationService implements NotificationService {
95  async send(message: string, recipient: string): Promise<boolean> {
96    console.log(`Wysyłanie wiadomości Slack do ${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(`Zapisywanie do pliku [${level.toUpperCase()}]: ${message}`);
110  }
111}
112
113// Wysokopoziomowy moduł - zależy tylko od abstrakcji
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', `Tworzenie użytkownika: ${userData.email}`);
124
125      this.database.save(userData);
126
127      await this.notificationService.send(
128        'Witaj w naszej aplikacji!',
129        userData.email
130      );
131
132      this.logger.log('info', `Użytkownik ${userData.email} został utworzony`);
133    } catch (error) {
134      this.logger.log('error', `Błąd podczas tworzenia użytkownika: ${error}`);
135      throw error;
136    }
137  }
138
139  getUser(id: string): any {
140    this.logger.log('info', `Pobieranie użytkownika: ${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', `Aktualizacja użytkownika: ${id}`);
147
148      this.database.update(id, userData);
149
150      await this.notificationService.send(
151        'Twoje dane zostały zaktualizowane',
152        userData.email
153      );
154
155      this.logger.log('info', `Użytkownik ${id} został zaktualizowany`);
156    } catch (error) {
157      this.logger.log('error', `Błąd podczas aktualizacji użytkownika: ${error}`);
158      throw error;
159    }
160  }
161
162  deleteUser(id: string): boolean {
163    this.logger.log('info', `Usuwanie użytkownika: ${id}`);
164    return this.database.delete(id);
165  }
166}
167
168// Dependency Injection Container (uproszczony)
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// Konfiguracja zależności
186const container = new DIContainer();
187
188// Rejestracja różnych implementacji
189container.register<Database>('database', new PostgreSQLDatabase());
190container.register<NotificationService>('notification', new EmailNotificationService());
191container.register<Logger>('logger', new ConsoleLogger());
192
193// Utworzenie serwisu ze wstrzykniętymi zależnościami
194const userService = new UserService(
195  container.get<Database>('database'),
196  container.get<NotificationService>('notification'),
197  container.get<Logger>('logger')
198);
199
200// Testowanie
201userService.createUser({
202  id: '1',
203  name: 'Jan Kowalski',
204  email: 'jan@example.com'
205});
206
207// Łatwe przełączanie implementacji
208const mobileUserService = new UserService(
209  new MongoDatabase(),              // Inna baza danych
210  new SMSNotificationService(),     // Inne powiadomienia
211  new FileLogger()                  // Inne logowanie
212);
213
214mobileUserService.createUser({
215  id: '2',
216  name: 'Anna Nowak',
217  email: '+48123456789'
218});

Kod UserService nie zmienił się ani o znak, a działa raz z PostgreSQL i mailem, raz z MongoDB i SMS-em. Wstrzykiwanie przez konstruktor polecam jako domyślny styl: w testach podajesz po prostu atrapy.

Event Bus

Event Bus to wspólny kanał zdarzeń, przez który moduły komunikują się bez bezpośrednich zależności, jak strażnicy przez radio parku. Nadawca wywołuje publish, a zainteresowani nasłuchują przez subscribe:

1class EventBus {
2  constructor() {
3    this.listeners = new Map(); // nazwa zdarzenia -> tablica callbacków
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('Alarm w sektorze', data.sector));
23bus.publish('fence:breach', { sector: 'B6' });

Moduł ogrodzeń nie wie, kto odbierze alarm, a system alarmowy go nie importuje: oba znają tylko szynę zdarzeń, czyli abstrakcję.

Singleton i rejestr modułów

Singleton gwarantuje jedną instancję, jak jedno centrum dowodzenia: zapamiętuje ją w statycznym polu, a każde kolejne new zwraca właśnie ją:

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

Nowy moduł rejestrujesz bez zmian w rdzeniu (zasada otwarte/zamknięte). Singleton to jednak ukryta zmienna globalna, więc lepiej przekazuj go przez konstruktor. Moduły chroń wzorcem Module: daje enkapsulację i nie zaśmieca globalnego zakresu.

Praktyczny przykład - System e-commerce zgodny z SOLID

Na koniec pięć zasad w jednym systemie zamówień, a komentarze z literami S, O, L, I i D pokazują, gdzie działa która:

1// Kompleksowy przykład zastosowania wszystkich zasad SOLID
2
3// Abstrakcje
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 - łatwe dodawanie nowych strategii
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 - wszystkie strategie są zamienne
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 - małe, dedykowane interfejsy
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    // Sprawdzenie dostępności i obliczenie cen
134    for (const item of items) {
135      const product = await this.productRepository.findById(item.productId);
136      if (!product) {
137        throw new Error(`Produkt ${item.productId} nie został znaleziony`);
138      }
139
140      const isAvailable = await this.inventoryService.checkAvailability(item.productId, item.quantity);
141      if (!isAvailable) {
142        throw new Error(`Niewystarczająca ilość produktu ${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    // Rezerwacja produktów
158    for (const item of orderItems) {
159      await this.inventoryService.reserveItems(item.productId, item.quantity);
160    }
161
162    // Utworzenie zamówienia
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('Zamówienie nie zostało znalezione');
182    }
183
184    order.status = 'confirmed';
185    await this.orderRepository.save(order);
186  }
187}
188
189// Przykład użycia z różnymi strategiami cenowymi
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% zniżki
213
214  return new OrderService(
215    new InMemoryProductRepository(),
216    new InMemoryOrderRepository(),
217    new SimpleInventoryService(),
218    seasonalPricing,
219    new SlackNotificationService()
220  );
221}
222
223// Implementacje pomocnicze (uproszczone)
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(`Email: Potwierdzenie zamówienia ${order.id} wysłane`);
289  }
290
291  async sendInventoryAlert(productId: string): Promise<void> {
292    console.log(`Email: Alert o niskim stanie magazynowym dla ${productId}`);
293  }
294}
295
296class SlackNotificationService implements NotificationService {
297  async sendOrderConfirmation(order: Order): Promise<void> {
298    console.log(`Slack: Potwierdzenie zamówienia ${order.id} wysłane`);
299  }
300
301  async sendInventoryAlert(productId: string): Promise<void> {
302    console.log(`Slack: Alert o niskim stanie magazynowym dla ${productId}`);
303  }
304}

OrderService nie wie, czy liczy ceny zwykłe, hurtowe czy sezonowe, bo strategię dostaje z zewnątrz, a jego kod pozostaje nietknięty.

Podsumowanie zasad SOLID

  1. Single Responsibility - jedna klasa, jedna odpowiedzialność
  2. Open/Closed - otwarte na rozszerzenie, zamknięte na modyfikację
  3. Liskov Substitution - obiekty pochodne mogą zastąpić bazowe
  4. Interface Segregation - małe, dedykowane interfejsy
  5. Dependency Inversion - zależność od abstrakcji, nie implementacji

Kod zgodny z SOLID jest łatwiejszy w utrzymaniu, testowalny, skalowalny i czytelny. Nie stosuj jednak zasad na siłę: trzy linijki nie potrzebują pięciu interfejsów. W edytorze poniżej uzupełnisz własny EventBus, a w kolejnej lekcji programowanie funkcyjne osiągnie podobne cele czystymi funkcjami.

Pamiętaj: dobry park to wiele małych, wyspecjalizowanych systemów połączonych abstrakcjami, a nie jeden gigantyczny panel sterowania.

Kod do tej lekcji: index.js
1// Wzorzec Observer - Event Bus
2// Ćwiczenie: Stwórz system zdarzeń dla Parku
3
4class EventBus {
5  constructor() {
6    this.listeners = {};
7  }
8
9  // Zaimplementuj metody:
10  // subscribe(event, callback) - subskrypcja
11  // unsubscribe(event, callback) - wypisanie
12  // publish(event, data) - publikacja zdarzenia
13
14  subscribe(event, callback) {
15    // Twój kod tutaj
16  }
17
18  publish(event, data) {
19    // Twój kod tutaj
20  }
21}
22
23const bus = new EventBus();
24bus.subscribe("alert", (data) => console.log("Alert:", data));
25bus.publish("alert", { level: "HIGH", message: "Ogrodzenie uszkodzone!" });

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy Event Bus w architekturze aplikacji?

  2. 2. Co zapewnia wzorzec Module w JavaScript?

Zadania praktyczne w grze

  • Edytor kodu

    W pliku index.js jest klasa ParkCore, która trzyma moduły parku w Map (this.modules). Uzupełnij luki metodami Map: ___BLANK1___ sprawdza, czy nazwa modułu jest już zajęta (wtedy registerModule zwraca false i nie nadpisuje modułu), ___BLANK2___ zwraca moduł zapisany pod nazwą, a ___BLANK3___ przechodzi po wszystkich modułach, żeby init() wywołał init() każdego modułu, który ją ma, i zwrócił liczbę zainicjalizowanych modułów.

  • Klikanie w kolejności

    Ułóż elementy implementacji wzorca Singleton.

  • Edytor kodu

    W pliku index.js jest klasa EventBus z lekcji o SOLID. Uzupełnij luki: ___BLANK1___ to metoda tablicy, która dopisuje callback na koniec listy zdarzenia, ___BLANK2___ to metoda tablicy, która zwraca nową tablicę bez usuwanego callbacku, a ___BLANK3___ to wywołanie callbacku cb z danymi zdarzenia. publish ma przekazać dane każdemu subskrybentowi zdarzenia, a po unsubscribe usunięty callback nie może już nic dostać.

Przydatne artykuły