Skip to main content

Design Patterns in JavaScript: Creational, Structural, and Behavioral

Master JavaScript design patterns with practical examples. Learn Singleton, Factory, Observer, Strategy, and more patterns to write maintainable, scalable code.

Table of Contents

Introduction

Design patterns are reusable solutions to common problems in software design. They represent best practices evolved over time by experienced developers and provide a shared vocabulary for discussing software architecture. In JavaScript, understanding design patterns is crucial for writing maintainable, scalable, and efficient code.

Whether you’re building a small application or a large-scale system, design patterns help you solve recurring problems without reinventing the wheel. They provide proven approaches to organizing code, managing state, handling communication between objects, and creating flexible architectures that can evolve over time.

This comprehensive guide explores the three main categories of design patterns—creational, structural, and behavioral—with practical JavaScript examples. You’ll learn when to use each pattern, how to implement them correctly, and how to avoid common pitfalls. By the end, you’ll have a solid understanding of how design patterns can improve your JavaScript code quality and architecture.


What Are Design Patterns?

Design patterns are general, reusable solutions to commonly occurring problems within a given context in software design. They aren’t finished code that can be directly transformed into source code, but rather templates for solving problems in a particular way.

The Origin of Design Patterns

The concept of design patterns was popularized by the “Gang of Four” (GoF) in their 1994 book “Design Patterns: Elements of Reusable Object-Oriented Software.” While originally focused on object-oriented programming languages like C++ and Smalltalk, these patterns have been adapted and applied to JavaScript and other modern languages.

Why Design Patterns Matter in JavaScript

JavaScript’s unique features—including its prototypal inheritance, first-class functions, and dynamic nature—make some patterns more natural than others. Understanding design patterns in JavaScript helps you:

  • Write more maintainable code: Patterns provide structure and consistency
  • Communicate effectively: Shared vocabulary with other developers
  • Solve problems efficiently: Proven solutions to common challenges
  • Build scalable applications: Patterns help manage complexity as applications grow

Categories of Design Patterns

Design patterns are typically organized into three main categories:

  1. Creational Patterns: Deal with object creation mechanisms, trying to create objects in a manner suitable to the situation
  2. Structural Patterns: Concern the composition of classes or objects to form larger structures
  3. Behavioral Patterns: Focus on communication between objects and how responsibilities are distributed

Creational Design Patterns

Creational patterns abstract the instantiation process. They help make a system independent of how its objects are created, composed, and represented. In JavaScript, these patterns are particularly useful for managing object creation in complex applications.

Singleton Pattern 🎯

The Singleton pattern ensures a class has only one instance and provides a global point of access to it. This is useful when exactly one object is needed to coordinate actions across the system.

Implementation

// Classic Singleton implementation
class DatabaseConnection {
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
this.connectionString = "mongodb://localhost:27017";
this.connected = false;
DatabaseConnection.instance = this;
}
connect() {
if (!this.connected) {
console.log("Connecting to database...");
this.connected = true;
}
return this;
}
disconnect() {
this.connected = false;
console.log("Disconnected from database");
}
}
// Usage
const db1 = new DatabaseConnection();
const db2 = new DatabaseConnection();
console.log(db1 === db2); // true - same instance
db1.connect();
db2.connect(); // Already connected, won't reconnect

Module Pattern Singleton

// Using module pattern (more JavaScript-idiomatic)
const ConfigManager = (function () {
let instance = null;
function createInstance() {
return {
settings: {},
get(key) {
return this.settings[key];
},
set(key, value) {
this.settings[key] = value;
},
};
}
return {
getInstance() {
if (!instance) {
instance = createInstance();
}
return instance;
},
};
})();
// Usage
const config1 = ConfigManager.getInstance();
const config2 = ConfigManager.getInstance();
console.log(config1 === config2); // true

Use Singleton when: You need exactly one instance of a class (database connections, configuration managers, logging utilities)

Avoid Singleton when: You need multiple instances or when it makes testing difficult

Factory Pattern 🏭

The Factory pattern provides an interface for creating objects without specifying their exact classes. It encapsulates object creation logic and allows you to create objects based on a condition or parameter.

Basic Factory

