Design Patterns
Learn the most important JavaScript design patterns for writing organized, reusable, and maintainable code.
Design Patterns
A design pattern is a reusable solution to a commonly occurring problem in software design. They are not libraries or specific code — they are blueprints. Templates for solving problems that you will encounter again and again.
Learning design patterns means you stop reinventing the wheel every time a common problem appears — you reach for a proven solution.
Why Design Patterns Matter
// Without a pattern — ad hoc solution every time
let config;
function getConfig() {
if (!config) {
config = { theme: "dark", lang: "en" };
}
return config;
}
// With a pattern — recognizable, named, understood by everyone
class Config {
static #instance = null;
static getInstance() {
if (!Config.#instance) {
Config.#instance = new Config();
}
return Config.#instance;
}
}The second version uses the Singleton pattern — anyone who knows the pattern immediately understands the intent. Patterns are a shared vocabulary for developers.
1. Module Pattern
Problem — you want to encapsulate related functionality and expose only a public API, keeping internals private.
Solution — use a function or object to create a private scope and return only what you want to expose.
const CartModule = (function() {
// Private state and methods
let items = [];
let discount = 0;
function calculateSubtotal() {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Public API — only these are exposed
return {
addItem(item) {
const existing = items.find(i => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
items.push({ ...item });
}
return this;
},
removeItem(id) {
items = items.filter(item => item.id !== id);
return this;
},
setDiscount(percent) {
discount = percent;
return this;
},
getTotal() {
const subtotal = calculateSubtotal();
return subtotal * (1 - discount / 100);
},
getItems() {
return [...items]; // return copy — not the real array
},
clear() {
items = [];
discount = 0;
return this;
}
};
})();
CartModule.addItem({ id: 1, name: "Laptop", price: 120000, quantity: 1 });
CartModule.addItem({ id: 2, name: "Mouse", price: 1500, quantity: 2 });
CartModule.setDiscount(10);
console.log(CartModule.getTotal()); // 110700
console.log(CartModule.getItems()); // copy of items array
// Private internals are inaccessible
console.log(CartModule.items); // undefined
console.log(CartModule.discount); // undefinedThe IIFE (Immediately Invoked Function Expression) creates a private scope. Only the returned object is public. This is one of the oldest and most important patterns in JavaScript.
2. Singleton Pattern
Problem — you need exactly one instance of something — a config object, a database connection, an app state store.
Solution — a class that creates the instance only once and returns the same instance every time.
class Database {
static #instance = null;
#connection = null;
#queryCount = 0;
constructor(url) {
if (Database.#instance) {
return Database.#instance; // return existing instance
}
this.url = url;
this.#connection = this.#connect();
Database.#instance = this;
}
#connect() {
console.log(`Connecting to ${this.url}...`);
return { connected: true };
}
query(sql) {
this.#queryCount++;
console.log(`[Query ${this.#queryCount}] ${sql}`);
return { rows: [], count: 0 };
}
getStats() {
return { url: this.url, queryCount: this.#queryCount };
}
static getInstance(url) {
if (!Database.#instance) {
new Database(url);
}
return Database.#instance;
}
}
const db1 = Database.getInstance("postgres://localhost/mydb");
const db2 = Database.getInstance("postgres://localhost/mydb");
console.log(db1 === db2); // true — same instance
db1.query("SELECT * FROM users");
db2.query("SELECT * FROM posts");
console.log(db1.getStats());
// { url: "postgres://localhost/mydb", queryCount: 2 }
// db2 queries counted on the same instance3. Observer Pattern
Problem — one object changes state and you need to notify many other objects without tightly coupling them.
Solution — maintain a list of subscribers. When something happens, notify all subscribers.
class EventEmitter {
#events = new Map();
on(event, listener) {
if (!this.#events.has(event)) {
this.#events.set(event, new Set());
}
this.#events.get(event).add(listener);
return () => this.off(event, listener); // return unsubscribe function
}
off(event, listener) {
this.#events.get(event)?.delete(listener);
return this;
}
once(event, listener) {
const wrapper = (...args) => {
listener(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
emit(event, ...args) {
this.#events.get(event)?.forEach(listener => {
listener(...args);
});
return this;
}
}
// Real use — a user store that notifies UI components
class UserStore extends EventEmitter {
#user = null;
setUser(user) {
this.#user = user;
this.emit("userChanged", user);
}
logout() {
this.#user = null;
this.emit("userChanged", null);
this.emit("logout");
}
getUser() {
return this.#user;
}
}
const store = new UserStore();
// Multiple subscribers
const unsubscribe = store.on("userChanged", (user) => {
if (user) {
console.log(`Welcome, ${user.name}!`);
} else {
console.log("User logged out.");
}
});
store.on("userChanged", (user) => {
document.title = user ? `${user.name} — DocsHub` : "DocsHub";
});
store.once("logout", () => {
console.log("Clearing local storage...");
});
store.setUser({ name: "Ali", role: "admin" });
// Welcome, Ali!
// (title updated)
store.logout();
// User logged out.
// (title updated)
// Clearing local storage... (fires only once)
unsubscribe(); // remove first listenerThe Observer pattern is the backbone of every UI framework, event system, and reactive data library.
4. Factory Pattern
Problem — you need to create objects of different types based on some condition, without exposing the creation logic.
Solution — a factory function or class that creates and returns the right object.
class TextNotification {
constructor(message) {
this.type = "text";
this.message = message;
}
send(recipient) {
console.log(`SMS to ${recipient}: ${this.message}`);
}
}
class EmailNotification {
constructor(message, subject) {
this.type = "email";
this.message = message;
this.subject = subject;
}
send(recipient) {
console.log(`Email to ${recipient} — Subject: ${this.subject}\n${this.message}`);
}
}
class PushNotification {
constructor(message, title) {
this.type = "push";
this.message = message;
this.title = title;
}
send(recipient) {
console.log(`Push to ${recipient} — ${this.title}: ${this.message}`);
}
}
// Factory — decides which class to instantiate
class NotificationFactory {
static create(type, options) {
switch (type) {
case "sms":
return new TextNotification(options.message);
case "email":
return new EmailNotification(options.message, options.subject);
case "push":
return new PushNotification(options.message, options.title);
default:
throw new Error(`Unknown notification type: ${type}`);
}
}
}
// Consumer does not know or care which class is used
const sms = NotificationFactory.create("sms", {
message: "Your order has shipped."
});
const email = NotificationFactory.create("email", {
message: "Your order has shipped. Track it here.",
subject: "Order Shipped"
});
const push = NotificationFactory.create("push", {
message: "Order shipped!",
title: "DocsHub Store"
});
sms.send("Ali");
email.send("ali@example.com");
push.send("device-token-123");5. Strategy Pattern
Problem — you have multiple algorithms for the same task and want to switch between them at runtime.
Solution — define each algorithm as a separate object/function and inject the one you want.
// Different sorting strategies
const strategies = {
bubble: (arr) => {
const result = [...arr];
for (let i = 0; i < result.length; i++) {
for (let j = 0; j < result.length - i - 1; j++) {
if (result[j] > result[j + 1]) {
[result[j], result[j + 1]] = [result[j + 1], result[j]];
}
}
}
return result;
},
quick: (arr) => {
if (arr.length <= 1) return arr;
const pivot = arr[arr.length - 1];
const left = arr.slice(0, -1).filter(x => x <= pivot);
const right = arr.slice(0, -1).filter(x => x > pivot);
return [...strategies.quick(left), pivot, ...strategies.quick(right)];
},
builtin: (arr) => [...arr].sort((a, b) => a - b)
};
class Sorter {
constructor(strategy = "builtin") {
this.strategy = strategies[strategy];
}
setStrategy(name) {
if (!strategies[name]) throw new Error(`Unknown strategy: ${name}`);
this.strategy = strategies[name];
return this;
}
sort(data) {
return this.strategy(data);
}
}
const sorter = new Sorter();
const data = [5, 3, 8, 1, 9, 2, 7];
console.log(sorter.sort(data)); // builtin
sorter.setStrategy("bubble");
console.log(sorter.sort(data)); // bubble sort
sorter.setStrategy("quick");
console.log(sorter.sort(data)); // quicksortReal use — payment processing with different providers:
const paymentStrategies = {
stripe: async (amount, card) => {
console.log(`Charging Rs. ${amount} via Stripe`);
// Stripe API call
},
paypal: async (amount, email) => {
console.log(`Charging Rs. ${amount} via PayPal`);
// PayPal API call
},
easypaisa: async (amount, phone) => {
console.log(`Charging Rs. ${amount} via Easypaisa`);
// Easypaisa API call
}
};
class PaymentProcessor {
constructor(strategy) {
this.strategy = paymentStrategies[strategy];
}
async charge(amount, details) {
return this.strategy(amount, details);
}
}
const processor = new PaymentProcessor("easypaisa");
await processor.charge(5000, "03001234567");6. Decorator Pattern
Problem — you want to add behavior to an object dynamically without modifying its class.
Solution — wrap the object in another object that adds the new behavior.
class Coffee {
cost() { return 200; }
description() { return "Coffee"; }
}
// Decorators — wrap and extend
function withMilk(beverage) {
return {
cost: () => beverage.cost() + 50,
description: () => beverage.description() + " + Milk"
};
}
function withSugar(beverage) {
return {
cost: () => beverage.cost() + 20,
description: () => beverage.description() + " + Sugar"
};
}
function withVanilla(beverage) {
return {
cost: () => beverage.cost() + 80,
description: () => beverage.description() + " + Vanilla"
};
}
let drink = new Coffee();
console.log(drink.description(), drink.cost()); // Coffee 200
drink = withMilk(drink);
console.log(drink.description(), drink.cost()); // Coffee + Milk 250
drink = withSugar(drink);
console.log(drink.description(), drink.cost()); // Coffee + Milk + Sugar 270
drink = withVanilla(drink);
console.log(drink.description(), drink.cost()); // Coffee + Milk + Sugar + Vanilla 350Real use — middleware in Express-style APIs:
// Each middleware is a decorator that wraps the handler
function withLogging(handler) {
return async (req, res) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
const start = Date.now();
await handler(req, res);
console.log(`Completed in ${Date.now() - start}ms`);
};
}
function withAuth(handler) {
return async (req, res) => {
if (!req.headers.authorization) {
res.status(401).json({ error: "Unauthorized" });
return;
}
await handler(req, res);
};
}
function withErrorHandling(handler) {
return async (req, res) => {
try {
await handler(req, res);
} catch (error) {
res.status(500).json({ error: error.message });
}
};
}
// Base handler
async function getUser(req, res) {
const user = await fetchUser(req.params.id);
res.json(user);
}
// Decorated handler — auth + logging + error handling
const protectedGetUser = withErrorHandling(withAuth(withLogging(getUser)));7. Proxy Pattern
Problem — you want to control access to an object — add validation, logging, caching, or lazy loading.
Solution — wrap the object in a Proxy that intercepts operations.
function createValidatedUser(data) {
const validators = {
name: (val) => typeof val === "string" && val.length >= 2,
age: (val) => typeof val === "number" && val >= 0 && val <= 150,
email: (val) => typeof val === "string" && val.includes("@")
};
return new Proxy(data, {
set(target, prop, value) {
if (validators[prop] && !validators[prop](value)) {
throw new Error(`Invalid value for ${prop}: ${value}`);
}
target[prop] = value;
return true;
},
get(target, prop) {
if (!(prop in target)) {
console.warn(`Property ${prop} does not exist`);
return undefined;
}
return target[prop];
}
});
}
const user = createValidatedUser({ name: "Ali", age: 22, email: "ali@example.com" });
user.name = "Sara"; // ✅ valid
user.age = 25; // ✅ valid
user.age = -5; // ❌ Error: Invalid value for age: -5
user.email = "notvalid"; // ❌ Error: Invalid value for email: notvalidWe cover Proxy and Reflect in full depth in the next topic.
Choosing the Right Pattern
| Pattern | Use when |
|---|---|
| Module | Encapsulating related functionality with private state |
| Singleton | Exactly one instance needed — config, DB connection, store |
| Observer | One-to-many notifications — UI updates, event systems |
| Factory | Creating objects of different types based on conditions |
| Strategy | Switching algorithms or behaviors at runtime |
| Decorator | Adding behavior to objects without modifying their class |
| Proxy | Controlling access — validation, logging, caching |
Summary
- Design patterns are reusable solutions to common problems — not specific code, but blueprints
- Module — private scope, public API — encapsulation with closure
- Singleton — one instance only — use for shared resources
- Observer — subscribe and notify — the backbone of event-driven programming
- Factory — centralized object creation — hide instantiation logic
- Strategy — swappable algorithms — inject behavior at runtime
- Decorator — wrap objects to add behavior — composable and flexible
- Proxy — intercept and control object operations — validation, logging, caching
- Patterns are a vocabulary — knowing them lets you communicate design decisions clearly