JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture

SOLID Principles in JavaScript/TypeScript

24 min read
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()); // true

You 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

  1. Single Responsibility - one class, one responsibility
  2. Open/Closed - open for extension, closed for modification
  3. Liskov Substitution - derived objects can replace base objects
  4. Interface Segregation - small, dedicated interfaces
  5. 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. 1. What is the purpose of an Event Bus in application architecture?

  2. 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.

Useful articles