Episode 9 — System Design / 9.2 — Design Principles

9.2.c — Introduction to Design Patterns

In one sentence: Design patterns are reusable solutions to recurring software problems — they give you a shared vocabulary, proven templates, and the wisdom to know when a pattern helps and when it's overkill.

Navigation: ← DRY and Other Principles · Next → Writing Extensible Code


Table of Contents

  1. What Are Design Patterns?
  2. The Gang of Four (GoF) — History and Context
  3. The Three Categories
  4. The Pattern Template
  5. Overview of Key Patterns
  6. When to Use Patterns vs When They're Overkill
  7. Anti-Patterns — Patterns Gone Wrong
  8. Design Patterns in JavaScript/TypeScript Context
  9. Key Takeaways
  10. Explain-It Challenge

1. What Are Design Patterns?

A design pattern is not a piece of code you can copy-paste. It is a description of a solution to a problem that occurs repeatedly in software design.

The Architecture Analogy

Architects don't reinvent the concept of a "door" for every building. They have patterns:

  • Revolving door — for high-traffic entrances
  • Sliding door — for space-constrained openings
  • Emergency exit — for safety compliance

Each "door pattern" specifies the problem it solves, the context where it applies, and the trade-offs. Software design patterns work the same way.

Pattern vs Algorithm vs Library

ConceptWhat It IsExample
AlgorithmStep-by-step instructions to solve a specific computationBinary search, quicksort
LibraryReusable code you import and callExpress, Lodash, Axios
Design PatternA template for structuring code to solve a design problemSingleton, Observer, Factory

A pattern is higher-level than an algorithm and more abstract than a library. You can implement the same pattern in any language.


2. The Gang of Four (GoF) — History and Context

The Book That Started It All

In 1994, four authors — Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides — published Design Patterns: Elements of Reusable Object-Oriented Software. They became known as the "Gang of Four" (GoF).

The book catalogued 23 design patterns organized into three categories. It was written in the context of C++ and Smalltalk, but the ideas transcend any specific language.

Why It Matters Today

  • Shared vocabulary — When you say "this uses the Observer pattern," every experienced developer immediately understands the structure.
  • Proven solutions — These patterns have been battle-tested across millions of applications for 30+ years.
  • Interview currency — Design pattern knowledge is heavily tested in system design interviews.

What Has Changed Since 1994

Then (1994)Now
C++ and SmalltalkJavaScript, TypeScript, Python, Go, Rust
Everything was OOPFunctional programming is mainstream
Patterns required verbose codeSome patterns are built into the language (iterators, closures)
Singletons were respectedSingletons are often considered an anti-pattern
Inheritance was kingComposition is preferred

