Design Patterns and SOLID Principles

Comprehensive Guide to Design Patterns and SOLID Principles in TypeScript ๐Ÿชผ


Project maintained by mutasim77 Hosted on GitHub Pages — Theme by mattgraham


๐Ÿ”ฎ Comprehensive Guide to Design Patterns and SOLID Principles in TypeScript ๐Ÿ”ฎ

Explore essential concepts in software engineering, such as Design Patterns and SOLID principles, for creating scalable, maintainable, and efficient code. This repo simplifies these ideas, ensuring accessibility for developers of all levels. Let's delve into this world together and unravel the secrets of effective software engineering!

Getting Started ๐Ÿ“œ

To get started, follow the navigation below to explore different sections of this repository:

Feel free to dive into the content that interests you the most!

Design Patterns ๐Ÿ”ฎ

  1. Creational Design Patterns ๐Ÿ—
  2. Structural Design Patterns ๐Ÿ› ๏ธ
  3. Behavioral Design Patterns ๐Ÿง 

What are Design Patterns? ๐Ÿ‘€

Design patterns are reusable solutions to common problems in software design, offering a structured and proven approach to addressing recurring challenges. They serve as templates or blueprints for solving specific types of problems, making it easier for developers to create efficient and maintainable code. Design patterns provide a shared vocabulary and understanding among developers, promoting reusability, modularity, and improved communication. They encapsulate the best practices of experienced developers, allowing for easier problem-solving and enhanced maintainability. However, itโ€™s crucial to apply design patterns judiciously, considering the specific context and potential trade-offs associated with their use.

Origins and Evolution of Design Patterns ๐Ÿช„

Imagine building houses. Sometimes, you use similar designs for windows or doors because they work well. The same idea applies to computer programs. Design patterns help us solve common problems in a smart and reusable way.

Architectural Genesis ๐ŸŽฉ

Christopher Alexander, an architect, initially introduced the concept of design patterns in the 1970s through his work โ€œA Pattern Language,โ€ where he explored the identification and application of patterns to solve recurring design dilemmas in architecture.

Transition to Software Development โœจ

The adoption and adaptation of this concept for software engineering occurred when a group of computer scientists, often known as the โ€œGang of Fourโ€ (GoF), brought forth the idea. In their influential book โ€œDesign Patterns: Elements of Reusable Object-Oriented Softwareโ€ (1994), Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides introduced 23 design patterns for object-oriented programming, marking a pivotal moment in the popularization of design patterns within software development.

Why Do We Use Design Patterns? ๐ŸŽ

Several compelling reasons drive the utilization of design patterns:

  1. Reusability: Design patterns offer proven solutions to common problems, reducing the time and effort required to address them from scratch, thereby promoting reusability and modularity in software systems.
  2. Improved Communication: These patterns establish a shared vocabulary and understanding among developers, facilitating more effective communication about design decisions and solutions.
  3. Best Practices: Encapsulating the best practices of experienced developers, design patterns provide a learning ground for novices to benefit from their expertise.
  4. Maintainability: The implementation of design patterns often results in more maintainable code, easing the process of updating, debugging, and extending the codebase in the future.
  5. Easier Problem-Solving: Design patterns offer a structured approach to problem-solving, aiding developers in breaking down complex issues into more manageable components.

Things to Remember When Using Design Patterns โš ๏ธ

Itโ€™s super important to use design patterns wisely. Imagine you have a cool tool, but you shouldnโ€™t use it for everything. Hereโ€™s why:

  1. Think About the Situation: Design patterns work best in certain situations. Using them blindly might not always be the right choice.
  2. Keep It Simple: Sometimes, a simple solution is better than a fancy one. Donโ€™t make things more complicated than they need to be.
  3. Watch Out for Speed Bumps: Design patterns can slow down our programs a bit. We need to decide if the benefits are worth it.
  4. Be Ready to Change: As projects grow, what worked before might not be the best choice anymore. We need to be flexible and adjust.

Using design patterns is like having a toolbox full of helpful tools. Just remember, not every tool is right for every job. We should pick the ones that fit the situation best. If we do that, our programs will be strong and reliable!

Creational Design Patterns ๐Ÿ—

Creational design patterns ๐ŸŽจ revolve around the intricacies of object creation. They introduce a level of abstraction to the instantiation process, ensuring the system remains agnostic to the specifics of how its objects come into existence, are composed, and represented. These design patterns offer a mechanism for object creation that conceals the intricacies of the creation logic, steering away from direct object instantiation using the new operator. By doing so, they grant greater flexibility in determining the objects necessary for a given use case. Notable examples of creational design patterns encompass Singleton, Factory Method, Abstract Factory, Builder, and Prototype. ๐Ÿš€

Creational Design Patterns


Singleton ๐Ÿ’

The Singleton pattern is a creational design pattern ensuring that a class has only one instance while providing global access to this instance.

In simple words:

โ€œSingleton - ensures that only one object of a particular class is ever created.โ€

Singleton Design Pattern

Steps of Implementation

Implementing the Singleton pattern in object-oriented programming typically involves the following steps:

  1. Declare a private static attribute in the singleton class.
  2. Create a public static method (commonly named getInstance()) to serve as a global access point for the singleton object. This method embraces โ€œlazy initialization,โ€ meaning it generates a new instance only when necessary.
  3. Set the constructor of the singleton class as private, preventing external objects from using the new operator with the singleton class.
  4. Within the static method of the class, verify the existence of the singleton instance. If it exists, return it; otherwise, create a new instance and return it.

Classic Implementation:

Here is how we might create a database connection using the Singleton pattern:

class Database {
  // Step 1: Declare a private static instance
  private static instance: Database;

  // Step 3: Make the constructor private
  private constructor() {}

  // Step 2: Create a public static getInstance method
  public static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }

  public query(query: string): void {
    console.log(`Executing query '${query}' on database.`);
  }
}

// Usage
const db1 = Database.getInstance();
const db2 = Database.getInstance();

db1.query("SELECT * FROM users"); // Executing query `SELECT * FROM users` on database.
db2.query("DROP DATABASE users"); // Executing query `DROP DATABASE users` on database.

console.log(db1 === db2); // true

In this example, the Database class represents a database connection. The getInstance method ensures that there is only one instance of the Database class, and the query method allows you to perform queries on the database. The usage demonstrates that db1 and db2 are the same instance, showcasing the Singleton pattern behavior.

When To Use Singleton Pattern ? โœ…

Consider using Singleton when:

Disadvantages of Singleton Pattern ๐Ÿ†˜ :

Despite its advantages, the Singleton pattern has drawbacks:

Prototype ๐Ÿงฌ

Prototype is a creational design pattern that lets you copy existing objects without making your code dependent on their classes. It allows you to create a copy of an existing object and modify it to your needs, instead of going through the trouble of creating an object from scratch and setting it up.

In simple words:

Create a new object based on an existing object through cloning.

Prototype Design Pattern

Implementation:

Letโ€™s see a simple implementation of the Prototype pattern in TS through an example in game development.

interface Prototype {
    clone(): Prototype;
    details: EnemyDetails;
}

interface EnemyDetails {
    type: string;
    strength: number;
}

/**
 * Concrete Prototype representing an Enemy in a Game
 */
class Enemy implements Prototype {
    constructor(public details: EnemyDetails) {}

    public clone(): Enemy {
        const clone = new Enemy({ ...this.details });
        return clone;
    }
}

// Usage
const originalEnemy: Prototype = new Enemy({ type: "Dragon", strength: 10 });
const clonedEnemy: Prototype = originalEnemy.clone();

