JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture
SOLID Principles in JavaScript/TypeScript
In this lesson7
Welcome back to Jurassic Park! Picture a control room where a single class runs the fences, sells tickets and sends emails. A fix at the ticket desk could then cut the power to the T-Rex paddock. The cure is SOLID: an acronym for five object-oriented design principles popularized by Robert C. Martin. Although they were born for object-oriented languages, they work great in JavaScript and TypeScript.
S - Single Responsibility Principle
Every class or module should have only one reason to change, that is, one responsibility. The DNA lab does not sell tickets, and the ticket desk does not clone dinosaurs.
Bad approach - violating SRP
Here is a jack of all trades. How many reasons to change does UserManager have?
1// UserManager class does too many things
2class UserManager {
3 constructor() {
4 this.users = [];
5 }
6
7 // User management
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 - different responsibility
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 // Logging - another responsibility
26 logUserAction(action, userId) {
27 console.log(`[${new Date().toISOString()}] User ${userId}: ${action}`);
28 }
29
30 // Sending emails - yet another responsibility
31 sendWelcomeEmail(userEmail) {
32 console.log(`Sending welcome email to ${userEmail}`);
33 }
34
35 // Data formatting - another responsibility
36 formatUserData(user) {
37 return `${user.firstName} ${user.lastName} (${user.email})`;
38 }
39}Five: storage, validation, logging, emails and formatting. Changing the log format forces you to edit a class that also guards passwords.
Good approach - compliant with SRP
Now every responsibility gets its own class, and UserService coordinates them:
1// Each class has one responsibility
2
3// User management
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('First name must be at least 2 characters');
57 }
58
59 if (!userData.lastName || userData.lastName.length < 2) {
60 errors.push('Last name must be at least 2 characters');
61 }
62
63 if (!userData.email || !this.validateEmail(userData.email)) {
64 errors.push('Invalid email format');
65 }
66
67 if (!userData.password || !this.validatePassword(userData.password)) {
68 errors.push('Password must have at least 8 characters, an uppercase letter, a lowercase letter, and a digit');
69 }
70
71 return errors;
72 }
73}
74
75// Logging
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// Sending emails
95class EmailService {
96 sendWelcomeEmail(userEmail: string, userName: string): Promise<boolean> {
97 // Simulating email sending
98 console.log(`Sending welcome email to ${userEmail}`);
99 return new Promise(resolve => {
100 setTimeout(() => {
101 console.log(`Welcome email sent to ${userName}`);
102 resolve(true);
103 }, 1000);
104 });
105 }
106
107 sendPasswordResetEmail(userEmail: string): Promise<boolean> {
108 console.log(`Sending password reset email to ${userEmail}`);
109 return Promise.resolve(true);
110 }
111}
112
113// Data formatting
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 - coordinates different components
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(`Validation errors: ${validationErrors.join(', ')}`);
147 }
148
149 // Check if email is taken
150 if (this.userRepository.findByEmail(userData.email)) {
151 throw new Error('A user with this email already exists');
152 }
153
154 // Create user
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 // Logging
163 this.userLogger.logAction('USER_CREATED', user.id);
164
165 // Send welcome email
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// Usage
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('User created:', userFormatter.formatUserDisplay(user));
213 }
214});The application behaves exactly as before, but you can test and replace each part separately. UserService receives its helpers in the constructor, which the letter D will expand on.
O - Open/Closed Principle
Classes should be open for extension but closed for modification. A new species means a new paddock, not tearing down fences that already work.
Example with a payment system
First the problem: this function needs editing for every new payment method.
1// OCP violation - every new payment type means changing this function
2function processPayment(type: string, amount: number): void {
3 if (type === 'credit-card') {
4 console.log(`Card: ${amount} PLN`);
5 } else if (type === 'paypal') {
6 console.log(`PayPal: ${amount} PLN`);
7 }
8 // cryptocurrency? Another else if in code that was already tested
9}The OCP-compliant version is built on the PaymentProcessor interface, and PaymentService keeps the processors in a map:
1// Good approach - compliant with 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// Basic implementation for credit cards
15class CreditCardProcessor implements PaymentProcessor {
16 async processPayment(amount: number, details: CreditCardDetails): Promise<PaymentResult> {
17 if (!this.validatePayment(details)) {
18 return { success: false, error: 'Invalid card data' };
19 }
20
21 // Simulating card payment processing
22 console.log(`Processing card payment: ${amount} USD`);
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 - new processor without modifying existing code
46class PayPalProcessor implements PaymentProcessor {
47 async processPayment(amount: number, details: PayPalDetails): Promise<PaymentResult> {
48 if (!this.validatePayment(details)) {
49 return { success: false, error: 'Invalid PayPal data' };
50 }
51
52 console.log(`Processing PayPal payment: ${amount} USD`);
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// Another extension
71class BankTransferProcessor implements PaymentProcessor {
72 async processPayment(amount: number, details: BankTransferDetails): Promise<PaymentResult> {
73 if (!this.validatePayment(details)) {
74 return { success: false, error: 'Invalid transfer data' };
75 }
76
77 console.log(`Processing bank transfer: ${amount} USD`);
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// New processor for cryptocurrencies - without modifying existing code
99class CryptocurrencyProcessor implements PaymentProcessor {
100 async processPayment(amount: number, details: CryptoDetails): Promise<PaymentResult> {
101 if (!this.validatePayment(details)) {
102 return { success: false, error: 'Invalid wallet address' };
103 }
104
105 console.log(`Processing ${details.currency} payment: ${amount} USD`);
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// Context using processors - does not need to be modified
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: `Unsupported payment type: ${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// Usage - easy to add new payment types
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// Testing different payment types
157async function testPayments() {
158 // Card payment
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 // PayPal payment
167 const paypalResult = await paymentService.processPayment('paypal', 50, {
168 email: 'user@example.com',
169 password: 'password123'
170 });
171
172 // Cryptocurrency payment
173 const cryptoResult = await paymentService.processPayment('crypto', 200, {
174 walletAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
175 currency: 'BTC'
176 });
177
178 console.log('Payment results:', {
179 creditCard: creditCardResult,
180 paypal: paypalResult,
181 crypto: cryptoResult
182 });
183}
184
185testPayments();Cryptocurrency did not change a single line in PaymentService: it was enough to add a class and call registerProcessor.
L - Liskov Substitution Principle
Objects of derived classes should be able to replace objects of the base class without affecting the correctness of the program. The principle was formulated by Barbara Liskov, and the example is about birds, the living descendants of dinosaurs.
Example with birds - violating LSP
The Bird class promises that every bird flies. What happens when a penguin joins?
1// Violating LSP
2abstract class Bird {
3 abstract fly(): void;
4 abstract makeSound(): void;
5}
6
7class Sparrow extends Bird {
8 fly(): void {
9 console.log('Sparrow is flying!');
10 }
11
12 makeSound(): void {
13 console.log('Chirp chirp!');
14 }
15}
16
17class Eagle extends Bird {
18 fly(): void {
19 console.log('Eagle is soaring!');
20 }
21
22 makeSound(): void {
23 console.log('Screech!');
24 }
25}
26
27// Problem: a penguin cannot fly!
28class Penguin extends Bird {
29 fly(): void {
30 throw new Error('Penguins cannot fly!'); // LSP violation
31 }
32
33 makeSound(): void {
34 console.log('Quack quack!');
35 }
36}
37
38// Function expecting every bird can fly
39function makeBirdFly(bird: Bird): void {
40 bird.fly(); // May throw an error for a penguin!
41}TypeScript stays silent, but makeBirdFly(new Penguin()) throws an exception at runtime. The subclass broke the promise made by the base class.
Corrected approach compliant with LSP
Abilities move into separate interfaces, Flyable, Swimmable and Walkable, and the base class promises only what every bird can do:
1// Compliant with LSP - better hierarchy modeling
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// Base class for birds
21abstract class Bird implements Animal {
22 abstract makeSound(): void;
23 abstract move(): void;
24
25 eat(): void {
26 console.log('Bird is eating');
27 }
28
29 sleep(): void {
30 console.log('Bird is sleeping');
31 }
32}
33
34// Flying birds
35class FlyingBird extends Bird implements Flyable {
36 makeSound(): void {
37 console.log('Bird makes a sound');
38 }
39
40 move(): void {
41 this.fly();
42 }
43
44 fly(): void {
45 console.log('Bird is flying');
46 }
47}
48
49// Swimming birds
50class SwimmingBird extends Bird implements Swimmable {
51 makeSound(): void {
52 console.log('Bird makes a sound');
53 }
54
55 move(): void {
56 this.swim();
57 }
58
59 swim(): void {
60 console.log('Bird is swimming');
61 }
62}
63
64// Concrete implementations
65class Sparrow extends FlyingBird {
66 makeSound(): void {
67 console.log('Chirp chirp!');
68 }
69
70 fly(): void {
71 console.log('Sparrow flies fast');
72 }
73}
74
75class Eagle extends FlyingBird {
76 makeSound(): void {
77 console.log('Screech!');
78 }
79
80 fly(): void {
81 console.log('Eagle soars majestically');
82 }
83}
84
85class Penguin extends SwimmingBird implements Walkable {
86 makeSound(): void {
87 console.log('Quack quack!');
88 }
89
90 swim(): void {
91 console.log('Penguin swims gracefully');
92 }
93
94 walk(): void {
95 console.log('Penguin walks on ice');
96 }
97
98 move(): void {
99 // Penguin can move in different ways
100 if (Math.random() > 0.5) {
101 this.swim();
102 } else {
103 this.walk();
104 }
105 }
106}
107
108// Functions respecting LSP
109function makeAnimalMove(animal: Animal): void {
110 animal.move(); // Will always work correctly
111}
112
113function makeFlyableObjectFly(flyable: Flyable): void {
114 flyable.fly(); // Only for objects that can actually fly
115}
116
117function makeSwimmableObjectSwim(swimmable: Swimmable): void {
118 swimmable.swim(); // Only for objects that can actually swim
119}
120
121// Testing
122const birds: Bird[] = [
123 new Sparrow(),
124 new Eagle(),
125 new Penguin()
126];
127
128birds.forEach(bird => {
129 bird.makeSound();
130 makeAnimalMove(bird); // Each bird can move in its own way
131});
132
133// Testing flight ability only for flying birds
134const flyingBirds: Flyable[] = [
135 new Sparrow(),
136 new Eagle()
137 // Penguin is not added because it doesn't implement Flyable
138];
139
140flyingBirds.forEach(bird => {
141 makeFlyableObjectFly(bird);
142});Now makeAnimalMove works for every bird, and you cannot add the penguin to a Flyable array, because the compiler reports an error.
I - Interface Segregation Principle
Classes should not be forced to implement interfaces they do not use. A paddock ranger does not need to know how to fly a helicopter.
Violating ISP
One huge interface forces a simple printer to pretend it can do everything:
1// Violating ISP - interface too large
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: a simple printer must implement all methods
11class SimplePrinter implements MultiFunctionDevice {
12 print(document: string): void {
13 console.log(`Printing: ${document}`);
14 }
15
16 // Forced to implement unused methods
17 scan(document: string): string {
18 throw new Error('Printer does not support scanning');
19 }
20
21 fax(document: string, number: string): void {
22 throw new Error('Printer does not support faxing');
23 }
24
25 copy(document: string): string {
26 throw new Error('Printer does not support copying');
27 }
28
29 email(document: string, address: string): void {
30 throw new Error('Printer does not support emailing');
31 }
32}Four out of five methods throw an exception, which only comes to light at runtime.
Compliant with ISP
We split the interface into small parts, and every class implements only what it needs:
1// Compliant with ISP - segregated interfaces
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// Simple printer implements only what it needs
24class SimplePrinter implements Printer {
25 print(document: string): void {
26 console.log(`Printing: ${document}`);
27 }
28}
29
30// Scanner implements only scanning
31class SimpleScanner implements Scanner {
32 scan(document: string): string {
33 console.log(`Scanning: ${document}`);
34 return `Scanned: ${document}`;
35 }
36}
37
38// Multi-function device implements needed interfaces
39class MultiFunctionPrinter implements Printer, Scanner, Copier {
40 print(document: string): void {
41 console.log(`[MFP] Printing: ${document}`);
42 }
43
44 scan(document: string): string {
45 console.log(`[MFP] Scanning: ${document}`);
46 return `Scanned: ${document}`;
47 }
48
49 copy(document: string): string {
50 console.log(`[MFP] Copying: ${document}`);
51 const scanned = this.scan(document);
52 this.print(scanned);
53 return scanned;
54 }
55}
56
57// Professional office machine
58class OfficeMachine implements Printer, Scanner, FaxMachine, Copier, EmailSender {
59 print(document: string): void {
60 console.log(`[Office] Printing: ${document}`);
61 }
62
63 scan(document: string): string {
64 console.log(`[Office] Scanning: ${document}`);
65 return `Scanned: ${document}`;
66 }
67
68 fax(document: string, number: string): void {
69 console.log(`[Office] Sending fax ${document} to number ${number}`);
70 }
71
72 copy(document: string): string {
73 console.log(`[Office] Copying: ${document}`);
74 return this.scan(document);
75 }
76
77 email(document: string, address: string): void {
78 console.log(`[Office] Sending email ${document} to address ${address}`);
79 }
80}
81
82// Functions using only needed interfaces
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// Usage
98const simplePrinter = new SimplePrinter();
99const simpleScanner = new SimpleScanner();
100const mfp = new MultiFunctionPrinter();
101const officeMachine = new OfficeMachine();
102
103// Each device uses only its capabilities
104DocumentProcessor.printDocument(simplePrinter, 'Document 1');
105DocumentProcessor.scanDocument(simpleScanner, 'Document 2');
106DocumentProcessor.copyDocument(mfp, 'Document 3');
107DocumentProcessor.printDocument(officeMachine, 'Document 4');DocumentProcessor asks for exactly what it uses, and OfficeMachine shows that a class can implement several interfaces at once.
D - Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. The control room uses a power socket, not one specific generator.
Violating DIP
Here UserService creates a specific MySQL database and a specific email service by itself:
1// Violating DIP - depending on concrete implementations
2
3class MySQLDatabase {
4 save(data: any): void {
5 console.log('Saving to MySQL:', data);
6 }
7
8 find(id: string): any {
9 console.log('Searching in MySQL:', id);
10 return { id, data: 'mysql_data' };
11 }
12}
13
14class EmailNotification {
15 send(message: string, recipient: string): void {
16 console.log(`Sending email to ${recipient}: ${message}`);
17 }
18}
19
20// Problem: UserService depends on concrete implementations
21class UserService {
22 private database: MySQLDatabase; // Concrete dependency
23 private notification: EmailNotification; // Concrete dependency
24
25 constructor() {
26 this.database = new MySQLDatabase(); // Creating dependencies inside the class
27 this.notification = new EmailNotification();
28 }
29
30 createUser(userData: any): void {
31 this.database.save(userData);
32 this.notification.send('Welcome!', userData.email);
33 }
34
35 getUser(id: string): any {
36 return this.database.find(id);
37 }
38}Switching the database means editing the service, and in tests you cannot swap it for a fake.
Compliant with DIP
Now the service knows only the Database, NotificationService and Logger interfaces, and receives concrete classes from outside, for example from a simple DI container:
1// Compliant with DIP - depending on 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// Concrete implementations - low level
20class MySQLDatabase implements Database {
21 save(data: any): void {
22 console.log('Saving to MySQL:', data);
23 }
24
25 find(id: string): any {
26 console.log('Searching in MySQL:', id);
27 return { id, data: 'mysql_data' };
28 }
29
30 update(id: string, data: any): void {
31 console.log(`Updating in MySQL ${id}:`, data);
32 }
33
34 delete(id: string): boolean {
35 console.log('Deleting from MySQL:', id);
36 return true;
37 }
38}
39
40class PostgreSQLDatabase implements Database {
41 save(data: any): void {
42 console.log('Saving to PostgreSQL:', data);
43 }
44
45 find(id: string): any {
46 console.log('Searching in PostgreSQL:', id);
47 return { id, data: 'postgres_data' };
48 }
49
50 update(id: string, data: any): void {
51 console.log(`Updating in PostgreSQL ${id}:`, data);
52 }
53
54 delete(id: string): boolean {
55 console.log('Deleting from PostgreSQL:', id);
56 return true;
57 }
58}
59
60class MongoDatabase implements Database {
61 save(data: any): void {
62 console.log('Saving to MongoDB:', data);
63 }
64
65 find(id: string): any {
66 console.log('Searching in MongoDB:', id);
67 return { id, data: 'mongo_data' };
68 }
69
70 update(id: string, data: any): void {
71 console.log(`Updating in MongoDB ${id}:`, data);
72 }
73
74 delete(id: string): boolean {
75 console.log('Deleting from MongoDB:', id);
76 return true;
77 }
78}
79
80class EmailNotificationService implements NotificationService {
81 async send(message: string, recipient: string): Promise<boolean> {
82 console.log(`Sending email to ${recipient}: ${message}`);
83 return true;
84 }
85}
86
87class SMSNotificationService implements NotificationService {
88 async send(message: string, recipient: string): Promise<boolean> {
89 console.log(`Sending SMS to ${recipient}: ${message}`);
90 return true;
91 }
92}
93
94class SlackNotificationService implements NotificationService {
95 async send(message: string, recipient: string): Promise<boolean> {
96 console.log(`Sending Slack message to ${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(`Writing to file [${level.toUpperCase()}]: ${message}`);
110 }
111}
112
113// High-level module - depends only on 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', `Creating user: ${userData.email}`);
124
125 this.database.save(userData);
126
127 await this.notificationService.send(
128 'Welcome to our application!',
129 userData.email
130 );
131
132 this.logger.log('info', `User ${userData.email} has been created`);
133 } catch (error) {
134 this.logger.log('error', `Error creating user: ${error}`);
135 throw error;
136 }
137 }
138
139 getUser(id: string): any {
140 this.logger.log('info', `Fetching user: ${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', `Updating user: ${id}`);
147
148 this.database.update(id, userData);
149
150 await this.notificationService.send(
151 'Your data has been updated',
152 userData.email
153 );
154
155 this.logger.log('info', `User ${id} has been updated`);
156 } catch (error) {
157 this.logger.log('error', `Error updating user: ${error}`);
158 throw error;
159 }
160 }
161
162 deleteUser(id: string): boolean {
163 this.logger.log('info', `Deleting user: ${id}`);
164 return this.database.delete(id);
165 }
166}
167
168// Dependency Injection Container (simplified)
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// Dependency configuration
186const container = new DIContainer();
187
188// Registering different implementations
189container.register<Database>('database', new PostgreSQLDatabase());
190container.register<NotificationService>('notification', new EmailNotificationService());
191container.register<Logger>('logger', new ConsoleLogger());
192
193// Creating the service with injected dependencies
194const userService = new UserService(
195 container.get<Database>('database'),
196 container.get<NotificationService>('notification'),
197 container.get<Logger>('logger')
198);
199
200// Testing
201userService.createUser({
202 id: '1',
203 name: 'Jan Kowalski',
204 email: 'jan@example.com'
205});
206
207// Easy to switch implementations
208const mobileUserService = new UserService(
209 new MongoDatabase(), // Different database
210 new SMSNotificationService(), // Different notifications
211 new FileLogger() // Different logging
212);
213
214mobileUserService.createUser({
215 id: '2',
216 name: 'Anna Nowak',
217 email: '+48123456789'
218});The code of UserService did not change by a single character, yet it runs once with PostgreSQL and email, and once with MongoDB and SMS. I recommend constructor injection as your default style: in tests you simply pass in fakes.
Event Bus
An Event Bus is a shared event channel through which modules communicate without direct dependencies, like rangers talking over the park radio. The sender calls publish, and whoever is interested listens with subscribe:
1class EventBus {
2 constructor() {
3 this.listeners = new Map(); // event name -> array of 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('Alarm in sector', data.sector));
23bus.publish('fence:breach', { sector: 'B6' });The fence module does not know who will receive the alarm, and the alarm system does not import it: both know only the event bus, which is the abstraction.
Singleton and module registry
A Singleton guarantees a single instance, like a single control room: it stores that instance in a static field, and every later new returns exactly that one:
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()); // trueYou register a new module without touching the core (the open/closed principle). A singleton is still a hidden global variable, though, so prefer passing it through the constructor. Protect your modules with the Module pattern: it gives you encapsulation and does not pollute the global scope.
Practical example - SOLID-compliant e-commerce system
Finally, all five principles in one ordering system, and the comments with the letters S, O, L, I and D show where each one is at work:
1// Comprehensive example applying all SOLID principles
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 - easy to add new strategies
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 - all strategies are interchangeable
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 - small, dedicated interfaces
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 // Check availability and calculate prices
134 for (const item of items) {
135 const product = await this.productRepository.findById(item.productId);
136 if (!product) {
137 throw new Error(`Product ${item.productId} not found`);
138 }
139
140 const isAvailable = await this.inventoryService.checkAvailability(item.productId, item.quantity);
141 if (!isAvailable) {
142 throw new Error(`Insufficient quantity of product ${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 // Reserve products
158 for (const item of orderItems) {
159 await this.inventoryService.reserveItems(item.productId, item.quantity);
160 }
161
162 // Create order
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('Order not found');
182 }
183
184 order.status = 'confirmed';
185 await this.orderRepository.save(order);
186 }
187}
188
189// Example usage with different pricing strategies
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% discount
213
214 return new OrderService(
215 new InMemoryProductRepository(),
216 new InMemoryOrderRepository(),
217 new SimpleInventoryService(),
218 seasonalPricing,
219 new SlackNotificationService()
220 );
221}
222
223// Helper implementations (simplified)
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: Order confirmation ${order.id} sent`);
289 }
290
291 async sendInventoryAlert(productId: string): Promise<void> {
292 console.log(`Email: Low inventory alert for ${productId}`);
293 }
294}
295
296class SlackNotificationService implements NotificationService {
297 async sendOrderConfirmation(order: Order): Promise<void> {
298 console.log(`Slack: Order confirmation ${order.id} sent`);
299 }
300
301 async sendInventoryAlert(productId: string): Promise<void> {
302 console.log(`Slack: Low inventory alert for ${productId}`);
303 }
304}OrderService does not know whether it calculates regular, bulk or seasonal prices, because it receives the strategy from outside, and its own code stays untouched.
Summary of SOLID principles
- Single Responsibility - one class, one responsibility
- Open/Closed - open for extension, closed for modification
- Liskov Substitution - derived objects can replace base objects
- Interface Segregation - small, dedicated interfaces
- Dependency Inversion - depend on abstractions, not implementations
Code that follows SOLID is easier to maintain, testable, scalable and readable. Do not force the principles, though: three lines of code do not need five interfaces. In the editor below you will complete your own EventBus, and in the next lesson functional programming will reach similar goals with pure functions.
Remember: a good park is many small, specialized systems connected through abstractions, not one giant control panel.
Code for this lesson: index.js
1// Observer pattern - Event Bus
2// Exercise: Create an event system for the Park
3
4class EventBus {
5 constructor() {
6 this.listeners = {};
7 }
8
9 // Implement the methods:
10 // subscribe(event, callback) - subscription
11 // unsubscribe(event, callback) - unsubscription
12 // publish(event, data) - publish an event
13
14 subscribe(event, callback) {
15 // Your code here
16 }
17
18 publish(event, data) {
19 // Your code here
20 }
21}
22
23const bus = new EventBus();
24bus.subscribe("alert", (data) => console.log("Alert:", data));
25bus.publish("alert", { level: "HIGH", message: "Fence damaged!" });Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the purpose of an Event Bus in application architecture?
2. What does the Module pattern provide in JavaScript?
Hands-on tasks in the game
- Code editor
The index.js file has a ParkCore class that keeps the park modules in a Map (this.modules). Fill in the blanks with Map methods: ___BLANK1___ checks whether a module name is already taken (then registerModule returns false and does not overwrite the module), ___BLANK2___ returns the module stored under a name, and ___BLANK3___ goes through all modules so that init() calls init() of every module that has it and returns the number of initialized modules.
- Click in order
Arrange the elements of a Singleton pattern implementation.
- Code editor
The index.js file has the EventBus class from the SOLID lesson. Fill in the blanks: ___BLANK1___ is the array method that adds the callback at the end of the event list, ___BLANK2___ is the array method that returns a new array without the removed callback, and ___BLANK3___ is the call of the callback cb with the event data. publish should pass the data to every subscriber of the event, and after unsubscribe the removed callback must not receive anything.