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
- What Are Design Patterns?
- Creational Design Patterns
- Structural Design Patterns
- Behavioral Design Patterns
- When to Use Design Patterns
- Common Anti-Patterns and Mistakes
- Design Patterns in Modern JavaScript
- Best Practices
- Conclusion
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:
- Creational Patterns: Deal with object creation mechanisms, trying to create objects in a manner suitable to the situation
- Structural Patterns: Concern the composition of classes or objects to form larger structures
- 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 implementationclass 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"); }}
// Usageconst db1 = new DatabaseConnection();const db2 = new DatabaseConnection();
console.log(db1 === db2); // true - same instancedb1.connect();db2.connect(); // Already connected, won't reconnectModule 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; }, };})();
// Usageconst 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 functionfunction 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();}
// Usageconst admin = createUser("admin");const editor = createUser("editor");console.log(admin.permissions); // ['read', 'write', 'delete']Factory Class Pattern
// Factory class with more flexibilityclass 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"]; }}
// Usageconst 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 interfaceconst 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 cloningconst carPrototype = { wheels: 4, start() { return `${this.brand} ${this.model} started`; }, stop() { return `${this.brand} ${this.model} stopped`; },};
// Create new instances by cloningconst 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 patternfunction 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 interfaceclass 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 interfaceclass 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, ); }}
// Usageconst oldSystem = new OldPaymentSystem();const adapter = new PaymentAdapter(oldSystem);
// Now we can use new interface with old systemconst 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 componentclass Coffee { cost() { return 5; }
description() { return "Simple coffee"; }}
// Decorator base classclass CoffeeDecorator { constructor(coffee) { this.coffee = coffee; }
cost() { return this.coffee.cost(); }
description() { return this.coffee.description(); }}
// Concrete decoratorsclass 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 dynamicallylet 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" 11Functional 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 functionfunction calculateSum(a, b) { return a + b;}
// Decorated functionconst 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 classesclass 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 interfaceclass 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 operationsconst 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 subjectclass 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 subjectclass ImageProxy { constructor(filename) { this.filename = filename; this.image = null; // Lazy loading }
display() { if (!this.image) { this.image = new Image(this.filename); } this.image.display(); }}
// Usageconst image1 = new ImageProxy("photo1.jpg");const image2 = new ImageProxy("photo2.jpg");
// Image not loaded yetimage1.display(); // Now loads and displaysimage2.display(); // Now loads and displaysProxy for Access Control
// Access control proxyclass 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); }}
// Usageconst 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 withdrawadminProxy.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 Proxyconst 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 interfaceclass Observer { update(data) { throw new Error("update() must be implemented"); }}
// Concrete observersclass 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}`); }}
// Usageconst 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 observersorderEmitter.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 shippedFunctional Observer Pattern
// Functional approach using callbacksclass 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)); }}
// Usageconst 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 unsubscribesnewsFeed.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 interfaceclass PaymentStrategy { pay(amount) { throw new Error("pay() must be implemented"); }}
// Concrete strategiesclass 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 strategiesclass 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); }}
// Usageconst 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 functionsconst 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);}
// UsageprocessPayment(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 interfaceclass Command { execute() { throw new Error("execute() must be implemented"); }
undo() { throw new Error("undo() must be implemented"); }}
// Receiverclass Light { constructor() { this.isOn = false; }
turnOn() { this.isOn = true; console.log("Light is ON"); }
turnOff() { this.isOn = false; console.log("Light is OFF"); }}
// Concrete commandsclass 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(); }}
// Invokerclass 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(); } }}
// Usageconst light = new Light();const remote = new RemoteControl();
const turnOn = new TurnOnCommand(light);const turnOff = new TurnOffCommand(light);
remote.executeCommand(turnOn); // Light is ONremote.executeCommand(turnOff); // Light is OFFremote.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 iteratorclass 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 }; }, }; }}
// Usageconst collection = new Collection();collection.add("item1");collection.add("item2");collection.add("item3");
// Iterate using for...offor (const item of collection) { console.log(item);}// Output:// item1// item2// item3
// Or use spread operatorconsole.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; } }}
// Usageconst 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
- Solving recurring problems: When you encounter the same problem multiple times
- Improving code maintainability: When patterns make code easier to understand and modify
- Facilitating communication: When patterns provide a shared vocabulary for your team
- Enhancing flexibility: When you need to change behavior without modifying existing code
- Managing complexity: When patterns help organize complex systems
❌ When NOT to Use Design Patterns
- Over-engineering: Don’t add patterns just because you can
- Simple problems: Simple problems don’t need complex solutions
- Premature optimization: Don’t add patterns “just in case”
- Learning projects: Focus on understanding before applying patterns
Pattern Selection Guide
| Pattern | Use When |
|---|---|
| Singleton | Need exactly one instance globally |
| Factory | Object creation logic is complex or conditional |
| Builder | Constructing complex objects with many optional parameters |
| Adapter | Integrating incompatible interfaces |
| Decorator | Adding behavior dynamically without subclassing |
| Facade | Simplifying complex subsystem interfaces |
| Proxy | Controlling access, lazy loading, or adding functionality |
| Observer | One-to-many dependency between objects |
| Strategy | Multiple algorithms, choose at runtime |
| Command | Need 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 singletonclass 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 injectionclass UserService { constructor(database) { this.database = database; }}❌ Creating Unnecessary Abstractions
// Anti-pattern: Over-abstracting simple codeclass 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 directlyconst calc = new SimpleCalculator();calc.add(1, 2);❌ Ignoring JavaScript’s Native Features
// Anti-pattern: Implementing Observer from scratchclass CustomObserver { // ... complex implementation}
// Problem: JavaScript has native event handling// Better: Use EventTarget or EventEmitterclass EventEmitter extends EventTarget { emit(event, data) { this.dispatchEvent(new CustomEvent(event, { detail: data })); }}❌ Tight Coupling
// Anti-pattern: Tight coupling between classesclass OrderProcessor { process(order) { const emailService = new EmailService(); // Tight coupling const smsService = new SMSService(); // Tight coupling emailService.send(order); smsService.send(order); }}
// Better: Use dependency injectionclass 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 patternsclass Animal { constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }}
class Dog extends Animal { speak() { return `${this.name} barks`; }}
// Template Method patternclass 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
// Module pattern (built into ES6 modules)export class UserService { constructor() { this.users = []; }
addUser(user) { this.users.push(user); }}
// Usageimport { 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 Promisesfunction 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 patternfunction withAuth(Component) { return function AuthenticatedComponent(props) { const isAuthenticated = useAuth();
if (!isAuthenticated) { return <LoginPage />; }
return <Component {...props} />; };}
// Render Props - Strategy patternfunction 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 patternimport { 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
- Understand the problem first: Make sure a pattern actually solves your problem
- Start simple: Begin with simple solutions and add patterns when needed
- Use JavaScript idioms: Leverage native features before implementing patterns
- Document your patterns: Explain why you’re using a pattern, not just how
- Test thoroughly: Patterns can hide complexity, so test carefully
- Consider performance: Some patterns add overhead; measure if it matters
- Refactor gradually: Don’t rewrite everything at once
❌ Don’ts
- Don’t force patterns: If a pattern doesn’t fit, don’t use it
- Don’t over-engineer: Simple code is often better than “perfect” patterns
- Don’t ignore language features: Use JavaScript’s native capabilities
- Don’t create pattern hierarchies: Avoid patterns within patterns unnecessarily
- Don’t skip testing: Patterns don’t eliminate the need for tests
- 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 overheadconst handler = { get(target, prop) { // This runs on every property access return target[prop]; },};
// Measure before optimizingconsole.time("proxy access");for (let i = 0; i < 1000000; i++) { proxy.value;}console.timeEnd("proxy access");
// Compare with direct accessconsole.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
- Practice: Implement patterns in your projects to gain hands-on experience
- Study: Read the original “Gang of Four” book for deeper understanding
- Explore: Look for patterns in popular libraries and frameworks
- Refactor: Apply patterns to existing code to improve its structure
- 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.