// Simple factory function
function createUser(type) {
const users = {
admin: () => ({
name: "Admin User",
role: "admin",
permissions: ["read", "write", "delete"],
}),
editor: () => ({
name: "Editor User",
role: "editor",
permissions: ["read", "write"],
}),
viewer: () => ({
name: "Viewer User",
role: "viewer",
permissions: ["read"],
}),
};
return users[type] ? users[type]() : users.viewer();
}
// Usage
const admin = createUser("admin");
const editor = createUser("editor");
console.log(admin.permissions); // ['read', 'write', 'delete']

Factory Class Pattern

// Factory class with more flexibility
class UserFactory {
static create(type, name) {
switch (type) {
case "admin":
return new AdminUser(name);
case "editor":
return new EditorUser(name);
case "viewer":
return new ViewerUser(name);
default:
throw new Error(`Unknown user type: ${type}`);
}
}
}
class AdminUser {
constructor(name) {
this.name = name;
this.role = "admin";
this.permissions = ["read", "write", "delete"];
}
}
class EditorUser {
constructor(name) {
this.name = name;
this.role = "editor";
this.permissions = ["read", "write"];
}
}
class ViewerUser {
constructor(name) {
this.name = name;
this.role = "viewer";
this.permissions = ["read"];
}
}
// Usage
const admin = UserFactory.create("admin", "John Doe");
const editor = UserFactory.create("editor", "Jane Smith");

Use Factory when: You need to create objects based on conditions or when object creation logic is complex

💡 Tip: Factory pattern is especially useful when you need to create objects that share similar interfaces but have different implementations

Builder Pattern 🏗️

The Builder pattern constructs complex objects step by step. It allows you to produce different types and representations of an object using the same construction code.

Implementation

class PizzaBuilder {
constructor() {
this.pizza = {
size: "medium",
crust: "thin",
toppings: [],
sauce: "tomato",
cheese: true,
};
}
setSize(size) {
this.pizza.size = size;
return this; // Return this for method chaining
}
setCrust(crust) {
this.pizza.crust = crust;
return this;
}
addTopping(topping) {
this.pizza.toppings.push(topping);
return this;
}
setSauce(sauce) {
this.pizza.sauce = sauce;
return this;
}
setCheese(cheese) {
this.pizza.cheese = cheese;
return this;
}
build() {
return this.pizza;
}
}
// Usage - fluent interface
const pizza = new PizzaBuilder()
.setSize("large")
.setCrust("thick")
.addTopping("pepperoni")
.addTopping("mushrooms")
.addTopping("olives")
.setSauce("bbq")
.setCheese(true)
.build();
console.log(pizza);
// {
// size: 'large',
// crust: 'thick',
// toppings: ['pepperoni', 'mushrooms', 'olives'],
// sauce: 'bbq',
// cheese: true
// }

Use Builder when: You need to create complex objects with many optional parameters or when you want to construct objects step by step

Prototype Pattern 🧬

The Prototype pattern creates objects by cloning an existing object (prototype) rather than creating new instances from scratch. JavaScript’s prototypal inheritance makes this pattern natural to the language.

Implementation

// Using Object.create() for prototype cloning
const carPrototype = {
wheels: 4,
start() {
return `${this.brand} ${this.model} started`;
},
stop() {
return `${this.brand} ${this.model} stopped`;
},
};
// Create new instances by cloning
const car1 = Object.create(carPrototype);
car1.brand = "Toyota";
car1.model = "Camry";
const car2 = Object.create(carPrototype);
car2.brand = "Honda";
car2.model = "Accord";
console.log(car1.start()); // "Toyota Camry started"
console.log(car2.start()); // "Honda Accord started"

Prototype with Constructor

// More advanced prototype pattern
function Car(brand, model) {
this.brand = brand;
this.model = model;
}
Car.prototype = {
wheels: 4,
start() {
return `${this.brand} ${this.model} started`;
},
stop() {
return `${this.brand} ${this.model} stopped`;
},
};
const car1 = new Car("Toyota", "Camry");
const car2 = new Car("Honda", "Accord");
console.log(car1.start()); // "Toyota Camry started"
console.log(car1.wheels); // 4 (inherited from prototype)