console.log(originalEnemy.details); // { type: 'Dragon', strength: 10 }
console.log(clonedEnemy.details); // { type: 'Dragon', strength: 10 }

clonedEnemy.details = { type: 'Goblin', strength: 8 };
console.log(clonedEnemy.details); // { type: 'Goblin', strength: 8 }

This approach enhances code efficiency and maintainability, allowing easy modification of specific properties without creating new instances for each enemy.

When to Use the Prototype Pattern ? โœ…

The Prototype pattern is handy when copying existing objects is more efficient than creating new ones. Itโ€™s beneficial for systems seeking independence in creating, composing, and representing products.

Advantages of the Prototype Pattern ๐Ÿช„ :

Disadvantages of the Prototype Pattern ๐Ÿ†˜ :

Builder ๐Ÿ‘ท

Builder is a creational design pattern facilitating the step-by-step construction of complex objects. It enables the creation of various object types using a unified construction process, preventing constructor overload. Use the Builder pattern to get rid of a โ€œtelescoping constructorโ€.

In simple words:

Builder helps in creating different versions of an object without cluttering the constructor.

Builder Design Pattern

Implementation Example in TypeScript:

interface IPizza {
    name: string;
    size: string;
    isCheese: boolean;
}

interface IPizzaBuilder {
    setName(name: string): IPizzaBuilder;
    setSize(size: string): IPizzaBuilder;
    setCheese(isCheese: boolean): IPizzaBuilder;
    build(): IPizza;
}

class Pizza implements IPizza {
    constructor(
        public name: string,
        public size: string,
        public isCheese: boolean
    ) { }
}

class PizzaBuilder implements IPizzaBuilder {
    private name: string = "";
    private size: string = "";
    private isCheese: boolean = false;

    setName(name: string): IPizzaBuilder {
        this.name = name;
        return this;
    }

    setSize(size: string): IPizzaBuilder {
        this.size = size;
        return this;
    }

    setCheese(isCheese: boolean): IPizzaBuilder {
        this.isCheese = isCheese;
        return this;
    }

    build(): IPizza {
        return new Pizza(this.name, this.size, this.isCheese);
    }
}

class PizzaDirector {
    constructor(private builder: IPizzaBuilder) { }

    public buildMinimalPizza(name: string, size: string): IPizza {
        return this.builder
            .setName(name)
            .setSize(size)
            .build();
    }

    public buildFullFeaturedPizza(name: string, size: string, isCheese: boolean): IPizza {
        return this.builder
            .setName(name)
            .setSize(size)
            .setCheese(isCheese)
            .build();
    }
}

// Usage:
const builder: IPizzaBuilder = new PizzaBuilder();
const director: PizzaDirector = new PizzaDirector(builder);
const pizzaWithoutCheese: IPizza = director.buildMinimalPizza('Pepperoni', 'Medium');
const pizzaWithCheese: IPizza = director.buildFullFeaturedPizza('Hawaiian', 'Small', true);

console.log(pizzaWithoutCheese); // Pizza: { name: 'Pepperoni', size: 'Medium', isCheese: false} 
console.log(pizzaWithCheese); // Pizza: { name: 'Hawaiian', size :'Small', isCheese: true} 

This TypeScript code implements a simplified Builder pattern for creating pizza objects, allowing customization of attributes like name, size, and the presence of cheese.

When to Use Builder Pattern ? โœ…

Advantages of Builder Pattern ๐Ÿช„ :

Disadvantages of Builder Pattern ๐Ÿ†˜ :

Factory Method ๐Ÿญ

The Factory Method Pattern is a creational design pattern that provides an interface for creating objects in a superclass, allowing subclasses to alter the type of objects created.

In Simple Terms:

It enables the delegation of object instantiation to child classes, offering a way to create objects without specifying their exact classes.

Factory Method Pattern

Implementation:

Consider a car manufacturing program with different car types (Sedan, Hatchback):

abstract class Car {
  constructor(public model: string, public productionYear: number) {}

  abstract displayCarInfo(): void;
}

class Sedan extends Car {
  displayCarInfo() {
    console.log(`This is a Sedan. Model: ${this.model}, Production Year: ${this.productionYear}`);
  }
}

class Hatchback extends Car {
  displayCarInfo() {
    console.log(`This is a Hatchback. Model: ${this.model}, Production Year: ${this.productionYear}`);
  }
}

class CarFactory {
  public createCar(type: string, model: string, productionYear: number): Car {
    switch (type) {
      case "Sedan":
        return new Sedan(model, productionYear);
      case "Hatchback":
        return new Hatchback(model, productionYear);
      default:
        throw new Error("Invalid car type");
    }
  }
}

// Usage:
const carFactory = new CarFactory();

const sedan = carFactory.createCar("Sedan", "Camry", 2023);
sedan.displayCarInfo(); // This is a Sedan. Model: Camry, Production Year: 2023

const hatchback = carFactory.createCar("Hatchback", "Corolla", 2019);
hatchback.displayCarInfo(); // // This is a Sedan. Model: Corolla, Production Year: 2019

When To Use Factory Pattern ? โœ…

Advantages Of The Factory Pattern ๐Ÿช„ :

Disadvantages of Factory Pattern ๐Ÿ†˜ :

Abstract Factory ๐Ÿ”จ

The Abstract Factory pattern is a creational design pattern that furnishes an interface for constructing families of objects that are related or dependent, all without explicitly specifying their concrete classes.

In Simple Terms:

A factory of factories.

Abstract Factory Pattern

Classical Implementation:

interface Button {
    render(): void;
    onClick(f: Function): void;
}

interface Checkbox {
    render(): void;
    toggle(): void;
}

interface GUIFactory {
    createButton(): Button;
    createCheckbox(button: Button): Checkbox;
}

class WindowsButton implements Button {
    render() {
        console.log("Render a button in Windows style");
    }

    onClick(f: Function) {
        console.log("Bind a Windows style button click event");
        f();
    }
}

class WindowsCheckbox implements Checkbox {
    private button: Button;

    constructor(button: Button) {
        this.button = button;
    }

    render() {
        console.log("Render a checkbox in Windows style");
    }

    toggle() {
        this.button.onClick(() => console.log("Checkbox state toggled!"));
    }
}

class MacOSButton implements Button {
    render() {
        console.log("Render a button in MacOS style");
    }

    onClick(f: Function) {
        console.log("Bind a MacOS style button click event");
        f();
    }
}

class MacOSCheckbox implements Checkbox {
    private button: Button;

    constructor(button: Button) {
        this.button = button;
    }

    render() {
        console.log("Render a checkbox in MacOS style");
    }

    toggle() {
        this.button.onClick(() => console.log("Checkbox state toggled!"));
    }
}

class WindowsFactory implements GUIFactory {
    createButton(): Button {
        return new WindowsButton();
    }

    createCheckbox(button: Button): Checkbox {
        return new WindowsCheckbox(button);
    }
}

class MacOSFactory implements GUIFactory {
    createButton(): Button {
        return new MacOSButton();
    }

    createCheckbox(button: Button): Checkbox {
        return new MacOSCheckbox(button);
    }
}

function renderUI(factory: GUIFactory) {
    const button = factory.createButton();
    const checkbox = factory.createCheckbox(button);

    button.render();
    checkbox.render();

    button.onClick(() => console.log("Button clicked!"));
    checkbox.toggle();
}

console.log("App: Launched with the Windows factory.");
renderUI(new WindowsFactory());

