Design Patterns: The Invisible Architecture of Elegant Code

From the Gang of Four's seminal work to modern microservices, design patterns have shaped how we think about software. This deep dive explores why patterns matter, how SOLID principles form their foundation, practical code examples, and when to use—or avoid—these powerful abstractions.

Ahmet ZeybekJanuary 4, 202632 min read

Design Patterns: The Invisible Architecture of Elegant Code

In 1994, four software engineers—Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides—published a book that would fundamentally reshape how programmers think about code. "Design Patterns: Elements of Reusable Object-Oriented Software" introduced 23 patterns that captured decades of collective wisdom about solving recurring design problems. These four authors became known simply as the "Gang of Four" (GoF), and their patterns became the vocabulary through which generations of developers would communicate complex architectural ideas.

Three decades later, we live in an era of AI-generated code, serverless architectures, and frameworks that abstract away entire categories of problems. One might reasonably ask: do design patterns still matter? The answer is not only yes—they matter more than ever. As the complexity of software systems explodes and the barrier to generating code approaches zero, the ability to recognize, evaluate, and apply appropriate design patterns becomes the distinguishing skill of a true software architect.

This article is not just a catalog of patterns—there are excellent resources for that. Instead, this is an exploration of why patterns exist, how they relate to fundamental design principles, practical code examples you can use, and perhaps most importantly, when not to use them. Because in the hands of an overzealous developer, patterns can create more problems than they solve.

The Philosophy Behind Patterns

Design patterns are not algorithms. An algorithm is a precise sequence of steps that solves a specific computational problem—sorting a list, finding a path through a graph, encrypting a message. A pattern, by contrast, is a template for solving a category of design problems. It describes the structure and relationships between objects without prescribing specific implementation details.

Christopher Alexander, the architect whose work on patterns in building design inspired the Gang of Four, described a pattern as "a solution to a problem in a context." This definition captures something essential: patterns are not universal truths to be applied everywhere. They are contextual responses to specific tensions and tradeoffs in software design.

Consider the Singleton pattern, perhaps the most famous—and infamous—of all patterns. The intent is straightforward: ensure a class has only one instance and provide a global point of access to it. Simple enough. But when do you actually need this? Logging frameworks, configuration managers, connection pools—these are classic use cases where having multiple instances would be wasteful or semantically incorrect.

The problem is that Singleton is seductively easy to apply. Need global access to something? Make it a Singleton! This leads to what some call "Singletonitis"—a codebase littered with global state masquerading as elegant design. The pattern that was meant to solve a specific problem becomes a crutch that introduces hidden dependencies, makes testing difficult, and violates the very principles of good design that patterns are supposed to embody.

This is the first lesson of design patterns: understanding a pattern means understanding not just how to implement it, but when it's appropriate and—crucially—when it isn't.

SOLID: The Bedrock Beneath the Patterns

Before diving into specific patterns, we need to understand the principles that underpin them. Robert C. Martin—known as "Uncle Bob"—codified five principles that form the foundation of maintainable object-oriented design. These principles, known by the acronym SOLID, are not patterns themselves, but they explain why patterns work when they work.

Single Responsibility Principle (SRP)

A class should have only one reason to change. This principle sounds almost trivially simple, but its implications are profound. Consider a typical Employee class in an enterprise application. It might contain methods for calculating pay, generating reports, and saving to a database. That's three different reasons to change: payroll rules might evolve, report formats might be updated, and database schemas might be refactored.

Here's what this violation looks like in code:

// ❌ Bad: Multiple responsibilities
class Employee {
  constructor(
    public id: string,
    public name: string,
    public hourlyRate: number
  ) {}
 
  calculatePay(hoursWorked: number): number {
    return this.hourlyRate * hoursWorked;
  }
  
  generateReport(): string {
    return `Employee Report for ${this.name}`;
  }
  
  saveToDatabase(): void {
    console.log(`Saving ${this.name} to database...`);
  }
}

The SRP tells us to split these concerns. Each class should have a single axis of change:

// ✅ Good: Single responsibility each
class Employee {
  constructor(
    public id: string,
    public name: string,
    public hourlyRate: number
  ) {}
}
 
class PayrollCalculator {
  calculate(employee: Employee, hoursWorked: number): number {
    return employee.hourlyRate * hoursWorked;
  }
}
 
class EmployeeReportGenerator {
  generate(employee: Employee): string {
    return `Employee Report: ${employee.name}`;
  }
}
 
class EmployeeRepository {
  save(employee: Employee): void {
    console.log(`Saving ${employee.name} to database...`);
  }
}

But here's where judgment comes in. If your application is simple and unlikely to change along these axes, aggressive decomposition might be overkill. The SRP is not a commandment to create the maximum number of classes; it's a heuristic for managing complexity as systems grow. Apply it thoughtfully.

Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification. This principle addresses a fundamental tension in software development: systems must evolve, but changes are risky. Every modification to existing code might break something that was working.

The OCP suggests designing systems where new behavior can be added without altering existing code. Here's a common violation:

// ❌ Bad: Must modify class to add new shipping types
class ShippingCalculator {
  calculate(type: string, weight: number): number {
    if (type === "ground") {
      return weight * 1.5;
    } else if (type === "air") {
      return weight * 3.0;
    } else if (type === "overnight") {
      return weight * 5.0;
    }
    // Every new shipping type requires modification!
    return 0;
  }
}

The Strategy pattern is a perfect example of OCP in action:

// ✅ Good: Open for extension, closed for modification
interface ShippingStrategy {
  calculate(weight: number): number;
}
 
class GroundShipping implements ShippingStrategy {
  calculate(weight: number): number {
    return weight * 1.5;
  }
}
 
class AirShipping implements ShippingStrategy {
  calculate(weight: number): number {
    return weight * 3.0;
  }
}
 