💡 Tip: JavaScript’s native prototypal inheritance is essentially the Prototype pattern built into the language. Understanding JavaScript closures helps when implementing more complex prototype patterns.


Structural Design Patterns

Structural patterns deal with object composition and relationships. They help ensure that if one part of a system changes, the entire system doesn’t need to change along with it.

Adapter Pattern 🔌

The Adapter pattern allows incompatible interfaces to work together. It acts as a wrapper between two objects, catching calls for one object and transforming them to format and interface recognizable by the second.

Implementation

// Old API interface
class OldPaymentSystem {
makePayment(amount, currency) {
return `Paid ${amount} ${currency} using old system`;
}
}
// New API interface (incompatible)
class NewPaymentSystem {
processPayment(paymentDetails) {
return `Processed payment: ${paymentDetails.amount} ${paymentDetails.currency}`;
}
}
// Adapter to make old system work with new interface
class PaymentAdapter {
constructor(oldSystem) {
this.oldSystem = oldSystem;
}
processPayment(paymentDetails) {
// Adapt the new interface to work with old system
return this.oldSystem.makePayment(
paymentDetails.amount,
paymentDetails.currency,
);
}
}
// Usage
const oldSystem = new OldPaymentSystem();
const adapter = new PaymentAdapter(oldSystem);
// Now we can use new interface with old system
const result = adapter.processPayment({
amount: 100,
currency: "USD",
});
console.log(result); // "Paid 100 USD using old system"

Use Adapter when: You need to integrate incompatible interfaces or when you want to use an existing class with an incompatible interface

Decorator Pattern 🎨

The Decorator pattern allows behavior to be added to individual objects dynamically without affecting the behavior of other objects from the same class. It’s useful for extending functionality in a flexible way.

Implementation

// Base component
class Coffee {
cost() {
return 5;
}
description() {
return "Simple coffee";
}
}
// Decorator base class
class CoffeeDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost();
}
description() {
return this.coffee.description();
}
}
// Concrete decorators
class MilkDecorator extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 2;
}
description() {
return this.coffee.description() + ", milk";
}
}
class SugarDecorator extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 1;
}
description() {
return this.coffee.description() + ", sugar";
}
}
class WhipDecorator extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 3;
}
description() {
return this.coffee.description() + ", whip";
}
}
// Usage - compose decorators dynamically
let coffee = new Coffee();
console.log(coffee.description(), coffee.cost()); // "Simple coffee" 5
coffee = new MilkDecorator(coffee);
console.log(coffee.description(), coffee.cost()); // "Simple coffee, milk" 7
coffee = new SugarDecorator(coffee);
console.log(coffee.description(), coffee.cost()); // "Simple coffee, milk, sugar" 8
coffee = new WhipDecorator(coffee);
console.log(coffee.description(), coffee.cost()); // "Simple coffee, milk, sugar, whip" 11

Functional Decorator Pattern

// Functional approach (more JavaScript-idiomatic)
function withLogging(fn) {
return function (...args) {
console.log(`Calling ${fn.name} with args:`, args);
const result = fn.apply(this, args);
console.log(`Result:`, result);
return result;
};
}
function withTiming(fn) {
return function (...args) {
const start = performance.now();
const result = fn.apply(this, args);
const end = performance.now();
console.log(`${fn.name} took ${end - start}ms`);
return result;
};
}
// Original function
function calculateSum(a, b) {
return a + b;
}
// Decorated function
const decoratedSum = withTiming(withLogging(calculateSum));
decoratedSum(5, 3);
// Output:
// Calling calculateSum with args: [5, 3]
// Result: 8
// calculateSum took 0.05ms

Use Decorator when: You need to add responsibilities to objects dynamically and transparently without affecting other objects

Facade Pattern 🏛️

The Facade pattern provides a simplified interface to a complex subsystem. It hides the complexities of the system and provides a simple interface to the client.

Implementation