console.log("App: Launched with the MacOS factory.");
renderUI(new MacOSFactory());

When To Use Abstract Factory Pattern ? โœ…

Advantages of Abstract Factory Pattern ๐Ÿช„ :

Disadvantages of Abstract Factory Pattern ๐Ÿ†˜ :

Structural Design Patterns ๐Ÿ› 

Structural design patterns are a type of design pattern that deal with object composition and the structure of classes/objects. They help ensure that when a change is made in one part of a system, it doesnโ€™t require changes in other parts. This makes the system more flexible and easier to maintain.

Structural Design Patterns


Adapter ๐Ÿ”Œ

The Adapter Design Pattern is a software design pattern that allows the interface of an existing class to be used from another interface. Itโ€™s often used to make existing classes work with others without modifying their source code. The Adapter Pattern is especially useful when the classes that need to communicate with each other do not have compatible interfaces.

In simple words:

Adapter allows objects with incompatible interfaces to collaborate.

Adapter

Classical Implementation:

// Duck class
class Duck {
  quack(): void {
    console.log("Quack, quack!");
  }

  fly(): void {
    console.log("I'm flying!");
  }
}

// Animal interface
interface Animal {
  makeSound(): void;
  move(): void;
}

// DuckAdapter class
class DuckAdapter implements Animal {
  private duck: Duck;

  constructor(duck: Duck) {
    this.duck = duck;
  }

  makeSound(): void {
    this.duck.quack();
  }

  move(): void {
    this.duck.fly();
  }
}

// Using the Duck and DuckAdapter
const duck = new Duck();
const adapter = new DuckAdapter(duck);

// Now, the duck can be used as an animal
adapter.makeSound(); // Output: Quack, quack!
adapter.move();      // Output: I'm flying!

When To Use Adapter Pattern ? โœ…

Advantages of Adapter Pattern ๐Ÿช„ :

Disadvantages of Adapter Pattern ๐Ÿ†˜ :

Bridge ๐ŸŒ‰

The Bridge pattern is a structural design pattern that lets you split a large class or a set of closely related classes into two separate hierarchiesโ€”abstraction and implementationโ€”which can be developed independently of each other.

In simple words:

Itโ€™s like a bridge between abstraction and implementation, enabling independent changes for flexibility.

Bridge

Letโ€™s implement:

  1. Implementor interface and concrete implementors: ```ts interface Database { connect(): void; query(sql: string): any; close(): void; }

class PostgreSQLDatabase implements Database { connect(): void { console.log(โ€œConnecting to PostgreSQL database.โ€); }

query(sql: string): any { console.log(Executing query '${sql}' on PostgreSQL database.); }

close(): void { console.log(โ€œClosing connection to PostgreSQL database.โ€); } }

class MongoDBDatabase implements Database { connect(): void { console.log(โ€œConnecting to MongoDB database.โ€); }

query(sql: string): any { console.log(Executing query '${sql}' on MongoDB database.); }

close(): void { console.log(โ€œClosing connection to MongoDB database.โ€); } }

2. Abstraction and refined abstractions:
```ts
abstract class DatabaseService {
  protected database: Database;

  constructor(database: Database) {
    this.database = database;
  }

  abstract fetchData(query: string): any;
}

class ClientDatabaseService extends DatabaseService {
  fetchData(query: string): any {
    this.database.connect();
    const result = this.database.query(query);
    this.database.close();
    return result;
  }
}
  1. Client code: ```ts let databaseService = new ClientDatabaseService(new PostgreSQLDatabase()); databaseService.fetchData(โ€œSELECT * FROM users;โ€); // use PostgreSQL database

databaseService = new ClientDatabaseService(new MongoDBDatabase()); databaseService.fetchData(โ€œdb.users.find({})โ€); // use MongoDB database

> In this example, we've created a "bridge" that decouples the high-level DatabaseService class from the specifics of the various Database implementations. By doing this, you can add a new type of database to the application without changing the DatabaseService class or the client code. Also, at runtime, the client can decide which database to use.

### When To Use Bridge Pattern ? โœ…
- **Hide Implementation Details:** Expose only necessary client methods for cleaner code.
- **Implementation-Specific Behavior:** Enable different platform implementations without altering client code.
- Prevent Monolithic Designs:** Promote modularity to avoid widespread implications of changes.

### Advantages of Bridge Pattern ๐Ÿช„ :
- **Decoupling ๐Ÿงฉ:** Separates abstraction and implementation for independent evolution.
- **Improved Readability ๐Ÿ“š:** Enhances code readability and maintainability.
- **Runtime Binding ๐Ÿ”„:** Allows changing implementations at runtime.

### Disadvantages of Bridge Pattern ๐Ÿ†˜ :
- **Over-engineering ๐Ÿ› ๏ธ:** Adds complexity if abstraction and implementation are stable.
- **Design Difficulty ๐Ÿค”:** Choosing the right abstraction can be challenging.
- **Development and Maintenance Costs ๐Ÿ’ธ:** Introducing the Bridge pattern requires refactoring, increasing complexity.


## Composite ๐ŸŒด
The Composite pattern is a structural design pattern that lets you compose objects into tree-like structures and then work with these structures as if they were individual objects.

In simple words:
> It lets clients treat the individual objects in a uniform manner.

![Composite Pattern](/design-patterns/images/composite-pattern.png)

## Implementation in TS:
```ts
// Component
interface Employee {
  getName(): string;
  getSalary(): number;
  getRole(): string;
}

// Leaf
class Developer implements Employee {
  constructor(private name: string, private salary: number) {}

  getName(): string {
    return this.name;
  }

  getSalary(): number {
    return this.salary;
  }

  getRole(): string {
    return "Developer";
  }
}

// Another Leaf
class Designer implements Employee {
  constructor(private name: string, private salary: number) {}

  getName(): string {
    return this.name;
  }

  getSalary(): number {
    return this.salary;
  }

  getRole(): string {
    return "Designer";
  }
}

// Composite
interface CompositeEmployee extends Employee {
  addEmployee(employee: Employee): void;
  removeEmployee(employee: Employee): void;
  getEmployees(): Employee[];
}

class Manager implements CompositeEmployee {
  private employees: Employee[] = [];

  constructor(private name: string, private salary: number) {}

  getName(): string {
    return this.name;
  }

  getSalary(): number {
    return this.salary;
  }

  getRole(): string {
    return "Manager";
  }

  addEmployee(employee: Employee) {
    this.employees.push(employee);
  }

  removeEmployee(employee: Employee) {
    const index = this.employees.indexOf(employee);
    if (index !== -1) {
      this.employees.splice(index, 1);
    }
  }

  getEmployees(): Employee[] {
    return this.employees;
  }
}

Hereโ€™s how you could use these classes:

const dev1 = new Developer("John Doe", 12000);
const dev2 = new Developer("Karl Durden", 15000);
const designer = new Designer("Mark", 10000);

const manager = new Manager("Michael", 25000);
manager.addEmployee(dev1);
manager.addEmployee(dev2);
manager.addEmployee(designer);

console.log(manager); // { name : "Michael", salary: 25000, employees: [ { name: "John Doe", salary: 12000 } ...] } 

When To Use Composite Pattern ? โœ…

Advantages of Composite Pattern ๐Ÿช„ :

Disadvantages of Composite Pattern ๐Ÿ†˜ :

Decorator ๐ŸŽจ