Some GoF patterns are less relevant in modern JavaScript because the language itself provides the mechanism (e.g., closures replace the Strategy pattern in many cases). Others are more important than ever (e.g., Observer is the basis of React's state management, event emitters, and pub/sub systems).


3. The Three Categories

The 23 GoF patterns are divided into three groups based on their purpose:

Creational Patterns — "How do I create objects?"

Control how objects are instantiated. They abstract the creation process so your code isn't locked to specific classes.

PatternOne-Line DescriptionReal-World Analogy
SingletonEnsure a class has only one instanceA country's president — only one at a time
Factory MethodLet subclasses decide which class to instantiateA pizza shop — you order "pizza" and the shop decides which type
Abstract FactoryCreate families of related objectsA furniture store — "modern" gives you modern chair + modern table
BuilderConstruct complex objects step by stepBuilding a custom burger — choose bun, patty, toppings, sauce
PrototypeCreate new objects by cloning existing onesPhotocopying a document — faster than rewriting

Structural Patterns — "How do I compose objects?"

Control how classes and objects are assembled into larger structures while keeping things flexible and efficient.

PatternOne-Line DescriptionReal-World Analogy
AdapterMake incompatible interfaces work togetherA power plug adapter — US plug in a European socket
BridgeSeparate abstraction from implementationA TV remote — works with any TV brand
CompositeTreat individual objects and groups uniformlyFile system — files and folders implement the same interface
DecoratorAdd responsibilities to objects dynamicallyWrapping a gift — add ribbon, bow, tag without changing the gift
FacadeProvide a simplified interface to a complex subsystemA hotel concierge — one person handles all your requests
FlyweightShare common state between many objectsCharacter glyphs in a document — 'a' is stored once, reused thousands of times
ProxyControl access to another objectA bodyguard — controls who gets to talk to the celebrity

Behavioral Patterns — "How do objects communicate?"

Control how objects interact and distribute responsibilities.

PatternOne-Line DescriptionReal-World Analogy
Chain of ResponsibilityPass requests along a chain of handlersCustomer support escalation — agent → supervisor → manager
CommandEncapsulate a request as an objectA restaurant order — waiter writes it down, chef executes later
IteratorAccess elements sequentially without exposing structureA playlist — next/previous, don't care how songs are stored
MediatorCentralize complex communication between objectsAir traffic control — planes don't talk to each other directly
MementoCapture and restore an object's stateCtrl+Z (undo) — save snapshots, restore when needed
ObserverNotify dependents when state changesYouTube subscriptions — creator uploads, all subscribers get notified
StateAlter behavior when internal state changesA vending machine — behavior depends on whether it has coins inserted
StrategyDefine a family of interchangeable algorithmsGPS navigation — same destination, choose fastest/shortest/scenic route
Template MethodDefine algorithm skeleton, let subclasses fill in stepsA recipe template — same steps, different ingredients
VisitorAdd operations to objects without modifying themA tax inspector visiting businesses — same visit structure, different business types

Remembering the Categories

Creational  = "How do I MAKE things?"
Structural  = "How do I CONNECT things?"
Behavioral  = "How do things TALK to each other?"

4. The Pattern Template

Every well-documented design pattern follows a standard template. Understanding this template helps you evaluate and apply patterns correctly.

Standard Pattern Documentation

SectionPurposeExample (Observer)
NameIdentifies the patternObserver
Also Known AsAlternative namesPub/Sub, Event-Subscriber, Listener
ProblemWhat design problem does it solve?When one object changes, multiple other objects need to be notified, but you don't want to hardcode the dependencies
Context/ApplicabilityWhen should you use this pattern?Event systems, UI updates, data binding, message queues
SolutionDescription of the structure and participantsSubject maintains a list of Observers; when state changes, it notifies all Observers
StructureClass/sequence diagramsSubject → Observer interface → ConcreteObservers
ParticipantsKey classes/interfaces and their rolesSubject (publishes), Observer (subscribes), ConcreteObserver (reacts)
ConsequencesTrade-offs, benefits, and liabilities(+) Loose coupling, (-) Memory leaks if observers aren't removed, (-) Notification order not guaranteed
ImplementationLanguage-specific tipsUse EventEmitter in Node.js, WeakRef for observer references
Known UsesWhere this pattern appears in real softwareDOM events, React state, Node.js EventEmitter, RxJS
Related PatternsPatterns that are often used alongsideMediator (centralized), Command (encapsulated actions)

Example: Observer Pattern Quick Implementation

// The Observer pattern in TypeScript

// Observer interface — anything that wants to be notified
interface Observer<T> {
  update(data: T): void;
}

// Subject — the thing being observed
class EventEmitter<T> {
  private observers: Set<Observer<T>> = new Set();

  subscribe(observer: Observer<T>): void {
    this.observers.add(observer);
  }

  unsubscribe(observer: Observer<T>): void {
    this.observers.delete(observer);
  }

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

// Concrete observers
class Logger implements Observer<string> {
  update(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

class AlertSystem implements Observer<string> {
  update(message: string): void {
    if (message.includes('ERROR')) {
      console.log(`[ALERT] Critical: ${message}`);
    }
  }
}

class MetricsCollector implements Observer<string> {
  private count = 0;
  update(message: string): void {
    this.count++;
    console.log(`[METRICS] Total events: ${this.count}`);
  }
}

// Usage
const eventBus = new EventEmitter<string>();
eventBus.subscribe(new Logger());
eventBus.subscribe(new AlertSystem());
eventBus.subscribe(new MetricsCollector());

eventBus.notify('User logged in');           // All three observers react
eventBus.notify('ERROR: Payment failed');    // Alert system triggers too

5. Overview of Key Patterns

Patterns You'll Use Most in JavaScript/TypeScript

PatternFrequency in JS/TSWhere You'll See It
ObserverVery HighEventEmitter, DOM events, React state, pub/sub
StrategyVery HighSorting comparators, validation rules, payment processors
FactoryHighObject creation, React.createElement, database drivers
SingletonHigh (often via modules)Database connections, config, loggers
DecoratorHighTypeScript decorators, Express middleware, higher-order functions
BuilderMedium-HighQuery builders (Knex), configuration objects, test fixtures
AdapterMediumAPI wrappers, library migrations, legacy code integration
FacadeMediumSimplified APIs over complex libraries
ProxyMediumCaching proxies, validation proxies, JavaScript Proxy object
Chain of ResponsibilityMediumExpress middleware, validation chains
CommandMediumUndo/redo, task queues, CQRS
IteratorBuilt-infor...of, generators, Symbol.iterator
StateMediumUI state machines, workflow engines
Template MethodLow-MediumFramework hooks (React lifecycle), base classes

Pattern Quick Reference

// SINGLETON — one database connection pool
class Database {
  private static instance: Database;
  private constructor() { /* connect */ }
  
  static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }
}

// FACTORY — create objects without specifying exact class
function createNotification(type: string, message: string): Notification {
  switch (type) {
    case 'email': return new EmailNotification(message);
    case 'sms': return new SMSNotification(message);
    case 'push': return new PushNotification(message);
    default: throw new Error(`Unknown type: ${type}`);
  }
}

// STRATEGY — swap algorithms at runtime
interface SortStrategy<T> {
  sort(data: T[]): T[];
}

class QuickSort<T> implements SortStrategy<T> {
  sort(data: T[]): T[] { /* quicksort implementation */ return data; }
}

class MergeSort<T> implements SortStrategy<T> {
  sort(data: T[]): T[] { /* mergesort implementation */ return data; }
}

class DataProcessor<T> {
  constructor(private strategy: SortStrategy<T>) {}
  
  process(data: T[]): T[] {
    return this.strategy.sort(data);
  }
  
  setStrategy(strategy: SortStrategy<T>): void {
    this.strategy = strategy;
  }
}

// DECORATOR — add behavior without changing the original
interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(message);
  }
}

class TimestampDecorator implements Logger {
  constructor(private wrapped: Logger) {}
  
  log(message: string): void {
    this.wrapped.log(`[${new Date().toISOString()}] ${message}`);
  }
}

class PrefixDecorator implements Logger {
  constructor(private wrapped: Logger, private prefix: string) {}
  
  log(message: string): void {
    this.wrapped.log(`${this.prefix} ${message}`);
  }
}

// Stack decorators
let logger: Logger = new ConsoleLogger();
logger = new TimestampDecorator(logger);
logger = new PrefixDecorator(logger, '[APP]');
logger.log('Server started');
// Output: [APP] [2025-01-15T10:30:00.000Z] Server started

6. When to Use Patterns vs When They're Overkill

The Pattern Decision Flowchart

Do you have a SPECIFIC design problem?
  │
  ├─ NO → Don't use a pattern. Just write simple code.
  │
  └─ YES → Is the problem RECURRING (happens in multiple places)?
             │
             ├─ NO → Write a one-off solution. Don't force a pattern.
             │
             └─ YES → Does a known pattern fit the problem?
                        │
                        ├─ NO → Design your own solution.
                        │
                        └─ YES → Does the pattern's COMPLEXITY 
                                 justify the BENEFIT?
                                   │
                                   ├─ NO → Simpler is better.
                                   │
                                   └─ YES → Use the pattern. ✅

When Patterns Are Overkill

// OVERKILL: Singleton pattern for a simple config object
class AppConfig {
  private static instance: AppConfig;
  private config: Record<string, string>;
  
  private constructor() {
    this.config = {
      port: process.env.PORT || '3000',
      dbUrl: process.env.DATABASE_URL || 'localhost',
    };
  }
  
  static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }
  
  get(key: string): string {
    return this.config[key];
  }
}