class OvernightShipping implements ShippingStrategy {
  calculate(weight: number): number {
    return weight * 5.0;
  }
}
 
// Adding new shipping? Just create a new class!
class DroneShipping implements ShippingStrategy {
  calculate(weight: number): number {
    return weight * 4.0;
  }
}
 
class ShippingCalculator {
  constructor(private strategy: ShippingStrategy) {}
  
  calculate(weight: number): number {
    return this.strategy.calculate(weight);
  }
}
 
// Usage
const groundCalc = new ShippingCalculator(new GroundShipping());
console.log(groundCalc.calculate(10)); // 15
 
const droneCalc = new ShippingCalculator(new DroneShipping());
console.log(droneCalc.calculate(10)); // 40

When the business adds a new shipping option, you add a new class; you don't touch the ShippingCalculator class at all. This principle is powerful but has limits. Predicting every axis of future change is impossible. Over-engineering for extensibility you never need wastes effort and adds complexity.

Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without breaking the program. Barbara Liskov formulated this principle in 1987, and it remains one of the most misunderstood principles in object-oriented design.

The classic violation is the square-rectangle problem. Mathematically, a square is a special case of a rectangle. So if you have a Rectangle class, shouldn't Square extend it?

// ❌ Bad: Square violates Rectangle's contract
class Rectangle {
  constructor(protected width: number, protected height: number) {}
  
  setWidth(width: number): void {
    this.width = width;
  }
  
  setHeight(height: number): void {
    this.height = height;
  }
  
  getArea(): number {
    return this.width * this.height;
  }
}
 
class Square extends Rectangle {
  setWidth(width: number): void {
    this.width = width;
    this.height = width; // Violates expectations!
  }
  
  setHeight(height: number): void {
    this.width = height;
    this.height = height; // Violates expectations!
  }
}
 
// This function breaks with Square!
function resizeRectangle(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(10);
  console.log(rect.getArea()); // Expected: 50, Square gives: 100
}

The problem emerges when Rectangle has setters for width and height independently. A client might call setWidth(5) followed by setHeight(10) and expect a 5x10 rectangle. But if the actual object is a Square, setting the height to 10 would also change the width to 10, violating the client's expectations.

The solution is to use composition or separate abstractions:

// ✅ Good: Use composition or separate abstractions
interface Shape {
  getArea(): number;
}
 
class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  
  getArea(): number {
    return this.width * this.height;
  }
}
 
class Square implements Shape {
  constructor(private side: number) {}
  
  getArea(): number {
    return this.side * this.side;
  }
}
 
// Now both work correctly
function printArea(shape: Shape) {
  console.log(`Area: ${shape.getArea()}`);
}
 
printArea(new Rectangle(5, 10)); // Area: 50
printArea(new Square(5));         // Area: 25

Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they don't use. Large, "fat" interfaces force implementing classes to provide methods they don't need:

// ❌ Bad: Fat interface
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}
 
class Human implements Worker {
  work(): void { console.log("Working..."); }
  eat(): void { console.log("Eating..."); }
  sleep(): void { console.log("Sleeping..."); }
}
 
class Robot implements Worker {
  work(): void { console.log("Working 24/7..."); }
  eat(): void { /* Robots don't eat! Forced to implement empty method */ }
  sleep(): void { /* Robots don't sleep! */ }
}

The solution is to segregate interfaces into smaller, focused contracts:

// ✅ Good: Segregated interfaces
interface Workable {
  work(): void;
}
 
interface Eatable {
  eat(): void;
}
 
interface Sleepable {
  sleep(): void;
}
 
class Human implements Workable, Eatable, Sleepable {
  work(): void { console.log("Working..."); }
  eat(): void { console.log("Eating..."); }
  sleep(): void { console.log("Sleeping..."); }
}
 
class Robot implements Workable {
  work(): void { console.log("Working 24/7..."); }
  // No need to implement eat() or sleep()!
}

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions. This principle is the foundation for dependency injection and is crucial for testable, maintainable code.

// ❌ Bad: High-level depends on low-level
class MySQLDatabase {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
}
 
class UserService {
  private database = new MySQLDatabase(); // Tight coupling!
  
  createUser(name: string): void {
    this.database.save(name);
  }
}
// Problem: Can't easily switch databases or mock for testing
// ✅ Good: Both depend on abstraction
interface Database {
  save(data: string): void;
}
 
class MySQLDatabase implements Database {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
}
 
class MongoDatabase implements Database {
  save(data: string): void {
    console.log(`Saving to MongoDB: ${data}`);
  }
}
 
class UserService {
  constructor(private database: Database) {} // Injected dependency
  
  createUser(name: string): void {
    this.database.save(name);
  }
}
 
// Now we can swap databases easily!
const mysqlService = new UserService(new MySQLDatabase());
const mongoService = new UserService(new MongoDatabase());
 
// And mock for testing!
class MockDatabase implements Database {
  save(data: string): void {
    console.log(`Mock save: ${data}`);
  }
}
const testService = new UserService(new MockDatabase());

Creational Patterns

Creational patterns deal with object creation mechanisms. They help make systems independent of how objects are created, composed, and represented. Instead of instantiating objects directly, these patterns provide ways to create objects while hiding the creation logic.

Factory Method Pattern

The Factory Method pattern defines an interface for creating objects, but lets subclasses decide which class to instantiate. This is useful when you don't know ahead of time what exact types of objects your code will need to create.

Consider a cross-platform UI framework. You want to create buttons, but the specific button type depends on the operating system:

// Product interface
interface Button {
  render(): void;
  onClick(callback: () => void): void;
}
 
// Concrete products
class WindowsButton implements Button {
  render(): void {
    console.log("Rendering Windows-style button with sharp corners");
  }
  