The Decorator design pattern is a structural design pattern that allows you to dynamically add or override behaviour in an existing object without changing its implementation. This pattern is particularly useful when you want to modify the behavior of an object without affecting other objects of the same class.

In simple words:

Dynamically enhances object behavior.

Decorator Pattern

Implementation in TS:

// Component
interface Coffee {
  cost(): number;
  description(): string;
}

// ConcreteComponent
class SimpleCoffee implements Coffee {
  cost() {
    return 10;
  }

  description() {
    return "Simple coffee";
  }
}

// Decorator
abstract class CoffeeDecorator implements Coffee {
  protected coffee: Coffee;

  constructor(coffee: Coffee) {
    this.coffee = coffee;
  }

  abstract cost(): number;
  abstract description(): string;
}

// ConcreteDecorator
class MilkDecorator extends CoffeeDecorator {
  constructor(coffee: Coffee) {
    super(coffee);
  }

  cost() {
    return this.coffee.cost() + 2;
  }

  description() {
    return `${this.coffee.description()}, with milk`;
  }
}

// Usage
const plainCoffee = new SimpleCoffee();
console.log("Plain Coffee Cost: $" + plainCoffee.cost()); // Plain Coffee Cost: $10
console.log("Description: " + plainCoffee.description()); // Description: Simple coffee

const coffeeWithMilk = new MilkDecorator(plainCoffee);
console.log("Coffee with Milk Cost: $" + coffeeWithMilk.cost()); // Coffee with Milk Cost: $12
console.log("Description: " + coffeeWithMilk.description()); // Description: Simple coffee, with milk

When To Use Decorator Pattern ? โœ…

Advantages of Decorator Pattern ๐Ÿช„ :

Disadvantages of Decorator Pattern ๐Ÿ†˜ :

Facade ๐Ÿฐ

In simple words:

It provides a simplified interface to a complex subsystem.

Facade Pattern

Implementation in TS:

// Subsystem 1
class AudioPlayer {
  play(): string {
    return "Playing audio";
  }
}

// Subsystem 2
class VideoPlayer {
  play(): string {
    return "Playing video";
  }
}

// Subsystem 3
class Projector {
  display(): string {
    return "Projector displaying content";
  }
}

// Facade
class MultimediaFacade {
  private audioPlayer: AudioPlayer;
  private videoPlayer: VideoPlayer;
  private projector: Projector;

  constructor(audioPlayer: AudioPlayer, videoPlayer: VideoPlayer, projector: Projector) {
    this.audioPlayer = audioPlayer;
    this.videoPlayer = videoPlayer;
    this.projector = projector;
  }

  startMovie(): string {
    const audio = this.audioPlayer.play();
    const video = this.videoPlayer.play();
    const display = this.projector.display();

    return `${audio}\n${video}\n${display}`;
  }

  stopMovie(): string {
    return "Stopping multimedia playback";
  }
}

// Example usage
const audioPlayer = new AudioPlayer();
const videoPlayer = new VideoPlayer();
const projector = new Projector();

const multimediaFacade = new MultimediaFacade(audioPlayer, videoPlayer, projector);

console.log(multimediaFacade.startMovie()); // Playing audio, Playing video, Projector displaying content
console.log(multimediaFacade.stopMovie()); // Stopping multimedia playback

When To Use Facade Pattern ? โœ…

Advantages of Facade Pattern ๐Ÿช„ :

Disadvantages of Facade Pattern ๐Ÿ†˜ :

Flyweight ๐Ÿชฐ

The Flyweight design pattern is a structural pattern that aims to minimize memory usage or computational expenses by sharing as much as possible with related objects; it provides a way to use objects in large numbers more efficiently. The pattern achieves this by sharing common portions of the objectโ€™s state among multiple instances, rather than each instance holding its own copy.

In simple words:

Flyweight pattern is like having a shared pool of objects, where common features are stored centrally, allowing multiple instances to reuse and reference them. This significantly reduces the memory footprint and improves performance.

Flyweight Pattern

Implementation in TS:

// Flyweight interface
interface TextStyle {
  applyStyle(): void;
}

// Concrete Flyweight
class SharedTextStyle implements TextStyle {
  private font: string;
  private size: number;
  private color: string;

  constructor(font: string, size: number, color: string) {
    this.font = font;
    this.size = size;
    this.color = color;
  }

  applyStyle(): void {
    console.log(`Applying style - Font: ${this.font}, Size: ${this.size}, Color: ${this.color}`);
  }
}

// Flyweight Factory
class TextStyleFactory {
  private textStyles: { [key: string]: TextStyle } = {};

  getTextStyle(font: string, size: number, color: string): TextStyle {
    const key = `${font}-${size}-${color}`;
    if (!this.textStyles[key]) {
      this.textStyles[key] = new SharedTextStyle(font, size, color);
    }
    return this.textStyles[key];
  }
}

// Client
class TextEditor {
  private textStyles: TextStyle[] = [];
  private textStyleFactory: TextStyleFactory;

  constructor(factory: TextStyleFactory) {
    this.textStyleFactory = factory;
  }

  applyStyle(font: string, size: number, color: string): void {
    const style = this.textStyleFactory.getTextStyle(font, size, color);
    this.textStyles.push(style);
  }

  printStyles(): void {
    this.textStyles.forEach((style) => style.applyStyle());
  }
}

// Usage
const textStyleFactory = new TextStyleFactory();
const textEditor = new TextEditor(textStyleFactory);

textEditor.applyStyle("Arial", 12, "Black");
textEditor.applyStyle("Times New Roman", 14, "Red");
textEditor.applyStyle("Arial", 12, "Black"); // Reusing existing style

textEditor.printStyles(); // print all styles...

When To Use Flyweight Pattern ? โœ…

Advantages of Flyweight Pattern ๐Ÿช„ :

Disadvantages of Flyweight Pattern ๐Ÿ†˜ :

Proxy ๐Ÿ”—

The Proxy design pattern is a structural pattern that acts as a surrogate or placeholder for another object, controlling access to it. This pattern is useful when we want to add an extra layer of control over the functionality of an object, such as adding security checks, lazy loading, or logging.

In simple words:

A Proxy acts as a middleman, standing between a client and an object. It controls access to the real object, allowing for additional functionalities or restrictions.

Proxy Pattern

Implementation :

// Subject interface representing the internet
interface Internet {
  accessWebsite(website: string): void;
}

// RealSubject representing the actual internet
class RealInternet implements Internet {
  accessWebsite(website: string): void {
    console.log(`Accessing website: ${website}`);
  }
}

// Proxy representing a Fortinet-like proxy internet for content filtering
class ProxyInternet implements Internet {
  private realInternet: RealInternet | null = null;
  private restrictedWebsites: Set<string> = new Set<string>();

  addRestrictedWebsite(website: string): void {
    this.restrictedWebsites.add(website);
    console.log(`Website ${website} is restricted.`);
  }

  accessWebsite(website: string): void {
    // Check if the website is restricted
    if (this.restrictedWebsites.has(website)) {
      console.log(`Access to ${website} is denied due to content restrictions.`);
      return;
    }

    // Only access the real internet if the website is not restricted
    if (this.realInternet === null) {
      this.realInternet = new RealInternet();
    }

    this.realInternet.accessWebsite(website);
  }
}


// Usage:
const internetUser: Internet = new ProxyInternet();

// Configuring the proxy internet to restrict access to certain websites
const proxyInternet = internetUser as ProxyInternet;
proxyInternet.addRestrictedWebsite("bad.com"); // Website bad.com is restricted.

