Introduction
We're gonna go over a few design patterns I think everyone should know about.
They'll come in handy countless times in your career.
Observer pattern
Without the pattern
First, here's how someone might try to solve this WITHOUT knowing the Observer pattern.
class StockPrice {
constructor(symbol, price) {
this.symbol = symbol;
this.price = price;
}
updatePrice(newPrice) {
this.price = newPrice;
// Directly updating everything that needs to know about price changes
priceDisplay.updateDisplay(this.symbol, this.price);
priceChart.updateChart(this.symbol, this.price);
priceAlert.checkAlertThreshold(this.symbol, this.price);
mobileNotification.sendUpdate(this.symbol, this.price);
// More things keep getting added here as requirements grow...
}
}
// Usage
const stock = new StockPrice("AAPL", 150);
stock.updatePrice(155);
Problems with this approach:
Tight coupling: StockPrice needs to know about every single component that uses the price
Violates Single Responsibility: StockPrice is doing too much
Hard to maintain: Adding new subscribers means modifying StockPrice
Hard to test: Can't test price updates without mocking all observers
Not flexible: Can't dynamically add/remove price watchers
With the pattern
Here's the BETTER way using the Observer pattern:
// Subject (Publisher)
class StockPrice {
constructor(symbol, price) {
this.symbol = symbol;
this.price = price;
this.observers = new Set();
}
addObserver(observer) {
this.observers.add(observer);
}
removeObserver(observer) {
this.observers.delete(observer);
}
updatePrice(newPrice) {
this.price = newPrice;
this.notifyObservers();
}
notifyObservers() {
for (const observer of this.observers) {
observer.update(this.symbol, this.price);
}
}
}
// Observers (Subscribers)
class PriceDisplay {
update(symbol, price) {
console.log(`Display updating for ${symbol}: $${price}`);
}
}
class PriceChart {
update(symbol, price) {
console.log(`Chart updating for ${symbol}: $${price}`);
}
}
class PriceAlert {
constructor(threshold) {
this.threshold = threshold;
}
update(symbol, price) {
if (price > this.threshold) {
console.log(`Alert! ${symbol} has exceeded ${this.threshold}`);
}
}
}
// Usage
const stock = new StockPrice("AAPL", 150);
const display = new PriceDisplay();
const chart = new PriceChart();
const alert = new PriceAlert(160);
// Add observers
stock.addObserver(display);
stock.addObserver(chart);
stock.addObserver(alert);
// Update price - all observers get notified automatically
stock.updatePrice(165);
// Can easily remove observers
stock.removeObserver(alert);
stock.updatePrice(170); // Alert won't receive this update
You see this patterns a lot when digging into libraries and frameworks.
Why this is better:
Loose coupling: StockPrice doesn't need to know the details of its observers
Single Responsibility: StockPrice just manages price and notifications
Easy to maintain: Add new observers without changing StockPrice
Easy to test: Can test price updates with mock observers
Flexible: Can add/remove observers at runtime
When to use Observer
When you have a one-to-many relationship between objects
When changes in one object should automatically cause changes in others
When the group of objects that need to be notified might change over time
What to avoid
Avoid using it if you only have one observer, as it makes things more complicated than necessary.
Don't use it if updates must happen in a specific order.
Don't overuse it, as having too many observers can make the code hard to understand.
Watch out for memory leaks and make sure to remove observers when they're not needed anymore.
Common use cases
Event handling systems
UI updates (React's state management is based on this)
Real-time data updates
Logging systems
Analytics tracking
Strategy pattern
Without the pattern
Here's how someone might write it WITHOUT knowing the Strategy pattern:
class DeliveryCost {
calculateCost(type, weight, distance) {
if (type === "ground") {
// Complex ground shipping calculation
const baseCost = 10;
const weightCost = weight * 0.5;
const distanceCost = distance * 0.1;
return baseCost + weightCost + distanceCost;
} else if (type === "air") {
// Air shipping calculation
const baseCost = 20;
const weightCost = weight * 0.8;
const distanceCost = distance * 0.2;
return baseCost + weightCost + distanceCost;
} else if (type === "sea") {
// Sea shipping calculation
const baseCost = 15;
const weightCost = weight * 0.3;
const distanceCost = distance * 0.05;
return baseCost + weightCost + distanceCost;
}
}
}
// Usage
const calculator = new DeliveryCost();
console.log(calculator.calculateCost("ground", 10, 100));
Problems with this approach:
Massive if-else chain that keeps growing
Hard to add new shipping methods
Hard to test each method separately
Business logic is all mixed together
Violates Open-Closed Principle (need to modify existing code to add new types)
With the pattern
Here's the BETTER way using the Strategy pattern:
// Strategy interface (in TypeScript this would be an actual interface)
class ShippingStrategy {
calculate(weight, distance) {
throw new Error("calculate method must be implemented");
}
}
// Concrete strategies
class GroundShipping extends ShippingStrategy {
calculate(weight, distance) {
const baseCost = 10;
const weightCost = weight * 0.5;
const distanceCost = distance * 0.1;
return baseCost + weightCost + distanceCost;
}
}
class AirShipping extends ShippingStrategy {
calculate(weight, distance) {
const baseCost = 20;
const weightCost = weight * 0.8;
const distanceCost = distance * 0.2;
return baseCost + weightCost + distanceCost;
}
}
class SeaShipping extends ShippingStrategy {
calculate(weight, distance) {
const baseCost = 15;
const weightCost = weight * 0.3;
const distanceCost = distance * 0.05;
return baseCost + weightCost + distanceCost;
}
}
// Context class that uses the strategies
class DeliveryCostCalculator {
constructor() {
this.strategies = {
ground: new GroundShipping(),
air: new AirShipping(),
sea: new SeaShipping(),
};
}
setStrategy(type) {
this.strategy = this.strategies[type];
}
calculate(weight, distance) {
if (!this.strategy) {
throw new Error("No shipping strategy set");
}
return this.strategy.calculate(weight, distance);
}
}
// Usage
const calculator = new DeliveryCostCalculator();
calculator.setStrategy("ground");
console.log(calculator.calculate(10, 100));
calculator.setStrategy("air");
console.log(calculator.calculate(10, 100));
Why this is better
Each strategy is isolated and encapsulated
Easy to add new shipping methods without changing existing code
Easy to test each strategy independently
Clean separation of concerns
Can switch strategies at runtime
When to use Strategy
When you have a family of similar algorithms
When you need to vary an algorithm depending on client context
When you have a lot of conditional statements around algorithm variants
When different variations of an algorithm are needed
Real-world use cases
Payment processing (PayPal, Stripe, etc.)
Sorting algorithms (different sort methods)
Compression algorithms
Authentication strategies
Tax calculation methods
What to avoid
Avoid using it when algorithms rarely change
Avoid using it when the behavior changes very little
Don't make simple conditional logic too complex
Be cautious about creating too many strategy classes for small differences
Factory pattern
Without the pattern
Imagine we're building a document processing system that needs to create different types of documents (PDF, Word, Excel):
// WITHOUT Factory Method - The bad way
class DocumentProcessor {
createDocument(type, data) {
if (type === "pdf") {
// Complex PDF creation logic
const pdf = {
type: "pdf",
content: data,
format: "A4",
createPDF() {
console.log("Creating PDF with:", this.content);
},
};
return pdf;
} else if (type === "word") {
// Complex Word doc creation
const word = {
type: "word",
content: data,
template: "default",
createDoc() {
console.log("Creating Word doc with:", this.content);
},
};
return word;
} else if (type === "excel") {
// Complex Excel creation
const excel = {
type: "excel",
content: data,
sheets: ["Sheet1"],
createSpreadsheet() {
console.log("Creating Excel with:", this.content);
},
};
return excel;
}
}
}
// Usage
const processor = new DocumentProcessor();
const doc = processor.createDocument("pdf", "Hello World");
Problems with this approach:
Violates Single Responsibility: one class doing too much
Hard to maintain: need to modify this class for every new document type
Duplication: Duplicate creation logic all in one place
Not extensible: tightly coupled to specific document types
With the pattern
Here's the BETTER way using Factory Method:
// Base Document class
class Document {
constructor(content) {
this.content = content;
}
create() {
throw new Error("create() must be implemented");
}
}
// Concrete Document classes
class PDFDocument extends Document {
create() {
console.log("Creating PDF with:", this.content);
// PDF-specific creation logic
}
}
class WordDocument extends Document {
create() {
console.log("Creating Word doc with:", this.content);
// Word-specific creation logic
}
}
class ExcelDocument extends Document {
create() {
console.log("Creating Excel with:", this.content);
// Excel-specific creation logic
}
}
// Factory classes
class DocumentFactory {
createDocument(type, content) {
switch (type) {
case "pdf":
return new PDFDocument(content);
case "word":
return new WordDocument(content);
case "excel":
return new ExcelDocument(content);
default:
throw new Error("Unknown document type");
}
}
}
// Usage
const factory = new DocumentFactory();
const pdfDoc = factory.createDocument("pdf", "Hello World");
pdfDoc.create();
// Easy to add new document types:
class MarkdownDocument extends Document {
create() {
console.log("Creating Markdown with:", this.content);
}
}
// Just add to factory
class BetterDocumentFactory extends DocumentFactory {
createDocument(type, content) {
if (type === "markdown") {
return new MarkdownDocument(content);
}
return super.createDocument(type, content);
}
}
Why this is better:
Separation of concerns: creation logic is separated from business logic
Easy to add new document types: without modifying existing code
Each document type encapsulates its own creation logic
Factory can be extended or replaced without changing document classes
When to use Factory Method
When you don't know the exact types of objects you'll need to create
When you want to let subclasses handle object creation
When you have a group of related objects that need similar creation logic
When you want to keep object creation logic separate
Real-world use cases
UI Component creation
Database connections for different DB types
File parsers for different file formats
Game enemy/obstacle creation
Plugin systems
What to avoid
Don't use it when you have a simple object creation that won't change.
Don't over-complicate when you only have one or two types.
Be careful not to create too many factory classes.
Don't use it when object creation is simple and unlikely to change.
State pattern
Without the pattern
First, here's how someone might handle character states WITHOUT the pattern:
class Character {
constructor() {
// Could be: 'idle', 'walking', 'jumping', 'attacking'
this.currentState = "idle";
}
update() {
if (this.currentState === "idle") {
if (this.isJumpPressed) {
this.currentState = "jumping";
// Jump logic
} else if (this.isMoving) {
this.currentState = "walking";
// Walk logic
}
} else if (this.currentState === "walking") {
if (this.isJumpPressed) {
this.currentState = "jumping";
// Jump logic
} else if (!this.isMoving) {
this.currentState = "idle";
// Idle logic
}
} else if (this.currentState === "jumping") {
if (this.hasLanded) {
if (this.isMoving) {
this.currentState = "walking";
// Walk logic
} else {
this.currentState = "idle";
// Idle logic
}
}
}
// More state checks and transitions...
}
}
Problems with this approach:
Massive if-else chains that grow with each new state
State transition logic is scattered
Hard to add new states
State-specific behavior mixed with transition logic
Hard to debug and maintain
With the pattern
Here's the BETTER way using the State pattern:
// State interface
class State {
constructor(character) {
this.character = character;
}
enter() {}
update() {}
exit() {}
}
// Concrete states
class IdleState extends State {
enter() {
console.log("Entering idle state");
this.character.setAnimation("idle");
}
update() {
if (this.character.isJumpPressed) {
this.character.setState(new JumpingState(this.character));
} else if (this.character.isMoving) {
this.character.setState(new WalkingState(this.character));
}
}
}
class WalkingState extends State {
enter() {
console.log("Entering walking state");
this.character.setAnimation("walk");
}
update() {
if (this.character.isJumpPressed) {
this.character.setState(new JumpingState(this.character));
} else if (!this.character.isMoving) {
this.character.setState(new IdleState(this.character));
}
}
}
class JumpingState extends State {
enter() {
console.log("Entering jumping state");
this.character.setAnimation("jump");
this.character.velocity.y = this.character.jumpForce;
}
update() {
if (this.character.hasLanded) {
if (this.character.isMoving) {
this.character.setState(new WalkingState(this.character));
} else {
this.character.setState(new IdleState(this.character));
}
}
}
}
// Character class using states
class Character {
constructor() {
this.isJumpPressed = false;
this.isMoving = false;
this.hasLanded = false;
this.velocity = { x: 0, y: 0 };
this.jumpForce = 10;
this.currentState = null;
// Start in idle state
this.setState(new IdleState(this));
}
setState(newState) {
if (this.currentState) {
this.currentState.exit();
}
this.currentState = newState;
this.currentState.enter();
}
update() {
this.currentState.update();
}
setAnimation(name) {
console.log(`Playing ${name} animation`);
}
}
// Usage
const character = new Character();
character.update(); // In idle
character.isMoving = true;
character.update(); // Transitions to walking
character.isJumpPressed = true;
character.update(); // Transitions to jumping
Why this is better:
Each state is encapsulated in its own class
Clear state transitions
Easy to add new states without modifying existing code
State-specific behavior is isolated
Much easier to debug and maintain
When to use State pattern
When an object's behavior depends on its state
When you have lots of conditional statements based on object state
When state transitions are complex
When you need to manage state-specific behaviors separately
Real-world use cases
Game character states (idle, walking, jumping, etc.)
Order processing (new, processing, shipped, delivered)
Document editing (draft, review, published)
UI element states (normal, hover, active, disabled)
Connection states (connecting, connected, disconnected)
What to avoid
Don't use it for simple state transitions
Don't use when states rarely change
Be careful of creating too many state classes for minor variations
Watch out for shared state between state classes