  onClick(callback: () => void): void {
    console.log("Binding Windows click event");
    callback();
  }
}
 
class MacButton implements Button {
  render(): void {
    console.log("Rendering macOS-style button with rounded corners");
  }
  
  onClick(callback: () => void): void {
    console.log("Binding macOS click event");
    callback();
  }
}
 
// Creator abstract class
abstract class Dialog {
  abstract createButton(): Button; // Factory method
  
  render(): void {
    // Call the factory method to create a button
    const button = this.createButton();
    button.render();
    button.onClick(() => console.log("Button was clicked!"));
  }
}
 
// Concrete creators
class WindowsDialog extends Dialog {
  createButton(): Button {
    return new WindowsButton();
  }
}
 
class MacDialog extends Dialog {
  createButton(): Button {
    return new MacButton();
  }
}
 
// Client code - doesn't know which concrete button it gets
function initializeApp(): Dialog {
  const os = process.platform;
  if (os === "win32") {
    return new WindowsDialog();
  }
  return new MacDialog();
}
 
const dialog = initializeApp();
dialog.render();

The key insight is that the Dialog class works with buttons through the Button interface, without knowing the concrete type. The subclasses provide that knowledge.

Abstract Factory Pattern

While Factory Method creates one product, Abstract Factory creates families of related objects without specifying their concrete classes. This is perfect when your system needs to work with multiple families of related products.

Imagine a furniture store application that needs to support different styles (Modern, Victorian, Art Deco):

// Abstract products
interface Chair {
  sitOn(): void;
  hasLegs(): number;
}
 
interface Sofa {
  lieOn(): void;
}
 
interface Table {
  putOn(item: string): void;
}
 
// Modern furniture family
class ModernChair implements Chair {
  sitOn(): void { console.log("Sitting on sleek modern chair"); }
  hasLegs(): number { return 4; }
}
 
class ModernSofa implements Sofa {
  lieOn(): void { console.log("Lying on minimalist modern sofa"); }
}
 
class ModernTable implements Table {
  putOn(item: string): void { console.log(`Putting ${item} on glass modern table`); }
}
 
// Victorian furniture family
class VictorianChair implements Chair {
  sitOn(): void { console.log("Sitting on ornate Victorian chair"); }
  hasLegs(): number { return 4; }
}
 
class VictorianSofa implements Sofa {
  lieOn(): void { console.log("Lying on tufted Victorian sofa"); }
}
 
class VictorianTable implements Table {
  putOn(item: string): void { console.log(`Putting ${item} on carved Victorian table`); }
}
 
// Abstract factory
interface FurnitureFactory {
  createChair(): Chair;
  createSofa(): Sofa;
  createTable(): Table;
}
 
// Concrete factories
class ModernFurnitureFactory implements FurnitureFactory {
  createChair(): Chair { return new ModernChair(); }
  createSofa(): Sofa { return new ModernSofa(); }
  createTable(): Table { return new ModernTable(); }
}
 
class VictorianFurnitureFactory implements FurnitureFactory {
  createChair(): Chair { return new VictorianChair(); }
  createSofa(): Sofa { return new VictorianSofa(); }
  createTable(): Table { return new VictorianTable(); }
}
 
// Client code works with any factory - guaranteed matching furniture
function furnishRoom(factory: FurnitureFactory): void {
  const chair = factory.createChair();
  const sofa = factory.createSofa();
  const table = factory.createTable();
  
  chair.sitOn();
  sofa.lieOn();
  table.putOn("laptop");
}
 
// Usage - all furniture will match the style
console.log("=== Modern Room ===");
furnishRoom(new ModernFurnitureFactory());
 
console.log("\n=== Victorian Room ===");
furnishRoom(new VictorianFurnitureFactory());

The Abstract Factory ensures that products from the same factory are compatible with each other—you won't accidentally mix a Victorian chair with a Modern table.

Builder Pattern

The Builder pattern constructs complex objects step by step. It's particularly useful when an object needs to be created with many optional parameters, or when the construction process must allow different representations.

Consider building a house with many optional features:

class House {
  walls: number = 0;
  doors: number = 0;
  windows: number = 0;
  roof: string = "";
  garage: boolean = false;
  swimmingPool: boolean = false;
  garden: boolean = false;
  
  describe(): void {
    console.log(`House: ${this.walls} walls, ${this.doors} doors, ${this.windows} windows`);
    console.log(`Roof: ${this.roof}`);
    console.log(`Extras: ${this.garage ? "Garage " : ""}${this.swimmingPool ? "Pool " : ""}${this.garden ? "Garden" : ""}`);
  }
}
 
// Builder with fluent interface
class HouseBuilder {
  private house: House = new House();
  
  reset(): this {
    this.house = new House();
    return this;
  }
  
  buildWalls(count: number): this {
    this.house.walls = count;
    return this;
  }
  
  buildDoors(count: number): this {
    this.house.doors = count;
    return this;
  }
  
  buildWindows(count: number): this {
    this.house.windows = count;
    return this;
  }
  
  buildRoof(type: string): this {
    this.house.roof = type;
    return this;
  }
  
  buildGarage(): this {
    this.house.garage = true;
    return this;
  }
  
  buildSwimmingPool(): this {
    this.house.swimmingPool = true;
    return this;
  }
  
  buildGarden(): this {
    this.house.garden = true;
    return this;
  }
  
  getResult(): House {
    const result = this.house;
    this.reset();
    return result;
  }
}
 
// Optional: Director for predefined configurations
class HouseDirector {
  constructor(private builder: HouseBuilder) {}
  
  buildMinimalHouse(): House {
    return this.builder
      .buildWalls(4)
      .buildDoors(1)
      .buildWindows(2)
      .buildRoof("flat")
      .getResult();
  }
  