// The user accesses the internet through the proxy
internetUser.accessWebsite("example.com"); // Accessing website: example.com
internetUser.accessWebsite("bad.com"); // Access to bad.com is denied due to content restrictions.

When To Use Proxy Pattern ? โœ…

Advantages of Proxy Pattern ๐Ÿช„ :

Disadvantages of Proxy Pattern ๐Ÿ†˜ :

Behavioral Design Patterns ๐Ÿง 

Behavioral design patterns help organize how different parts of a software system communicate and collaborate. They provide solutions for common challenges in defining algorithms and managing responsibilities, enhancing flexibility and extensibility. Essentially, these patterns guide the flow of communication and behavior in a software application.

Behavioral Design Patterns


Chain of Responsibility โ›“

The Chain of Responsibility is a behavioral design pattern that lets you pass requests along a chain of handlers. Upon receiving a request, each handler decides either to process the request or to pass it to the next handler in the chain.

In simple words:

Imagine you have a series of processing tasks, and each task can be handled by a different entity. The Chain of Responsibility pattern allows you to link these entities in a chain. When a task is presented, each entity in the chain has the chance to handle it. If one entity can handle it, the chain stops; otherwise, the task moves along the chain until it finds a handler.

Chain of Responsibility Pattern

Implementation :

// Handler interface
interface Approver {
    setNext(nextApprover: Approver): Approver;
    processRequest(amount: number): void;
}

// Concrete Handler 1
class Manager implements Approver {
    private nextApprover: Approver | null = null;

    setNext(nextApprover: Approver): Approver {
        this.nextApprover = nextApprover;
        return nextApprover;
    }

    processRequest(amount: number): void {
        if (amount <= 1000) {
            console.log(`Manager approves the purchase of $${amount}.`);
        } else if (this.nextApprover) {
            this.nextApprover.processRequest(amount);
        }
    }
}

// Concrete Handler 2
class Director implements Approver {
    private nextApprover: Approver | null = null;

    setNext(nextApprover: Approver): Approver {
        this.nextApprover = nextApprover;
        return nextApprover;
    }

    processRequest(amount: number): void {
        if (amount <= 5000) {
            console.log(`Director approves the purchase of $${amount}.`);
        } else if (this.nextApprover) {
            this.nextApprover.processRequest(amount);
        }
    }
}

// Concrete Handler 3
class VicePresident implements Approver {
    private nextApprover: Approver | null = null;

    setNext(nextApprover: Approver): Approver {
        this.nextApprover = nextApprover;
        return nextApprover;
    }

    processRequest(amount: number): void {
        if (amount <= 10000) {
            console.log(`Vice President approves the purchase of $${amount}.`);
        } else if (this.nextApprover) {
            this.nextApprover.processRequest(amount);
        }
    }
}

// Client
const manager = new Manager();

// Set up the chain of responsibility
manager
  .setNext(new Director())
  .setNext(new VicePresident());

// Test the chain with different purchase amounts
manager.processRequest(800);   // Manager approves the purchase of $800
manager.processRequest(4500);  // Director approves the purchase of $4500
manager.processRequest(10000); // Vice President approves the purchase of $10000

When To Use Chain of Responsibility Pattern ? โœ…

Advantages of Chain of Responsibility Pattern ๐Ÿช„ :

Disadvantages of Chain of Responsibility Pattern ๐Ÿ†˜ :

Command ๐Ÿ‘ฎโ€โ™‚

The Command design pattern transforms requests into standalone objects, making it easy to pass requests as method arguments, delay or queue their execution, and support undoable operations.

In simple words:

It encapsulates actions, letting clients operate independently from receivers.

Command Pattern

Classic implementation :

// Command interface
interface Command {
    execute(): void;
}

// Concrete Command 1: Light On
class LightOnCommand implements Command {
    private light: Light;

    constructor(light: Light) {
        this.light = light;
    }

    execute(): void {
        this.light.turnOn();
    }
}

// Concrete Command 2: Light Off
class LightOffCommand implements Command {
    private light: Light;

    constructor(light: Light) {
        this.light = light;
    }

    execute(): void {
        this.light.turnOff();
    }
}

// Receiver: Light
class Light {
    turnOn(): void {
        console.log("Light is ON");
    }

    turnOff(): void {
        console.log("Light is OFF");
    }
}

// Invoker: Remote Control
class RemoteControl {
    private command: Command | null = null;

    setCommand(command: Command): void {
        this.command = command;
    }

    pressButton(): void {
        if (this.command) {
            this.command.execute();
        } else {
            console.log("No command assigned.");
        }
    }
}

// Client Code
const light = new Light();
const lightOnCommand = new LightOnCommand(light);
const lightOffCommand = new LightOffCommand(light);

const remote = new RemoteControl();

remote.setCommand(lightOnCommand);
remote.pressButton(); // Light is ON

remote.setCommand(lightOffCommand);
remote.pressButton(); // Light is OFF

NOTE: This is a basic TypeScript implementation of a Command pattern, but in reality, it can encompass additional functionalities such as performing undo, redo, and more.

When To Use Command Pattern ? โœ…

Advantages of Command Pattern ๐Ÿช„ :

Disadvantages of Command Pattern ๐Ÿ†˜ :

Iterator ๐Ÿ”

The Iterator pattern is a design pattern that allows sequential access to elements in a collection, without exposing its underlying representation. It provides a way to access the elements of an aggregate object sequentially without exposing the underlying details.

In simple words:

It allows accessing elements without exposing how theyโ€™re stored.

Iterator Pattern

Simple Implementation:

class ArrayIterator<T> {
  private collection: T[];
  private position: number = 0;

  constructor(collection: T[]) {
    this.collection = collection;
  }

  public next(): T {
    const result: T = this.collection[this.position];
    this.position += 1;
    return result;
  }

  public hasNext(): boolean {
    return this.position < this.collection.length;
  }
}

// Usage
const stringArray = ["Hello", "World", "!"];
const numberArray = [1, 2, 3, 4, 5];

const stringIterator = new ArrayIterator<string>(stringArray);
const numberIterator = new ArrayIterator<number>(numberArray);

console.log(numberIterator.next()); // 1

while (stringIterator.hasNext()) {
  console.log(stringIterator.next()); // Logs 'Hello', 'World', '!'
}

NOTE: This was a simple TypeScript implementation of an iterator, but in reality, it can include more functionalities such as traversing in reverse and so on.

When To Use Iterator Pattern ? โœ…

Advantages of Iterator Pattern ๐Ÿช„ :

Disadvantages of Iterator Pattern ๐Ÿ†˜ :

Mediator ๐Ÿค

The Mediator pattern is a design pattern that defines an object to centralize communication between different components, promoting loose coupling. It allows components to interact without directly referencing each other, reducing dependencies.

In simple words:

It acts as a central hub, enabling components to communicate indirectly, minimizing direct connections.

Mediator Pattern

Implementation in TS :

interface IUser {
    notify(message: string): void;
    receive(message: string): void;
}

class Mediator {
    private users: Set<IUser> = new Set();

    addUser(user: IUser): void {
        this.users.add(user);
    }

    notifyUsers(message: string, originator: IUser): void {
        for (const user of this.users) {
            if (user !== originator) {
                user.receive(message);
            }
        }
    }
}

class User implements IUser {
    private mediator: Mediator;
    private name: string;

    constructor(mediator: Mediator, name: string) {
        this.mediator = mediator;
        this.name = name;
        this.mediator.addUser(this);
    }

