Four fundamental design patterns

Four fundamental design patterns

Introduction

We're gonna go over a few design patterns I think everyone should know about.

They'll come in handy countless times in your career.

Observer pattern

Without the pattern

First, here's how someone might try to solve this WITHOUT knowing the Observer pattern.

class StockPrice {
  constructor(symbol, price) {
    this.symbol = symbol;
    this.price = price;
  }

  updatePrice(newPrice) {
    this.price = newPrice;

    // Directly updating everything that needs to know about price changes
    priceDisplay.updateDisplay(this.symbol, this.price);
    priceChart.updateChart(this.symbol, this.price);
    priceAlert.checkAlertThreshold(this.symbol, this.price);
    mobileNotification.sendUpdate(this.symbol, this.price);
    // More things keep getting added here as requirements grow...
  }
}

// Usage
const stock = new StockPrice("AAPL", 150);
stock.updatePrice(155);

Problems with this approach:

  1. Tight coupling: StockPrice needs to know about every single component that uses the price

  2. Violates Single Responsibility: StockPrice is doing too much

  3. Hard to maintain: Adding new subscribers means modifying StockPrice

  4. Hard to test: Can't test price updates without mocking all observers

  5. Not flexible: Can't dynamically add/remove price watchers

With the pattern

Here's the BETTER way using the Observer pattern:

// Subject (Publisher)
class StockPrice {
  constructor(symbol, price) {
    this.symbol = symbol;
    this.price = price;
    this.observers = new Set();
  }

  addObserver(observer) {
    this.observers.add(observer);
  }

  removeObserver(observer) {
    this.observers.delete(observer);
  }

  updatePrice(newPrice) {
    this.price = newPrice;
    this.notifyObservers();
  }

  notifyObservers() {
    for (const observer of this.observers) {
      observer.update(this.symbol, this.price);
    }
  }
}

// Observers (Subscribers)
class PriceDisplay {
  update(symbol, price) {
    console.log(`Display updating for ${symbol}: $${price}`);
  }
}

class PriceChart {
  update(symbol, price) {
    console.log(`Chart updating for ${symbol}: $${price}`);
  }
}

class PriceAlert {
  constructor(threshold) {
    this.threshold = threshold;
  }

  update(symbol, price) {
    if (price > this.threshold) {
      console.log(`Alert! ${symbol} has exceeded ${this.threshold}`);
    }
  }
}

// Usage
const stock = new StockPrice("AAPL", 150);
const display = new PriceDisplay();
const chart = new PriceChart();
const alert = new PriceAlert(160);

// Add observers
stock.addObserver(display);
stock.addObserver(chart);
stock.addObserver(alert);

// Update price - all observers get notified automatically
stock.updatePrice(165);

// Can easily remove observers
stock.removeObserver(alert);
stock.updatePrice(170); // Alert won't receive this update

You see this patterns a lot when digging into libraries and frameworks.

Why this is better:

  1. Loose coupling: StockPrice doesn't need to know the details of its observers

  2. Single Responsibility: StockPrice just manages price and notifications

  3. Easy to maintain: Add new observers without changing StockPrice

  4. Easy to test: Can test price updates with mock observers

  5. Flexible: Can add/remove observers at runtime

When to use Observer

  1. When you have a one-to-many relationship between objects

  2. When changes in one object should automatically cause changes in others

  3. When the group of objects that need to be notified might change over time

What to avoid

  • Avoid using it if you only have one observer, as it makes things more complicated than necessary.

  • Don't use it if updates must happen in a specific order.

  • Don't overuse it, as having too many observers can make the code hard to understand.

  • Watch out for memory leaks and make sure to remove observers when they're not needed anymore.