// JUST RIGHT: A plain object. Node modules are singletons by default.
// config.ts
export const config = {
  port: process.env.PORT || '3000',
  dbUrl: process.env.DATABASE_URL || 'localhost',
};

// Both achieve the same goal. The plain object is simpler, testable, and clear.
// OVERKILL: Factory pattern for two types of users
class UserFactory {
  static create(type: string, data: UserData): User {
    if (type === 'admin') return new AdminUser(data);
    if (type === 'regular') return new RegularUser(data);
    throw new Error('Unknown user type');
  }
}

// JUST RIGHT for 2 types: use a simple conditional
function createUser(data: UserData): User {
  return data.isAdmin ? new AdminUser(data) : new RegularUser(data);
}

// Factory becomes JUSTIFIED when you have 10+ types, complex creation logic,
// or need to register new types at runtime.

When Patterns Are Justified

Use This Pattern When...Because...
FactoryObject creation is complex, involves config, or the type is determined at runtime
StrategyYou have 3+ interchangeable algorithms and might add more
ObserverMultiple parts of the system need to react to changes, and they change independently
DecoratorYou need to combine behaviors dynamically without a class explosion
AdapterYou're integrating with a third-party API whose interface doesn't match yours
BuilderObject construction requires 5+ optional parameters or conditional steps
ProxyYou need to add caching, logging, access control, or lazy loading transparently