    notify(message: string): void {
        console.log(`${this.name} sending message: ${message}`);
        this.mediator.notifyUsers(message, this);
    }

    receive(message: string): void {
        console.log(`${this.name} received message: ${message}`);
    }
}

// Example Usage
const mediator = new Mediator();
const user1 = new User(mediator, 'User1');
const user2 = new User(mediator, 'User2');
const user3 = new User(mediator, 'User3');

user1.notify('Hello User2!');
user2.notify('Hi there!');
user3.notify('Greetings, everyone!');

// "User1" sending message: "Hello User2!" 
// "User2" received message: "Hello User2! 
// "User3" received message: "Hello User2!" 

When To Use Mediator Pattern ? โœ…

Advantages of Mediator Pattern ๐Ÿช„ :

Disadvantages of Mediator Pattern ๐Ÿ†˜ :

Memento ๐Ÿ’พ

The Memento pattern is a behavioral design pattern that allows an objectโ€™s state to be captured and restored at a later time without exposing its internal structure. It enables the ability to undo or rollback changes and is particularly useful when dealing with the history or snapshots of an objectโ€™s state.

In simple words:

Lets you save and restore the previous state of an object

Memento Pattern

Implementation in TS :

// Memento
class EditorMemento {
    private state: string;

    constructor(state: string) {
        this.state = state;
    }

    getState(): string {
        return this.state;
    }
}

// Originator
class TextDocument {
    private text!: string;

    createMemento(): EditorMemento {
        return new EditorMemento(this.text);
    }

    restoreMemento(memento: EditorMemento): void {
        this.text = memento.getState();
    }

    setText(text: string): void {
        this.text = text;
    }

    getText(): string {
        return this.text;
    }
}

// Caretaker
class DocumentHistory {
    private mementos: EditorMemento[] = [];

    addMemento(memento: EditorMemento): void {
        this.mementos.push(memento);
    }

    getMemento(index: number): EditorMemento {
        return this.mementos[index];
    }
}

// Client Code
const editor = new TextDocument();
const documentHistory = new DocumentHistory();

editor.setText("Hello World!");
documentHistory.addMemento(editor.createMemento());

editor.setText("Good Bye World!");
documentHistory.addMemento(editor.createMemento());

console.log(editor.getText()); // Good Bye World!

editor.restoreMemento(documentHistory.getMemento(0));
console.log(editor.getText()); // Hello World!

When To Use Memento Pattern ? โœ…

Advantages of Memento Pattern ๐Ÿช„ :

Disadvantages of Memento Pattern ๐Ÿ†˜ :

Observer ๐Ÿ‘€

The Observer pattern is a behavioral design pattern where an object, known as the subject, maintains a list of dependents, known as observers, that are notified of any changes in the subjectโ€™s state. This pattern establishes a one-to-many relationship between the subject and its observers, allowing multiple objects to react to changes in another object.

In Simple Words:

Defines a subscription mechanism to notify multiple objects about changes in an objectโ€™s state.

Observer Pattern

Implementation:

// Subject interface
interface Subject {
    addObserver(observer: Observer): void;
    removeObserver(observer: Observer): void;
    notifyObservers(): void;
}

// Concrete Subject: WeatherStation
class WeatherStation implements Subject {
    private temperature: number = 0;
    private observers: Observer[] = [];

    addObserver(observer: Observer): void {
        this.observers.push(observer);
    }

    removeObserver(observer: Observer): void {
        const index = this.observers.indexOf(observer);
        if (index !== -1) {
            this.observers.splice(index, 1);
        }
    }

    notifyObservers(): void {
        for (const observer of this.observers) {
            observer.update(this.temperature);
        }
    }

    setTemperature(temperature: number): void {
        this.temperature = temperature;
        this.notifyObservers();
    }
}

// Observer interface
interface Observer {
    update(temperature: number): void;
}

// Concrete Observer: TemperatureDisplay
class TemperatureDisplay implements Observer {
    private temperature: number = 0;

    update(temperature: number): void {
        this.temperature = temperature;
        this.display();
    }

    display(): void {
        console.log(`Temperature Display: ${this.temperature}ยฐC`);
    }
}

// Client Code
const weatherStation = new WeatherStation();

const display1 = new TemperatureDisplay();
const display2 = new TemperatureDisplay();

weatherStation.addObserver(display1);
weatherStation.addObserver(display2);

weatherStation.setTemperature(25);
// Output:
// Temperature Display: 25ยฐC
// Temperature Display: 25ยฐC

When to Use Observer Pattern? โœ…

Advantages of Observer Pattern ๐Ÿช„

Disadvantages of Observer Pattern ๐Ÿ†˜

State ๐Ÿ“„

The State pattern is a behavioral design pattern that allows an object to change its behavior when its internal state changes. The pattern represents states as separate classes and allows the context (the object whose behavior changes) to switch between these states dynamically.

In Simple Words:

Enables an object to alter its behavior when its internal state changes by encapsulating states in separate classes.

Observer Pattern

Implementation:

// State interface
interface EditingState {
    write(text: string): void;
    save(): void;
}

// Concrete State 1: DraftState
class DraftState implements EditingState {
    write(text: string): void {
        console.log(`Drafting: ${text}`);
    }

    save(): void {
        console.log("Draft saved");
    }
}

// Concrete State 2: ReviewState
class ReviewState implements EditingState {
    write(text: string): void {
        console.log(`Reviewing: ${text}`);
    }

    save(): void {
        console.log("Cannot save in review mode");
    }
}

// Context: DocumentEditor
class DocumentEditor {
    private editingState: EditingState;

    constructor(initialState: EditingState) {
        this.editingState = initialState;
    }

    setEditingState(state: EditingState): void {
        this.editingState = state;
    }

    write(text: string): void {
        this.editingState.write(text);
    }

    save(): void {
        this.editingState.save();
    }
}

// Usage
const documentEditor = new DocumentEditor(new DraftState());

documentEditor.write("Hello World");
documentEditor.save(); // Draft saved

documentEditor.setEditingState(new ReviewState());
documentEditor.write("Review comments");
documentEditor.save(); // Cannot save in review mode

When to Use State Pattern? โœ…

Advantages of State Pattern ๐Ÿช„

Disadvantages of State Pattern ๐Ÿ†˜

Strategy ๐ŸŽฏ

The Strategy pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each algorithm, and makes them interchangeable. It allows the client to choose an appropriate algorithm at runtime without altering the context (the object that uses the algorithm). This pattern enables a class to vary its behavior dynamically by having multiple algorithms and selecting one of them.

In Simple Words:

Defines a set of algorithms, encapsulates each one, and makes them interchangeable. Allows a client to choose an algorithm at runtime.

Strategy Pattern

Implementation:

// Strategy interface
interface SortingStrategy {
    sort(data: number[]): number[];
}

// Concrete Strategy 1: BubbleSort
class BubbleSort implements SortingStrategy {
    sort(data: number[]): number[] {
        console.log("Using Bubble Sort");
        // Implementation of Bubble Sort algorithm
        return data.slice().sort((a, b) => a - b);
    }
}

// Concrete Strategy 2: QuickSort
class QuickSort implements SortingStrategy {
    sort(data: number[]): number[] {
        console.log("Using Quick Sort");
        // Implementation of Quick Sort algorithm
        return data.slice().sort((a, b) => a - b);
    }
}

// Context: Sorter
class Sorter {
    private strategy: SortingStrategy;

    constructor(strategy: SortingStrategy) {
        this.strategy = strategy;
    }