// Complex subsystem classes
class CPU {
freeze() {
console.log("CPU: Freezing...");
}
jump(position) {
console.log(`CPU: Jumping to position ${position}`);
}
execute() {
console.log("CPU: Executing...");
}
}
class Memory {
load(position, data) {
console.log(`Memory: Loading data at position ${position}`);
}
}
class HardDrive {
read(lba, size) {
console.log(`HardDrive: Reading ${size} bytes from LBA ${lba}`);
return "boot data";
}
}
// Facade - simplified interface
class ComputerFacade {
constructor() {
this.cpu = new CPU();
this.memory = new Memory();
this.hardDrive = new HardDrive();
}
start() {
// Hide complex startup sequence
this.cpu.freeze();
const bootData = this.hardDrive.read(0, 1024);
this.memory.load(0, bootData);
this.cpu.jump(0);
this.cpu.execute();
console.log("Computer started successfully!");
}
}
// Usage - simple interface for complex operations
const computer = new ComputerFacade();
computer.start();
// Output:
// CPU: Freezing...
// HardDrive: Reading 1024 bytes from LBA 0
// Memory: Loading data at position 0
// CPU: Jumping to position 0
// CPU: Executing...
// Computer started successfully!

Use Facade when: You want to provide a simple interface to a complex subsystem or when you want to decouple clients from subsystem components

Proxy Pattern 🎭

The Proxy pattern provides a placeholder or surrogate for another object to control access to it. It’s useful for lazy loading, access control, logging, and more.

Implementation

// Real subject
class Image {
constructor(filename) {
this.filename = filename;
this.loadImage();
}
loadImage() {
console.log(`Loading image: ${this.filename}`);
// Simulate expensive operation
}
display() {
console.log(`Displaying image: ${this.filename}`);
}
}
// Proxy - controls access to real subject
class ImageProxy {
constructor(filename) {
this.filename = filename;
this.image = null; // Lazy loading
}
display() {
if (!this.image) {
this.image = new Image(this.filename);
}
this.image.display();
}
}
// Usage
const image1 = new ImageProxy("photo1.jpg");
const image2 = new ImageProxy("photo2.jpg");
// Image not loaded yet
image1.display(); // Now loads and displays
image2.display(); // Now loads and displays

Proxy for Access Control

// Access control proxy
class BankAccount {
constructor(balance) {
this.balance = balance;
}
deposit(amount) {
this.balance += amount;
return this.balance;
}
withdraw(amount) {
if (amount > this.balance) {
throw new Error("Insufficient funds");
}
this.balance -= amount;
return this.balance;
}
}
class BankAccountProxy {
constructor(account, userRole) {
this.account = account;
this.userRole = userRole;
}
deposit(amount) {
if (this.userRole !== "admin" && this.userRole !== "user") {
throw new Error("Unauthorized");
}
return this.account.deposit(amount);
}
withdraw(amount) {
if (this.userRole !== "admin") {
throw new Error("Only admins can withdraw");
}
return this.account.withdraw(amount);
}
}
// Usage
const account = new BankAccount(1000);
const userProxy = new BankAccountProxy(account, "user");
const adminProxy = new BankAccountProxy(account, "admin");
userProxy.deposit(100); // OK
// userProxy.withdraw(50); // Error: Only admins can withdraw
adminProxy.withdraw(50); // OK

💡 Tip: JavaScript’s Proxy object provides native support for the Proxy pattern, allowing you to intercept and customize operations on objects.

// Using native JavaScript Proxy
const target = {
message: "Hello",
};
const handler = {
get(target, prop) {
if (prop === "message") {
return target[prop].toUpperCase();
}
return target[prop];
},
set(target, prop, value) {
if (prop === "message" && typeof value !== "string") {
throw new TypeError("Message must be a string");
}
target[prop] = value;
return true;
},
};
const proxy = new Proxy(target, handler);
console.log(proxy.message); // "HELLO"
proxy.message = "World";
console.log(proxy.message); // "WORLD"

Behavioral Design Patterns

Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. They describe not just patterns of objects or classes 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 and updated automatically. This is fundamental to event-driven programming.

Implementation

