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
- What Are Design Patterns?
- The Gang of Four (GoF) — History and Context
- The Three Categories
- The Pattern Template
- Overview of Key Patterns
- When to Use Patterns vs When They're Overkill
- Anti-Patterns — Patterns Gone Wrong
- Design Patterns in JavaScript/TypeScript Context
- Key Takeaways
- 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
| Concept | What It Is | Example |
|---|---|---|
| Algorithm | Step-by-step instructions to solve a specific computation | Binary search, quicksort |
| Library | Reusable code you import and call | Express, Lodash, Axios |
| Design Pattern | A template for structuring code to solve a design problem | Singleton, 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 Smalltalk | JavaScript, TypeScript, Python, Go, Rust |
| Everything was OOP | Functional programming is mainstream |
| Patterns required verbose code | Some patterns are built into the language (iterators, closures) |
| Singletons were respected | Singletons are often considered an anti-pattern |
| Inheritance was king | Composition 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.
| Pattern | One-Line Description | Real-World Analogy |
|---|---|---|
| Singleton | Ensure a class has only one instance | A country's president — only one at a time |
| Factory Method | Let subclasses decide which class to instantiate | A pizza shop — you order "pizza" and the shop decides which type |
| Abstract Factory | Create families of related objects | A furniture store — "modern" gives you modern chair + modern table |
| Builder | Construct complex objects step by step | Building a custom burger — choose bun, patty, toppings, sauce |
| Prototype | Create new objects by cloning existing ones | Photocopying 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.
| Pattern | One-Line Description | Real-World Analogy |
|---|---|---|
| Adapter | Make incompatible interfaces work together | A power plug adapter — US plug in a European socket |
| Bridge | Separate abstraction from implementation | A TV remote — works with any TV brand |
| Composite | Treat individual objects and groups uniformly | File system — files and folders implement the same interface |
| Decorator | Add responsibilities to objects dynamically | Wrapping a gift — add ribbon, bow, tag without changing the gift |
| Facade | Provide a simplified interface to a complex subsystem | A hotel concierge — one person handles all your requests |
| Flyweight | Share common state between many objects | Character glyphs in a document — 'a' is stored once, reused thousands of times |
| Proxy | Control access to another object | A bodyguard — controls who gets to talk to the celebrity |
Behavioral Patterns — "How do objects communicate?"
Control how objects interact and distribute responsibilities.
| Pattern | One-Line Description | Real-World Analogy |
|---|---|---|
| Chain of Responsibility | Pass requests along a chain of handlers | Customer support escalation — agent → supervisor → manager |
| Command | Encapsulate a request as an object | A restaurant order — waiter writes it down, chef executes later |
| Iterator | Access elements sequentially without exposing structure | A playlist — next/previous, don't care how songs are stored |
| Mediator | Centralize complex communication between objects | Air traffic control — planes don't talk to each other directly |
| Memento | Capture and restore an object's state | Ctrl+Z (undo) — save snapshots, restore when needed |
| Observer | Notify dependents when state changes | YouTube subscriptions — creator uploads, all subscribers get notified |
| State | Alter behavior when internal state changes | A vending machine — behavior depends on whether it has coins inserted |
| Strategy | Define a family of interchangeable algorithms | GPS navigation — same destination, choose fastest/shortest/scenic route |
| Template Method | Define algorithm skeleton, let subclasses fill in steps | A recipe template — same steps, different ingredients |
| Visitor | Add operations to objects without modifying them | A 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
| Section | Purpose | Example (Observer) |
|---|---|---|
| Name | Identifies the pattern | Observer |
| Also Known As | Alternative names | Pub/Sub, Event-Subscriber, Listener |
| Problem | What 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/Applicability | When should you use this pattern? | Event systems, UI updates, data binding, message queues |
| Solution | Description of the structure and participants | Subject maintains a list of Observers; when state changes, it notifies all Observers |
| Structure | Class/sequence diagrams | Subject → Observer interface → ConcreteObservers |
| Participants | Key classes/interfaces and their roles | Subject (publishes), Observer (subscribes), ConcreteObserver (reacts) |
| Consequences | Trade-offs, benefits, and liabilities | (+) Loose coupling, (-) Memory leaks if observers aren't removed, (-) Notification order not guaranteed |
| Implementation | Language-specific tips | Use EventEmitter in Node.js, WeakRef for observer references |
| Known Uses | Where this pattern appears in real software | DOM events, React state, Node.js EventEmitter, RxJS |
| Related Patterns | Patterns that are often used alongside | Mediator (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
| Pattern | Frequency in JS/TS | Where You'll See It |
|---|---|---|
| Observer | Very High | EventEmitter, DOM events, React state, pub/sub |
| Strategy | Very High | Sorting comparators, validation rules, payment processors |
| Factory | High | Object creation, React.createElement, database drivers |
| Singleton | High (often via modules) | Database connections, config, loggers |
| Decorator | High | TypeScript decorators, Express middleware, higher-order functions |
| Builder | Medium-High | Query builders (Knex), configuration objects, test fixtures |
| Adapter | Medium | API wrappers, library migrations, legacy code integration |
| Facade | Medium | Simplified APIs over complex libraries |
| Proxy | Medium | Caching proxies, validation proxies, JavaScript Proxy object |
| Chain of Responsibility | Medium | Express middleware, validation chains |
| Command | Medium | Undo/redo, task queues, CQRS |
| Iterator | Built-in | for...of, generators, Symbol.iterator |
| State | Medium | UI state machines, workflow engines |
| Template Method | Low-Medium | Framework 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... |
|---|---|
| Factory | Object creation is complex, involves config, or the type is determined at runtime |
| Strategy | You have 3+ interchangeable algorithms and might add more |
| Observer | Multiple parts of the system need to react to changes, and they change independently |
| Decorator | You need to combine behaviors dynamically without a class explosion |
| Adapter | You're integrating with a third-party API whose interface doesn't match yours |
| Builder | Object construction requires 5+ optional parameters or conditional steps |
| Proxy | You 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-Pattern | What It Is | Fix |
|---|---|---|
| God Object | One class does everything | Apply SRP, extract classes |
| Spaghetti Code | No structure, tangled logic | Apply SoC, modularize |
| Golden Hammer | Using one tool/pattern for everything | Choose the right tool for the job |
| Lava Flow | Dead code nobody removes | Delete it; use git to recover if needed |
| Premature Optimization | Optimizing before measuring | Profile first, optimize bottlenecks |
| Copy-Paste Programming | Duplicating code instead of abstracting | Apply DRY (after Rule of Three) |
| Boat Anchor | Keeping unused code "just in case" | YAGNI — delete it |
| Magic Numbers/Strings | Hardcoded values without explanation | Use 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
- 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.
- The Gang of Four defined 23 patterns in three categories: Creational (how to make objects), Structural (how to compose objects), Behavioral (how objects communicate).
- Every pattern has trade-offs. The "Consequences" section is as important as the "Solution" section.
- Some patterns are built into JavaScript — iterators, closures-as-strategies, module singletons, EventEmitter, native Proxy.
- Anti-patterns are just as important to learn. Recognizing God Objects, Spaghetti Code, Golden Hammers, and Premature Optimization will save you from common traps.
- The Rule of Three applies to patterns: Don't force a pattern until you've seen the problem at least three times.
- Patterns provide vocabulary. Saying "we use the Observer pattern here" communicates more in 6 words than a paragraph of description.
- 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:
-
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."
-
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?
-
Anti-pattern detective: A codebase has a file called
helpers.jswith 150 exported functions covering formatting, validation, API calls, date math, and string manipulation. What anti-pattern is this? How would you fix it? -
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.