The "Three Strikes" Rule for Patterns

1st time: Write simple, direct code.
2nd time: Note the similarity, maybe extract a helper function.
3rd time: Now a pattern is justified — you have a real, recurring problem.

7. Anti-Patterns — Patterns Gone Wrong

An anti-pattern is a commonly occurring solution that appears helpful but ultimately creates more problems than it solves.

Anti-Pattern 1: God Object / God Class

// Everything lives in one massive class
class Application {
  // User management
  createUser() { /* ... */ }
  deleteUser() { /* ... */ }
  authenticateUser() { /* ... */ }
  
  // Orders
  createOrder() { /* ... */ }
  processPayment() { /* ... */ }
  calculateShipping() { /* ... */ }
  
  // Reporting
  generateSalesReport() { /* ... */ }
  generateUserReport() { /* ... */ }
  
  // Notifications
  sendEmail() { /* ... */ }
  sendSMS() { /* ... */ }
  
  // Logging
  logActivity() { /* ... */ }
  
  // ... 2000 more lines
}

// Fix: Apply SRP — split into focused services

Anti-Pattern 2: Spaghetti Code

// No structure, no separation, globals everywhere
let users = [];
let orders = [];
let currentUser = null;

function doStuff(action, data) {
  if (action === 'login') {
    for (let i = 0; i < users.length; i++) {
      if (users[i].email === data.email) {
        currentUser = users[i];
        // Now also check orders?
        for (let j = 0; j < orders.length; j++) {
          if (orders[j].userId === currentUser.id) {
            // Do something with the order...
            if (orders[j].status === 'pending') {
              // Send notification maybe?
              console.log('You have pending orders');
            }
          }
        }
      }
    }
  } else if (action === 'signup') {
    // ... another 100 lines
  } else if (action === 'order') {
    // ... another 100 lines
  }
}
// Fix: Structure into modules, apply SoC

Anti-Pattern 3: Golden Hammer

"When all you have is a hammer, everything looks like a nail."
// Using Redux for EVERY piece of state, even local form values
// Using microservices for a 2-page app
// Using a design pattern just because you learned it