// Subject (Observable)
class EventEmitter {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
// Return unsubscribe function
return () => {
this.observers = this.observers.filter((obs) => obs !== observer);
};
}
notify(data) {
this.observers.forEach((observer) => observer.update(data));
}
}
// Observer interface
class Observer {
update(data) {
throw new Error("update() must be implemented");
}
}
// Concrete observers
class EmailObserver extends Observer {
update(data) {
console.log(
`Email: Order ${data.orderId} status changed to ${data.status}`,
);
}
}
class SMSObserver extends Observer {
update(data) {
console.log(`SMS: Order ${data.orderId} status changed to ${data.status}`);
}
}
class PushObserver extends Observer {
update(data) {
console.log(`Push: Order ${data.orderId} status changed to ${data.status}`);
}
}
// Usage
const orderEmitter = new EventEmitter();
const emailObserver = new EmailObserver();
const smsObserver = new SMSObserver();
const pushObserver = new PushObserver();
orderEmitter.subscribe(emailObserver);
orderEmitter.subscribe(smsObserver);
orderEmitter.subscribe(pushObserver);
// Notify all observers
orderEmitter.notify({
orderId: "12345",
status: "shipped",
});
// Output:
// Email: Order 12345 status changed to shipped
// SMS: Order 12345 status changed to shipped
// Push: Order 12345 status changed to shipped

Functional Observer Pattern

// Functional approach using callbacks
class Observable {
constructor() {
this.subscribers = new Set();
}
subscribe(callback) {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
notify(data) {
this.subscribers.forEach((callback) => callback(data));
}
}
// Usage
const newsFeed = new Observable();
const unsubscribe1 = newsFeed.subscribe((article) => {
console.log("User 1 received:", article.title);
});
const unsubscribe2 = newsFeed.subscribe((article) => {
console.log("User 2 received:", article.title);
});
newsFeed.notify({ title: "Breaking: New JavaScript Features" });
// Output:
// User 1 received: Breaking: New JavaScript Features
// User 2 received: Breaking: New JavaScript Features
unsubscribe1(); // User 1 unsubscribes
newsFeed.notify({ title: "Update: Design Patterns Guide" });
// Output:
// User 2 received: Update: Design Patterns Guide

Use Observer when: You need to notify multiple objects about changes in state or when you want to decouple the subject from its observers

💡 Tip: React’s state management and event system are built on the Observer pattern. Understanding this pattern helps when working with React hooks and state management.

Strategy Pattern 🎯

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

Implementation

// Strategy interface
class PaymentStrategy {
pay(amount) {
throw new Error("pay() must be implemented");
}
}
// Concrete strategies
class CreditCardStrategy extends PaymentStrategy {
constructor(cardNumber, cvv) {
super();
this.cardNumber = cardNumber;
this.cvv = cvv;
}
pay(amount) {
console.log(
`Paid $${amount} using credit card ending in ${this.cardNumber.slice(-4)}`,
);
return true;
}
}
class PayPalStrategy extends PaymentStrategy {
constructor(email) {
super();
this.email = email;
}
pay(amount) {
console.log(`Paid $${amount} using PayPal account ${this.email}`);
return true;
}
}
class CryptoStrategy extends PaymentStrategy {
constructor(walletAddress) {
super();
this.walletAddress = walletAddress;
}
pay(amount) {
console.log(
`Paid $${amount} using crypto wallet ${this.walletAddress.slice(0, 8)}...`,
);
return true;
}
}
// Context that uses strategies
class PaymentProcessor {
constructor() {
this.strategy = null;
}
setStrategy(strategy) {
this.strategy = strategy;
}
processPayment(amount) {
if (!this.strategy) {
throw new Error("Payment strategy not set");
}
return this.strategy.pay(amount);
}
}
// Usage
const processor = new PaymentProcessor();
processor.setStrategy(new CreditCardStrategy("1234567890123456", "123"));
processor.processPayment(100); // "Paid $100 using credit card ending in 3456"
processor.setStrategy(new PayPalStrategy("user@example.com"));
processor.processPayment(50); // "Paid $50 using PayPal account user@example.com"
processor.setStrategy(new CryptoStrategy("0x1234567890abcdef"));
processor.processPayment(200); // "Paid $200 using crypto wallet 0x123456..."

Functional Strategy Pattern

// Functional approach - strategies as functions
const paymentStrategies = {
creditCard: (amount, cardNumber) => {
console.log(
`Paid $${amount} using credit card ending in ${cardNumber.slice(-4)}`,
);
},
paypal: (amount, email) => {
console.log(`Paid $${amount} using PayPal account ${email}`);
},
crypto: (amount, wallet) => {
console.log(`Paid $${amount} using crypto wallet ${wallet.slice(0, 8)}...`);
},
};
function processPayment(amount, method, ...args) {
const strategy = paymentStrategies[method];
if (!strategy) {
throw new Error(`Unknown payment method: ${method}`);
}
return strategy(amount, ...args);
}
// Usage
processPayment(100, "creditCard", "1234567890123456");
processPayment(50, "paypal", "user@example.com");
processPayment(200, "crypto", "0x1234567890abcdef");

Use Strategy when: You have multiple ways to perform a task and want to choose the algorithm at runtime

Command Pattern 📝

The Command pattern encapsulates a request as an object, allowing you to parameterize clients with different requests, queue operations, and support undo operations.

Implementation

// Command interface
class Command {
execute() {
throw new Error("execute() must be implemented");
}
undo() {
throw new Error("undo() must be implemented");
}
}
// Receiver
class Light {
constructor() {
this.isOn = false;
}
turnOn() {
this.isOn = true;
console.log("Light is ON");
}
turnOff() {
this.isOn = false;
console.log("Light is OFF");
}
}
// Concrete commands
class TurnOnCommand extends Command {
constructor(light) {
super();
this.light = light;
}
execute() {
this.light.turnOn();
}
undo() {
this.light.turnOff();
}
}
class TurnOffCommand extends Command {
constructor(light) {
super();
this.light = light;
}
execute() {
this.light.turnOff();
}
undo() {
this.light.turnOn();
}
}
// Invoker
class RemoteControl {
constructor() {
this.commands = [];
this.history = [];
}
executeCommand(command) {
command.execute();
this.history.push(command);
}
undo() {
if (this.history.length > 0) {
const command = this.history.pop();
command.undo();
}
}
}
// Usage
const light = new Light();
const remote = new RemoteControl();
const turnOn = new TurnOnCommand(light);
const turnOff = new TurnOffCommand(light);
remote.executeCommand(turnOn); // Light is ON
remote.executeCommand(turnOff); // Light is OFF
remote.undo(); // Light is ON (undo last command)

Use Command when: You need to parameterize objects with operations, queue operations, or support undo/redo functionality

Iterator Pattern 🔄

The Iterator pattern provides a way to access elements of an aggregate object sequentially without exposing its underlying representation. JavaScript has built-in iterator support.

Implementation

// Custom collection with iterator
class Collection {
constructor() {
this.items = [];
}
add(item) {
this.items.push(item);
}
[Symbol.iterator]() {
let index = 0;
const items = this.items;
return {
next() {
if (index < items.length) {
return { value: items[index++], done: false };
}
return { done: true };
},
};
}
}
// Usage
const collection = new Collection();
collection.add("item1");
collection.add("item2");
collection.add("item3");
// Iterate using for...of
for (const item of collection) {
console.log(item);
}
// Output:
// item1
// item2
// item3
// Or use spread operator
console.log([...collection]); // ['item1', 'item2', 'item3']

Generator-based Iterator

// Using generators (more JavaScript-idiomatic)
class NumberRange {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i += this.step) {
yield i;
}
}
}
// Usage
const range = new NumberRange(1, 10, 2);
for (const num of range) {
console.log(num);
}
// Output: 1, 3, 5, 7, 9

