Design Patterns for Humans: Making Complex Software Simple with TypeScript Snippets (Part 1)

Design Patterns for Humans: Making Complex Software Simple with TypeScript Snippets (Part 1)

Introduction:

Imagine you're trying to fix something at home maybe a leaky faucet or a loose cabinet door. You’ve never done it before, so you take a guess, fiddle with some tools, maybe even make the problem worse. But if you had a simple step-by-step guide, or someone who had solved this problem before, you’d be done in minutes without any stress.

That’s exactly what design patterns do in software.

Design patterns are like problem-solving blueprints. They’re not code you copy and paste they’re time tested approaches that help you tackle common software challenges. Whether you’re building a signup form, a messaging system, or a game engine, chances are someone has already figured out a smart, reusable way to do it.

And when teams use these patterns, they speak the same language. Saying “Let’s use a Factory Pattern” or “This feels like a good use case for Observer” makes collaboration smoother. It means less explaining and more building.


So, Why Do Patterns Matter?

  • They reduce complexity by giving you a structure to follow.
  • They help teams communicate, because patterns give us shared language (e.g., “Let’s use a Factory here”).
  • They improve reusability and flexibility, making your code easier to scale and adapt.
  • And most importantly they make your life easier.


What This Article Will Cover

We’re not here to make things sound academic or intimidating. We’re going to break down design patterns into four friendly categories:

  1. Creational Patterns – smart ways to create objects.
  2. Structural Patterns – how to organize and connect parts of your code.
  3. Behavioral Patterns – how pieces of your app communicate.
  4. Modern & Concurrency Patterns – how to handle async and parallel tasks without chaos.


What Exactly Are Design Patterns?

So, what are design patterns really?

At their core, design patterns are not code. They’re not plugins, libraries, or frameworks either. They’re simply ideas smart ways of solving problems that keep popping up in software development.

Think of them like this:

When a problem is common, and the solution has worked well in many situations, someone gives that solution a name and writes it down. That’s a design pattern.

They’re kind of like recipes in cooking. You don’t have to follow a recipe every time you make pasta, but if someone hands you a tried-and-true method that works every time, why not use it? And once you know the pattern, you can tweak it to fit your taste or in coding terms, your specific project.

Design Patterns Are About:

  • Reusability: Stop solving the same problem from scratch.
  • Consistency: Help teams write code that follows the same logic and structure.
  • Communication: Give developers a common vocabulary (“This is a Strategy pattern.”).
  • Fewer bugs: Use patterns that have been tested and proven in real-world systems.


Where Do They Come From?

In the 1990s, four software engineers Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides wrote a book called Design Patterns: Elements of Reusable Object-Oriented Software. People call them the Gang of Four (GoF).

They looked at tons of real projects and picked out patterns that kept working well across different systems. Their book introduced 23 patterns that became the foundation for most of what we still use today.

But you don’t have to read the book cover to cover. You just need to understand why these patterns exist and how they can help you write better software.


The Four Pattern Families

Design patterns aren’t about memorizing fancy names they’re about solving everyday code problems in smart, reusable ways. Below, we’ll break them down into four human-friendly categories. Each pattern comes with a metaphor, a real-world example, and a Typescript snippet you can actually understand.


Creational Patterns : Smart Ways to Create Objects

“Like choosing how to make a sandwich: do it yourself, use a chef, or always eat the same one.”

Creational patterns help you control how objects are created, so your code doesn’t get stuck doing the same thing over and over in different places. Without these patterns, you risk having code that’s messy, tightly connected, and hard to change later.

Object creation can get messy, repetitive, and tightly coupled. Creational patterns give you clean, flexible ways to build objects. Some example scenarios:

  • When different components need different types of objects.
  • When object creation depends on conditions.
  • When creating objects is resource-heavy or complex.


Singleton

"Only ever one. Global, but safely controlled instance."

Think of it like the President of a country. No matter how many times you try to “create” a president, there should only be one recognized at any time.

The Singleton pattern is a creational design pattern that ensures a class has only one instance while providing a global access point to that instance. This is useful when exactly one object is needed to coordinate actions across the system. (like a database connection, logger or configuration manager).


Key Characteristics

  • Only one instance of the class can be created
  • Provides a global point of access to that instance
  • The instance is created only when it's needed for the first time (lazy initialization)


Potential Pitfalls

  1. Testing Difficulties: Singletons can make unit testing challenging because they maintain state between tests.
  2. Hidden Dependencies: Since they're globally accessible, it's not always clear when they're being used.
  3. Violates Single Responsibility Principle: They manage their own creation and lifecycle.
  4. Concurrency Issues: In multi-threaded environments, special care must be taken to prevent race conditions during initialization.


Real-World Use Cases

  1. Configuration Management: When you need a single source of configuration settings throughout your application.
  2. Logging: A single logger instance that all components use.
  3. Database Connections: Managing a single connection pool.
  4. Caching: A global cache store.
  5. State Management: In frontend applications (like Redux store in React).


When to Use

  • When you need exactly one instance of a class that's accessible from a well-known access point
  • When the single instance should be extensible by subclassing, and clients should be able to use an extended instance without modifying their code


When to Avoid

  • When your application doesn't need strict control over instance creation
  • When you're working with dependency injection frameworks (they often provide better ways to manage single instances)
  • When you need multiple instances with different configurations

//Basic Singleton Implementation
class Singleton {
    private static instance: Singleton;

    // Private constructor to prevent direct construction calls
    private constructor() {}

    // Static method to get the single instance
    public static getInstance(): Singleton {
        if (!Singleton.instance) {
            Singleton.instance = new Singleton();
        }
        return Singleton.instance;
    }

    // Example business logic method
    public someBusinessLogic(): void {
        console.log("Executing some business logic...");
    }
}

// Usage
const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();

console.log(instance1 === instance2); // true - both variables contain the same instance
instance1.someBusinessLogic();
        
//Singleton with Early Initialization
class EarlySingleton {
    private static instance: EarlySingleton = new EarlySingleton();

    private constructor() {}

    public static getInstance(): EarlySingleton {
        return EarlySingleton.instance;
    }

    public someMethod(): void {
        console.log("Early initialized singleton method");
    }
}        
//Singleton with TypeScript's readonly and static
class ReadonlySingleton {
    public static readonly Instance = new ReadonlySingleton();

    private constructor() {}

    public doSomething(): void {
        console.log("Doing something...");
    }
}

//Usage
ReadonlySingleton.Instance.doSomething();        
//Singleton with Lazy Initialization using a Closure
namespace LazySingleton {
    class _Singleton {
        public doWork(): void {
            console.log("Doing some work");
        }
    }

    let instance: _Singleton;

    export function getInstance(): _Singleton {
        if (!instance) {
            instance = new _Singleton();
        }
        return instance;
    }
}

//Usage
LazySingleton.getInstance().doWork();        
//Thread-Safe Singleton (for Node.js/TypeScript)
class ThreadSafeSingleton {
    private static instance: ThreadSafeSingleton;
    private static lock: boolean = false;

    private constructor() {}

    public static getInstance(): ThreadSafeSingleton {
        if (!ThreadSafeSingleton.instance) {
            ThreadSafeSingleton.lock = true;
            ThreadSafeSingleton.instance = new ThreadSafeSingleton();
            ThreadSafeSingleton.lock = false;
        }
        while (ThreadSafeSingleton.lock) {
            // Wait for the lock to be released
        }
        return ThreadSafeSingleton.instance;
    }
}        

Factory Method

"A smart method that creates objects so you don’t have to."

Imagine a pizza shop. You order a pizza by telling the shop the type like "pepperoni" or "veggie" and the shop (factory) knows how to prepare it and hands it to you. You don’t need to know the ingredients or how it’s made. Well factory method is that metaphorical shop.

Instead of directly using new on classes, you use a factory function or class that decides which subclass or variation of an object to create and returns it. You want to create many types of similar objects, but you don't want your code to depend on their concrete classes (to reduce tight coupling). Example: You’re building a notification system, and your factory decides whether to return an EmailNotification, SMSNotification, or PushNotification.


Key Characteristics

  1. Decoupling: Separates object creation from object usage
  2. Extensibility: Makes it easy to add new product types
  3. Single Responsibility Principle: Creation code is in one place
  4. Open/Closed Principle: New products can be introduced without breaking existing code
  5. Polymorphism: Client code works with abstract products


Potential Pitfalls

  1. Over-engineering: Can make simple code unnecessarily complex
  2. Class explosion: May lead to many small creator classes
  3. Tight coupling: If concrete products need different constructor parameters
  4. Testing complexity: Mocking becomes more involved
  5. Hidden dependencies: It's not always clear which concrete product is being created


Real-World Use Cases

  1. UI Frameworks: Creating platform-specific UI components
  2. Database Connections: Different connectors for various DBMS
  3. Document Converters: Supporting multiple file formats
  4. Logging Systems: Different log destinations
  5. E-commerce Systems: Multiple payment gateways


When to Use

  1. When you don't know beforehand the exact types and dependencies of the objects your code should work with
  2. When you want to provide users of your library/framework with a way to extend its internal components
  3. When you want to save system resources by reusing existing objects instead of rebuilding them each time
  4. When your code needs to work with multiple families of related products


When to Avoid

  1. When the factory method would just delegate object creation to a constructor without adding any value
  2. When the class hierarchy is simple and unlikely to change
  3. When performance is critical (factory methods add a small overhead)
  4. When object creation is straightforward and doesn't need abstraction

//Basic structure
interface Product {
  operation(): string;
}

abstract class Creator {
  public abstract factoryMethod(): Product;

  public someOperation(): string {
    const product = this.factoryMethod();
    return `Creator: ${product.operation()}`;
  }
}

class ConcreteProduct1 implements Product {
  public operation(): string {
    return '{Result of ConcreteProduct1}';
  }
}

class ConcreteCreator1 extends Creator {
  public factoryMethod(): Product {
    return new ConcreteProduct1();
  }
}

class ConcreteProduct2 implements Product {
  public operation(): string {
    return '{Result of ConcreteProduct2}';
  }
}

class ConcreteCreator2 extends Creator {
  public factoryMethod(): Product {
    return new ConcreteProduct2();
  }
}

// Client code
function clientCode(creator: Creator) {
  console.log(creator.someOperation());
}

console.log('App launched with ConcreteCreator1');
clientCode(new ConcreteCreator1());

console.log('App launched with ConcreteCreator2');
clientCode(new ConcreteCreator2());        
interface PaymentMethod {
  pay(amount: number): string;
}

class CreditCardPayment implements PaymentMethod {
  pay(amount: number): string {
    return `Paid $${amount} via Credit Card`;
  }
}

class PayPalPayment implements PaymentMethod {
  pay(amount: number): string {
    return `Paid $${amount} via PayPal`;
  }
}

class CryptoPayment implements PaymentMethod {
  pay(amount: number): string {
    return `Paid $${amount} via Cryptocurrency`;
  }
}

abstract class PaymentProcessor {
  abstract createPaymentMethod(): PaymentMethod;

  processPayment(amount: number): string {
    const paymentMethod = this.createPaymentMethod();
    return paymentMethod.pay(amount);
  }
}

class CreditCardProcessor extends PaymentProcessor {
  createPaymentMethod(): PaymentMethod {
    return new CreditCardPayment();
  }
}

class PayPalProcessor extends PaymentProcessor {
  createPaymentMethod(): PaymentMethod {
    return new PayPalPayment();
  }
}

class CryptoProcessor extends PaymentProcessor {
  createPaymentMethod(): PaymentMethod {
    return new CryptoPayment();
  }
}

// Usage
function processOrder(processor: PaymentProcessor, amount: number) {
  console.log(processor.processPayment(amount));
}

processOrder(new CreditCardProcessor(), 100);
processOrder(new PayPalProcessor(), 200);
processOrder(new CryptoProcessor(), 300);        
interface Plugin {
  initialize(): void;
  execute(): string;
}

abstract class PluginFactory {
  abstract createPlugin(): Plugin;

  loadAndRun(): string {
    const plugin = this.createPlugin();
    plugin.initialize();
    return plugin.execute();
  }
}

class AnalyticsPlugin implements Plugin {
  initialize() {
    console.log('Initializing analytics plugin...');
  }
  
  execute(): string {
    return 'Analytics data processed';
  }
}

class AnalyticsPluginFactory extends PluginFactory {
  createPlugin(): Plugin {
    return new AnalyticsPlugin();
  }
}

class BackupPlugin implements Plugin {
  initialize() {
    console.log('Initializing backup plugin...');
  }
  
  execute(): string {
    return 'Backup completed successfully';
  }
}

class BackupPluginFactory extends PluginFactory {
  createPlugin(): Plugin {
    return new BackupPlugin();
  }
}

// Dynamic plugin loading
type PluginConstructor = new () => Plugin;

class DynamicPluginFactory extends PluginFactory {
  constructor(private pluginClass: PluginConstructor) {
    super();
  }

  createPlugin(): Plugin {
    return new this.pluginClass();
  }
}

// Usage
const analytics = new AnalyticsPluginFactory();
console.log(analytics.loadAndRun());

const backup = new BackupPluginFactory();
console.log(backup.loadAndRun());

// Dynamic usage
const dynamicFactory = new DynamicPluginFactory(AnalyticsPlugin);
console.log(dynamicFactory.loadAndRun());        

Abstract Factory

"A factory of factories."

Imagine a furniture store that produces Victorian and Modern furniture sets. You want to make sure that if someone picks the Victorian theme, they get Victorian Chair, Victorian Sofa, etc., and not a mix of styles.

The Abstract Factory pattern is a creational design pattern that provides an interface for creating families of related or dependent objects without specifying their concrete classes. Example: UI components for different operating systems.


Key Characteristics

  1. Creates families of related objects - The factory produces multiple types of objects that are designed to work together
  2. Encapsulates object creation - Client code works with factories and products through abstract interfaces
  3. Promotes consistency - Ensures that products are compatible with each other
  4. Decouples client code from concrete implementations
  5. Hierarchical - Typically involves multiple levels of abstraction (abstract factory → concrete factory → abstract product → concrete product)


Potential Pitfalls

  1. Increased complexity - The pattern introduces many additional classes and interfaces
  2. Difficulty in adding new product types - Adding a new product to all factories can be challenging
  3. Over-engineering - Using the pattern for simple cases where it's not needed
  4. Tight coupling between products - Products are designed to work together which can make them less reusable individually
  5. Runtime changes can be difficult - Switching factories at runtime may require re-creating existing objects


Real-World Use Cases

  1. Cross-platform UI frameworks - Creating platform-specific UI components that need to work together
  2. Database access - Creating families of database connection, command, and reader objects for different database systems
  3. Theme systems - Creating visual elements that follow a specific theme (dark/light mode)
  4. Game development - Creating sets of characters, weapons, or levels with consistent styles
  5. Payment processing - Creating payment gateways with related transaction, verification, and refund objects


When to Use

  1. When your system needs to be independent of how its objects are created
  2. When you need to enforce consistency among related objects
  3. When you need to support multiple families of products (e.g., different themes, platforms)
  4. When you want to hide product implementations from client code
  5. When you anticipate adding new product families in the future


When to Avoid

  1. When the products you're creating don't have clear families or groupings
  2. When you only need to create a single type of object (use Factory Method instead)
  3. When the product families are unlikely to change or expand
  4. When the codebase is small and the added complexity isn't justified
  5. When you can easily configure objects through constructor parameters instead

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

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

// Abstract Factory
interface GUIFactory {
  createButton(): Button;
  createCheckbox(): Checkbox;
}

