Comprehensive Guide to Design Patterns and SOLID Principles in TypeScript ๐ชผ
๐ฎ 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!
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 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.
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.
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.
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.
Several compelling reasons drive the utilization of 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:
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 ๐จ 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. ๐
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.โ
Implementing the Singleton pattern in object-oriented programming typically involves the following steps:
static
attribute in the singleton class.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.private
, preventing external objects from using the new
operator with the singleton class.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.
Consider using Singleton when:
Despite its advantages, the Singleton pattern has drawbacks:
Violates Single Responsibility Principle ๐ซ: Simultaneously managing object instantiation and global access might breach the Single Responsibility Principle.
Masking Design Issues ๐ญ: Singleton can hide underlying design problems, offering a quick fix without addressing the root causes.
Multithreading Challenges ๐: Implementing Singleton in a multithreaded environment requires careful synchronization to prevent unintended multiple instantiations.
Unit Testing Complexity ๐งช: Unit testing client code using Singleton can be complex due to private constructors and challenges in mocking the singleton instance.
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.
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.
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.
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.
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.
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.
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
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.
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());
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.
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.
// 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!
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.
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;
}
}
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 } ...] }
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.
// 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
In simple words:
It provides a simplified interface to a complex subsystem.
// 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
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 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...
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.
// 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.
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.
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.
// 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
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 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.
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.
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.
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.
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!"
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
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!
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.
// 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
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.
// 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
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 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]
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: 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
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.
// 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
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.
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
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 theSingle Responsibility Principle
because a class should have only one reason to change, and mixing data management with rendering introduces multiple responsibilities.
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, andUserRenderer
is responsible for rendering users. This adheres to theSingle Responsibility Principle
, making each class focused on a single task and improving maintainability.
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
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 theDiscount
class.
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, likeGoldCustomer
, can be added without modifying existing code. Each customer type implements theCustomer
interface, and theDiscount
class is open for extension but closed for modification.
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 asupertype
as far as you can tell by using thesupertype
methods.
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 methodfly()
, indicating that all birds should be able to fly. However, the Penguin class, being a bird, does not fulfill this contract appropriately.
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
andPenguin
classes correctly adhere to the Liskov Substitution Principle. When substituting instances of these classes for theBird
abstract class, theperformMove
function behaves as expected, demonstrating consistency in the behavior of all bird subclasses.
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
// 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 theeat
method, which is irrelevant for theRobot
class. This violates theInterface Segregation Principle
because theRobot
class is forced to implement a method it doesnโt use.
// 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 thework
method. Additionally, a separateEater
interface is introduced for classes that need aneat
method. This adheres to theInterface Segregation Principle
, as classes now implement only the interfaces relevant to their functionalities.
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
// 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
andFan
are low-level modules implementing theSwitchable
interface.Switch
is the high-level module that depends on the abstraction (Switchable), adhering toDIP
.