💡 Tip: JavaScript’s native array methods like map, filter, and reduce are implementations of iterator patterns. Understanding JavaScript promises and async patterns helps when working with async iterators.


When to Use Design Patterns

Design patterns are powerful tools, but they should be used judiciously. Not every problem requires a design pattern, and overusing patterns can lead to unnecessary complexity.

✅ Good Reasons to Use Design Patterns

  1. Solving recurring problems: When you encounter the same problem multiple times
  2. Improving code maintainability: When patterns make code easier to understand and modify
  3. Facilitating communication: When patterns provide a shared vocabulary for your team
  4. Enhancing flexibility: When you need to change behavior without modifying existing code
  5. Managing complexity: When patterns help organize complex systems

❌ When NOT to Use Design Patterns

  1. Over-engineering: Don’t add patterns just because you can
  2. Simple problems: Simple problems don’t need complex solutions
  3. Premature optimization: Don’t add patterns “just in case”
  4. Learning projects: Focus on understanding before applying patterns

Pattern Selection Guide

PatternUse When
SingletonNeed exactly one instance globally
FactoryObject creation logic is complex or conditional
BuilderConstructing complex objects with many optional parameters
AdapterIntegrating incompatible interfaces
DecoratorAdding behavior dynamically without subclassing
FacadeSimplifying complex subsystem interfaces
ProxyControlling access, lazy loading, or adding functionality
ObserverOne-to-many dependency between objects
StrategyMultiple algorithms, choose at runtime
CommandNeed to queue, log, or undo operations