// Concrete Products for Windows
class WindowsButton implements Button {
  render() {
    console.log("Rendering a Windows style button");
  }
  
  onClick(f: Function) {
    console.log("Windows button clicked");
    f();
  }
}

class WindowsCheckbox implements Checkbox {
  render() {
    console.log("Rendering a Windows style checkbox");
  }
  
  toggle() {
    console.log("Windows checkbox toggled");
  }
}

// Concrete Products for MacOS
class MacOSButton implements Button {
  render() {
    console.log("Rendering a MacOS style button");
  }
  
  onClick(f: Function) {
    console.log("MacOS button clicked");
    f();
  }
}

class MacOSCheckbox implements Checkbox {
  render() {
    console.log("Rendering a MacOS style checkbox");
  }
  
  toggle() {
    console.log("MacOS checkbox toggled");
  }
}

// Concrete Factories
class WindowsFactory implements GUIFactory {
  createButton(): Button {
    return new WindowsButton();
  }
  
  createCheckbox(): Checkbox {
    return new WindowsCheckbox();
  }
}

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

// Client code
class Application {
  private factory: GUIFactory;
  private button: Button;
  
  constructor(factory: GUIFactory) {
    this.factory = factory;
    this.button = this.factory.createButton();
  }
  
  createUI() {
    this.button.render();
    const checkbox = this.factory.createCheckbox();
    checkbox.render();
  }
  
  onClick() {
    this.button.onClick(() => console.log("Button callback executed"));
  }
}

// Usage
function createApp(os: string): Application {
  let factory: GUIFactory;
  
  switch(os) {
    case "Windows":
      factory = new WindowsFactory();
      break;
    case "MacOS":
      factory = new MacOSFactory();
      break;
    default:
      throw new Error("Unknown OS");
  }
  
  return new Application(factory);
}

// Create Windows app
const windowsApp = createApp("Windows");
windowsApp.createUI();
windowsApp.onClick();

// Create MacOS app
const macApp = createApp("MacOS");
macApp.createUI();
macApp.onClick();        

Builder

"Create complex objects step by step."

Imagine you're at a pizza place:

  • You tell the PizzaBuilder what you want: thick crust, cheese, pepperoni, etc.
  • The builder constructs it step by step.
  • Finally, you call .bake() and get the final Pizza object.

You want to create a complex object (with lots of optional or nested parts), but you don't want to have a constructor with a million parameters or messy initialization logic.

Think: making a custom pizza, where you choose size, crust, sauce, toppings, etc. Not everyone wants olives!


Key Characteristics

  1. Step-by-step construction: The object is constructed in a series of steps.
  2. Separation of concerns: Construction logic is separated from the object's representation.
  3. Fluent interface: Often uses method chaining for a more readable API.
  4. Director (optional): Can include a director class that defines the order of construction steps.
  5. Product independence: The client is shielded from the details of the product's construction.


Potential Pitfalls

  1. Increased complexity: The pattern requires creating multiple new classes, which can overcomplicate simple object creation scenarios.
  2. Maintenance overhead: Changes to the product often require changes to the builder, potentially violating the Open/Closed Principle.
  3. Verbose code: The pattern can lead to verbose code, especially for objects with many properties.
  4. Runtime errors: If the builder doesn't properly validate the construction process, errors might only appear at runtime.


Real-World Use Cases

  1. Complex object creation: When an object requires multiple steps or configurations to be created.
  2. Immutable objects: When you want to create immutable objects with many properties.
  3. Validation during construction: When you need to validate parameters before object creation.


When to Use

  • When an object requires multiple steps to be created, and these steps need to be clear and explicit.
  • When you need to create different representations of the same construction process.
  • When the construction process should be independent of the object's components.
  • When you want to create immutable objects but need a flexible construction process.


When to Avoid

  • When the object construction is simple and can be done with a constructor or factory method.
  • When the number of parameters is small and unlikely to grow.
  • When performance is critical (the pattern adds some overhead).
  • When the construction process doesn't vary significantly between different representations.

//Basic implementation
// Product
class Pizza {
    public size: number;
    public cheese: boolean;
    public pepperoni: boolean;
    public bacon: boolean;
    public mushrooms: boolean;

    constructor(builder: PizzaBuilder) {
        this.size = builder.size;
        this.cheese = builder.cheese;
        this.pepperoni = builder.pepperoni;
        this.bacon = builder.bacon;
        this.mushrooms = builder.mushrooms;
    }

    describe(): string {
        let description = `This is a ${this.size} inch pizza with:`;
        if (this.cheese) description += ' cheese,';
        if (this.pepperoni) description += ' pepperoni,';
        if (this.bacon) description += ' bacon,';
        if (this.mushrooms) description += ' mushrooms,';
        return description.slice(0, -1); // Remove trailing comma
    }
}

// Builder
class PizzaBuilder {
    public size: number;
    public cheese: boolean = false;
    public pepperoni: boolean = false;
    public bacon: boolean = false;
    public mushrooms: boolean = false;

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

    addCheese(): PizzaBuilder {
        this.cheese = true;
        return this;
    }

    addPepperoni(): PizzaBuilder {
        this.pepperoni = true;
        return this;
    }

    addBacon(): PizzaBuilder {
        this.bacon = true;
        return this;
    }

    addMushrooms(): PizzaBuilder {
        this.mushrooms = true;
        return this;
    }

    build(): Pizza {
        return new Pizza(this);
    }
}

// Usage
const myPizza = new PizzaBuilder(14)
    .addCheese()
    .addPepperoni()
    .addMushrooms()
    .build();

console.log(myPizza.describe());
// Output: This is a 14 inch pizza with: cheese, pepperoni, mushrooms        
//Advanced implementation
// Director
class PizzaChef {
    constructMargherita(builder: PizzaBuilder): Pizza {
        return builder
            .addCheese()
            .build();
    }

    constructPepperoniFeast(builder: PizzaBuilder): Pizza {
        return builder
            .addCheese()
            .addPepperoni()
            .addBacon()
            .build();
    }

    constructVegetarian(builder: PizzaBuilder): Pizza {
        return builder
            .addCheese()
            .addMushrooms()
            .build();
    }
}

// Usage with director
const chef = new PizzaChef();
const builder = new PizzaBuilder(12);

const margherita = chef.constructMargherita(builder);
const pepperoniFeast = chef.constructPepperoniFeast(new PizzaBuilder(16));

console.log(margherita.describe());
// Output: This is a 12 inch pizza with: cheese
console.log(pepperoniFeast.describe());
// Output: This is a 16 inch pizza with: cheese, pepperoni, bacon        

Prototype

“Clone an object instead of creating a new one.”

Imagine you're designing a video game. You have a Monster with complex setup logic (AI, animations, etc.). Instead of recreating similar monsters from scratch, you create one base monster and clone it for others with slight tweaks.

The Prototype pattern is a creational design pattern that allows objects to be created by cloning an existing object (prototype) rather than creating new instances from scratch. This is particularly useful when object creation is expensive or complex.

Think of it like "copy-paste and tweak" rather than "build from scratch"


Key Characteristics

  1. Cloning Mechanism: Objects are created by copying a prototype instance
  2. Reduced Subclassing: Avoids the need for many subclasses by varying properties
  3. Dynamic Configuration: Objects can be configured at runtime by changing their prototype
  4. Performance Benefit: Cloning can be faster than creating new instances


Potential Pitfalls

  1. Shallow Copy Issues: References are copied, not the actual objects
  2. Circular References: Can cause infinite recursion during cloning
  3. Hidden Dependencies: Cloned objects might have hidden dependencies on the original
  4. Overhead: Maintaining the cloning mechanism adds complexity
  5. Initialization Control: Some objects might need re-initialization after cloning


Real-World Use Cases

  1. Game Development: Cloning game entities (characters, items) with similar properties
  2. Graphics Applications: Creating copies of complex graphical objects
  3. Database Operations: Cloning record templates for new entries
  4. Configuration Objects: Creating pre-configured objects for different scenarios
  5. Caching: Storing and reusing expensive-to-create objects


When to Use

  • When object creation is expensive (database calls, complex computations)
  • When you need to create many similar objects with slight variations
  • When you want to avoid a hierarchy of factories parallel to your class hierarchy
  • When classes are instantiated at runtime and you want to avoid building a class hierarchy


When to Avoid

  • When objects have circular references (can complicate cloning)
  • When the cost of implementing cloning is higher than the benefit
  • When objects contain resources that shouldn't be shared (file handles, network connections)
  • When subclasses differ significantly from their parent classes

interface Prototype {
    clone(): Prototype;
    toString(): string;
}

class ConcretePrototype1 implements Prototype {
    private property: string;

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

    clone(): Prototype {
        return new ConcretePrototype1(this.property);
    }

    setProperty(property: string): void {
        this.property = property;
    }

    toString(): string {
        return `ConcretePrototype1 with property: ${this.property}`;
    }
}

class ConcretePrototype2 implements Prototype {
    private number: number;

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

    clone(): Prototype {
        return new ConcretePrototype2(this.number);
    }

    setNumber(number: number): void {
        this.number = number;
    }

    toString(): string {
        return `ConcretePrototype2 with number: ${this.number}`;
    }
}

// Client code
const prototype1 = new ConcretePrototype1("initial");
const clone1 = prototype1.clone();
console.log(clone1.toString()); // ConcretePrototype1 with property: initial

const prototype2 = new ConcretePrototype2(100);
const clone2 = prototype2.clone();
console.log(clone2.toString()); // ConcretePrototype2 with number: 100        
//Shallow vs deep copy
class ComplexObject implements Prototype {
    public primitive: number;
    public object: Date;
    public array: number[];

    constructor(primitive: number, object: Date, array: number[]) {
        this.primitive = primitive;
        this.object = object;
        this.array = array;
    }

    // Shallow copy
    shallowClone(): Prototype {
        return new ComplexObject(this.primitive, this.object, this.array);
    }

    // Deep copy
    deepClone(): Prototype {
        return new ComplexObject(
            this.primitive,
            new Date(this.object.getTime()),
            [...this.array]
        );
    }
}

// Usage
const original = new ComplexObject(1, new Date(), [1, 2, 3]);
const shallowCopy = original.shallowClone();
const deepCopy = original.deepClone();        
//Advanced example with Prototype Registry
class PrototypeRegistry {
    private prototypes: { [key: string]: Prototype } = {};

    addPrototype(key: string, prototype: Prototype): void {
        this.prototypes[key] = prototype;
    }

    getPrototype(key: string): Prototype | undefined {
        const prototype = this.prototypes[key];
        return prototype?.clone();
    }
}

// Usage
const registry = new PrototypeRegistry();
registry.addPrototype("basicEnemy", new ConcretePrototype1("enemy"));
registry.addPrototype("powerfulEnemy", new ConcretePrototype1("super enemy"));

const enemy1 = registry.getPrototype("basicEnemy");
const enemy2 = registry.getPrototype("powerfulEnemy");        

Structural Patterns – Building Better Lego Blocks

“Think of plugins, adapters, or extensions that let things fit together better.”

These patterns help you organize your code’s structure, especially when dealing with large systems or pieces that weren’t designed to work together. Some examples scenarios are below:

  • Integrating old APIs into modern apps.
  • Wrapping and extending functionality.
  • Optimizing memory use with many small similar objects.


Adapter

“This doesn’t fit... oh wait, now it does!”

You’re traveling from the US to Europe. Your laptop plug (US) doesn't fit the power socket (EU). What do you do? You use a plug adapter that converts the US plug to fit the EU socket.

The Adapter pattern is a structural design pattern that allows objects with incompatible interfaces to collaborate. It acts as a bridge between two incompatible interfaces by converting the interface of one class into another interface that clients expect. Example: A new charger that needs to fit an old power outlet.


Key Characteristics

  1. Interface Translation: Converts one interface to another
  2. Reusability: Allows reuse of existing classes that otherwise couldn't be used
  3. Decoupling: Keeps client code separate from adapted code
  4. Two Types: Class Adapter (uses inheritance) and Object Adapter (uses composition)


Potential Pitfalls

  1. Overuse: Creating adapters for everything can lead to unnecessary complexity
  2. Performance Overhead: Each adapter call adds a layer of indirection which may impact performance
  3. Confusion: Too many adapters can make the system harder to understand
  4. Hidden Complexity: Adapters can hide the true complexity of the system behind simple interfaces
  5. Maintenance: Need to maintain both the adapter and the adapted code


Real-World Use Cases

  1. Legacy System Integration: When integrating new systems with legacy systems that have incompatible interfaces
  2. Third-Party Library Adaptation: When you need to use a third-party library that doesn't match your application's interface
  3. API Versioning: Adapting between different versions of an API
  4. Device Drivers: Hardware drivers often use adapters to make different devices work with standard interfaces
  5. UI Component Libraries: Adapting components from different UI libraries to work together


When to Use

  • When you want to use an existing class, but its interface doesn't match what you need
  • When you want to create a reusable class that cooperates with unrelated or unforeseen classes
  • When you need to use several existing subclasses but it's impractical to adapt their interface by subclassing each one


When to Avoid

  • When the interfaces are already compatible - direct usage is simpler
  • When you can refactor the code to have matching interfaces
  • When the adaptation would be so complex that it's better to rewrite the code
  • For simple one-off conversions - a simple function might suffice

// Object adapter
// Target interface that clients expect
interface MediaPlayer {
  play(audioType: string, fileName: string): void;
}

// Adaptee - existing class with incompatible interface
class AdvancedMediaPlayer {
  playVlc(fileName: string): void {
    console.log(`Playing vlc file: ${fileName}`);
  }
  
  playMp4(fileName: string): void {
    console.log(`Playing mp4 file: ${fileName}`);
  }
}

// Adapter that implements the target interface
class MediaAdapter implements MediaPlayer {
  private advancedPlayer: AdvancedMediaPlayer;

  constructor() {
    this.advancedPlayer = new AdvancedMediaPlayer();
  }

  play(audioType: string, fileName: string): void {
    if (audioType === 'vlc') {
      this.advancedPlayer.playVlc(fileName);
    } else if (audioType === 'mp4') {
      this.advancedPlayer.playMp4(fileName);
    } else {
      throw new Error(`Unsupported media type: ${audioType}`);
    }
  }
}

// Client code
class AudioPlayer implements MediaPlayer {
  private mediaAdapter: MediaAdapter;

  play(audioType: string, fileName: string): void {
    if (audioType === 'mp3') {
      console.log(`Playing mp3 file: ${fileName}`);
    } else if (audioType === 'vlc' || audioType === 'mp4') {
      this.mediaAdapter = new MediaAdapter();
      this.mediaAdapter.play(audioType, fileName);
    } else {
      throw new Error(`Invalid media type: ${audioType}`);
    }
  }
}

// Usage
const player = new AudioPlayer();
player.play('mp3', 'song.mp3');
player.play('mp4', 'movie.mp4');
player.play('vlc', 'video.vlc');        
//Class adapter
// Target interface
interface CelsiusThermometer {
  getTemperature(): number;
}

// Adaptee
class FahrenheitThermometer {
  getFahrenheitTemp(): number {
    return 32; // example value
  }
}

// Class Adapter (using inheritance)
class ThermometerAdapter extends FahrenheitThermometer implements CelsiusThermometer {
  getTemperature(): number {
    return (this.getFahrenheitTemp() - 32) * 5/9;
  }
}

// Usage
const celsiusThermometer: CelsiusThermometer = new ThermometerAdapter();
console.log(`Temperature in Celsius: ${celsiusThermometer.getTemperature()}`);        
//Advanced TypeScript Implementation with Generics
interface DataReader<T> {
  read(): T;
}