  buildLuxuryVilla(): House {
    return this.builder
      .buildWalls(12)
      .buildDoors(6)
      .buildWindows(20)
      .buildRoof("dome")
      .buildGarage()
      .buildSwimmingPool()
      .buildGarden()
      .getResult();
  }
}
 
// Usage
const builder = new HouseBuilder();
const director = new HouseDirector(builder);
 
console.log("=== Minimal House ===");
const minimal = director.buildMinimalHouse();
minimal.describe();
 
console.log("\n=== Luxury Villa ===");
const villa = director.buildLuxuryVilla();
villa.describe();
 
console.log("\n=== Custom House ===");
const custom = builder
  .buildWalls(6)
  .buildDoors(2)
  .buildWindows(8)
  .buildRoof("gabled")
  .buildGarden()
  .getResult();
custom.describe();

The Builder pattern is especially valuable when you'd otherwise have a constructor with many parameters, many of which are optional.

Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global point of access to it. While often overused (as discussed earlier), it has legitimate use cases for resources that should truly be shared:

class DatabaseConnection {
  private static instance: DatabaseConnection | null = null;
  private isConnected: boolean = false;
  
  // Private constructor prevents direct instantiation
  private constructor() {
    console.log("Creating database connection instance...");
  }
  
  static getInstance(): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection();
    }
    return DatabaseConnection.instance;
  }
  
  connect(connectionString: string): void {
    if (!this.isConnected) {
      console.log(`Connecting to: ${connectionString}`);
      this.isConnected = true;
    } else {
      console.log("Already connected!");
    }
  }
  
  query(sql: string): void {
    if (this.isConnected) {
      console.log(`Executing: ${sql}`);
    } else {
      console.log("Error: Not connected to database!");
    }
  }
  
  disconnect(): void {
    if (this.isConnected) {
      console.log("Disconnecting...");
      this.isConnected = false;
    }
  }
}
 
// Usage - always returns the same instance
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
 
console.log(db1 === db2); // true - same instance
 
db1.connect("mongodb://localhost:27017/myapp");
db2.query("SELECT * FROM users"); // Works - using same connection

Use Singleton sparingly. It's essentially global state, which can make code harder to test and reason about.

Structural Patterns

Structural patterns explain how to assemble objects and classes into larger structures while keeping them flexible and efficient. They use inheritance and composition to create new functionality.

Adapter Pattern

The Adapter pattern allows incompatible interfaces to work together. It wraps an object with an incompatible interface inside an adapter that makes it compatible with the expected interface:

// Target interface (what the client expects)
interface MediaPlayer {
  play(filename: string): void;
}
 
// Adaptee (existing class with incompatible interface)
class AdvancedVideoPlayer {
  playMp4(filename: string): void {
    console.log(`▶️ Playing MP4 video: ${filename}`);
  }
  
  playMkv(filename: string): void {
    console.log(`▶️ Playing MKV video: ${filename}`);
  }
}
 
// Adapter - makes AdvancedVideoPlayer compatible with MediaPlayer
class VideoPlayerAdapter implements MediaPlayer {
  private advancedPlayer = new AdvancedVideoPlayer();
  
  play(filename: string): void {
    if (filename.endsWith(".mp4")) {
      this.advancedPlayer.playMp4(filename);
    } else if (filename.endsWith(".mkv")) {
      this.advancedPlayer.playMkv(filename);
    } else {
      console.log(`❌ Unsupported format: ${filename}`);
    }
  }
}
 
// Client that works with MediaPlayer interface
class AudioPlayer implements MediaPlayer {
  private videoAdapter = new VideoPlayerAdapter();
  
  play(filename: string): void {
    if (filename.endsWith(".mp3")) {
      console.log(`🎵 Playing MP3 audio: ${filename}`);
    } else if (filename.endsWith(".wav")) {
      console.log(`🎵 Playing WAV audio: ${filename}`);
    } else {
      // Delegate to adapter for video formats
      this.videoAdapter.play(filename);
    }
  }
}
 
// Usage
const player = new AudioPlayer();
player.play("song.mp3");      // 🎵 Playing MP3 audio: song.mp3
player.play("movie.mp4");     // ▶️ Playing MP4 video: movie.mp4
player.play("video.mkv");     // ▶️ Playing MKV video: video.mkv
player.play("doc.pdf");       // ❌ Unsupported format: doc.pdf

Decorator Pattern

The Decorator pattern attaches additional responsibilities to an object dynamically. It provides a flexible alternative to subclassing for extending functionality:

// 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 {
  constructor(protected coffee: Coffee) {}
  
  abstract cost(): number;
  abstract description(): string;
}
 
// Concrete decorators
class MilkDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 2;
  }
  
  description(): string {
    return this.coffee.description() + ", milk";
  }
}
 
class SugarDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 1;
  }
  
  description(): string {
    return this.coffee.description() + ", sugar";
  }
}
 
class WhipCreamDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 3;
  }
  
  description(): string {
    return this.coffee.description() + ", whipped cream";
  }
}
 
class CaramelDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 2.5;
  }
  
  description(): string {
    return this.coffee.description() + ", caramel drizzle";
  }
}
 
// Usage - stack decorators dynamically!
let order: Coffee = new SimpleCoffee();
console.log(`${order.description()} = $${order.cost()}`);
// Simple coffee = $5
 
order = new MilkDecorator(order);
console.log(`${order.description()} = $${order.cost()}`);
// Simple coffee, milk = $7
 
order = new SugarDecorator(order);
order = new WhipCreamDecorator(order);
order = new CaramelDecorator(order);
console.log(`${order.description()} = $${order.cost()}`);
// Simple coffee, milk, sugar, whipped cream, caramel drizzle = $13.5

The beauty of Decorator is that you can combine behaviors at runtime without creating a subclass for every combination.