Common Anti-Patterns and Mistakes

Understanding what NOT to do is just as important as understanding design patterns themselves. Here are common mistakes developers make when implementing design patterns.

❌ Overusing Singleton

// Anti-pattern: Making everything a singleton
class UserService {
static instance = null;
static getInstance() {
if (!UserService.instance) {
UserService.instance = new UserService();
}
return UserService.instance;
}
}
// Problem: Makes testing difficult, creates hidden dependencies
// Better: Use dependency injection
class UserService {
constructor(database) {
this.database = database;
}
}

❌ Creating Unnecessary Abstractions

// Anti-pattern: Over-abstracting simple code
class SimpleCalculator {
add(a, b) {
return a + b;
}
}
class CalculatorFactory {
static create(type) {
if (type === "simple") {
return new SimpleCalculator();
}
// ... more complexity
}
}
// Problem: Factory pattern for a simple class
// Better: Just use the class directly
const calc = new SimpleCalculator();
calc.add(1, 2);

❌ Ignoring JavaScript’s Native Features

// Anti-pattern: Implementing Observer from scratch
class CustomObserver {
// ... complex implementation
}
// Problem: JavaScript has native event handling
// Better: Use EventTarget or EventEmitter
class EventEmitter extends EventTarget {
emit(event, data) {
this.dispatchEvent(new CustomEvent(event, { detail: data }));
}
}

❌ Tight Coupling

// Anti-pattern: Tight coupling between classes
class OrderProcessor {
process(order) {
const emailService = new EmailService(); // Tight coupling
const smsService = new SMSService(); // Tight coupling
emailService.send(order);
smsService.send(order);
}
}
// Better: Use dependency injection
class OrderProcessor {
constructor(notifiers) {
this.notifiers = notifiers;
}
process(order) {
this.notifiers.forEach((notifier) => notifier.send(order));
}
}

⚠️ Warning: Always consider if a pattern adds value before implementing it. Simple, readable code is often better than “pattern-perfect” code.


Design Patterns in Modern JavaScript

Modern JavaScript (ES6+) and frameworks have integrated many design patterns natively. Understanding these native implementations helps you write more idiomatic JavaScript code.

ES6+ Features and Patterns

Classes and Inheritance

// Modern class syntax implements several patterns
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
speak() {
return `${this.name} barks`;
}
}
// Template Method pattern
class DataProcessor {
process(data) {
const validated = this.validate(data);
const transformed = this.transform(validated);
return this.save(transformed);
}
validate(data) {
throw new Error("validate() must be implemented");
}
transform(data) {
return data;
}
save(data) {
throw new Error("save() must be implemented");
}
}

Modules and Namespace Pattern

userService.js
// Module pattern (built into ES6 modules)
export class UserService {
constructor() {
this.users = [];
}
addUser(user) {
this.users.push(user);
}
}
// Usage
import { UserService } from "./userService.js";

Promises and Async Patterns

// Promise pattern (built into JavaScript)
async function fetchUserData(userId) {
try {
const user = await fetch(`/api/users/${userId}`);
const posts = await fetch(`/api/users/${userId}/posts`);
return { user, posts };
} catch (error) {
console.error("Error fetching user data:", error);
throw error;
}
}
// Observer pattern with Promises
function createObservable() {
const subscribers = new Set();
return {
subscribe(callback) {
subscribers.add(callback);
return () => subscribers.delete(callback);
},
async notify(data) {
await Promise.all(
Array.from(subscribers).map((cb) => Promise.resolve(cb(data))),
);
},
};
}

Framework Patterns

React Patterns