interface DataWriter<T> {
  write(data: T): void;
}

// Adaptee - CSV processor
class CsvProcessor {
  readCsv(): string[][] {
    return [["name", "age"], ["Alice", "30"], ["Bob", "25"]];
  }
  
  writeCsv(data: string[][]): void {
    console.log("Writing CSV:", data);
  }
}

// Generic Adapter
class CsvAdapter<T> implements DataReader<T[]>, DataWriter<T[]> {
  private csvProcessor: CsvProcessor;
  private parseFn: (row: string[]) => T;
  private serializeFn: (item: T) => string[];

  constructor(
    parseFn: (row: string[]) => T,
    serializeFn: (item: T) => string[]
  ) {
    this.csvProcessor = new CsvProcessor();
    this.parseFn = parseFn;
    this.serializeFn = serializeFn;
  }

  read(): T[] {
    const csvData = this.csvProcessor.readCsv();
    const [_, ...rows] = csvData; // Skip header
    return rows.map(this.parseFn);
  }

  write(data: T[]): void {
    const header = Object.keys(data[0] as object);
    const rows = data.map(this.serializeFn);
    this.csvProcessor.writeCsv([header, ...rows]);
  }
}

// Usage
interface Person {
  name: string;
  age: number;
}

const personAdapter = new CsvAdapter<Person>(
  ([name, age]) => ({ name, age: parseInt(age) }),
  (person) => [person.name, person.age.toString()]
);

const people = personAdapter.read();
console.log(people); // [{name: "Alice", age: 30}, {name: "Bob", age: 25}]

personAdapter.write([...people, { name: "Charlie", age: 35 }]);        

Decorator

"You wrap an object with another object that adds new behavior."

Think of a basic cake. You can add frosting, sprinkles, and candles all without changing the original cake. You’re just decorating it.

  • The cake is the base object.
  • Frosting, sprinkles, and candles are decorators.

The Decorator pattern is a structural design pattern that allows behavior to be added to individual objects dynamically, without affecting the behavior of other objects from the same class. Example: A basic coffee becomes a fancy latte by adding milk, then sugar.


Key Characteristics

  1. Adds responsibilities to objects dynamically: Decorators provide a flexible alternative to subclassing for extending functionality.
  2. Transparent to the client: The decorated object can be used the same way as the original object (follows the Liskov Substitution Principle).
  3. Recursive composition: Decorators can be nested to add multiple behaviors.
  4. Alternative to subclassing: Avoids explosion of subclasses when you need many combinations of features.


Potential Pitfalls

  1. Overuse: Can lead to systems with lots of small objects that are hard to understand and debug.
  2. Complexity: Decorators can complicate the instantiation process since you need to wrap objects in multiple layers.
  3. Order dependency: The order of decorators matters, which can lead to subtle bugs if not managed properly.
  4. Type safety: In some implementations, decorators might make it harder to maintain type safety.
  5. Performance overhead: Each decorator adds a layer of indirection which might impact performance in critical paths.


Real-World Use Cases

  1. UI Components: Adding borders, scrollbars, or other decorations to visual components.
  2. Stream Processing: Adding compression, encryption, or buffering to I/O streams.
  3. Middleware: In web frameworks (like Express.js), middleware functions are decorators that add functionality to request handlers.
  4. Logging/Caching: Adding logging or caching behavior to service methods.
  5. Form Validation: Adding validation rules to form fields dynamically.


When to Use

  1. When you need to add responsibilities to objects dynamically and transparently.
  2. When you need to add responsibilities that can be withdrawn.
  3. When extension by subclassing is impractical (too many combinations would create a class explosion).
  4. When you want to keep new functionality separate (Single Responsibility Principle).


When to Avoid

  1. When the decorator hierarchy would be too complex and hard to maintain.
  2. When performance is critical (each decorator adds a small overhead).
  3. When the language provides simpler alternatives (e.g., mixins, traits, or composition).
  4. When the behavior you want to add shouldn't be optional or removable.

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

// Concrete Component
class SimpleCoffee implements Coffee {
    cost(): number {
        return 5;
    }

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

// Base Decorator
abstract class CoffeeDecorator implements Coffee {
    protected decoratedCoffee: Coffee;

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

    cost(): number {
        return this.decoratedCoffee.cost();
    }

    description(): string {
        return this.decoratedCoffee.description();
    }
}

// Concrete Decorators
class MilkDecorator extends CoffeeDecorator {
    cost(): number {
        return this.decoratedCoffee.cost() + 2;
    }

    description(): string {
        return this.decoratedCoffee.description() + ", with milk";
    }
}

class WhipDecorator extends CoffeeDecorator {
    cost(): number {
        return this.decoratedCoffee.cost() + 3;
    }

    description(): string {
        return this.decoratedCoffee.description() + ", with whip";
    }
}

class VanillaDecorator extends CoffeeDecorator {
    cost(): number {
        return this.decoratedCoffee.cost() + 4;
    }

    description(): string {
        return this.decoratedCoffee.description() + ", with vanilla";
    }
}

// Usage
let coffee: Coffee = new SimpleCoffee();
console.log(coffee.description(), coffee.cost());

coffee = new MilkDecorator(coffee);
console.log(coffee.description(), coffee.cost());

coffee = new WhipDecorator(coffee);
console.log(coffee.description(), coffee.cost());

coffee = new VanillaDecorator(coffee);
console.log(coffee.description(), coffee.cost());        
// Class decorator
function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}

// Method decorator
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    
    descriptor.value = function(...args: any[]) {
        console.log(`Called ${propertyKey} with args: ${JSON.stringify(args)}`);
        const result = originalMethod.apply(this, args);
        console.log(`Method ${propertyKey} returned: ${JSON.stringify(result)}`);
        return result;
    };
    
    return descriptor;
}

@sealed
class Calculator {
    @log
    add(a: number, b: number): number {
        return a + b;
    }
}

const calc = new Calculator();
calc.add(2, 3);        
interface DataService {
    fetchData(id: string): Promise<any>;
}

class RealDataService implements DataService {
    async fetchData(id: string): Promise<any> {
        // Simulate network request
        console.log(`Fetching data for ID: ${id}`);
        await new Promise(resolve => setTimeout(resolve, 1000));
        return { id, data: `Data for ${id}` };
    }
}

class CachingDecorator implements DataService {
    private cache: Map<string, Promise<any>> = new Map();
    private wrapped: DataService;

    constructor(service: DataService) {
        this.wrapped = service;
    }

    fetchData(id: string): Promise<any> {
        if (!this.cache.has(id)) {
            this.cache.set(id, this.wrapped.fetchData(id));
        }
        return this.cache.get(id)!;
    }
}

// Usage
const service: DataService = new CachingDecorator(new RealDataService());

// First call - will fetch
service.fetchData('1').then(console.log);

// Second call - will use cache
setTimeout(() => {
    service.fetchData('1').then(console.log);
}, 2000);        

Composite

"Treat groups of objects like a single object."

Think of a folder (directory) in your computer:

  • A folder can contain files or other folders.
  • You can perform actions like open, delete, or get size on both files and folders in the same way.

This is what the Composite Pattern models: parts and wholes having the same interface.

The Composite pattern is a structural design pattern that lets you compose objects into tree structures and then work with these structures as if they were individual objects. Example: A file system where files and folders can be handled the same way.


Key Characteristics

  1. Tree Structure: Organizes objects in a tree-like hierarchy
  2. Uniform Treatment: Allows clients to treat individual objects and compositions uniformly
  3. Recursive Composition: Components can contain other components recursively
  4. Component Interface: Defines common operations for both simple and complex objects


Potential Pitfalls

  1. Overgeneralization: Designing too broad an interface that makes leaf components implement irrelevant methods
  2. Type Safety: Type systems can't always enforce that leaves don't have children
  3. Performance Issues: Operations over large hierarchies can be slow
  4. Ordering Challenges: When order of child components matters, the composite must manage it
  5. Memory Management: Large hierarchies can consume significant memory


Real-World Use Cases

  1. Graphics Editors: Representing shapes that can contain other shapes (groups)
  2. File Systems: Directories that can contain files or other directories
  3. UI Components: Complex UI elements that contain other elements (windows with buttons, panels, etc.)
  4. Organization Structures: Representing hierarchies like departments and employees
  5. Document Structures: Documents with sections that contain paragraphs, images, etc.


When to Use

  • When you need to represent part-whole hierarchies of objects
  • When you want clients to treat individual objects and compositions uniformly
  • When your application has recursive tree structures
  • When you want to simplify client code that would otherwise need to distinguish between simple and complex elements


When to Avoid

  • When your hierarchy is simple and doesn't benefit from recursive composition
  • When the differences between individual and composite objects are too significant to treat uniformly
  • When performance is critical (the pattern can add overhead due to recursive operations)

// Component Interface
interface Graphic {
    move(x: number, y: number): void;
    draw(): void;
}

//Leaf Class (represents individual objects)
class Dot implements Graphic {
    constructor(private x: number, private y: number) {}

    move(x: number, y: number): void {
        this.x += x;
        this.y += y;
    }

    draw(): void {
        console.log(`Drawing dot at (${this.x}, ${this.y})`);
    }
}

class Circle implements Graphic {
    constructor(private x: number, private y: number, private radius: number) {}

    move(x: number, y: number): void {
        this.x += x;
        this.y += y;
    }

    draw(): void {
        console.log(`Drawing circle at (${this.x}, ${this.y}) with radius ${this.radius}`);
    }
}

//Composite Class (represents complex components)
class CompoundGraphic implements Graphic {
    private children: Graphic[] = [];

    add(child: Graphic): void {
        this.children.push(child);
    }

    remove(child: Graphic): void {
        const index = this.children.indexOf(child);
        if (index !== -1) {
            this.children.splice(index, 1);
        }
    }

    move(x: number, y: number): void {
        for (const child of this.children) {
            child.move(x, y);
        }
    }

    draw(): void {
        console.log("Drawing compound graphic:");
        for (const child of this.children) {
            child.draw();
        }
        console.log("Finished drawing compound graphic");
    }
}

//Client Code
function clientCode() {
    const dot1 = new Dot(1, 2);
    const dot2 = new Dot(3, 4);
    const circle = new Circle(5, 6, 10);

    const compound1 = new CompoundGraphic();
    compound1.add(dot1);
    compound1.add(dot2);
    compound1.add(circle);

    const dot3 = new Dot(7, 8);
    const compound2 = new CompoundGraphic();
    compound2.add(dot3);
    compound2.add(compound1);

    console.log("Drawing all graphics:");
    compound2.draw();

    console.log("\nMoving all graphics by (10, 10):");
    compound2.move(10, 10);
    compound2.draw();
}

clientCode();        

Facade

"Hide the mess behind a simple door."

Think of a universal remote control. Instead of pressing 10 buttons on your TV, speakers, and DVD player individually, you just press one button on the remote — it takes care of the rest.

The Facade pattern is a structural design pattern that provides a simplified interface to a complex subsystem, library, or framework. It hides the complexities of the underlying system and provides a cleaner, more understandable API to the client. Example: A remote control that hides the complexity of the TV’s internals.


Key Characteristics

  1. Simplifies complex systems: Provides a unified interface to a set of interfaces in a subsystem
  2. Decouples clients from subsystems: Clients interact only with the facade, not directly with subsystem components
  3. Promotes loose coupling: Subsystems can change without affecting clients as long as the facade interface remains stable
  4. Often represents entry points: Many frameworks and libraries use facade classes as entry points to their functionality


Potential Pitfalls

  1. God object anti-pattern: The facade can become too large if it tries to do too much
  2. Limited functionality: Clients might need more control than the facade provides
  3. Performance overhead: Additional layer of indirection can impact performance
  4. Over-abstraction: Might hide necessary complexity that clients actually need
  5. Maintenance burden: The facade needs to be updated whenever the subsystem changes


Real-World Use Cases

  1. JavaScript libraries/frameworks: jQuery is essentially a facade for DOM manipulation
  2. Payment processing: A facade can simplify complex payment gateways with multiple steps
  3. Database access: ORMs often provide a facade over raw database connections
  4. APIs: Wrapping complex third-party APIs with a simpler interface
  5. Microservices: Creating a unified interface for multiple microservices


When to Use

  1. When you need to provide a simple interface to a complex subsystem
  2. When you want to decouple clients from implementation details of subsystems
  3. When you need to organize a layered system with clear entry points
  4. When working with legacy code that's difficult to understand or modify


When to Avoid

  1. When the subsystem is simple enough that a facade would add unnecessary abstraction
  2. When clients need access to all the functionality of the subsystem (a facade would limit this)
  3. When performance is critical and the facade would add noticeable overhead
  4. When the subsystem interface is already clean and easy to use

// Subsystem components
class BluRayPlayer {
    turnOn() {
        console.log('BluRay player turning on...');
    }
    
    turnOff() {
        console.log('BluRay player turning off...');
    }
    
    play(movie: string) {
        console.log(`Playing movie: ${movie}`);
    }
}

class SoundSystem {
    turnOn() {
        console.log('Sound system turning on...');
    }
    
    turnOff() {
        console.log('Sound system turning off...');
    }
    
    setVolume(level: number) {
        console.log(`Setting volume to ${level}`);
    }
}

class Projector {
    turnOn() {
        console.log('Projector turning on...');
    }
    
    turnOff() {
        console.log('Projector turning off...');
    }
    
    setInput(source: string) {
        console.log(`Setting input to ${source}`);
    }
}

class Lights {
    dim(level: number) {
        console.log(`Dimming lights to ${level}%`);
    }
    
    brighten() {
        console.log('Bringing lights back to normal');
    }
}

// Facade
class HomeTheaterFacade {
    private bluRay: BluRayPlayer;
    private sound: SoundSystem;
    private projector: Projector;
    private lights: Lights;

    constructor(bluRay: BluRayPlayer, sound: SoundSystem, projector: Projector, lights: Lights) {
        this.bluRay = bluRay;
        this.sound = sound;
        this.projector = projector;
        this.lights = lights;
    }

    watchMovie(movie: string) {
        console.log('Getting ready to watch a movie...');
        
        this.lights.dim(10);
        this.projector.turnOn();
        this.projector.setInput('bluray');
        this.sound.turnOn();
        this.sound.setVolume(50);
        this.bluRay.turnOn();
        this.bluRay.play(movie);
    }

    endMovie() {
        console.log('Shutting down the home theater...');
        
        this.bluRay.turnOff();
        this.sound.turnOff();
        this.projector.turnOff();
        this.lights.brighten();
    }
}

// Client code
const bluRay = new BluRayPlayer();
const sound = new SoundSystem();
const projector = new Projector();
const lights = new Lights();

const homeTheater = new HomeTheaterFacade(bluRay, sound, projector, lights);

homeTheater.watchMovie('Inception');
// Later...
homeTheater.endMovie();        
//Advanced facade
// Complex API subsystem
class AuthService {
    login(username: string, password: string): string {
        console.log(`Authenticating user: ${username}`);
        return 'auth-token-123';
    }
}

class DataService {
    fetchData(token: string, endpoint: string): any {
        console.log(`Fetching data from ${endpoint} with token ${token}`);
        return { data: 'some data' };
    }
}

class CacheService {
    cacheData(key: string, data: any): void {
        console.log(`Caching data with key ${key}`);
    }
    
    getCachedData(key: string): any {
        console.log(`Retrieving cached data for key ${key}`);
        return null;
    }
}