Common use cases

  • Event handling systems

  • UI updates (React's state management is based on this)

  • Real-time data updates

  • Logging systems

  • Analytics tracking

Strategy pattern

Without the pattern

Here's how someone might write it WITHOUT knowing the Strategy pattern:

class DeliveryCost {
  calculateCost(type, weight, distance) {
    if (type === "ground") {
      // Complex ground shipping calculation
      const baseCost = 10;
      const weightCost = weight * 0.5;
      const distanceCost = distance * 0.1;
      return baseCost + weightCost + distanceCost;
    } else if (type === "air") {
      // Air shipping calculation
      const baseCost = 20;
      const weightCost = weight * 0.8;
      const distanceCost = distance * 0.2;
      return baseCost + weightCost + distanceCost;
    } else if (type === "sea") {
      // Sea shipping calculation
      const baseCost = 15;
      const weightCost = weight * 0.3;
      const distanceCost = distance * 0.05;
      return baseCost + weightCost + distanceCost;
    }
  }
}

// Usage
const calculator = new DeliveryCost();
console.log(calculator.calculateCost("ground", 10, 100));

Problems with this approach:

  1. Massive if-else chain that keeps growing

  2. Hard to add new shipping methods

  3. Hard to test each method separately

  4. Business logic is all mixed together

  5. Violates Open-Closed Principle (need to modify existing code to add new types)

With the pattern

Here's the BETTER way using the Strategy pattern:

// Strategy interface (in TypeScript this would be an actual interface)
class ShippingStrategy {
  calculate(weight, distance) {
    throw new Error("calculate method must be implemented");
  }
}

// Concrete strategies
class GroundShipping extends ShippingStrategy {
  calculate(weight, distance) {
    const baseCost = 10;
    const weightCost = weight * 0.5;
    const distanceCost = distance * 0.1;
    return baseCost + weightCost + distanceCost;
  }
}

class AirShipping extends ShippingStrategy {
  calculate(weight, distance) {
    const baseCost = 20;
    const weightCost = weight * 0.8;
    const distanceCost = distance * 0.2;
    return baseCost + weightCost + distanceCost;
  }
}

class SeaShipping extends ShippingStrategy {
  calculate(weight, distance) {
    const baseCost = 15;
    const weightCost = weight * 0.3;
    const distanceCost = distance * 0.05;
    return baseCost + weightCost + distanceCost;
  }
}

// Context class that uses the strategies
class DeliveryCostCalculator {
  constructor() {
    this.strategies = {
      ground: new GroundShipping(),
      air: new AirShipping(),
      sea: new SeaShipping(),
    };
  }

  setStrategy(type) {
    this.strategy = this.strategies[type];
  }

  calculate(weight, distance) {
    if (!this.strategy) {
      throw new Error("No shipping strategy set");
    }
    return this.strategy.calculate(weight, distance);
  }
}

// Usage
const calculator = new DeliveryCostCalculator();
calculator.setStrategy("ground");
console.log(calculator.calculate(10, 100));

calculator.setStrategy("air");
console.log(calculator.calculate(10, 100));

Why this is better

  1. Each strategy is isolated and encapsulated

  2. Easy to add new shipping methods without changing existing code

  3. Easy to test each strategy independently

  4. Clean separation of concerns

  5. Can switch strategies at runtime

When to use Strategy

  1. When you have a family of similar algorithms

  2. When you need to vary an algorithm depending on client context

  3. When you have a lot of conditional statements around algorithm variants

  4. When different variations of an algorithm are needed

Real-world use cases

  • Payment processing (PayPal, Stripe, etc.)

  • Sorting algorithms (different sort methods)

  • Compression algorithms

  • Authentication strategies

  • Tax calculation methods

What to avoid

  1. Avoid using it when algorithms rarely change

  2. Avoid using it when the behavior changes very little

  3. Don't make simple conditional logic too complex

  4. Be cautious about creating too many strategy classes for small differences

Factory pattern

Without the pattern

Imagine we're building a document processing system that needs to create different types of documents (PDF, Word, Excel):

// WITHOUT Factory Method - The bad way
class DocumentProcessor {
  createDocument(type, data) {
    if (type === "pdf") {
      // Complex PDF creation logic
      const pdf = {
        type: "pdf",
        content: data,
        format: "A4",
        createPDF() {
          console.log("Creating PDF with:", this.content);
        },
      };
      return pdf;
    } else if (type === "word") {
      // Complex Word doc creation
      const word = {
        type: "word",
        content: data,
        template: "default",
        createDoc() {
          console.log("Creating Word doc with:", this.content);
        },
      };
      return word;
    } else if (type === "excel") {
      // Complex Excel creation
      const excel = {
        type: "excel",
        content: data,
        sheets: ["Sheet1"],
        createSpreadsheet() {
          console.log("Creating Excel with:", this.content);
        },
      };
      return excel;
    }
  }
}

// Usage
const processor = new DocumentProcessor();
const doc = processor.createDocument("pdf", "Hello World");

Problems with this approach:

  1. Violates Single Responsibility: one class doing too much

  2. Hard to maintain: need to modify this class for every new document type

  3. Duplication: Duplicate creation logic all in one place

  4. Not extensible: tightly coupled to specific document types

With the pattern

Here's the BETTER way using Factory Method:

// Base Document class
class Document {
  constructor(content) {
    this.content = content;
  }

  create() {
    throw new Error("create() must be implemented");
  }
}

// Concrete Document classes
class PDFDocument extends Document {
  create() {
    console.log("Creating PDF with:", this.content);
    // PDF-specific creation logic
  }
}

class WordDocument extends Document {
  create() {
    console.log("Creating Word doc with:", this.content);
    // Word-specific creation logic
  }
}

class ExcelDocument extends Document {
  create() {
    console.log("Creating Excel with:", this.content);
    // Excel-specific creation logic
  }
}

// Factory classes
class DocumentFactory {
  createDocument(type, content) {
    switch (type) {
      case "pdf":
        return new PDFDocument(content);
      case "word":
        return new WordDocument(content);
      case "excel":
        return new ExcelDocument(content);
      default:
        throw new Error("Unknown document type");
    }
  }
}

// Usage
const factory = new DocumentFactory();
const pdfDoc = factory.createDocument("pdf", "Hello World");
pdfDoc.create();

// Easy to add new document types:
class MarkdownDocument extends Document {
  create() {
    console.log("Creating Markdown with:", this.content);
  }
}

// Just add to factory
class BetterDocumentFactory extends DocumentFactory {
  createDocument(type, content) {
    if (type === "markdown") {
      return new MarkdownDocument(content);
    }
    return super.createDocument(type, content);
  }
}

Why this is better:

  1. Separation of concerns: creation logic is separated from business logic

  2. Easy to add new document types: without modifying existing code

  3. Each document type encapsulates its own creation logic

  4. Factory can be extended or replaced without changing document classes

When to use Factory Method

  1. When you don't know the exact types of objects you'll need to create

  2. When you want to let subclasses handle object creation

  3. When you have a group of related objects that need similar creation logic

  4. When you want to keep object creation logic separate

Real-world use cases

  • UI Component creation

  • Database connections for different DB types

  • File parsers for different file formats

  • Game enemy/obstacle creation

  • Plugin systems

What to avoid

  1. Don't use it when you have a simple object creation that won't change.

  2. Don't over-complicate when you only have one or two types.

  3. Be careful not to create too many factory classes.

  4. Don't use it when object creation is simple and unlikely to change.

State pattern

Without the pattern

First, here's how someone might handle character states WITHOUT the pattern:

class Character {
  constructor() {
    // Could be: 'idle', 'walking', 'jumping', 'attacking'
    this.currentState = "idle";
  }

  update() {
    if (this.currentState === "idle") {
      if (this.isJumpPressed) {
        this.currentState = "jumping";
        // Jump logic
      } else if (this.isMoving) {
        this.currentState = "walking";
        // Walk logic
      }
    } else if (this.currentState === "walking") {
      if (this.isJumpPressed) {
        this.currentState = "jumping";
        // Jump logic
      } else if (!this.isMoving) {
        this.currentState = "idle";
        // Idle logic
      }
    } else if (this.currentState === "jumping") {
      if (this.hasLanded) {
        if (this.isMoving) {
          this.currentState = "walking";
          // Walk logic
        } else {
          this.currentState = "idle";
          // Idle logic
        }
      }
    }
    // More state checks and transitions...
  }
}

Problems with this approach:

  1. Massive if-else chains that grow with each new state

  2. State transition logic is scattered

  3. Hard to add new states

  4. State-specific behavior mixed with transition logic

  5. Hard to debug and maintain

With the pattern

Here's the BETTER way using the State pattern:

// State interface
class State {
  constructor(character) {
    this.character = character;
  }

  enter() {}
  update() {}
  exit() {}
}

// Concrete states
class IdleState extends State {
  enter() {
    console.log("Entering idle state");
    this.character.setAnimation("idle");
  }

  update() {
    if (this.character.isJumpPressed) {
      this.character.setState(new JumpingState(this.character));
    } else if (this.character.isMoving) {
      this.character.setState(new WalkingState(this.character));
    }
  }
}

class WalkingState extends State {
  enter() {
    console.log("Entering walking state");
    this.character.setAnimation("walk");
  }

  update() {
    if (this.character.isJumpPressed) {
      this.character.setState(new JumpingState(this.character));
    } else if (!this.character.isMoving) {
      this.character.setState(new IdleState(this.character));
    }
  }
}

class JumpingState extends State {
  enter() {
    console.log("Entering jumping state");
    this.character.setAnimation("jump");
    this.character.velocity.y = this.character.jumpForce;
  }

  update() {
    if (this.character.hasLanded) {
      if (this.character.isMoving) {
        this.character.setState(new WalkingState(this.character));
      } else {
        this.character.setState(new IdleState(this.character));
      }
    }
  }
}

// Character class using states
class Character {
  constructor() {
    this.isJumpPressed = false;
    this.isMoving = false;
    this.hasLanded = false;
    this.velocity = { x: 0, y: 0 };
    this.jumpForce = 10;
    this.currentState = null;

    // Start in idle state
    this.setState(new IdleState(this));
  }

  setState(newState) {
    if (this.currentState) {
      this.currentState.exit();
    }
    this.currentState = newState;
    this.currentState.enter();
  }

  update() {
    this.currentState.update();
  }

  setAnimation(name) {
    console.log(`Playing ${name} animation`);
  }
}

// Usage
const character = new Character();
character.update(); // In idle

character.isMoving = true;
character.update(); // Transitions to walking

character.isJumpPressed = true;
character.update(); // Transitions to jumping

Why this is better:

  1. Each state is encapsulated in its own class

  2. Clear state transitions

  3. Easy to add new states without modifying existing code

  4. State-specific behavior is isolated

  5. Much easier to debug and maintain

When to use State pattern

  1. When an object's behavior depends on its state

  2. When you have lots of conditional statements based on object state

  3. When state transitions are complex

  4. When you need to manage state-specific behaviors separately

Real-world use cases

  • Game character states (idle, walking, jumping, etc.)

  • Order processing (new, processing, shipped, delivered)

  • Document editing (draft, review, published)

  • UI element states (normal, hover, active, disabled)

  • Connection states (connecting, connected, disconnected)

What to avoid

  1. Don't use it for simple state transitions

  2. Don't use when states rarely change

  3. Be careful of creating too many state classes for minor variations

  4. Watch out for shared state between state classes