Facade Pattern

The Facade pattern provides a simplified interface to a complex subsystem. It doesn't encapsulate the subsystem—you can still access it directly—but it provides a convenient default way to use it:

// Complex subsystem classes
class CPU {
  freeze(): void { console.log("CPU: Freezing processor..."); }
  jump(position: number): void { console.log(`CPU: Jumping to position ${position}`); }
  execute(): void { console.log("CPU: Executing instructions..."); }
}
 
class Memory {
  load(position: number, data: string): void {
    console.log(`Memory: Loading "${data}" at position ${position}`);
  }
}
 
class HardDrive {
  read(sector: number, size: number): string {
    console.log(`HardDrive: Reading ${size} bytes from sector ${sector}`);
    return "boot_data";
  }
}
 
class Fan {
  start(): void { console.log("Fan: Starting cooling system..."); }
  setSpeed(rpm: number): void { console.log(`Fan: Setting speed to ${rpm} RPM`); }
}
 
// Facade - provides simple interface to complex subsystem
class ComputerFacade {
  private cpu: CPU;
  private memory: Memory;
  private hardDrive: HardDrive;
  private fan: Fan;
  
  constructor() {
    this.cpu = new CPU();
    this.memory = new Memory();
    this.hardDrive = new HardDrive();
    this.fan = new Fan();
  }
  
  start(): void {
    console.log("\n🖥️  === Starting Computer ===\n");
    this.fan.start();
    this.fan.setSpeed(2000);
    this.cpu.freeze();
    this.memory.load(0, this.hardDrive.read(0, 1024));
    this.cpu.jump(0);
    this.cpu.execute();
    console.log("\n✅ Computer started successfully!\n");
  }
  
  shutdown(): void {
    console.log("\n🖥️  === Shutting Down ===\n");
    this.cpu.freeze();
    this.fan.setSpeed(0);
    console.log("\n✅ Computer shut down.\n");
  }
}
 
// Client code - simple interface!
const computer = new ComputerFacade();
computer.start();
computer.shutdown();

Composite Pattern

The Composite pattern composes objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions uniformly:

// Component interface
interface FileSystemItem {
  getName(): string;
  getSize(): number;
  print(indent?: string): void;
}
 
// Leaf - represents individual files
class File implements FileSystemItem {
  constructor(private name: string, private size: number) {}
  
  getName(): string { return this.name; }
  getSize(): number { return this.size; }
  
  print(indent: string = ""): void {
    console.log(`${indent}📄 ${this.name} (${this.size} KB)`);
  }
}
 
// Composite - represents folders that can contain files and other folders
class Folder implements FileSystemItem {
  private items: FileSystemItem[] = [];
  
  constructor(private name: string) {}
  
  add(item: FileSystemItem): void {
    this.items.push(item);
  }
  
  remove(item: FileSystemItem): void {
    const index = this.items.indexOf(item);
    if (index > -1) this.items.splice(index, 1);
  }
  
  getName(): string { return this.name; }
  
  getSize(): number {
    return this.items.reduce((total, item) => total + item.getSize(), 0);
  }
  
  print(indent: string = ""): void {
    console.log(`${indent}📁 ${this.name}/ (${this.getSize()} KB total)`);
    this.items.forEach(item => item.print(indent + "  "));
  }
}
 
// Usage - build a file system tree
const root = new Folder("root");
 
const documents = new Folder("documents");
documents.add(new File("resume.pdf", 120));
documents.add(new File("cover_letter.docx", 45));
 
const photos = new Folder("photos");
const vacation = new Folder("vacation");
vacation.add(new File("beach.jpg", 2048));
vacation.add(new File("sunset.jpg", 1856));
photos.add(vacation);
photos.add(new File("profile.png", 512));
 
const code = new Folder("code");
code.add(new File("app.ts", 25));
code.add(new File("utils.ts", 12));
 
root.add(documents);
root.add(photos);
root.add(code);
root.add(new File("readme.txt", 5));
 
root.print();
// 📁 root/ (4623 KB total)
//   📁 documents/ (165 KB total)
//     📄 resume.pdf (120 KB)
//     📄 cover_letter.docx (45 KB)
//   📁 photos/ (4416 KB total)
//     📁 vacation/ (3904 KB total)
//       📄 beach.jpg (2048 KB)
//       📄 sunset.jpg (1856 KB)
//     📄 profile.png (512 KB)
//   📁 code/ (37 KB total)
//     📄 app.ts (25 KB)
//     📄 utils.ts (12 KB)
//   📄 readme.txt (5 KB)

Behavioral Patterns

Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. They describe not just patterns of objects but also the patterns of communication between them.

Observer Pattern

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified automatically. This is the foundation of reactive programming and event-driven architectures:

// Observer interface
interface Observer {
  update(data: any): void;
}
 
// Subject interface
interface Subject {
  subscribe(observer: Observer): void;
  unsubscribe(observer: Observer): void;
  notify(data: any): void;
}
 
// Concrete Subject - a news agency
class NewsAgency implements Subject {
  private observers: Observer[] = [];
  private latestNews: string = "";
  
  subscribe(observer: Observer): void {
    this.observers.push(observer);
    console.log("📋 New subscriber added");
  }
  
  unsubscribe(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
      console.log("📋 Subscriber removed");
    }
  }
  
  notify(data: any): void {
    console.log(`📢 Notifying ${this.observers.length} subscribers...`);
    this.observers.forEach(observer => observer.update(data));
  }
  
  publishNews(news: string): void {
    this.latestNews = news;
    console.log(`\n📰 BREAKING NEWS: ${news}\n`);
    this.notify(news);
  }
}
 
// Concrete Observers
class NewsWebsite implements Observer {
  constructor(private name: string) {}
  