// Examples of Golden Hammer:
// - "Let's use Kubernetes" (for a single Node.js app with 100 users)
// - "Let's add GraphQL" (for an API with 3 endpoints)
// - "Let's use the Abstract Factory pattern" (for creating two types of objects)
// - "Let's use Event Sourcing" (for a basic CRUD app)

Anti-Pattern 4: Lava Flow

Dead code that nobody dares to remove because "it might be used somewhere."

// These functions haven't been called in 2 years
// But nobody removes them because "what if we need them?"

function calculateTaxV1(amount: number): number { return amount * 0.08; }
function calculateTaxV2(amount: number): number { return amount * 0.085; }
function calculateTaxV3_old(amount: number): number { return amount * 0.09; }
function calculateTaxV3_new(amount: number): number { return amount * 0.09; }
function calculateTaxFinal(amount: number): number { return amount * 0.095; }
function calculateTaxFinalFinal(amount: number): number { return amount * 0.095; }
// Nobody knows which one is actually used.

// Fix: Use version control (git). Delete dead code. You can always recover it.

Anti-Pattern 5: Premature Optimization

// BEFORE: "This MIGHT be slow, so I'll add caching everywhere"
class UserService {
  private cache = new Map<string, User>();
  private cacheTimestamps = new Map<string, number>();
  private readonly CACHE_TTL = 60000;

  async getUser(id: string): Promise<User> {
    const cached = this.cache.get(id);
    const timestamp = this.cacheTimestamps.get(id);
    
    if (cached && timestamp && Date.now() - timestamp < this.CACHE_TTL) {
      return cached;
    }
    
    const user = await this.repo.findById(id);
    this.cache.set(id, user);
    this.cacheTimestamps.set(id, Date.now());
    return user;
  }
  // Added 20 lines of caching code for a function called 3 times per day.
}

// AFTER: Simple. Add caching IF and WHEN profiling shows it's needed.
class UserService {
  async getUser(id: string): Promise<User> {
    return this.repo.findById(id);
  }
}

Anti-Pattern Summary

Anti-PatternWhat It IsFix
God ObjectOne class does everythingApply SRP, extract classes
Spaghetti CodeNo structure, tangled logicApply SoC, modularize
Golden HammerUsing one tool/pattern for everythingChoose the right tool for the job
Lava FlowDead code nobody removesDelete it; use git to recover if needed
Premature OptimizationOptimizing before measuringProfile first, optimize bottlenecks
Copy-Paste ProgrammingDuplicating code instead of abstractingApply DRY (after Rule of Three)
Boat AnchorKeeping unused code "just in case"YAGNI — delete it
Magic Numbers/StringsHardcoded values without explanationUse named constants

8. Design Patterns in JavaScript/TypeScript Context

Patterns the Language Gives You for Free

Some GoF patterns are baked into JavaScript — you use them without realizing:

// ITERATOR — built into the language
const items = [1, 2, 3, 4, 5];
for (const item of items) { /* ... */ } // Uses Symbol.iterator under the hood

// Custom iterator via generator
function* range(start: number, end: number) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}
for (const n of range(1, 5)) { console.log(n); }

// STRATEGY — closures make it trivial
const strategies = {
  add: (a: number, b: number) => a + b,
  multiply: (a: number, b: number) => a * b,
  power: (a: number, b: number) => a ** b,
};

function calculate(strategy: keyof typeof strategies, a: number, b: number): number {
  return strategies[strategy](a, b);
}

// OBSERVER — EventEmitter is built into Node.js
import { EventEmitter } from 'events';
const emitter = new EventEmitter();
emitter.on('order:placed', (order) => console.log('New order:', order));
emitter.emit('order:placed', { id: 1, total: 99.99 });

// SINGLETON — Node.js modules are cached after first require/import
// config.ts
export const config = { port: 3000 };
// Every file that imports config gets the SAME object

// PROXY — JavaScript has a native Proxy object
const handler = {
  get(target: any, prop: string) {
    console.log(`Accessing ${prop}`);
    return target[prop];
  },
  set(target: any, prop: string, value: any) {
    console.log(`Setting ${prop} = ${value}`);
    target[prop] = value;
    return true;
  },
};
const user = new Proxy({}, handler);
user.name = 'Alice';  // Logs: "Setting name = Alice"