// Higher-Order Component (HOC) - Decorator pattern
function withAuth(Component) {
return function AuthenticatedComponent(props) {
const isAuthenticated = useAuth();
if (!isAuthenticated) {
return <LoginPage />;
}
return <Component {...props} />;
};
}
// Render Props - Strategy pattern
function DataFetcher({ url, children }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((data) => {
setData(data);
setLoading(false);
});
}, [url]);
return children({ data, loading });
}
// Usage
<DataFetcher url="/api/users">
{({ data, loading }) => (loading ? <Spinner /> : <UserList users={data} />)}
</DataFetcher>;

Vue.js Patterns

// Composition API - Strategy pattern
import { ref, computed } from "vue";
export function useCounter() {
const count = ref(0);
const increment = () => count.value++;
const decrement = () => count.value--;
const double = computed(() => count.value * 2);
return { count, increment, decrement, double };
}
// Mixin pattern (Vue 2) / Composables (Vue 3)

Best Practices

Following best practices ensures that your use of design patterns improves code quality rather than complicating it.

✅ Do’s

  1. Understand the problem first: Make sure a pattern actually solves your problem
  2. Start simple: Begin with simple solutions and add patterns when needed
  3. Use JavaScript idioms: Leverage native features before implementing patterns
  4. Document your patterns: Explain why you’re using a pattern, not just how
  5. Test thoroughly: Patterns can hide complexity, so test carefully
  6. Consider performance: Some patterns add overhead; measure if it matters
  7. Refactor gradually: Don’t rewrite everything at once

❌ Don’ts

  1. Don’t force patterns: If a pattern doesn’t fit, don’t use it
  2. Don’t over-engineer: Simple code is often better than “perfect” patterns
  3. Don’t ignore language features: Use JavaScript’s native capabilities
  4. Don’t create pattern hierarchies: Avoid patterns within patterns unnecessarily
  5. Don’t skip testing: Patterns don’t eliminate the need for tests
  6. Don’t copy blindly: Understand patterns before implementing them

Code Review Checklist

When reviewing code that uses design patterns, ask:

Performance Considerations

Some patterns have performance implications:

// Proxy pattern adds overhead
const handler = {
get(target, prop) {
// This runs on every property access
return target[prop];
},
};
// Measure before optimizing
console.time("proxy access");
for (let i = 0; i < 1000000; i++) {
proxy.value;
}
console.timeEnd("proxy access");
// Compare with direct access
console.time("direct access");
for (let i = 0; i < 1000000; i++) {
obj.value;
}
console.timeEnd("direct access");

💡 Tip: Profile your code before optimizing. Most patterns have negligible performance impact, but it’s good to verify.


Conclusion

Design patterns are powerful tools that help you write maintainable, scalable, and efficient JavaScript code. They provide proven solutions to common problems and create a shared vocabulary for your development team.

Throughout this guide, we’ve explored:

  • Creational patterns (Singleton, Factory, Builder, Prototype) for managing object creation
  • Structural patterns (Adapter, Decorator, Facade, Proxy) for organizing object relationships
  • Behavioral patterns (Observer, Strategy, Command, Iterator) for managing object communication

Remember that design patterns are tools, not goals. Use them when they solve real problems and improve code quality. Don’t force patterns where they don’t fit, and always prefer simple, readable solutions when appropriate.

Modern JavaScript and frameworks have integrated many patterns natively. Understanding these native implementations helps you write more idiomatic code. Whether you’re working with React, Vue, or vanilla JavaScript, design patterns can help you build better applications.

Next Steps

  1. Practice: Implement patterns in your projects to gain hands-on experience
  2. Study: Read the original “Gang of Four” book for deeper understanding
  3. Explore: Look for patterns in popular libraries and frameworks
  4. Refactor: Apply patterns to existing code to improve its structure
  5. Share: Discuss patterns with your team to build shared understanding

Design patterns are not about memorizing implementations—they’re about understanding principles and applying them appropriately. As you continue your JavaScript journey, keep these patterns in mind, but always prioritize writing code that is clear, maintainable, and solves real problems.

For more JavaScript fundamentals, check out our guides on understanding closures and mastering promises and async/await. If you’re working with React, our guide on common React pitfalls covers patterns specific to React development.


References