  update(news: string): void {
    console.log(`🌐 ${this.name} published article: "${news}"`);
  }
}
 
class MobileApp implements Observer {
  constructor(private appName: string) {}
  
  update(news: string): void {
    console.log(`📱 ${this.appName} sent push notification: "${news}"`);
  }
}
 
class EmailSubscriber implements Observer {
  constructor(private email: string) {}
  
  update(news: string): void {
    console.log(`📧 Email sent to ${this.email}: "${news}"`);
  }
}
 
// Usage
const agency = new NewsAgency();
 
const website = new NewsWebsite("TechCrunch");
const app = new MobileApp("NewsFlash");
const email = new EmailSubscriber("user@example.com");
 
agency.subscribe(website);
agency.subscribe(app);
agency.subscribe(email);
 
agency.publishNews("TypeScript 6.0 Released!");
 
agency.unsubscribe(email);
 
agency.publishNews("New JavaScript Framework Announced!");

Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The strategy lets the algorithm vary independently from clients that use it:

// Strategy interface
interface PaymentStrategy {
  pay(amount: number): boolean;
  getName(): string;
}
 
// Concrete strategies
class CreditCardPayment implements PaymentStrategy {
  constructor(
    private cardNumber: string,
    private cvv: string,
    private expiryDate: string
  ) {}
  
  pay(amount: number): boolean {
    console.log(`💳 Processing credit card payment of $${amount}`);
    console.log(`   Card: **** **** **** ${this.cardNumber.slice(-4)}`);
    // Simulate payment processing
    return true;
  }
  
  getName(): string { return "Credit Card"; }
}
 
class PayPalPayment implements PaymentStrategy {
  constructor(private email: string) {}
  
  pay(amount: number): boolean {
    console.log(`🅿️ Processing PayPal payment of $${amount}`);
    console.log(`   Account: ${this.email}`);
    return true;
  }
  
  getName(): string { return "PayPal"; }
}
 
class CryptoPayment implements PaymentStrategy {
  constructor(private walletAddress: string) {}
  
  pay(amount: number): boolean {
    console.log(`₿ Processing cryptocurrency payment of $${amount}`);
    console.log(`   Wallet: ${this.walletAddress.slice(0, 10)}...`);
    return true;
  }
  
  getName(): string { return "Cryptocurrency"; }
}
 
// Context
class ShoppingCart {
  private items: { name: string; price: number }[] = [];
  
  addItem(name: string, price: number): void {
    this.items.push({ name, price });
    console.log(`🛒 Added: ${name} - $${price}`);
  }
  
  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
  
  checkout(paymentStrategy: PaymentStrategy): void {
    const total = this.getTotal();
    console.log(`\n📦 Checkout - Total: $${total}`);
    console.log(`   Payment method: ${paymentStrategy.getName()}\n`);
    
    if (paymentStrategy.pay(total)) {
      console.log("\n✅ Payment successful! Order placed.\n");
      this.items = [];
    } else {
      console.log("\n❌ Payment failed. Please try again.\n");
    }
  }
}
 
// Usage
const cart = new ShoppingCart();
cart.addItem("Mechanical Keyboard", 149);
cart.addItem("Mouse Pad", 29);
cart.addItem("USB Hub", 45);
 
// Choose payment strategy at runtime
cart.checkout(new CreditCardPayment("1234567890123456", "123", "12/25"));
 
cart.addItem("Monitor Stand", 89);
cart.checkout(new PayPalPayment("user@example.com"));
 
cart.addItem("Webcam", 79);
cart.checkout(new CryptoPayment("0x742d35Cc6634C0532925a3b844Bc454e4438f44e"));

Command Pattern

The Command pattern encapsulates a request as an object, allowing you to parameterize clients with different requests, queue or log requests, and support undoable operations:

// Command interface
interface Command {
  execute(): void;
  undo(): void;
}
 
// Receiver
class TextEditor {
  private content: string = "";
  private cursorPosition: number = 0;
  
  getContent(): string { return this.content; }
  
  insertText(text: string, position: number): void {
    this.content = 
      this.content.slice(0, position) + 
      text + 
      this.content.slice(position);
    this.cursorPosition = position + text.length;
  }
  
  deleteText(position: number, length: number): string {
    const deleted = this.content.slice(position, position + length);
    this.content = 
      this.content.slice(0, position) + 
      this.content.slice(position + length);
    this.cursorPosition = position;
    return deleted;
  }
  
  print(): void {
    console.log(`📝 Content: "${this.content}"`);
  }
}
 
// Concrete commands
class InsertCommand implements Command {
  constructor(
    private editor: TextEditor,
    private text: string,
    private position: number
  ) {}
  
  execute(): void {
    this.editor.insertText(this.text, this.position);
  }
  
  undo(): void {
    this.editor.deleteText(this.position, this.text.length);
  }
}
 
class DeleteCommand implements Command {
  private deletedText: string = "";
  
  constructor(
    private editor: TextEditor,
    private position: number,
    private length: number
  ) {}
  
  execute(): void {
    this.deletedText = this.editor.deleteText(this.position, this.length);
  }
  
  undo(): void {
    this.editor.insertText(this.deletedText, this.position);
  }
}
 
// Invoker with history for undo/redo
class CommandManager {
  private history: Command[] = [];
  private redoStack: Command[] = [];
  
  execute(command: Command): void {
    command.execute();
    this.history.push(command);
    this.redoStack = []; // Clear redo stack on new command
  }
  
  undo(): void {
    const command = this.history.pop();
    if (command) {
      command.undo();
      this.redoStack.push(command);
      console.log("↩️ Undo");
    } else {
      console.log("Nothing to undo");
    }
  }
  
  redo(): void {
    const command = this.redoStack.pop();
    if (command) {
      command.execute();
      this.history.push(command);
      console.log("↪️ Redo");
    } else {
      console.log("Nothing to redo");
    }
  }
}
 