// Facade
class ApiFacade {
    private auth: AuthService;
    private data: DataService;
    private cache: CacheService;

    constructor() {
        this.auth = new AuthService();
        this.data = new DataService();
        this.cache = new CacheService();
    }

    async getData(username: string, password: string, endpoint: string): Promise<any> {
        try {
            // Check cache first
            const cached = this.cache.getCachedData(endpoint);
            if (cached) return cached;
            
            // Authenticate
            const token = this.auth.login(username, password);
            
            // Fetch data
            const data = this.data.fetchData(token, endpoint);
            
            // Cache the data
            this.cache.cacheData(endpoint, data);
            
            return data;
        } catch (error) {
            console.error('API Error:', error);
            throw error;
        }
    }
}

// Client code
const api = new ApiFacade();

async function fetchUserData() {
    const data = await api.getData('user1', 'password123', '/users');
    console.log('Received data:', data);
}

fetchUserData();        

Bridge

"Separate the 'what' from the 'how'"

Imagine you're designing remote controls for TVs. You could have:

  • A basic remote
  • An advanced remote

And TVs like:

  • Samsung TV
  • Sony TV

Instead of creating classes like SamsungBasicRemote, SamsungAdvancedRemote, SonyBasicRemote, etc., you bridge the remote from the TV making them independent of each other.

The Bridge Pattern is a structural design pattern that decouples an abstraction from its implementation so the two can vary independently. This is useful when you have multiple dimensions that might change independently like shapes and rendering APIs, or devices and communication methods.

Abstraction → Implementation
   ↑                ↑
RefinedAbstraction  ConcreteImplementation        


Key Characteristics

  1. Decoupling: Separates abstraction (interface) from implementation
  2. Composition over inheritance: Uses object composition to delegate responsibilities
  3. Runtime binding: Implementation can be switched at runtime
  4. Reduced complexity: Avoids permanent binding between abstraction and implementation
  5. Single responsibility principle: Abstraction and implementation can evolve separately


Potential Pitfalls

  1. Over-engineering: Can add unnecessary complexity for simple scenarios
  2. Indirection confusion: May make code harder to understand due to multiple layers
  3. Performance overhead: The additional delegation can impact performance in critical systems
  4. Design rigidity: If not designed carefully, changes might require modifications in both abstraction and implementation
  5. Interface bloat: The abstraction interface might become too large if not properly designed


Real-World Use Cases

  1. GUI Frameworks: Separating window abstractions from platform-specific implementations
  2. Database Drivers: Decoupling database client APIs from vendor-specific implementations
  3. Device Control: As shown in the example above with remote controls and devices
  4. Cross-platform applications: Where you need to support multiple platforms with the same interface
  5. Plugin architectures: Where the core system needs to work with various plugins


When to Use

  1. When you want to divide a monolithic class that has several variants of some functionality
  2. When you need to extend a class in several independent dimensions
  3. When you need to switch implementations at runtime
  4. When you want to share an implementation among multiple objects
  5. When you want to hide implementation details from clients


When to Avoid

  1. For simple classes that won't change frequently
  2. When the abstraction and implementation will always be tightly coupled
  3. When performance is critical (the indirection adds a slight overhead)
  4. For problems that can be solved with simple inheritance
  5. When the number of implementations is fixed and won't change

// Implementation interface
interface Device {
    isEnabled(): boolean;
    enable(): void;
    disable(): void;
    getVolume(): number;
    setVolume(percent: number): void;
    getChannel(): number;
    setChannel(channel: number): void;
}

// Concrete Implementations
class TV implements Device {
    private on = false;
    private volume = 30;
    private channel = 1;

    isEnabled(): boolean {
        return this.on;
    }

    enable(): void {
        this.on = true;
    }

    disable(): void {
        this.on = false;
    }

    getVolume(): number {
        return this.volume;
    }

    setVolume(percent: number): void {
        this.volume = percent;
    }

    getChannel(): number {
        return this.channel;
    }

    setChannel(channel: number): void {
        this.channel = channel;
    }
}

class Radio implements Device {
    private on = false;
    private volume = 50;
    private channel = 100.5;

    isEnabled(): boolean {
        return this.on;
    }

    enable(): void {
        this.on = true;
    }

    disable(): void {
        this.on = false;
    }

    getVolume(): number {
        return this.volume;
    }

    setVolume(percent: number): void {
        this.volume = percent;
    }

    getChannel(): number {
        return this.channel;
    }

    setChannel(channel: number): void {
        this.channel = channel;
    }
}

// Abstraction
class RemoteControl {
    protected device: Device;

    constructor(device: Device) {
        this.device = device;
    }

    togglePower(): void {
        if (this.device.isEnabled()) {
            this.device.disable();
        } else {
            this.device.enable();
        }
    }

    volumeDown(): void {
        this.device.setVolume(this.device.getVolume() - 10);
    }

    volumeUp(): void {
        this.device.setVolume(this.device.getVolume() + 10);
    }

    channelDown(): void {
        this.device.setChannel(this.device.getChannel() - 1);
    }

    channelUp(): void {
        this.device.setChannel(this.device.getChannel() + 1);
    }
}

// Refined Abstraction
class AdvancedRemoteControl extends RemoteControl {
    mute(): void {
        this.device.setVolume(0);
    }

    record(): void {
        console.log("Recording current channel");
    }
}

// Usage
const tv = new TV();
const remote = new RemoteControl(tv);
remote.togglePower();
remote.volumeUp();

const radio = new Radio();
const advancedRemote = new AdvancedRemoteControl(radio);
advancedRemote.mute();
        
//Advanced Example with Generics
// Implementation interface with generics
interface Renderer<T> {
    renderShape(shape: T): string;
}

// Concrete Implementations
class VectorRenderer implements Renderer<Shape> {
    renderShape(shape: Shape): string {
        return `Drawing ${shape.getName()} as vector graphics`;
    }
}

class RasterRenderer implements Renderer<Shape> {
    renderShape(shape: Shape): string {
        return `Drawing ${shape.getName()} as pixels`;
    }
}

// Abstraction
abstract class Shape {
    protected renderer: Renderer<Shape>;

    constructor(renderer: Renderer<Shape>) {
        this.renderer = renderer;
    }

    abstract draw(): string;
    abstract getName(): string;
}

// Refined Abstractions
class Circle extends Shape {
    constructor(renderer: Renderer<Shape>) {
        super(renderer);
    }

    draw(): string {
        return this.renderer.renderShape(this);
    }

    getName(): string {
        return "Circle";
    }
}

class Square extends Shape {
    constructor(renderer: Renderer<Shape>) {
        super(renderer);
    }

    draw(): string {
        return this.renderer.renderShape(this);
    }

    getName(): string {
        return "Square";
    }
}

// Usage
const vectorRenderer = new VectorRenderer();
const rasterRenderer = new RasterRenderer();

const circle = new Circle(vectorRenderer);
console.log(circle.draw()); // "Drawing Circle as vector graphics"

const square = new Square(rasterRenderer);
console.log(square.draw()); // "Drawing Square as pixels"
        

Flyweight

"Share objects to save memory."

Imagine a text editor rendering a massive document. Each character has formatting like font, size, and style. Instead of creating a new object for every character, we can reuse the same objects for characters with the same formatting.

The Flyweight pattern is a structural design pattern that minimizes memory usage by sharing as much data as possible with similar objects. It's particularly useful when you need to create a large number of similar objects that would otherwise consume too much memory.


Key Characteristics

  • Shared State: Divides object properties into intrinsic (shared) and extrinsic (unique) states
  • Object Pooling: Maintains a pool of reusable objects
  • Memory Efficiency: Reduces memory footprint by sharing common data
  • Immutable Shared State: Shared data should be immutable to prevent accidental changes


Potential Pitfalls

  1. Overhead of Managing Shared Objects: The Flyweight factory adds some overhead that might not be worth it for small numbers of objects.
  2. Thread Safety Issues: In multi-threaded environments, shared flyweight objects need to be thread-safe.
  3. Increased Complexity: The pattern can make the code more complex by separating intrinsic and extrinsic states.
  4. Not Suitable for All Scenarios: If objects have mostly unique state, the pattern provides little benefit.
  5. Cache Management: Without proper cache management, the flyweight pool might grow indefinitely.


Real-World Use Cases

  1. Text Processing: Character formatting in word processors (shared character objects with different positions) and Rich text editors where font information can be shared.
  2. Game Development: Particle systems where many similar particles are rendered and Tree or grass rendering in game environments.
  3. Graphical Applications: Drawing applications with many similar shapes and Icon rendering where same icons appear multiple times.
  4. Networking: Connection pooling in database applications and Socket management in server applications.


When to Use

  • When your application needs to create a large number of similar objects
  • When memory usage is a primary concern due to object quantity
  • When most of each object's state can be made extrinsic (context-specific)
  • When the application doesn't depend on object identity


When to Avoid

  • When the number of distinct shared objects would be nearly as large as the number of objects you're trying to create
  • When the pattern would introduce unnecessary complexity to your code
  • When object identity is important for your application logic
  • When the extrinsic state would require complex synchronization in multi-threaded environments

// Flyweight interface
interface Flyweight {
  operation(extrinsicState: string): void;
}

// Concrete Flyweight
class ConcreteFlyweight implements Flyweight {
  private intrinsicState: string;

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

  operation(extrinsicState: string): void {
    console.log(`ConcreteFlyweight: Intrinsic (${this.intrinsicState}) and Extrinsic (${extrinsicState})`);
  }
}

// Flyweight Factory
class FlyweightFactory {
  private flyweights: {[key: string]: Flyweight} = {};

  getFlyweight(key: string): Flyweight {
    if (!(key in this.flyweights)) {
      console.log(`Creating new flyweight for key: ${key}`);
      this.flyweights[key] = new ConcreteFlyweight(key);
    }
    return this.flyweights[key];
  }

  countFlyweights(): number {
    return Object.keys(this.flyweights).length;
  }
}

// Client code
const factory = new FlyweightFactory();

function addCharacter(factory: FlyweightFactory, char: string, font: string, size: number) {
  const flyweight = factory.getFlyweight(char);
  flyweight.operation(`Font: ${font}, Size: ${size}`);
}

// Simulate text processing
addCharacter(factory, 'A', 'Helvetica', 12);
addCharacter(factory, 'B', 'Times New Roman', 14);
addCharacter(factory, 'A', 'Helvetica', 12); // Reuses existing 'A' flyweight
addCharacter(factory, 'C', 'Arial', 16);
addCharacter(factory, 'B', 'Verdana', 14); // Reuses existing 'B' flyweight

console.log(`Total flyweights created: ${factory.countFlyweights()}`);        
//Advanced example: game tree
// Tree types - intrinsic state
enum TreeType {
  OAK,
  MAPLE,
  PINE
}

// Flyweight
class TreeModel {
  constructor(
    public type: TreeType,
    public mesh: string, // 3D model data
    public texture: string
  ) {}

  render(x: number, y: number, height: number): void {
    console.log(`Rendering ${TreeType[this.type]} tree at (${x}, ${y}) with height ${height}`);
  }
}

// Flyweight Factory
class TreeModelFactory {
  private static cache: Map<TreeType, TreeModel> = new Map();

  static getTreeModel(type: TreeType): TreeModel {
    if (!this.cache.has(type)) {
      // In a real app, these would be loaded from files
      const mesh = `${TreeType[type]}_mesh.obj`;
      const texture = `${TreeType[type]}_texture.png`;
      this.cache.set(type, new TreeModel(type, mesh, texture));
      console.log(`Created new TreeModel for ${TreeType[type]}`);
    }
    return this.cache.get(type)!;
  }
}

// Context - contains extrinsic state
class Tree {
  constructor(
    private model: TreeModel,
    private x: number,
    private y: number,
    private height: number
  ) {}

  render(): void {
    this.model.render(this.x, this.y, this.height);
  }
}

// Client code
const forest: Tree[] = [];

// Create 1000 trees with only 3 models
for (let i = 0; i < 1000; i++) {
  const type = Math.floor(Math.random() * 3); // Random tree type
  const treeModel = TreeModelFactory.getTreeModel(type);
  const tree = new Tree(
    treeModel,
    Math.random() * 1000, // x
    Math.random() * 1000, // y
    5 + Math.random() * 20 // height
  );
  forest.push(tree);
}

// Render all trees
forest.forEach(tree => tree.render());

console.log(`Total tree models created: ${TreeModelFactory['cache'].size}`);        

Proxy

"A middleman who controls access."

Think of it like a personal assistant: the assistant can handle tasks for you, decide who gets access to you, or log interactions on your behalf.

The Proxy Pattern is a structural design pattern that provides a surrogate or placeholder for another object to control access to it. Example: A security guard standing in for access to a private vault.


Key Characteristics

  1. Controlled Access: The proxy controls access to the original object
  2. Same Interface: The proxy implements the same interface as the original object
  3. Lazy Initialization: Can delay creation/initialization of expensive objects until needed
  4. Additional Functionality: Can add extra behavior before or after forwarding requests to the original object
  5. Protection: Can protect the original object from unauthorized access


Types of Proxies

  1. Virtual Proxy: Controls access to a resource-intensive object
  2. Protection Proxy: Controls access to sensitive objects
  3. Remote Proxy: Represents an object in a different address space (like a local representative for a remote object)
  4. Smart Reference Proxy: Adds additional actions when an object is accessed


Potential Pitfalls

  1. Performance Overhead: Additional layer can introduce latency
  2. Complexity: Can make code harder to understand if overused
  3. Debugging Difficulty: Proxy behavior might mask real object issues
  4. Over-Engineering: Not always necessary for simple scenarios
  5. Tight Coupling: Poorly designed proxies might become too dependent on concrete implementations


Real-World Use Cases

  1. Lazy Loading: Delay loading of large objects (images, videos) until needed
  2. Access Control: Restrict access to sensitive objects (protection proxy)
  3. Logging: Track method calls and access to objects
  4. Caching: Cache results of expensive operations
  5. Remote Objects: Local representation of remote services (like API clients)
  6. Validation: Validate requests before passing them to the real object


When to Use

  1. When you need lazy initialization of expensive objects
  2. When you want to control access to an object
  3. When you need to add functionality around object access (logging, caching)
  4. When working with remote resources
  5. When you need to implement access rights management


When to Avoid

  1. When performance is critical and you can't afford the overhead
  2. When the object is simple and doesn't need additional control
  3. When direct access is sufficient for your needs
  4. When it would unnecessarily complicate your codebase

// Subject interface
interface Image {
    display(): void;
}

// RealSubject
class RealImage implements Image {
    private filename: string;

    constructor(filename: string) {
        this.filename = filename;
        this.loadFromDisk();
    }

    private loadFromDisk(): void {
        console.log(`Loading image: ${this.filename}`);
    }

    display(): void {
        console.log(`Displaying image: ${this.filename}`);
    }
}

// Proxy
class ImageProxy implements Image {
    private realImage: RealImage | null = null;
    private filename: string;

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

    display(): void {
        if (this.realImage === null) {
            this.realImage = new RealImage(this.filename);
        }
        this.realImage.display();
    }
}

// Client code
const image1: Image = new ImageProxy("photo1.jpg");
const image2: Image = new ImageProxy("photo2.jpg");

// Image will be loaded only when display() is called
image1.display(); // Loads and displays
image1.display(); // Only displays (already loaded)
image2.display(); // Loads and displays        
//Advanced Example: Protection Proxy
// Subject interface
interface Database {
    query(sql: string): any[];
}