    setStrategy(strategy: SortingStrategy) {
        this.strategy = strategy;
    }

    performSort(data: number[]): number[] {
        console.log('SortingContext: Sorting data using the strategy.');
        return this.strategy.sort(data);
    }
}

// Usage
const dataset = [1, 9, 100, 7, 77, 0, 3];
const sorter = new Sorter(new BubbleSort());
sorter.performSort(dataset); // Using Bubble Sort ; [0, 1, 3, 7, 9, 77, 100] 

sorter.setStrategy(new QuickSort());
sorter.performSort(dataset);// // Using Quick Sort ; [0, 1, 3, 7, 9, 77, 100] 

When to Use Strategy Pattern? โœ…

Advantages of Strategy Pattern ๐Ÿช„

Disadvantages of Strategy Pattern ๐Ÿ†˜

Template Method ๐Ÿ›

The Template Method pattern is a behavioral design pattern that defines the skeleton of an algorithm in the superclass but lets subclasses override specific steps of the algorithm without changing its structure. It allows a class to delegate certain steps of an algorithm to its subclasses, providing a framework for creating a family of related algorithms.

In Simple Words:

Defines the structure of an algorithm in a superclass but allows subclasses to customize specific steps of the algorithm without changing its overall structure.

Template Method Pattern

Implementation in TS:

// Template Method: DocumentGenerator
abstract class DocumentGenerator {
    generateDocument(): string {
        const header = this.createHeader();
        const content = this.createContent();
        const footer = this.createFooter();

        return `${header} - ${content} - ${footer}`;
    }

    abstract createHeader(): string;
    abstract createContent(): string;
    abstract createFooter(): string;
}

// Concrete Template Method 1: PDFDocumentGenerator
class PDFDocumentGenerator extends DocumentGenerator {
    createHeader(): string {
        return "PDF Header";
    }

    createContent(): string {
        return "PDF Content";
    }

    createFooter(): string {
        return "PDF Footer";
    }
}

// Concrete Template Method 2: WordDocumentGenerator
class WordDocumentGenerator extends DocumentGenerator {
    createHeader(): string {
        return "Word Header";
    }

    createContent(): string {
        return "Word Content";
    }

    createFooter(): string {
        return "Word Footer";
    }
}

// Usage
const pdfGenerator = new PDFDocumentGenerator();
console.log(pdfGenerator.generateDocument()); // PDF Header - PDF Content - PDF Footer

const wordGenerator = new WordDocumentGenerator();
console.log(wordGenerator.generateDocument()); // Word Header - Word Content - Word Footer

When to Use Template Method Pattern? โœ…

Advantages of Template Method Pattern ๐Ÿช„

Disadvantages of Template Method Pattern ๐Ÿ†˜

Visitor ๐Ÿšถโ€โ™‚

The Visitor pattern is a behavioral design pattern that allows you to define a new operation without changing the classes of the elements on which it operates. It separates the algorithms from the objects on which they operate by encapsulating these algorithms in visitor objects. This pattern enables you to add new behaviors to a set of classes without modifying their structure.

In Simple Words:

Defines a way to perform operations on elements of a structure without changing the classes of those elements.

Visitor Pattern

Implementation in TS:

// Element interface
interface Shape {
    accept(visitor: ShapeVisitor): void;
}

// Concrete Element 1: Circle
class Circle implements Shape {
    radius: number;

    constructor(radius: number) {
        this.radius = radius;
    }

    accept(visitor: ShapeVisitor): void {
        visitor.visitCircle(this);
    }
}

// Concrete Element 2: Square
class Square implements Shape {
    side: number;

    constructor(side: number) {
        this.side = side;
    }

    accept(visitor: ShapeVisitor): void {
        visitor.visitSquare(this);
    }
}

// Visitor interface
interface ShapeVisitor {
    visitCircle(circle: Circle): void;
    visitSquare(square: Square): void;
}

// Concrete Visitor 1: DrawingVisitor
class DrawingVisitor implements ShapeVisitor {
    visitCircle(circle: Circle): void {
        console.log(`Drawing Circle with radius ${circle.radius}`);
    }

    visitSquare(square: Square): void {
        console.log(`Drawing Square with side ${square.side}`);
    }
}

// Concrete Visitor 2: AreaCalculatorVisitor
class AreaCalculatorVisitor implements ShapeVisitor {
    visitCircle(circle: Circle): void {
        const area = Math.PI * circle.radius * circle.radius;
        console.log(`Area of Circle: ${area.toFixed(2)}`);
    }

    visitSquare(square: Square): void {
        const area = square.side * square.side;
        console.log(`Area of Square: ${area}`);
    }
}

// Usage
const circle = new Circle(5);
const square = new Square(4);

const drawingVisitor = new DrawingVisitor();
const areaCalculatorVisitor = new AreaCalculatorVisitor();

circle.accept(drawingVisitor); // Drawing Circle with radius 5
circle.accept(areaCalculatorVisitor); // Area of Circle: 78.54

square.accept(drawingVisitor); // Drawing Square with side 4
square.accept(areaCalculatorVisitor); // Area of Square: 16

When to Use Visitor Pattern? โœ…

Advantages of Visitor Pattern ๐Ÿช„

Disadvantages of Visitor Pattern ๐Ÿ†˜



SOLID Principles โš–

SOLID is an acronym that represents a set of five design principles for writing maintainable and scalable software. These principles were introduced by Robert C. Martin and are considered foundational concepts in object-oriented programming and design. The SOLID principles aim to create robust, flexible, and easily maintainable software by promoting clean and efficient code organization.

SOLID Principles

Why Do We Use SOLID? ๐ŸŽ

  1. Maintainability: SOLID principles enhance code maintainability by providing a clear structure, reducing code smells, and making it easier to add or modify features.
  2. Scalability: As the codebase grows, adhering to SOLID principles helps manage complexity and ensures that the system remains scalable and adaptable to changes.
  3. Collaboration: A codebase following SOLID principles is more accessible and understandable, facilitating collaboration among developers and making it easier for new team members to grasp the code.

Things to Remember When Using SOLID โš 

  1. Gradual Adoption: Implement SOLID principles gradually to existing codebases. Refactoring all code at once might not be practical.
  2. Real-world Applicability: Apply the principles judiciously. There are scenarios where breaking a principle is a better choice for specific reasons.
  3. Balance and Context: Achieving a balance between SOLID principles may require trade-offs. Consider the specific context of your application and team.

What are SOLID? ๐Ÿ‘€

Single Responsibility Principle ๐Ÿ•บ

The Single Responsibility Principle is one of the SOLID principles of object-oriented design. It states that a class should have only one reason to change, meaning it should have only one responsibility. Each class should focus on doing one thing and doing it well. This principle aims to enhance maintainability, readability, and flexibility in software development.

โ€œA class should have only one reason to change.โ€ โ€“ Robert C. Martin

Code in TS:

Bad Example (Violating SRP) โ€ผ๏ธ

class UserManager {
    getUsers(): User[] { /* ... */ }
    saveUser(user: User): void { /* ... */ }
    deleteUser(userId: string): void { /* ... */ }
    renderUser(user: User): void { /* ... */ } // Rendering logic in UserManager
}

In this bad example, the UserManager class is responsible for both managing user data (get, save, delete) and rendering users. This violates the Single Responsibility Principle because a class should have only one reason to change, and mixing data management with rendering introduces multiple responsibilities.

Good Example (With SRP) โœ…

class UserManager {
    getUsers(): User[] { /* ... */ }
    saveUser(user: User): void { /* ... */ }
    deleteUser(userId: string): void { /* ... */ }
}