// Usage
const editor = new TextEditor();
const manager = new CommandManager();
 
manager.execute(new InsertCommand(editor, "Hello", 0));
editor.print(); // 📝 Content: "Hello"
 
manager.execute(new InsertCommand(editor, " World", 5));
editor.print(); // 📝 Content: "Hello World"
 
manager.execute(new InsertCommand(editor, "!", 11));
editor.print(); // 📝 Content: "Hello World!"
 
manager.undo();
editor.print(); // 📝 Content: "Hello World"
 
manager.undo();
editor.print(); // 📝 Content: "Hello"
 
manager.redo();
editor.print(); // 📝 Content: "Hello World"

State Pattern

The State pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class. This is cleaner than having giant switch statements checking state throughout your code:

// State interface
interface OrderState {
  next(order: Order): void;
  cancel(order: Order): void;
  getStatus(): string;
}
 
// Context
class Order {
  private state: OrderState;
  
  constructor(public orderId: string) {
    this.state = new PendingState();
    console.log(`📦 Order ${orderId} created`);
  }
  
  setState(state: OrderState): void {
    this.state = state;
    console.log(`   Status changed to: ${state.getStatus()}`);
  }
  
  nextStep(): void {
    this.state.next(this);
  }
  
  cancel(): void {
    this.state.cancel(this);
  }
  
  getStatus(): string {
    return this.state.getStatus();
  }
}
 
// Concrete states
class PendingState implements OrderState {
  getStatus(): string { return "⏳ Pending"; }
  
  next(order: Order): void {
    console.log("✅ Payment confirmed");
    order.setState(new PaidState());
  }
  
  cancel(order: Order): void {
    console.log("❌ Order cancelled");
    order.setState(new CancelledState());
  }
}
 
class PaidState implements OrderState {
  getStatus(): string { return "💳 Paid"; }
  
  next(order: Order): void {
    console.log("📤 Order shipped");
    order.setState(new ShippedState());
  }
  
  cancel(order: Order): void {
    console.log("💰 Refund initiated, order cancelled");
    order.setState(new CancelledState());
  }
}
 
class ShippedState implements OrderState {
  getStatus(): string { return "🚚 Shipped"; }
  
  next(order: Order): void {
    console.log("📬 Order delivered");
    order.setState(new DeliveredState());
  }
  
  cancel(order: Order): void {
    console.log("⚠️ Cannot cancel shipped order");
  }
}
 
class DeliveredState implements OrderState {
  getStatus(): string { return "✅ Delivered"; }
  
  next(order: Order): void {
    console.log("ℹ️ Order already delivered");
  }
  
  cancel(order: Order): void {
    console.log("⚠️ Cannot cancel delivered order. Please initiate return.");
  }
}
 
class CancelledState implements OrderState {
  getStatus(): string { return "❌ Cancelled"; }
  
  next(order: Order): void {
    console.log("⚠️ Cannot process cancelled order");
  }
  
  cancel(order: Order): void {
    console.log("ℹ️ Order already cancelled");
  }
}
 
// Usage
console.log("\n=== Order 1: Normal flow ===");
const order1 = new Order("ORD-001");
order1.nextStep(); // Pending -> Paid
order1.nextStep(); // Paid -> Shipped
order1.nextStep(); // Shipped -> Delivered
order1.nextStep(); // Already delivered
 
console.log("\n=== Order 2: Cancelled after payment ===");
const order2 = new Order("ORD-002");
order2.nextStep(); // Pending -> Paid
order2.cancel();   // Paid -> Cancelled (with refund)
 
console.log("\n=== Order 3: Try to cancel shipped ===");
const order3 = new Order("ORD-003");
order3.nextStep(); // Pending -> Paid
order3.nextStep(); // Paid -> Shipped
order3.cancel();   // Cannot cancel shipped

Template Method Pattern

The Template Method pattern defines the skeleton of an algorithm in a base class, letting subclasses override specific steps without changing the algorithm's structure:

// Abstract class with template method
abstract class DataProcessor {
  // Template method - defines the algorithm skeleton
  process(): void {
    this.readData();
    this.processData();
    this.summarize();
    
    // Hook - optional step
    if (this.shouldExport()) {
      this.exportData();
    }
    
    this.cleanup();
  }
  
  // Abstract methods - must be implemented by subclasses
  protected abstract readData(): void;
  protected abstract processData(): void;
  protected abstract exportData(): void;
  
  // Hook - can be overridden, has default implementation
  protected shouldExport(): boolean {
    return true;
  }
  
  // Concrete methods - common implementation
  protected summarize(): void {
    console.log("📊 Generating summary...");
  }
  
  protected cleanup(): void {
    console.log("🧹 Cleaning up resources...\n");
  }
}
 
// Concrete implementation for CSV
class CSVProcessor extends DataProcessor {
  protected readData(): void {
    console.log("📄 Reading CSV file...");
  }
  
  protected processData(): void {
    console.log("⚙️ Parsing CSV rows and columns...");
  }
  
  protected exportData(): void {
    console.log("💾 Exporting to JSON format...");
  }
}
 
// Concrete implementation for Database
class DatabaseProcessor extends DataProcessor {
  protected readData(): void {
    console.log("🗄️ Connecting to database...");
    console.log("📖 Executing SELECT query...");
  }
  
  protected processData(): void {
    console.log("⚙️ Processing database records...");
  }
  
  protected exportData(): void {
    console.log("💾 Exporting to CSV format...");
  }
  
  // Override hook - don't export by default
  protected shouldExport(): boolean {
    return false;
  }
}
 
// Concrete implementation for API
class APIProcessor extends DataProcessor {
  protected readData(): void {
    console.log("🌐 Fetching data from REST API...");
  }
  