Patterns That Require Extra Thought in JS/TS

// ABSTRACT FACTORY — TypeScript interfaces help, but JS has no abstract classes
// Use interfaces + factory functions

interface UIFactory {
  createButton(): Button;
  createInput(): Input;
  createModal(): Modal;
}

class MaterialUIFactory implements UIFactory {
  createButton(): Button { return new MaterialButton(); }
  createInput(): Input { return new MaterialInput(); }
  createModal(): Modal { return new MaterialModal(); }
}

class AntDesignFactory implements UIFactory {
  createButton(): Button { return new AntButton(); }
  createInput(): Input { return new AntInput(); }
  createModal(): Modal { return new AntModal(); }
}

// BUILDER — very useful for complex object construction
class QueryBuilder {
  private query: Partial<QueryConfig> = {};

  select(...fields: string[]): this {
    this.query.fields = fields;
    return this;
  }

  from(table: string): this {
    this.query.table = table;
    return this;
  }

  where(condition: string): this {
    this.query.conditions = this.query.conditions || [];
    this.query.conditions.push(condition);
    return this;
  }

  limit(n: number): this {
    this.query.limit = n;
    return this;
  }

  build(): string {
    const fields = this.query.fields?.join(', ') || '*';
    let sql = `SELECT ${fields} FROM ${this.query.table}`;
    if (this.query.conditions?.length) {
      sql += ` WHERE ${this.query.conditions.join(' AND ')}`;
    }
    if (this.query.limit) {
      sql += ` LIMIT ${this.query.limit}`;
    }
    return sql;
  }
}

const query = new QueryBuilder()
  .select('name', 'email')
  .from('users')
  .where('active = true')
  .where('role = "admin"')
  .limit(10)
  .build();
// "SELECT name, email FROM users WHERE active = true AND role = "admin" LIMIT 10"

9. Key Takeaways

  1. Design patterns are solutions, not goals. Don't start with "I want to use a pattern." Start with "I have a problem" and see if a pattern fits.
  2. The Gang of Four defined 23 patterns in three categories: Creational (how to make objects), Structural (how to compose objects), Behavioral (how objects communicate).
  3. Every pattern has trade-offs. The "Consequences" section is as important as the "Solution" section.
  4. Some patterns are built into JavaScript — iterators, closures-as-strategies, module singletons, EventEmitter, native Proxy.
  5. Anti-patterns are just as important to learn. Recognizing God Objects, Spaghetti Code, Golden Hammers, and Premature Optimization will save you from common traps.
  6. The Rule of Three applies to patterns: Don't force a pattern until you've seen the problem at least three times.
  7. Patterns provide vocabulary. Saying "we use the Observer pattern here" communicates more in 6 words than a paragraph of description.
  8. Patterns evolve. The GoF book was written in 1994. Modern languages reduce the need for some patterns while introducing new ones (e.g., middleware chains, reactive streams).

10. Explain-It Challenge

Test your understanding:

  1. Categorize these problems: For each scenario, identify whether the solution is Creational, Structural, or Behavioral:

    • "I need to create database connections, but the specific database type is chosen by configuration."
    • "I need old code using XML to work with a new JSON-based API."
    • "I need multiple UI components to update when a user changes their theme preference."
  2. Pattern or overkill? Your team is building an internal tool with 3 notification types (email, Slack, in-app). A teammate suggests implementing the Abstract Factory pattern to create notifications. Is this justified?

  3. Anti-pattern detective: A codebase has a file called helpers.js with 150 exported functions covering formatting, validation, API calls, date math, and string manipulation. What anti-pattern is this? How would you fix it?

  4. JS-native patterns: Name three GoF design patterns that JavaScript developers use every day without realizing it, and explain what language feature provides the pattern.


Next → 9.2.d — Writing Extensible Code