// RealSubject
class RealDatabase implements Database {
    query(sql: string): any[] {
        console.log(`Executing query: ${sql}`);
        // Actual database query logic would go here
        return [{ id: 1, name: "Example Data" }];
    }
}

// Proxy
class DatabaseProxy implements Database {
    private realDatabase: RealDatabase;
    private userRole: string;

    constructor(userRole: string) {
        this.realDatabase = new RealDatabase();
        this.userRole = userRole;
    }

    private isAllowed(query: string): boolean {
        // Simple authorization check
        if (this.userRole === "admin") return true;
        if (query.includes("DELETE") || query.includes("UPDATE")) {
            return false;
        }
        return true;
    }

    query(sql: string): any[] {
        if (!this.isAllowed(sql)) {
            throw new Error("Access denied: You don't have permission to execute this query");
        }
        
        console.log(`Logging query by ${this.userRole}: ${sql}`);
        return this.realDatabase.query(sql);
    }
}

// Client code
const adminDb: Database = new DatabaseProxy("admin");
const userDb: Database = new DatabaseProxy("user");

console.log(adminDb.query("SELECT * FROM users")); // Works
console.log(userDb.query("SELECT * FROM products")); // Works
console.log(userDb.query("DELETE FROM users WHERE id = 1")); // Throws error        

Behavioral Patterns – Making Your Code Communicate

“Like social rules for your code: who talks to whom, and how.”

How different parts of your app talk to each other without causing chaos or being too tightly coupled. These patterns manage interactions between objects, ensuring they collaborate cleanly and flexibly. Example scenarios are below:

  • Event handling in UIs
  • Game engines and rules engines
  • Chat systems, undo features, AI behaviors


Observer

“Let me know when that changes.”

Imagine you're subscribed to a newsletter:

  • The newsletter system is the subject.
  • You're the observer.
  • When there’s a new post, you get an email (notification).

The Observer Pattern is like a group chat.

  • One person (the subject) posts updates.
  • Everyone in the chat (observers) gets notified when there's a new message.

In software:

  • The Subject maintains a list of observers.
  • When something changes, it notifies all observers.

Example: Subscribing to changes in a data store or UI state.


Key Characteristics

  1. Subject (Observable): Maintains a list of observers and provides methods to add/remove observers
  2. Observers: Objects that want to be notified of changes in the subject
  3. Loose coupling: Subjects know nothing about observers beyond that they implement a simple interface
  4. Push vs Pull: Observers can either receive just a notification (push) or query the subject for details (pull)
  5. Dynamic relationships: Observers can be added/removed at runtime


Potential Pitfalls

  1. Memory leaks: Forgetting to unsubscribe observers can prevent garbage collection
  2. Performance issues: Notifying many observers can be expensive
  3. Unexpected updates: Observers may trigger cascading updates
  4. Debugging complexity: Hard to trace notification chains
  5. Order dependence: Notification order may matter but isn't guaranteed
  6. Over-notification: Subjects may send more notifications than needed


Real-World Use Cases

  1. Event handling systems: DOM event listeners in browsers
  2. Pub/Sub systems: Message queues, WebSocket communications
  3. MVC architectures: Model changes notify views to update
  4. Stock market applications: Price changes notify investors
  5. Social media feeds: New posts notify followers
  6. Weather stations: Weather changes notify displays


When to Use

  1. When changes to one object require changing others, and you don't know how many objects need to be changed
  2. When an object should notify others without knowing who they are (loose coupling)
  3. When you need to broadcast notifications to multiple recipients
  4. When you need dynamic relationships between objects that can change at runtime


When to Avoid

  1. When the notification chain becomes too complex and hard to maintain
  2. When performance is critical and the overhead of notifications is too high
  3. When a simple callback would suffice for one-to-one communication
  4. When the order of notifications is important but can't be guaranteed
  5. When you need to know exactly which observer handled the notification

// Observer interface
interface Observer {
  update(data: any): void;
}

// Subject (Observable) interface
interface Subject {
  subscribe(observer: Observer): void;
  unsubscribe(observer: Observer): void;
  notify(data: any): void;
}

// Concrete Subject
class NewsAgency implements Subject {
  private observers: Observer[] = [];
  private latestNews: string = '';

  subscribe(observer: Observer): void {
    if (!this.observers.includes(observer)) {
      this.observers.push(observer);
    }
  }

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

  notify(data: any): void {
    for (const observer of this.observers) {
      observer.update(data);
    }
  }

  setNews(news: string): void {
    this.latestNews = news;
    this.notify(news);
  }

  getLatestNews(): string {
    return this.latestNews;
  }
}

// Concrete Observer
class NewsChannel implements Observer {
  private name: string;
  private latestNews: string = '';

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

  update(data: any): void {
    this.latestNews = data;
    this.display();
  }

  display(): void {
    console.log(`${this.name} received news: ${this.latestNews}`);
  }
}

// Usage
const bbc = new NewsChannel('BBC');
const cnn = new NewsChannel('CNN');

const newsAgency = new NewsAgency();
newsAgency.subscribe(bbc);
newsAgency.subscribe(cnn);

newsAgency.setNews('Breaking News: TypeScript 5.0 released!');
// Output:
// BBC received news: Breaking News: TypeScript 5.0 released!
// CNN received news: Breaking News: TypeScript 5.0 released!

newsAgency.unsubscribe(cnn);
newsAgency.setNews('Update: New features in TS 5.0');
// Output:
// BBC received news: Update: New features in TS 5.0        
//Advanced implementation with generics
// Generic Observable implementation
class Observable<T> {
  private observers: ((value: T) => void)[] = [];

  subscribe(observer: (value: T) => void): () => void {
    this.observers.push(observer);
    return () => {
      this.observers = this.observers.filter(obs => obs !== observer);
    };
  }

  notify(value: T): void {
    for (const observer of this.observers) {
      observer(value);
    }
  }
}

// Usage example
const temperatureObservable = new Observable<number>();

const unsubscribe = temperatureObservable.subscribe(temp => {
  console.log(`Current temperature: ${temp}°C`);
});

temperatureObservable.notify(23.5); // Logs: Current temperature: 23.5°C
temperatureObservable.notify(24.1); // Logs: Current temperature: 24.1°C

// Later...
unsubscribe(); // Stop receiving updates        


Strategy

“Swap logic without changing the object.”

Imagine a character in a game that can attack using a sword, bow, or magic. Instead of writing separate classes for each type of character, you use the Strategy Pattern to inject the attack behavior dynamically. Example: Different payment methods (credit card, PayPal, Bitcoin) in a checkout process.

The Strategy Pattern is a behavioral design pattern that enables selecting an algorithm's behavior at runtime. It defines a family of algorithms, encapsulates each one, and makes them interchangeable. This pattern is particularly useful when you have multiple ways to perform an action and want to switch between them easily without changing the code that uses the algorithm.


Key Characteristics

  1. Algorithm Encapsulation: Each strategy encapsulates a specific algorithm or behavior.
  2. Interchangeability: Strategies can be swapped at runtime.
  3. Decoupling: The context class is decoupled from the concrete strategies.
  4. Open/Closed Principle: New strategies can be added without modifying existing code.


Potential Pitfalls

  1. Over-engineering: Creating strategies for simple variations that could be handled with simple conditionals.
  2. Strategy explosion: Too many small strategy classes can make the system harder to understand.
  3. Client awareness: Clients must understand differences between strategies to select the appropriate one.
  4. Communication overhead: Strategies may need to pass extra data they don't use to other strategies via the context.


Real-World Use Cases

  1. Payment Processing: Different payment methods (credit card, PayPal, crypto) as strategies.
  2. Navigation Systems: Different route calculation algorithms (fastest, shortest, scenic).
  3. Data Compression: Different compression algorithms (ZIP, RAR, 7z).
  4. Sorting Algorithms: Different sort strategies (quick sort, merge sort) selectable at runtime.
  5. Discount Systems: Different discount calculation strategies (percentage, fixed amount, seasonal).


When to Use

  1. When you need different variants of an algorithm within an object.
  2. When you have many similar classes that only differ in their behavior.
  3. When you want to isolate the business logic of a class from implementation details of algorithms.
  4. When your class has a massive conditional operator that switches between different variants of the same algorithm.


When to Avoid

  1. Simple algorithms: If you only have one algorithm that rarely changes, the pattern may add unnecessary complexity.
  2. Tight coupling needed: When algorithms need access to internal state of the context class.
  3. Performance-critical code: The pattern adds a layer of indirection which might impact performance.

// Strategy Interface
interface PaymentStrategy {
    pay(amount: number): void;
}

// Concrete Strategies
class CreditCardPayment implements PaymentStrategy {
    constructor(private cardNumber: string, private cvv: string) {}

    pay(amount: number): void {
        console.log(`Paying $${amount} using credit card ${this.cardNumber.substring(0, 4)}...`);
        // Actual credit card payment logic would go here
    }
}

class PayPalPayment implements PaymentStrategy {
    constructor(private email: string) {}

    pay(amount: number): void {
        console.log(`Paying $${amount} using PayPal account ${this.email}`);
        // Actual PayPal payment logic would go here
    }
}

class BankTransferPayment implements PaymentStrategy {
    constructor(private accountNumber: string) {}

    pay(amount: number): void {
        console.log(`Paying $${amount} via bank transfer to account ${this.accountNumber}`);
        // Actual bank transfer logic would go here
    }
}

// Context Class
class ShoppingCart {
    private paymentStrategy: PaymentStrategy;

    constructor(private items: {name: string, price: number}[] = []) {}

    setPaymentStrategy(strategy: PaymentStrategy): void {
        this.paymentStrategy = strategy;
    }

    checkout(): void {
        const total = this.items.reduce((sum, item) => sum + item.price, 0);
        if (!this.paymentStrategy) {
            throw new Error("Payment strategy not set");
        }
        this.paymentStrategy.pay(total);
    }

    addItem(item: {name: string, price: number}): void {
        this.items.push(item);
    }
}

// Usage
const cart = new ShoppingCart();
cart.addItem({name: "Laptop", price: 999});
cart.addItem({name: "Mouse", price: 25});

// Select payment strategy at runtime
cart.setPaymentStrategy(new CreditCardPayment("1234567890123456", "123"));
cart.checkout();

// Change strategy
cart.setPaymentStrategy(new PayPalPayment("user@example.com"));
cart.checkout();
        
// Generic Strategy Interface
interface TransformationStrategy<T, R> {
    execute(input: T): R;
}

// Concrete Strategies
class StringToUpperCase implements TransformationStrategy<string, string> {
    execute(input: string): string {
        return input.toUpperCase();
    }
}

class StringToLowerCase implements TransformationStrategy<string, string> {
    execute(input: string): string {
        return input.toLowerCase();
    }
}

class StringReverse implements TransformationStrategy<string, string> {
    execute(input: string): string {
        return input.split('').reverse().join('');
    }
}

// Context Class with Dependency Injection
class TextProcessor {
    constructor(private strategy: TransformationStrategy<string, string>) {}

    setStrategy(strategy: TransformationStrategy<string, string>): void {
        this.strategy = strategy;
    }

    process(text: string): string {
        return this.strategy.execute(text);
    }
}

// Usage
const processor = new TextProcessor(new StringToUpperCase());
console.log(processor.process("Hello Strategy!")); // "HELLO STRATEGY!"

processor.setStrategy(new StringReverse());
console.log(processor.process("Hello Strategy!")); // "!yegarteS olleH"        

Command

"Encapsulate actions as objects."

The Command Pattern turns a request into a standalone object that contains all the information about the request — what needs to be done, who should do it, and when. This transformation lets you parameterize methods with different requests, delay or queue a request's execution, and support undoable operations.

It decouples sender (invoker) from the receiver (executor) of a request. Example: Undo/redo systems in a text editor.


Key Characteristics

  1. Decouples the object that invokes the operation from the one that knows how to perform it
  2. Encapsulates a request as an object
  3. Allows for undo/redo functionality
  4. Supports queuing and logging requests
  5. Enables composite commands (macros)


Potential Pitfalls

  1. Overhead: Each command is a separate class, which can lead to class explosion
  2. Complexity: Can make simple systems more complex than necessary
  3. Memory Usage: Maintaining history for undo/redo can consume significant memory
  4. Partial Undo: Implementing undo for only some commands can lead to inconsistent behavior
  5. Tight Coupling: If commands are too aware of receivers, it can reduce flexibility


Real-World Use Cases

  1. GUI Buttons and Menu Items: Each button click or menu selection can be represented as a command object
  2. Undo/Redo Functionality: Text editors, graphic editors where you need to reverse operations
  3. Transactional Systems: Where you need to execute a series of operations as a single atomic operation
  4. Job Queues: Scheduling tasks to be executed at different times
  5. Remote Controls: Smart home systems where commands need to be sent to different devices
  6. Multi-level Operations: Wizards or multi-step processes where you might need to rollback


When to Use

  • When you need to parameterize objects with operations
  • When you need to queue operations, schedule their execution, or execute them remotely
  • When you need to implement reversible operations (undo/redo)
  • When you want to structure a system around high-level operations built on primitive operations
  • When you need to support logging changes or auditing


When to Avoid

  • For simple operations that don't need undo/redo, queuing, or logging
  • When performance is critical (the pattern adds some overhead)
  • When the operations are always executed immediately and don't need to be encapsulated
  • When the codebase is small and the added complexity isn't justified

// Command interface
interface Command {
  execute(): void;
  undo?(): void; // Optional for undo functionality
}

// Receiver - knows how to perform the operations
class Light {
  turnOn(): void {
    console.log("Light is ON");
  }
  
  turnOff(): void {
    console.log("Light is OFF");
  }
}

// Concrete Command
class TurnOnLightCommand implements Command {
  private light: Light;

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

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

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

// Concrete Command
class TurnOffLightCommand implements Command {
  private light: Light;

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

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

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

// Invoker - asks the command to carry out the request
class RemoteControl {
  private command: Command | null = null;

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

  pressButton(): void {
    if (this.command) {
      this.command.execute();
    }
  }

  pressUndo(): void {
    if (this.command && this.command.undo) {
      this.command.undo();
    }
  }
}

// Client code
const light = new Light();
const remote = new RemoteControl();

const turnOn = new TurnOnLightCommand(light);
const turnOff = new TurnOffLightCommand(light);

remote.setCommand(turnOn);
remote.pressButton(); // Light is ON
remote.pressUndo();   // Light is OFF

remote.setCommand(turnOff);
remote.pressButton(); // Light is OFF
remote.pressUndo();   // Light is ON        
// Macro Command (Composite)
class MacroCommand implements Command {
  private commands: Command[] = [];

  add(command: Command): void {
    this.commands.push(command);
  }

  execute(): void {
    this.commands.forEach(command => command.execute());
  }

  undo(): void {
    // Undo in reverse order
    for (let i = this.commands.length - 1; i >= 0; i--) {
      if (this.commands[i].undo) {
        this.commands[i].undo();
      }
    }
  }
}

// Usage
const macro = new MacroCommand();
macro.add(turnOn);
macro.add(new TurnOnLightCommand(new Light())); // Another light

remote.setCommand(macro);
remote.pressButton(); // Both lights turn on
remote.pressUndo();   // Both lights turn off
        
// Generic command interface with typed receiver
interface Command<T> {
  execute(receiver: T): void;
  undo?(receiver: T): void;
}

// Receiver
class BankAccount {
  private balance: number = 0;

  deposit(amount: number): void {
    this.balance += amount;
    console.log(`Deposited ${amount}, balance is now ${this.balance}`);
  }

  withdraw(amount: number): boolean {
    if (this.balance >= amount) {
      this.balance -= amount;
      console.log(`Withdrew ${amount}, balance is now ${this.balance}`);
      return true;
    }
    console.log(`Failed to withdraw ${amount}, balance is ${this.balance}`);
    return false;
  }

  getBalance(): number {
    return this.balance;
  }
}

// Concrete Command
class DepositCommand implements Command<BankAccount> {
  constructor(private amount: number) {}

  execute(account: BankAccount): void {
    account.deposit(this.amount);
  }

  undo(account: BankAccount): void {
    account.withdraw(this.amount);
  }
}

// Concrete Command
class WithdrawCommand implements Command<BankAccount> {
  private succeeded: boolean = false;

  constructor(private amount: number) {}

  execute(account: BankAccount): void {
    this.succeeded = account.withdraw(this.amount);
  }

  undo(account: BankAccount): void {
    if (this.succeeded) {
      account.deposit(this.amount);
    }
  }
}

// Invoker
class BankAccountManager {
  private commands: Command<BankAccount>[] = [];
  private account: BankAccount;

  constructor(account: BankAccount) {
    this.account = account;
  }

  executeCommand(command: Command<BankAccount>): void {
    command.execute(this.account);
    this.commands.push(command);
  }

  undoLastCommand(): void {
    const command = this.commands.pop();
    if (command && command.undo) {
      command.undo(this.account);
    }
  }
}

// Usage
const account = new BankAccount();
const manager = new BankAccountManager(account);

manager.executeCommand(new DepositCommand(100)); // Deposited 100, balance is now 100
manager.executeCommand(new WithdrawCommand(50)); // Withdrew 50, balance is now 50
manager.executeCommand(new WithdrawCommand(200)); // Failed to withdraw 200, balance is 50

manager.undoLastCommand(); // Nothing happens (last withdraw failed)
manager.undoLastCommand(); // Deposited 50, balance is now 100        

Chain of Responsibility

“Pass a request down a chain until someone handles it.”

Imagine a customer complaint. First, it goes to the customer service agent, then to a manager, and finally to the CEO. Each one checks if they can handle it. If not, they forward it to the next level.

The Chain of Responsibility pattern is a behavioral design pattern that lets you pass requests along a chain of handlers, allowing multiple objects a chance to handle the request without knowing who will handle it.

This is especially useful when:

  • You want to decouple senders from receivers.
  • Multiple objects may handle a request, and the handler isn't known a priori.
  • You want to process objects in a sequence (like middleware, filters, etc.).
  • Example: Tech support moving a customer up the ladder from rep → supervisor → specialist.


Key Characteristics

  1. Decoupling: Senders of requests are decoupled from receivers
  2. Dynamic Chain: Handlers can be added or removed at runtime
  3. Flexible Processing: Requests can be processed by zero, one, or multiple handlers
  4. Order Matters: The sequence of handlers affects processing
  5. Termination: Processing can stop at any handler or continue down the chain


Potential Pitfalls

  1. Guaranteed Handling: There's no guarantee that a request will be handled unless the chain is properly designed
  2. Debugging Difficulty: It can be hard to trace which handler processed the request
  3. Performance Overhead: Long chains can introduce performance issues
  4. Cyclic References: Improper chain setup can lead to infinite loops
  5. Overuse: Using this pattern for simple conditional logic can be overengineering


Real-World Use Cases

  1. Middleware in Web Frameworks: Express.js middleware or ASP.NET Core request pipeline or Authentication/authorization chains
  2. Event Handling Systems: DOM event bubbling or GUI event propagation
  3. Logging Systems: Different log levels handled by different loggers or Chain of console logger → file logger → email logger
  4. Validation Chains: Form validation where each validator checks a specific rule or API request validation.
  5. Customer Support Systems: Tiered support (Level 1 → Level 2 → Level 3)


When to Use

  1. When you need to process a request by multiple handlers in a specific order
  2. When the set of handlers and their order should be configurable at runtime
  3. When you want to decouple the sender of a request from its receivers
  4. When you need to process different variants of requests in different ways without knowing the exact handler in advance


When to Avoid

  1. When the request should always be handled by a single handler
  2. When the chain of handlers is static and won't change at runtime
  3. When the order of processing doesn't matter
  4. When the overhead of traversing the chain impacts performance significantly

// Handler interface
interface Handler {
  setNext(handler: Handler): Handler;
  handle(request: string): string | null;
}

// Abstract handler implementing the chain mechanism
abstract class AbstractHandler implements Handler {
  private nextHandler: Handler | null = null;

  public setNext(handler: Handler): Handler {
    this.nextHandler = handler;
    return handler;
  }

  public handle(request: string): string | null {
    if (this.nextHandler) {
      return this.nextHandler.handle(request);
    }
    return null;
  }
}

// Concrete handlers
class MonkeyHandler extends AbstractHandler {
  public handle(request: string): string | null {
    if (request === 'Banana') {
      return `Monkey: I'll eat the ${request}.`;
    }
    return super.handle(request);
  }
}

class SquirrelHandler extends AbstractHandler {
  public handle(request: string): string | null {
    if (request === 'Nut') {
      return `Squirrel: I'll eat the ${request}.`;
    }
    return super.handle(request);
  }
}

class DogHandler extends AbstractHandler {
  public handle(request: string): string | null {
    if (request === 'MeatBall') {
      return `Dog: I'll eat the ${request}.`;
    }
    return super.handle(request);
  }
}

// Client code
function clientCode(handler: Handler) {
  const foods = ['Nut', 'Banana', 'Cup of coffee', 'MeatBall'];

  for (const food of foods) {
    console.log(`Client: Who wants a ${food}?`);
    
    const result = handler.handle(food);
    if (result) {
      console.log(`  ${result}`);
    } else {
      console.log(`  ${food} was left untouched.`);
    }
  }
}

// Building the chain
const monkey = new MonkeyHandler();
const squirrel = new SquirrelHandler();
const dog = new DogHandler();

monkey.setNext(squirrel).setNext(dog);

// Start the chain
console.log('Chain: Monkey > Squirrel > Dog\n');
clientCode(monkey);

console.log('\nSubchain: Squirrel > Dog\n');
clientCode(squirrel);        
interface Middleware {
  next(middleware: Middleware): Middleware;
  handle(request: any): any;
}

abstract class AbstractMiddleware implements Middleware {
  private nextMiddleware: Middleware | null = null;
  
  public next(middleware: Middleware): Middleware {
    this.nextMiddleware = middleware;
    return middleware;
  }
  
  public handle(request: any): any {
    if (this.nextMiddleware) {
      return this.nextMiddleware.handle(request);
    }
    return request;
  }
}

class AuthMiddleware extends AbstractMiddleware {
  public handle(request: any): any {
    console.log('AuthMiddleware: Checking authentication');
    if (!request.user) {
      throw new Error('Unauthorized');
    }
    return super.handle(request);
  }
}

class LoggingMiddleware extends AbstractMiddleware {
  public handle(request: any): any {
    console.log('LoggingMiddleware: Logging request');
    console.log(request);
    return super.handle(request);
  }
}

class ValidationMiddleware extends AbstractMiddleware {
  public handle(request: any): any {
    console.log('ValidationMiddleware: Validating data');
    if (!request.data) {
      throw new Error('Invalid data');
    }
    return super.handle(request);
  }
}

// Building the middleware chain
const auth = new AuthMiddleware();
const logging = new LoggingMiddleware();
const validation = new ValidationMiddleware();

auth.next(logging).next(validation);

// Using the middleware
try {
  const request = { user: 'admin', data: 'secret        

Mediator

“Centralize complex communication.”

The Mediator pattern is a behavioral design pattern that reduces coupling between components by having them communicate indirectly through a central mediator object instead of directly with each other.


Key Characteristics

  • Decouples components: Objects don't communicate directly but through the mediator
  • Centralized control: The mediator contains the communication logic
  • Simplified communication: Many-to-many relationships become one-to-many
  • Single responsibility: Components focus on their core functionality
  • Open/closed principle: New mediators can be introduced without changing components


Potential Pitfalls

  1. God Object: The mediator can become overly complex and turn into a "god object"
  2. Performance Issues: Centralized communication can become a bottleneck
  3. Debugging Complexity: Indirect communication can make debugging harder
  4. Over-Engineering: Simple systems might not need this level of abstraction
  5. Tight Coupling: Components become dependent on the mediator interface


Real-World Use Cases

  1. Chat Applications: The mediator handles message routing between users
  2. Air Traffic Control: Coordinates communication between aircraft
  3. GUI Components: Manages interactions between buttons, forms, dialogs
  4. Microservices Orchestration: Coordinates communication between services
  5. Game Development: Handles interactions between game objects/entities


When to Use

  • When communication between objects becomes complex and hard to maintain
  • When you want to reuse a component in a different context without changing its communication logic
  • When you need to centralize complex communication logic in one place
  • When you have many components communicating in many-to-many relationships


When to Avoid

  • When components are simple and have minimal communication
  • When performance is critical (mediators can become bottlenecks)
  • When the mediator itself becomes too complex and hard to maintain
  • When components naturally have direct relationships that shouldn't be abstracted

// Mediator Interface
interface Mediator {
  notify(sender: object, event: string, payload?: any): void;
}

// Concrete Mediator
class ConcreteMediator implements Mediator {
  private component1: Component1;
  private component2: Component2;

  constructor(c1: Component1, c2: Component2) {
    this.component1 = c1;
    this.component1.setMediator(this);
    this.component2 = c2;
    this.component2.setMediator(this);
  }

  public notify(sender: object, event: string, payload?: any): void {
    if (event === 'A') {
      console.log('Mediator reacts on A and triggers following operations:');
      this.component2.doC();
    }

    if (event === 'D') {
      console.log('Mediator reacts on D and triggers following operations:');
      this.component1.doB();
      this.component2.doC();
    }
  }
}

// Base Component
class BaseComponent {
  protected mediator: Mediator | null = null;

  public setMediator(mediator: Mediator): void {
    this.mediator = mediator;
  }
}

// Concrete Component 1
class Component1 extends BaseComponent {
  public doA(): void {
    console.log('Component 1 does A.');
    this.mediator?.notify(this, 'A');
  }

  public doB(): void {
    console.log('Component 1 does B.');
    this.mediator?.notify(this, 'B');
  }
}

// Concrete Component 2
class Component2 extends BaseComponent {
  public doC(): void {
    console.log('Component 2 does C.');
    this.mediator?.notify(this, 'C');
  }

  public doD(): void {
    console.log('Component 2 does D.');
    this.mediator?.notify(this, 'D');
  }
}

// Usage
const c1 = new Component1();
const c2 = new Component2();
const mediator = new ConcreteMediator(c1, c2);

console.log('Client triggers operation A.');
c1.doA();

console.log('\nClient triggers operation D.');
c2.doD();        
// Mediator Interface
interface ChatRoomMediator {
  showMessage(user: User, message: string): void;
}

// Concrete Mediator
class ChatRoom implements ChatRoomMediator {
  showMessage(user: User, message: string): void {
    const time = new Date().toLocaleTimeString();
    const sender = user.getName();
    
    console.log(`${time} [${sender}]: ${message}`);
  }
}

// Colleague
class User {
  constructor(
    private name: string,
    private chatMediator: ChatRoomMediator
  ) {}

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

  send(message: string): void {
    this.chatMediator.showMessage(this, message);
  }
}

// Usage
const chatRoom = new ChatRoom();

const user1 = new User('John Doe', chatRoom);
const user2 = new User('Jane Doe', chatRoom);

user1.send('Hi there!');
user2.send('Hey!');        

State

"Objects that change their behavior when their state changes."

Think of a media player with states like:

  • Playing
  • Paused
  • Stopped

Each state has different behavior for a button press. For example:

  • When playing: pause() → goes to paused state
  • When paused: play() → goes to playing state
  • When stopped: play() → goes to playing state.

The State Pattern is a behavioral design pattern that allows an object to change its behavior when its internal state changes. It appears as if the object changed its class. Example: A traffic light switching between red, yellow, and green.


Key Characteristics

  1. State Interface: Defines a common interface for all concrete states
  2. Concrete States: Implement state-specific behavior
  3. Context: Maintains a reference to the current state and delegates state-specific behavior to it
  4. State Transitions: States can transition the context to other states


Potential Pitfalls

  1. Over-engineering: Don't use for simple state management needs
  2. State explosion: Too many states can make the system complex
  3. Tight coupling: States often need to know about each other for transitions
  4. Memory usage: Each state is typically a separate object
  5. Debugging complexity: Flow can be harder to follow with many states


Real-World Use Cases

  1. Document Workflow: Draft -> Moderation -> Published states
  2. Order Processing: Pending -> Paid -> Shipped -> Delivered
  3. Game Development: Character states (Idle, Walking, Running, Jumping)
  4. UI Components: Button states (Normal, Hover, Pressed, Disabled)
  5. Network Connections: Connecting -> Connected -> Disconnecting -> Disconnected


When to Use

  1. When an object's behavior depends on its state and it must change its behavior at runtime
  2. When you have many conditional statements that change behavior based on the object's state
  3. When you need to manage state transitions cleanly
  4. When you want to avoid large monolithic conditionals or switch statements


When to Avoid

  1. When state transitions are simple and don't require complex behavior changes
  2. When the number of states is small and unlikely to grow
  3. When performance is critical (the pattern adds some overhead)
  4. When states don't have distinct behaviors

// State interface
interface VendingMachineState {
    insertMoney(amount: number): void;
    selectItem(item: string): void;
    dispenseItem(): void;
    returnMoney(): void;
}

// Concrete States
class NoMoneyState implements VendingMachineState {
    constructor(private vendingMachine: VendingMachine) {}

    insertMoney(amount: number): void {
        console.log(`Inserted $${amount}`);
        this.vendingMachine.setCurrentAmount(amount);
        this.vendingMachine.setState(new HasMoneyState(this.vendingMachine));
    }

    selectItem(): void {
        console.log("Please insert money first");
    }

    dispenseItem(): void {
        console.log("Please insert money first");
    }

    returnMoney(): void {
        console.log("No money to return");
    }
}

class HasMoneyState implements VendingMachineState {
    constructor(private vendingMachine: VendingMachine) {}

    insertMoney(amount: number): void {
        console.log(`Added $${amount} to current amount`);
        this.vendingMachine.addToCurrentAmount(amount);
    }

    selectItem(item: string): void {
        const itemPrice = this.vendingMachine.getItemPrice(item);
        if (itemPrice > this.vendingMachine.getCurrentAmount()) {
            console.log("Not enough money");
            return;
        }
        console.log(`Selected ${item}, price: $${itemPrice}`);
        this.vendingMachine.setSelectedItem(item);
        this.vendingMachine.setState(new ItemSelectedState(this.vendingMachine));
    }

    dispenseItem(): void {
        console.log("Please select an item first");
    }

    returnMoney(): void {
        console.log(`Returning $${this.vendingMachine.getCurrentAmount()}`);
        this.vendingMachine.setCurrentAmount(0);
        this.vendingMachine.setState(new NoMoneyState(this.vendingMachine));
    }
}

class ItemSelectedState implements VendingMachineState {
    constructor(private vendingMachine: VendingMachine) {}

    insertMoney(): void {
        console.log("Item already selected");
    }

    selectItem(): void {
        console.log("Item already selected");
    }

    dispenseItem(): void {
        const item = this.vendingMachine.getSelectedItem();
        const price = this.vendingMachine.getItemPrice(item);
        const change = this.vendingMachine.getCurrentAmount() - price;
        
        console.log(`Dispensing ${item}`);
        if (change > 0) {
            console.log(`Returning change: $${change}`);
        }
        
        this.vendingMachine.setCurrentAmount(0);
        this.vendingMachine.setSelectedItem("");
        this.vendingMachine.setState(new NoMoneyState(this.vendingMachine));
    }

    returnMoney(): void {
        console.log(`Cancelling selection, returning $${this.vendingMachine.getCurrentAmount()}`);
        this.vendingMachine.setCurrentAmount(0);
        this.vendingMachine.setSelectedItem("");
        this.vendingMachine.setState(new NoMoneyState(this.vendingMachine));
    }
}

// Context
class VendingMachine {
    private currentState: VendingMachineState;
    private currentAmount: number = 0;
    private selectedItem: string = "";
    private itemPrices: Record<string, number> = {
        "Soda": 1.50,
        "Chips": 1.00,
        "Candy": 0.75
    };

    constructor() {
        this.currentState = new NoMoneyState(this);
    }

    setState(state: VendingMachineState): void {
        this.currentState = state;
    }

    // State delegation methods
    insertMoney(amount: number): void {
        this.currentState.insertMoney(amount);
    }

    selectItem(item: string): void {
        this.currentState.selectItem(item);
    }

    dispenseItem(): void {
        this.currentState.dispenseItem();
    }

    returnMoney(): void {
        this.currentState.returnMoney();
    }

    // Getters and setters
    getCurrentAmount(): number {
        return this.currentAmount;
    }

    setCurrentAmount(amount: number): void {
        this.currentAmount = amount;
    }

    addToCurrentAmount(amount: number): void {
        this.currentAmount += amount;
    }

    getSelectedItem(): string {
        return this.selectedItem;
    }

    setSelectedItem(item: string): void {
        this.selectedItem = item;
    }

    getItemPrice(item: string): number {
        return this.itemPrices[item] || 0;
    }
}

// Usage
const vendingMachine = new VendingMachine();

vendingMachine.selectItem("Soda"); // "Please insert money first"
vendingMachine.insertMoney(2.00); // "Inserted $2"
vendingMachine.selectItem("Soda"); // "Selected Soda, price: $1.5"
vendingMachine.dispenseItem(); // "Dispensing Soda", "Returning change: $0.5"
        

Template Method

"Define the skeleton of an algorithm, but let subclasses fill in the steps."

Suppose you're building a data parser. All parsers do the following:

  1. Load file
  2. Parse data
  3. Validate data
  4. Save to DB

The structure is the same, but steps like parsing/validation vary depending on file type (e.g., CSV, JSON).

The Template Pattern is a behavioral design pattern that defines the skeleton of an algorithm in a method, deferring some steps to subclasses. It lets subclasses redefine certain steps of an algorithm without changing its structure.

Example: A data exporter that defines general steps, but customizes output format.


Key Characteristics

  1. Defines algorithm skeleton: The overall structure and sequence of operations is defined in the base class
  2. Protected operations: Specific steps are marked as abstract or virtual for subclasses to implement
  3. Inversion of control: The base class controls the flow, calling subclass methods when needed
  4. Minimizes code duplication: Common behavior is implemented once in the base class
  5. Hook operations: Optional steps that subclasses can override for additional behavior


Potential Pitfalls

  1. Overly rigid structure: The template can become too restrictive if requirements change
  2. Violating Liskov Substitution Principle: If subclasses radically change algorithm steps
  3. Too many hook methods: Can make the template confusing and harder to maintain
  4. Inversion of control confusion: Developers might not expect parent class to call their methods
  5. Overuse of inheritance: Can lead to deep class hierarchies that are hard to maintain


Real-World Use Cases

  1. Data processing pipelines: Different formats (CSV, JSON, XML) with similar processing steps
  2. Document generation: Various document types with common structure but different rendering
  3. Game development: Game loop with customizable initialization, update, and render steps
  4. Web frameworks: Request handling with common middleware but customizable handlers
  5. Cross-platform development: Platform-specific implementations of common operations


When to Use

  1. When you want to let clients extend only particular parts of a large algorithm
  2. When you have several classes that contain almost identical algorithms with minor differences
  3. When you need to control the sequence of operations while allowing customization
  4. When you want to avoid code duplication in multiple classes with similar behavior


When to Avoid

  1. When the algorithm is simple and unlikely to change
  2. When the number of steps in the algorithm is small and variations are minimal
  3. When subclassing would lead to too many small classes for minor variations
  4. When the algorithm steps vary too much between implementations (breaking the template structure)

// Abstract base class defining the template method
abstract class DataProcessor {
  // The template method defining the algorithm skeleton
  public processData(): void {
    this.readData();
    this.transformData();
    if (this.shouldFilter()) {
      this.filterData();
    }
    this.writeData();
  }

  // Abstract steps to be implemented by subclasses
  protected abstract readData(): void;
  protected abstract transformData(): void;
  protected abstract writeData(): void;

  // Hook method - optional step with default implementation
  protected filterData(): void {
    console.log("Default filtering applied");
  }

  // Hook method - can be overridden to change behavior
  protected shouldFilter(): boolean {
    return true;
  }
}

// Concrete implementation for CSV processing
class CsvDataProcessor extends DataProcessor {
  protected readData(): void {
    console.log("Reading data from CSV file");
  }

  protected transformData(): void {
    console.log("Transforming CSV data");
  }

  protected writeData(): void {
    console.log("Writing processed CSV data to destination");
  }

  protected shouldFilter(): boolean {
    return false; // CSV processing doesn't need filtering
  }
}

// Concrete implementation for JSON processing
class JsonDataProcessor extends DataProcessor {
  protected readData(): void {
    console.log("Reading data from JSON file");
  }

  protected transformData(): void {
    console.log("Transforming JSON data");
  }

  protected writeData(): void {
    console.log("Writing processed JSON data to destination");
  }

  protected filterData(): void {
    console.log("Applying custom JSON filtering");
  }
}

// Usage
const csvProcessor = new CsvDataProcessor();
csvProcessor.processData();

const jsonProcessor = new JsonDataProcessor();
jsonProcessor.processData();        
abstract class Beverage {
  // The template method - final to prevent overriding
  public final prepareBeverage(): void {
    this.boilWater();
    this.brew();
    this.pourInCup();
    if (this.customerWantsCondiments()) {
      this.addCondiments();
    }
  }

  // Abstract methods to be implemented by subclasses
  protected abstract brew(): void;
  protected abstract addCondiments(): void;

  // Common operations with default implementations
  protected boilWater(): void {
    console.log("Boiling water");
  }

  protected pourInCup(): void {
    console.log("Pouring into cup");
  }

  // Hook method - can be overridden
  protected customerWantsCondiments(): boolean {
    return true;
  }
}

class Coffee extends Beverage {
  protected brew(): void {
    console.log("Dripping coffee through filter");
  }

  protected addCondiments(): void {
    console.log("Adding sugar and milk");
  }

  protected customerWantsCondiments(): boolean {
    // Could be determined by user input in a real app
    return confirm("Would you like milk and sugar?");
  }
}

class Tea extends Beverage {
  protected brew(): void {
    console.log("Steeping the tea");
  }

  protected addCondiments(): void {
    console.log("Adding lemon");
  }
}

// Usage
const coffee = new Coffee();
coffee.prepareBeverage();

const tea = new Tea();
tea.prepareBeverage();        

Visitor

"Separate an algorithm from the object structure it works on."

Imagine you have a complex object structure (like a tree of nodes), and you want to perform various unrelated operations (e.g., rendering, exporting, analytics) on these objects. Adding all those operations directly into the classes clutters them and violates the Single Responsibility Principle.

The Visitor Pattern allows you to add these operations separately, without modifying the object structure.

The Visitor Pattern is a behavioral design pattern that lets you separate algorithms from the objects on which they operate. It allows adding new operations to existing object structures without modifying them.

Example: A tax calculator that visits different types of income records.


Key Characteristics

  1. Separation of Concerns: Keeps related operations together in visitor classes rather than spreading them across object classes
  2. Double Dispatch: Uses a technique where the operation executed depends on both the visitor type and the element type
  3. Open/Closed Principle: Allows adding new operations without changing the element classes
  4. Visitable Hierarchy: Works best with stable object structures that don't change often


Potential Pitfalls

  1. Breaking Encapsulation: Visitors often need access to internal details of elements they visit
  2. Maintainability: Adding new element types requires modifying all visitor interfaces and implementations
  3. Complexity: Can make simple operations unnecessarily complex
  4. Tight Coupling: Visitors and elements become tightly coupled through the visitor interface


Real-World Use Cases

  1. Document Processing: Different operations (rendering, spell-checking, etc.) on document elements (paragraphs, images, tables)
  2. Compiler Design: Operations like type-checking, code generation on AST nodes
  3. UI Components: Operations like rendering, hit-testing on UI elements
  4. Insurance Systems: Different calculations (risk assessment, premium calculation) on various policy types


When to Use

  1. When you need to perform many distinct and unrelated operations on objects in a complex structure
  2. When the object structure is stable but you need to frequently add new operations
  3. When you want to keep related operations together in one class rather than spreading across many classes
  4. When the classes defining the object structure rarely change but you often need to define new operations


When to Avoid

  1. When the object structure changes frequently (requires updating all visitors)
  2. For simple object hierarchies where direct methods would suffice
  3. When performance is critical (visitor pattern adds some overhead)
  4. When the operations naturally belong with the objects themselves

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

// Concrete Elements
class Circle implements Shape {
  constructor(public radius: number) {}

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

class Square implements Shape {
  constructor(public side: number) {}

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

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

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

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

class PerimeterCalculator implements ShapeVisitor {
  visitCircle(circle: Circle): void {
    const perimeter = 2 * Math.PI * circle.radius;
    console.log(`Perimeter of circle with radius ${circle.radius}: ${perimeter.toFixed(2)}`);
  }

  visitSquare(square: Square): void {
    const perimeter = 4 * square.side;
    console.log(`Perimeter of square with side ${square.side}: ${perimeter}`);
  }
}

// Usage
const shapes: Shape[] = [
  new Circle(5),
  new Square(4),
  new Circle(3)
];

const areaVisitor = new AreaCalculator();
const perimeterVisitor = new PerimeterCalculator();

console.log('Calculating areas:');
shapes.forEach(shape => shape.accept(areaVisitor));

console.log('\nCalculating perimeters:');
shapes.forEach(shape => shape.accept(perimeterVisitor));        
// Composite example
class ShapeGroup implements Shape {
  private shapes: Shape[] = [];

  add(shape: Shape): void {
    this.shapes.push(shape);
  }

  accept(visitor: ShapeVisitor): void {
    visitor.visitShapeGroup(this);
    this.shapes.forEach(shape => shape.accept(visitor));
  }
}

// Extended visitor interface
interface ShapeVisitor {
  visitCircle(circle: Circle): void;
  visitSquare(square: Square): void;
  visitShapeGroup(group: ShapeGroup): void;
}

// Updated AreaCalculator
class AreaCalculator implements ShapeVisitor {
  private totalArea = 0;

  visitCircle(circle: Circle): void {
    const area = Math.PI * circle.radius ** 2;
    this.totalArea += area;
    console.log(`Adding circle area: ${area.toFixed(2)}`);
  }

  visitSquare(square: Square): void {
    const area = square.side ** 2;
    this.totalArea += area;
    console.log(`Adding square area: ${area}`);
  }

  visitShapeGroup(group: ShapeGroup): void {
    console.log('Entering shape group');
  }

  getTotalArea(): number {
    return this.totalArea;
  }
}

// Usage with composite
const group = new ShapeGroup();
group.add(new Circle(5));
group.add(new Square(4));

const complexShape = new ShapeGroup();
complexShape.add(new Circle(3));
complexShape.add(group);

const areaVisitor = new AreaCalculator();
complexShape.accept(areaVisitor);
console.log(`Total area: ${areaVisitor.getTotalArea().toFixed(2)}`);        

Memento

"Save and restore the state of an object."

Think of an "undo" button in a text editor. Every time you type something, it saves a snapshot. When you click "undo", it restores the previous state without needing to know how the text was managed internally.. Example: Save/load feature in a game.

The Memento Pattern is a behavioral design pattern that lets you capture and store the current state of an object so it can be restored later, without exposing its internal structure.


Key Characteristics

  • Originator: The object whose state needs to be saved
  • Memento: The object that stores the state of the Originator
  • Caretaker: The object that keeps track of multiple mementos
  • Encapsulation: The Originator's internal state isn't exposed directly
  • Single Responsibility: State management is separated from the main object logic


Potential Pitfalls

  1. Memory consumption: Storing many mementos can consume significant memory
  2. Performance impact: Frequent state saving/restoring may affect performance
  3. Complexity: Adds additional classes and indirection to your codebase
  4. Shallow vs deep copy: Need to carefully consider whether to implement shallow or deep copying of state
  5. Serialization challenges: If mementos need to be persisted to disk, serialization can be complex


Real-World Use Cases

  1. Undo/Redo functionality in text editors or graphic applications
  2. Game save systems where player progress needs to be saved and restored
  3. Transaction rollbacks in database systems
  4. Version control systems that maintain history of file changes
  5. Browser session history allowing users to navigate back through pages


When to Use

  • When you need to implement snapshot functionality for an object's state
  • When direct access to an object's state would expose implementation details and break encapsulation
  • When you need to provide undo/redo capabilities in your application
  • When you want to maintain a history of states without coupling the originator to the storage mechanism


When to Avoid

  • When the object's state is very large or complex (consider command pattern instead)
  • When you don't need to maintain state history
  • When the overhead of creating and storing state snapshots outweighs the benefits
  • When the object's state changes very frequently, making mementos quickly outdated
  • When you can achieve the same functionality more simply with other patterns

// Memento class - stores the state of the Originator
class Memento {
    constructor(private state: string) {}

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

// Originator class - creates and restores mementos
class Originator {
    private state: string;

    constructor(state: string) {
        this.state = state;
        console.log(`Originator: Initial state is ${state}`);
    }

    doSomething(): void {
        console.log("Originator: Doing something important...");
        this.state = this.generateRandomString(30);
        console.log(`Originator: State changed to ${this.state}`);
    }

    private generateRandomString(length: number): string {
        const charSet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
        return Array.from({length}, () => 
            charSet[Math.floor(Math.random() * charSet.length)]
        ).join('');
    }

    save(): Memento {
        return new Memento(this.state);
    }

    restore(memento: Memento): void {
        this.state = memento.getState();
        console.log(`Originator: State restored to ${this.state}`);
    }
}

// Caretaker class - manages mementos
class Caretaker {
    private mementos: Memento[] = [];
    private originator: Originator;

    constructor(originator: Originator) {
        this.originator = originator;
    }

    backup(): void {
        console.log("\nCaretaker: Saving Originator's state...");
        this.mementos.push(this.originator.save());
    }

    undo(): void {
        if (!this.mementos.length) return;
        
        const memento = this.mementos.pop();
        console.log(`Caretaker: Restoring state to: ${memento?.getState()}`);
        this.originator.restore(memento!);
    }

    showHistory(): void {
        console.log("Caretaker: Here's the list of mementos:");
        for (const memento of this.mementos) {
            console.log(memento.getState());
        }
    }
}

// Client code
const originator = new Originator("Initial state");
const caretaker = new Caretaker(originator);

caretaker.backup();
originator.doSomething();

caretaker.backup();
originator.doSomething();

caretaker.backup();
originator.doSomething();

console.log("");
caretaker.showHistory();

console.log("\nClient: Now, let's rollback!\n");
caretaker.undo();
caretaker.undo();        

Interpreter

"Define your own language or grammar and interpret it."

Used when building simple scripting or command languages. Example: A calculator or query engine parsing custom user input.

The Interpreter Pattern is a behavioral design pattern used to define a grammar for a language and provide an interpreter to interpret sentences in that language. It’s commonly used for parsing expressions, rules, or custom scripting languages.


Key Characteristics

  1. Grammar Representation: Defines a grammar for a language using a class hierarchy.
  2. Interpretation: Provides a way to evaluate language grammar or expressions.
  3. Abstract Syntax Tree (AST): Uses a tree structure to represent sentences in the language.
  4. Extensibility: Easy to extend the grammar by adding new expression classes.
  5. Separation of Concerns: Separates grammar rules from interpretation logic.


Potential Pitfalls

  • Performance: Can be slow for complex interpretations
  • Maintainability: Complex grammars can lead to many classes
  • Limited Scope: Not suitable for full programming languages
  • Error Handling: Can be challenging to provide good error messages


Real-World Use Cases

  • Regular expression interpreters
  • SQL query interpreters
  • Mathematical expression evaluators
  • Business rule engines
  • Configuration file interpreters
  • Domain-Specific Languages (DSLs)


When to Use

  • When you need to implement a simple language or grammar
  • When the grammar is relatively simple (complex grammars may require parsers/compilers)
  • When efficiency is not a critical concern
  • When you want to provide a way to extend the language


When to Avoid

  • For complex grammars (use parser generators instead)
  • When performance is critical (interpreters can be slow)
  • When the grammar changes frequently (can lead to many class changes)

// Abstract Expression
interface Expression {
    interpret(): number;
}

// Terminal Expression
class NumberExpression implements Expression {
    constructor(private value: number) {}

    interpret(): number {
        return this.value;
    }
}

// Non-terminal Expression
class AddExpression implements Expression {
    constructor(private left: Expression, private right: Expression) {}

    interpret(): number {
        return this.left.interpret() + this.right.interpret();
    }
}

class SubtractExpression implements Expression {
    constructor(private left: Expression, private right: Expression) {}

    interpret(): number {
        return this.left.interpret() - this.right.interpret();
    }
}

class MultiplyExpression implements Expression {
    constructor(private left: Expression, private right: Expression) {}

    interpret(): number {
        return this.left.interpret() * this.right.interpret();
    }
}

class DivideExpression implements Expression {
    constructor(private left: Expression, private right: Expression) {}

    interpret(): number {
        return this.left.interpret() / this.right.interpret();
    }
}

// Context (optional in simple cases)
class Context {
    // Could store variables or other context here
}

// Client code
function parseExpression(tokens: string[]): Expression {
    // This is a simplified parser - real implementations would be more complex
    let stack: Expression[] = [];
    
    for (let token of tokens) {
        switch (token) {
            case '+':
                const rightAdd = stack.pop()!;
                const leftAdd = stack.pop()!;
                stack.push(new AddExpression(leftAdd, rightAdd));
                break;
            case '-':
                const rightSub = stack.pop()!;
                const leftSub = stack.pop()!;
                stack.push(new SubtractExpression(leftSub, rightSub));
                break;
            case '*':
                const rightMul = stack.pop()!;
                const leftMul = stack.pop()!;
                stack.push(new MultiplyExpression(leftMul, rightMul));
                break;
            case '/':
                const rightDiv = stack.pop()!;
                const leftDiv = stack.pop()!;
                stack.push(new DivideExpression(leftDiv, rightDiv));
                break;
            default:
                // Assume it's a number
                stack.push(new NumberExpression(parseFloat(token)));
        }
    }
    
    return stack.pop()!;
}

// Example usage
const expression = "3 4 2 * 1 5 - 2 3 ^ ^ / +";
const tokens = expression.split(' ').filter(token => token.trim() !== '');
const ast = parseExpression(tokens);
console.log(`Result of "${expression}":`, ast.interpret()); // Result: 3.0001220703125        
// Abstract Expression
interface BooleanExpression {
    interpret(context: VariableContext): boolean;
}

// Context
class VariableContext {
    private variables: Record<string, boolean> = {};

    setVariable(name: string, value: boolean): void {
        this.variables[name] = value;
    }

    getVariable(name: string): boolean {
        return this.variables[name];
    }
}

// Terminal Expressions
class VariableExpression implements BooleanExpression {
    constructor(private name: string) {}

    interpret(context: VariableContext): boolean {
        return context.getVariable(this.name);
    }
}

class ConstantExpression implements BooleanExpression {
    constructor(private value: boolean) {}

    interpret(_context: VariableContext): boolean {
        return this.value;
    }
}

// Non-terminal Expressions
class AndExpression implements BooleanExpression {
    constructor(
        private left: BooleanExpression,
        private right: BooleanExpression
    ) {}

    interpret(context: VariableContext): boolean {
        return this.left.interpret(context) && this.right.interpret(context);
    }
}

class OrExpression implements BooleanExpression {
    constructor(
        private left: BooleanExpression,
        private right: BooleanExpression
    ) {}

    interpret(context: VariableContext): boolean {
        return this.left.interpret(context) || this.right.interpret(context);
    }
}

class NotExpression implements BooleanExpression {
    constructor(private expression: BooleanExpression) {}

    interpret(context: VariableContext): boolean {
        return !this.expression.interpret(context);
    }
}

// Client code
function parseBooleanExpression(tokens: string[]): BooleanExpression {
    // Simplified parser - assumes properly formatted input
    const stack: BooleanExpression[] = [];
    
    for (const token of tokens) {
        switch (token.toLowerCase()) {
            case 'and':
                const rightAnd = stack.pop()!;
                const leftAnd = stack.pop()!;
                stack.push(new AndExpression(leftAnd, rightAnd));
                break;
            case 'or':
                const rightOr = stack.pop()!;
                const leftOr = stack.pop()!;
                stack.push(new OrExpression(leftOr, rightOr));
                break;
            case 'not':
                const expr = stack.pop()!;
                stack.push(new NotExpression(expr));
                break;
            case 'true':
                stack.push(new ConstantExpression(true));
                break;
            case 'false':
                stack.push(new ConstantExpression(false));
                break;
            default:
                // Assume it's a variable
                stack.push(new VariableExpression(token));
        }
    }
    
    return stack.pop()!;
}

// Example usage
const context = new VariableContext();
context.setVariable('x', true);
context.setVariable('y', false);
context.setVariable('z', true);

const expression = "x y and z or not";
const tokens = expression.split(' ').filter(token => token.trim() !== '').reverse();
const ast = parseBooleanExpression(tokens);

console.log(`Result of "${expression}":`, ast.interpret(context)); // Result: false        

Modern & Concurrency Patterns – Taming the Async Beast

“Imagine a kitchen with many cooks, timers, and waiters – who does what, and when?”

As software becomes more concurrent and event-driven, these patterns help you manage complexity in asynchronous or multi-threaded environments.


What it solves:

Keeps your app from freezing while waiting for I/O, heavy tasks, or background operations.


Real-world software context:

  • Web servers handling many requests
  • UI apps staying responsive while fetching data
  • Background jobs and notifications


Thread Pool

"Reuse a fixed set of threads instead of creating new ones for every task."

The Thread Pool pattern is a concurrency pattern that manages a pool of worker threads to execute tasks efficiently, avoiding the overhead of thread creation and destruction for each task.


Key Characteristics

  1. Reusable Threads: Maintains a set of pre-created threads that can be reused for multiple tasks
  2. Task Queue: Incoming tasks are placed in a queue when all threads are busy
  3. Load Balancing: Distributes tasks evenly across available threads
  4. Resource Management: Controls the number of concurrent threads to prevent system overload
  5. Thread Lifecycle Management: Handles thread creation, reuse, and teardown


Potential Pitfalls

  1. Overhead of Thread Creation: Even though thread pools reuse threads, the initial creation and management of threads still introduce overhead, especially if the pool size is large.
  2. Resource Exhaustion: Setting an excessively large thread pool size can lead to high memory consumption, excessive context switching, and CPU thrashing, degrading overall performance.
  3. Starvation and Deadlocks: If tasks submitted to the thread pool depend on other tasks in the same pool, they may block indefinitely, leading to deadlocks or thread starvation.


Real-World Use Cases

  1. Web Servers: Handling multiple incoming requests concurrently
  2. Image Processing: Applying filters or transformations to multiple images
  3. Data Processing: Large dataset transformations or calculations
  4. Video Encoding: Converting videos to different formats
  5. Scientific Computing: Parallel processing of complex algorithms


When to Use

  1. When you have many short-lived, CPU-intensive tasks
  2. When thread creation overhead is significant compared to task duration
  3. When you need to limit concurrent resource usage
  4. When tasks are independent and can be executed in parallel
  5. When you need better control over system resources


When to Avoid

  1. For I/O-bound tasks (use event-driven patterns instead)
  2. When tasks require long-running dedicated threads
  3. When tasks have vastly different execution times (can lead to starvation)
  4. When tasks need to communicate with each other frequently
  5. For very simple applications where thread management isn't needed

TypeScript/Node.js uses worker threads for CPU-intensive tasks since JavaScript is single-threaded by nature.

// In Node.js: Use a worker pool with 'worker_threads' or external libs like Piscina

import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
import { cpus } from 'os';

class ThreadPool {
  private taskQueue: Array<{ task: any; resolve: (value: any) => void; reject: (reason?: any) => void }>;
  private workers: Worker[];
  private activeWorkers: number;
  private maxThreads: number;

  constructor(maxThreads: number = cpus().length) {
    this.taskQueue = [];
    this.workers = [];
    this.activeWorkers = 0;
    this.maxThreads = maxThreads;
  }

  public async execute(task: any): Promise<any> {
    return new Promise((resolve, reject) => {
      if (this.activeWorkers < this.maxThreads) {
        this.runTask(task, resolve, reject);
      } else {
        this.taskQueue.push({ task, resolve, reject });
      }
    });
  }

  private runTask(task: any, resolve: (value: any) => void, reject: (reason?: any) => void) {
    this.activeWorkers++;
    
    const worker = new Worker(__filename, {
      workerData: task
    });

    worker.on('message', (result) => {
      resolve(result);
      this.workerFinished(worker);
    });

    worker.on('error', (error) => {
      reject(error);
      this.workerFinished(worker);
    });

    worker.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
      this.workerFinished(worker);
    });

    this.workers.push(worker);
  }

  private workerFinished(worker: Worker) {
    this.activeWorkers--;
    const index = this.workers.indexOf(worker);
    if (index !== -1) {
      this.workers.splice(index, 1);
    }
    
    if (this.taskQueue.length > 0) {
      const nextTask = this.taskQueue.shift();
      if (nextTask) {
        this.runTask(nextTask.task, nextTask.resolve, nextTask.reject);
      }
    }
  }

  public async shutdown() {
    await Promise.all(this.workers.map(worker => worker.terminate()));
    this.taskQueue = [];
    this.workers = [];
    this.activeWorkers = 0;
  }
}

// Worker thread implementation
if (!isMainThread) {
  // Simulate CPU-intensive task
  function processTask(data: any): any {
    // Example: Fibonacci calculation (CPU-intensive)
    function fibonacci(n: number): number {
      if (n <= 1) return n;
      return fibonacci(n - 1) + fibonacci(n - 2);
    }
    
    if (data.task === 'fibonacci') {
      return fibonacci(data.value);
    }
    
    // Other task types can be added here
    throw new Error('Unknown task type');
  }

  const result = processTask(workerData);
  parentPort?.postMessage(result);
}

// Example usage
async function main() {
  const pool = new ThreadPool(4); // Pool with 4 worker threads
  
  try {
    // Execute multiple tasks
    const results = await Promise.all([
      pool.execute({ task: 'fibonacci', value: 40 }),
      pool.execute({ task: 'fibonacci', value: 38 }),
      pool.execute({ task: 'fibonacci', value: 35 }),
      pool.execute({ task: 'fibonacci', value: 30 }),
      pool.execute({ task: 'fibonacci', value: 25 }),
    ]);
    
    console.log('Results:', results);
  } finally {
    await pool.shutdown();
  }
}

if (isMainThread) {
  main().catch(console.error);
}        

Future / Promise

"A placeholder for a result that hasn’t arrived yet."

The Future/Promise pattern is a fundamental concept in asynchronous programming that represents a value that may not be available yet but will be resolved at some point in the future. Example: Fetching data from an API.


Key Characteristics

  1. Asynchronous Operations: Promises represent operations that haven't completed yet but will in the future
  2. Immutable: Once a Promise is resolved or rejected, its state cannot change
  3. Chaining: Promises can be chained using .then() and .catch()
  4. Error Handling: Provides a structured way to handle both success and failure cases
  5. State: A Promise can be in one of three states: Pending (initial state) or Fulfilled (operation completed successfully) or Rejected (operation failed)


When to Use

  1. Any asynchronous operation (HTTP requests, file I/O, timers)
  2. When you need to compose multiple async operations (sequential or parallel)
  3. When working with APIs that return Promises (Fetch API, many database libraries)
  4. When you need better error handling than callbacks provide


When to Avoid

  1. Synchronous operations - Just return the value directly
  2. Simple one-off callbacks where Promises would add unnecessary complexity
  3. Event streams - Consider Observables (RxJS) for continuous events
  4. Operations that need to be cancellable - Native Promises can't be cancelled (though you can implement cancellation patterns)

// Creating a Promise
const fetchData = (url: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (url === 'https://api.example.com/data') {
        resolve('Data retrieved successfully');
      } else {
        reject(new Error('Invalid URL'));
      }
    }, 1000);
  });
};

// Using the Promise
fetchData('https://api.example.com/data')
  .then((data) => {
    console.log(data); // "Data retrieved successfully"
  })
  .catch((error) => {
    console.error(error.message);
  });        
//Promise Timeout
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      reject(new Error('Promise timeout'));
    }, timeoutMs);

    promise
      .then(resolve)
      .catch(reject)
      .finally(() => clearTimeout(timeoutId));
  });
}        
//Promise Retry
async function retry<T>(
  operation: () => Promise<T>,
  maxRetries: number,
  delayMs: number
): Promise<T> {
  try {
    return await operation();
  } catch (error) {
    if (maxRetries <= 0) throw error;
    await new Promise(resolve => setTimeout(resolve, delayMs));
    return retry(operation, maxRetries - 1, delayMs);
  }
}        
//Promise Pool (limiting concurrent operations)
async function promisePool<T>(
  tasks: (() => Promise<T>)[],
  concurrency: number
): Promise<T[]> {
  const results: T[] = [];
  const executing: Promise<void>[] = [];
  
  for (const task of tasks) {
    const p = task().then(result => {
      results.push(result);
      executing.splice(executing.indexOf(p), 1);
    });
    
    executing.push(p);
    
    if (executing.length >= concurrency) {
      await Promise.race(executing);
    }
  }
  
  await Promise.all(executing);
  return results;
}        
//async await
async function processUserOrder(userId: number): Promise<OrderStatus> {
  try {
    const user = await getUser(userId);
    const cart = await getCart(user.id);
    const order = await createOrder(cart.items);
    await sendConfirmationEmail(user.email, order.id);
    return 'SUCCESS';
  } catch (error) {
    console.error('Order processing failed:', error);
    return 'FAILED';
  }
}        




To view or add a comment, sign in

Others also viewed

Explore topics