  protected processData(): void {
    console.log("⚙️ Transforming JSON response...");
  }
  
  protected exportData(): void {
    console.log("💾 Caching processed data...");
  }
}
 
// Usage
console.log("=== Processing CSV ===");
const csvProcessor = new CSVProcessor();
csvProcessor.process();
 
console.log("=== Processing Database ===");
const dbProcessor = new DatabaseProcessor();
dbProcessor.process();
 
console.log("=== Processing API ===");
const apiProcessor = new APIProcessor();
apiProcessor.process();

The Dark Side of Patterns

If you've read this far, you might think patterns are an unalloyed good—tools to be applied liberally for better code. This is a dangerous mindset. Patterns have costs, and overusing them is a common affliction among developers who have recently discovered them.

Premature abstraction is perhaps the most common sin. You're building a simple CRUD application with one type of user and one database. Do you need a Factory Method for creating users? An Abstract Factory for database connections? A Strategy for (the single) authentication method? Almost certainly not. These patterns add indirection, which means more code to write, more code to understand, and more code to debug.

The principle is simple: don't apply a pattern until you feel the pain it's designed to solve. If you have one shipping method, you don't need Strategy. When you add a second, consider it. When you add a third, you'll be glad you refactored.

Pattern-oriented design is a related pathology. Some developers approach problems by asking, "Which pattern applies here?" This inverts the correct approach, which is to understand the problem deeply and then, if a pattern fits, apply it. Patterns are descriptive (capturing common solutions) not prescriptive (mandating how to solve problems).

Cargo cult pattern usage occurs when developers apply patterns without understanding them. They see a Singleton in one codebase and sprinkle Singletons throughout their own code. They read that Dependency Injection is good and inject everything, even trivial dependencies that will never change. They create elaborate Abstract Factories for objects that are instantiated once at startup.

The antidote is understanding. Don't just learn what a pattern is; learn why it exists, what problem it solves, and what the tradeoffs are. Then apply judgment.

Patterns in the Modern Era

The Gang of Four wrote in a world of desktop applications, object-oriented languages like C++ and Smalltalk, and relatively monolithic architectures. How do patterns fare in the modern landscape of microservices, functional programming, cloud-native applications, and AI-generated code?

The patterns themselves remain relevant, though some have evolved and some have faded in importance. Factory patterns are less common in languages with first-class functions (pass a factory function instead of a factory class) but the underlying principle of abstracting object creation persists. Observer has evolved into sophisticated reactive programming frameworks like RxJS and reactive Spring, but the core idea—subscribing to changes—is unchanged. Strategy is often implemented with lambdas rather than full-blown class hierarchies, but the pattern of swappable algorithms remains.

Some patterns have become so fundamental that they've been absorbed into platforms and frameworks. Dependency injection, an application of the Dependency Inversion Principle, is built into frameworks like Spring, Angular, and ASP.NET. You rarely implement it from scratch; you configure it.

New patterns have emerged for new problems. Circuit Breaker manages fault tolerance in distributed systems, preventing cascading failures when services are down. Saga coordinates transactions across microservices, maintaining consistency without distributed transactions. Sidecar deploys components alongside a primary application to provide supporting features like logging and proxying. These aren't in the GoF book, but they follow the same approach of capturing recurring solutions to design problems.

Perhaps most importantly, the mindset that patterns encourage—thinking abstractly about design, recognizing recurring problems, communicating solutions through a shared vocabulary—is more valuable than ever. When your AI assistant generates code, it's generating implementations. The design decisions—what abstractions to use, how components should interact, which tradeoffs to make—remain the province of human judgment. Patterns help you make those decisions wisely.

The Humble Craftsman's Approach

I want to close with a perspective on how to relate to design patterns as a practicing software developer. Patterns are tools, not goals. They're means, not ends. The goal is working software that meets user needs, is maintainable over time, and is pleasant to work with. Patterns can help achieve that goal—or they can get in the way if applied thoughtlessly.

Here's my recommended approach:

Learn the patterns deeply. Understand not just the structure but the intent, the forces that lead to the pattern, the consequences of applying it. Internalize them so they become part of your design intuition.

Recognize patterns in the wild. When you read code—your team's code, open-source libraries, framework internals—practice identifying the patterns at play. This deepens your understanding and helps you see how patterns work in practice.

Apply patterns when they fit. When you feel the tension a pattern addresses—code duplication, rigid dependencies, complicated conditionals—consider whether a pattern provides a cleaner solution. But always start simple and refactor toward patterns as complexity warrants.

Resist pattern fever. Don't look for opportunities to apply patterns. Don't evaluate your code by counting patterns. A simple, clear solution with no explicit patterns is often better than a complex solution studded with design pattern gems.

Learn the principles beneath the patterns. SOLID, encapsulation, cohesion, coupling—these principles are more fundamental than any individual pattern. If you deeply understand the principles, you'll reinvent patterns as needed and recognize when novel solutions are warranted.

Stay pragmatic. Software development is ultimately about delivering value. Patterns are one tool for doing that effectively. But deadlines are real, budgets are finite, and perfect is the enemy of good. Make wise tradeoffs.

The Gang of Four knew this. In their book, they wrote: "Design patterns should not be applied indiscriminately. Often they achieve flexibility and variability by introducing additional levels of indirection, and that can complicate a design and/or cost you some performance. A design pattern should only be applied when the flexibility it affords is actually needed."

Thirty years later, those words remain the best advice on using design patterns wisely. Learn the patterns. Understand the principles. Apply judgment. Build software that works.


This article draws on concepts from "Design Patterns: Elements of Reusable Object-Oriented Software" by the Gang of Four, "Dive Into Design Patterns" by Alexander Shvets, and "Clean Architecture" by Robert C. Martin.

Share this post