class UserRenderer {
    renderUser(user: User): void { /* ... */ }
}

class User {
    constructor(public id: string, public name: string, public email: string) {}
}

const userManager = new UserManager();
const userRenderer = new UserRenderer();

const users = userManager.getUsers();
users.forEach(userRenderer.renderUser);

In this good example, responsibilities are separated into distinct classes. UserManager is responsible for managing user data, and UserRenderer is responsible for rendering users. This adheres to the Single Responsibility Principle, making each class focused on a single task and improving maintainability.

Advantages of the SRP ๐Ÿช„:

Open/Closed Principle ๐Ÿšช๐Ÿ”’

The Open/Closed Principle is a SOLID design principle that suggests a class should be open for extension but closed for modification. This means that a classโ€™s behavior can be extended without altering its source code, promoting the addition of new features or functionalities without changing existing ones.

โ€œThe Open-Closed Principle states that โ€œsoftware entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.โ€ โ€“ Robert C. Martin

Code in TS:

Bad Example (Violating OCP) ๐Ÿ†˜

class Discount {
  giveDiscount(customerType: string): number {
    if (customerType === "Regular") {
      return 10;
    } else if (customerType === "Premium") {
      return 20;
    }
  }
}

In this bad example, if we want to introduce a new type of customer, letโ€™s say a โ€œGoldโ€ customer with a different discount, we would have to modify the giveDiscount method in the Discount class.

Good Example (With OCP) โœ…

interface Customer {
  giveDiscount(): number;
}

class RegularCustomer implements Customer {
  giveDiscount(): number {
    return 10;
  }
}

class PremiumCustomer implements Customer {
  giveDiscount(): number {
    return 20;
  }
}

class GoldCustomer implements Customer {
  giveDiscount(): number {
    return 30;
  }
}

class Discount {
  giveDiscount(customer: Customer): number {
    return customer.giveDiscount();
  }
}

In this good example, the Open-Closed Principle is adhered to. New customer types, like GoldCustomer, can be added without modifying existing code. Each customer type implements the Customer interface, and the Discount class is open for extension but closed for modification.

Advantages of the OCP ๐Ÿช„:

Liskov Substitution Principle ๐Ÿงฉ

The Liskov Substitution Principle is a SOLID design principle that states objects of a superclass should be replaceable with objects of its subclass without affecting the correctness of the program. In simpler terms, a derived class should be able to substitute its base class without causing errors.

A subtype should behave like a supertype as far as you can tell by using the supertype methods.

Code in TS:

Bad Example (Violating LSP) ๐Ÿ†˜

abstract class Bird {
  abstract fly(): void;
}

class Penguin extends Bird {
  public fly(): void {
    // ??? (Penguins cannot fly)
  }
}

This example violates the Liskov Substitution Principle. The Bird abstract class declares an abstract method fly(), indicating that all birds should be able to fly. However, the Penguin class, being a bird, does not fulfill this contract appropriately.

Good Example (With LSP) โœ…

abstract class Bird {
  abstract move(): void;
}

class Sparrow extends Bird {
  public move(): void {
    console.log("Chirp chirp")
  }
}

class Penguin extends Bird {
  public move(): void {
    console.log("Swimming")
  }
}

// Function utilizing LSP
function performMove(bird: Bird): void {
    bird.move();
}

const sparrow = new Sparrow();
const penguin = new Penguin();

performMove(sparrow); // Chirp chirp
performMove(penguin); // Swimming

In this good example, the Sparrow and Penguin classes correctly adhere to the Liskov Substitution Principle. When substituting instances of these classes for the Bird abstract class, the performMove function behaves as expected, demonstrating consistency in the behavior of all bird subclasses.

Advantages of the LSP ๐Ÿช„:

Interface Segregation Principle ๐Ÿงโ€โ™‚๐Ÿค๐Ÿงโ€โ™€

The Interface Segregation Principle is a SOLID design principle that suggests a class should not be forced to implement interfaces it does not use. In simpler terms, it encourages breaking large interfaces into smaller, more specific ones, preventing classes from being burdened with methods they donโ€™t need.

โ€œNo client should be forced to depend on interfaces they do not use.โ€ - Robert C. Martin

Code in TS:

Bad Example (Without ISP) ๐Ÿ†˜

// Interface: Worker
interface Worker {
    work(): void;
    eat(): void;
}

// Class 1: Robot (implements Worker)
class Robot implements Worker {
    work(): void {
        console.log("Robot is working");
    }

    eat(): void { 
        // Robots don't eat.
        console.log("Robot does not eat");
    }
}

// Class 2: Human (implements Worker)
class Human implements Worker {
    work(): void {
        console.log("Human is working");
    }

    eat(): void {
        console.log("Human is eating");
    }
}

In this bad example, the Worker interface is broad and includes the eat method, which is irrelevant for the Robot class. This violates the Interface Segregation Principle because the Robot class is forced to implement a method it doesnโ€™t use.

Good Example (With ISP) โœ…

// Interface: Worker
interface Worker {
    work(): void;
}

// Interface: Eater
interface Eater {
    eat(): void;
}

// Class 1: Robot (implements only Worker)
class Robot implements Worker {
    work(): void {
        console.log("Robot is working");
    }
}

// Class 2: Human (implements Worker and Eater)
class Human implements Worker, Eater {
    work(): void {
        console.log("Human is working");
    }

    eat(): void {
        console.log("Human is eating");
    }
}

// Class 3: Dog (implements only Eater)
class Dog implements Eater {
    eat(): void {
        console.log("Dog is eating");
    }
}

In this good example, the Worker interface is focused only on the work method. Additionally, a separate Eater interface is introduced for classes that need an eat method. This adheres to the Interface Segregation Principle, as classes now implement only the interfaces relevant to their functionalities.

Advantages of the ISP ๐Ÿช„:

Dependency Inversion Principle ๐Ÿ”„

The Dependency Inversion Principle is a SOLID design principle that emphasizes high-level modules should not depend on low-level modules, but both should depend on abstractions. It promotes the use of abstractions (interfaces or abstract classes) to decouple higher-level and lower-level modules, fostering flexibility and maintainability.

โ€œHigh-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.โ€ - Robert C. Martin

Code in TS:

// Abstraction (interface) 
interface Switchable {
    turnOn(): void;
    turnOff(): void;
}

// Low-level module 1: Bulb
class Bulb implements Switchable {
    turnOn(): void {
        console.log("Bulb is on");
    }

    turnOff(): void {
        console.log("Bulb is off");
    }
}

// Low-level module 2: Fan
class Fan implements Switchable {
    turnOn(): void {
        console.log("Fan is on");
    }

    turnOff(): void {
        console.log("Fan is off");
    }
}

// High-level module utilizing DIP
class Switch {
    constructor(private device: Switchable) {}

    operate(): void {
        this.device.turnOn();
        // Additional operations if needed
        this.device.turnOff();
    }
}

// Usage
const bulb = new Bulb();
const fan = new Fan();

const switchForBulb = new Switch(bulb);
const switchForFan = new Switch(fan);

switchForBulb.operate(); // Bulb is on, Bulb is off
switchForFan.operate(); // Fan is on, Fan is off

Switchable is the abstraction (interface) representing switchable devices. Bulb and Fan are low-level modules implementing the Switchable interface. Switch is the high-level module that depends on the abstraction (Switchable), adhering to DIP.

Advantages of the DIP ๐Ÿช„: