OOP
05 / 10

Design Patterns

Common OOP Design Patterns

Design patterns are reusable solutions to common problems in software design. They represent best practices evolved over time.

Singleton Pattern

Ensures a class has only one instance and provides a global access point to it.

class Database {
  private static instance: Database;
  private connection: any;

  // Private constructor prevents external instantiation
  private constructor() {
    this.connection = this.createConnection();
    console.log("Database instance created");
  }

  public static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }

  private createConnection(): any {
    return { connected: true, host: "localhost" };
  }

  public query(sql: string): any {
    console.log(`Executing: ${sql}`);
    return [];
  }
}

// Usage
const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true - same instance

// const db3 = new Database(); // ERROR: Constructor is private

Factory Pattern

Creates objects without specifying the exact class of object that will be created.

interface Notification {
  send(message: string): void;
}

class EmailNotification implements Notification {
  constructor(private recipient: string) {}

  send(message: string): void {
    console.log(`Email to ${this.recipient}: ${message}`);
  }
}

class SMSNotification implements Notification {
  constructor(private phoneNumber: string) {}

  send(message: string): void {
    console.log(`SMS to ${this.phoneNumber}: ${message}`);
  }
}

class PushNotification implements Notification {
  constructor(private deviceId: string) {}

  send(message: string): void {
    console.log(`Push to ${this.deviceId}: ${message}`);
  }
}

// Factory
class NotificationFactory {
  static create(type: string, recipient: string): Notification {
    switch (type) {
      case "email":
        return new EmailNotification(recipient);
      case "sms":
        return new SMSNotification(recipient);
      case "push":
        return new PushNotification(recipient);
      default:
        throw new Error(`Unknown notification type: ${type}`);
    }
  }
}

// Usage
const notifications = [
  NotificationFactory.create("email", "user@example.com"),
  NotificationFactory.create("sms", "+1234567890"),
  NotificationFactory.create("push", "device-123")
];

notifications.forEach(notification => {
  notification.send("Hello!");
});

Observer Pattern

Defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified.

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

class Subject {
  private observers: Observer[] = [];
  private state: any;

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

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

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

  setState(state: any): void {
    this.state = state;
    this.notify();
  }
}

class StockMarket extends Subject {
  private stockPrices: Map<string, number> = new Map();

  updatePrice(symbol: string, price: number): void {
    this.stockPrices.set(symbol, price);
    this.setState({ symbol, price });
  }

  getPrice(symbol: string): number | undefined {
    return this.stockPrices.get(symbol);
  }
}

class Investor implements Observer {
  constructor(private name: string) {}

  update(data: { symbol: string; price: number }): void {
    console.log(`${this.name} notified: ${data.symbol} = $${data.price}`);
  }
}

class TradingBot implements Observer {
  constructor(private strategy: string) {}

  update(data: { symbol: string; price: number }): void {
    console.log(`Bot (${this.strategy}) analyzing: ${data.symbol} = $${data.price}`);
    if (data.price < 100) {
      console.log(`  → Executing BUY order`);
    }
  }
}

// Usage
const market = new StockMarket();
const investor1 = new Investor("Alice");
const investor2 = new Investor("Bob");
const bot = new TradingBot("Value Investing");

market.attach(investor1);
market.attach(investor2);
market.attach(bot);

market.updatePrice("AAPL", 150);
market.updatePrice("GOOGL", 95);

Strategy Pattern

Defines a family of algorithms, encapsulates each one, and makes them interchangeable.

interface SortStrategy {
  sort(data: number[]): number[];
}

class BubbleSort implements SortStrategy {
  sort(data: number[]): number[] {
    console.log("Using Bubble Sort");
    const arr = [...data];
    for (let i = 0; i < arr.length; i++) {
      for (let j = 0; j < arr.length - i - 1; j++) {
        if (arr[j] > arr[j + 1]) {
          [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        }
      }
    }
    return arr;
  }
}

class QuickSort implements SortStrategy {
  sort(data: number[]): number[] {
    console.log("Using Quick Sort");
    if (data.length <= 1) return data;
    
    const pivot = data[Math.floor(data.length / 2)];
    const left = data.filter(x => x < pivot);
    const middle = data.filter(x => x === pivot);
    const right = data.filter(x => x > pivot);
    
    return [...this.sort(left), ...middle, ...this.sort(right)];
  }
}

class MergeSort implements SortStrategy {
  sort(data: number[]): number[] {
    console.log("Using Merge Sort");
    if (data.length <= 1) return data;

    const mid = Math.floor(data.length / 2);
    const left = this.sort(data.slice(0, mid));
    const right = this.sort(data.slice(mid));

    return this.merge(left, right);
  }

  private merge(left: number[], right: number[]): number[] {
    const result: number[] = [];
    let i = 0, j = 0;

    while (i < left.length && j < right.length) {
      if (left[i] < right[j]) {
        result.push(left[i++]);
      } else {
        result.push(right[j++]);
      }
    }

    return result.concat(left.slice(i)).concat(right.slice(j));
  }
}

class DataSorter {
  private strategy: SortStrategy;

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

  setStrategy(strategy: SortStrategy): void {
    this.strategy = strategy;
  }

  sort(data: number[]): number[] {
    return this.strategy.sort(data);
  }
}

// Usage
const data = [64, 34, 25, 12, 22, 11, 90];
const sorter = new DataSorter(new BubbleSort());

console.log("Result:", sorter.sort(data));

// Change strategy at runtime
sorter.setStrategy(new QuickSort());
console.log("Result:", sorter.sort(data));

sorter.setStrategy(new MergeSort());
console.log("Result:", sorter.sort(data));

Decorator Pattern

Attaches additional responsibilities to an object dynamically, providing a flexible alternative to subclassing.

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

class SimpleCoffee implements Coffee {
  cost(): number {
    return 2;
  }

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

// Decorator base class
abstract class CoffeeDecorator implements Coffee {
  constructor(protected coffee: Coffee) {}

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

class MilkDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 0.5;
  }

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

class SugarDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 0.2;
  }

  description(): string {
    return this.coffee.description() + ", sugar";
  }
}

class WhippedCreamDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 0.7;
  }

  description(): string {
    return this.coffee.description() + ", whipped cream";
  }
}

// Usage - stack decorators
let coffee: Coffee = new SimpleCoffee();
console.log(`${coffee.description()} = $${coffee.cost()}`);

coffee = new MilkDecorator(coffee);
console.log(`${coffee.description()} = $${coffee.cost()}`);

coffee = new SugarDecorator(coffee);
console.log(`${coffee.description()} = $${coffee.cost()}`);

coffee = new WhippedCreamDecorator(coffee);
console.log(`${coffee.description()} = $${coffee.cost()}`);

// Or all at once
const fancyCoffee = new WhippedCreamDecorator(
  new SugarDecorator(
    new MilkDecorator(
      new SimpleCoffee()
    )
  )
);
console.log(`${fancyCoffee.description()} = $${fancyCoffee.cost()}`);

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free