Advanced Design Patterns in JavaScript
Reviewed & published by Brayan K
Design patterns are proven, reusable solutions to common software problems — like the Singleton, Observer, and Factory patterns — that give developers a shared vocabulary for structuring flexible, maintainable JavaScript code.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a .html file with <script> tags and open it in your browser
Master Factory, Singleton, Strategy, Observer, Decorator, and Facade patterns for scalable applications
What You'll Learn
- Factory Pattern
- Singleton Pattern
- Strategy Pattern
- Observer Pattern
- Decorator Pattern
- Facade Pattern
Design patterns are reusable solutions for common problems in JavaScript applications. They are not "copy/paste code" — they're mental tools to structure your app cleanly so it's easier to extend, debug, maintain, test, and scale. Every professional developer uses patterns daily.
- Avoid "spaghetti code"
- Keep logic modular and reusable
- Swap algorithms easily
- Change behaviour without touching everything else
- Make your code predictable for teams
⭐ The 3 Core Pattern Categories
1. Creational
Object creation: Factory, Singleton, Builder, Prototype
2. Structural
Object relationships: Adapter, Facade, Decorator, Proxy
3. Behavioral
Object behaviour: Strategy, Observer, Command, State
⭐ 1. Factory Pattern
Creates objects without exposing the creation logic. Use when object creation depends on input or you need different classes based on conditions.
class User {
constructor(name, permissions) {
this.name = name;
this.permissions = permissions;
}
}
const roleMap = {
basic: ["read"],
pro: ["read", "write"],
admin: ["read", "write", "delete"],
};
class UserFactory {
static create(name, role) {
const permissions = roleMap[role];
return new User(name, permissions);
}
}
const admin = UserFactory.create("Alice", "admin");
console.log(admin.name, admin.permissions);
const basic = UserFactory.create("Bob", "basic");
console.log(basic.name, basic.permissions);Use when: Creation logic is variable, many object types
Avoid when: Only one type exists, simple creation
⭐ 2. Singleton Pattern
Ensures only one instance of a class exists throughout the app. Perfect for configuration, database connections, logging, caching, or global state.
class AppConfig {
constructor() {
if (AppConfig.instance) return AppConfig.instance;
this.env = "production";
this.cacheEnabled = true;
this.theme = "dark";
AppConfig.instance = this;
}
setOption(key, value) {
this[key] = value;
}
}
const cfg1 = new AppConfig();
const cfg2 = new AppConfig();
console.log("Same instance?", cfg1 === cfg2); // true
cfg1.setOption("theme", "light");
console.log("cfg2 theme:", cfg2.theme); // "light" (same object!)
// Pro tip: freeze the instance
Object.freeze(cfg1);⚠️ Warning: Don't use Singletons for user-specific data, UI state, or anything that should reset per page/component.
⭐ 3. Strategy Pattern
Switch between different algorithms without rewriting code. Your anti-if/else superpower for payment methods, sorting, filters, shipping rules, etc.
// Strategy classes
class Paypal {
pay(amount) {
console.log("PayPal payment:", amount);
return true;
}
}
class Card {
pay(amount) {
console.log("Card payment:", amount);
return true;
}
}
class Crypto {
pay(amount) {
console.log("Crypto payment:", amount);
return true;
}
}
// Strategy registry
const strategies = {
paypal: new Paypal(),
card: new Card(),
crypto: new Crypto(),
};
// Clean usage
function processPayment(method, amount) {
const strategy = strategies[method];
if (!strategy) throw new Error("Unknown payment method");
return strategy.pay(amount);
}
processPayment("paypal", 100);
processPayment("card", 250);
processPayment("crypto", 500);⭐ 4. Observer Pattern
One event, many listeners. Used in UI frameworks, WebSockets, pub/sub systems, notifications, and data stores.
class EventBus {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(callback);
}
off(event, callback) {
if (!this.listeners[event]) return;
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
}
emit(event, payload) {
if (this.listeners[event]) {
this.listeners[event].forEach(cb => cb(payload));
}
}
}
// Usage
const bus = new EventBus();
bus.on("login", (user) => console.log("User logged in:", user));
bus.on("login", (user) => console.log("Start analytics for:", user));
bus.on("logout", () => console.log("User logged out"));
bus.emit("login", "Alice");
bus.emit("logout");⭐ 5. Decorator Pattern
Add features to an object without changing its original code. Perfect for logging, caching, authentication checks, retry logic, and performance measurement.
// Logging decorator
function withLogging(fn) {
return function(...args) {
console.log("[LOG] Calling with args:", args);
const result = fn(...args);
console.log("[LOG] Result:", result);
return result;
};
}
// Timing decorator
function withTiming(fn) {
return function(...args) {
const start = performance.now();
const result = fn(...args);
const end = performance.now();
console.log("[TIMING] Execution time:", (end - start).toFixed(2), "ms");
return result;
};
}
// Original function
function calculate(a, b) {
return a * b + a;
}
// Apply decorators (can stack them!)
const enhancedCalculate = withTiming(withLogging(calculate));
enhancedCalculate(5, 10);🧪 Worked Example — Stacking Three Decorators
The demo above shows one decorator. In real code you stack them, and the order you stack them in changes the behaviour. Below is a shipping-cost function wrapped in validation, caching and logging — read the comments first, then press Run and compare the output with the list at the bottom of the code.
The one thing to hold on to: withLogging(withCache(withValidation(fn))) is read inside out. A call falls through logging, then caching, then validation, and finally reaches your original function — and the answer climbs back out the same way.
// -- WORKED EXAMPLE - three decorators stacked on one function --
// A decorator takes a function and returns a NEW function with the same
// shape plus extra behaviour. The original function is never edited.
// The original. Pure, boring, and completely unaware it will be wrapped.
function shippingCost(weightKg, zone) {
const rates = { uk: 3.5, eu: 6.0, world: 12.0 };
return weightKg * rates[zone];
}
// DECORATOR 1 - VALIDATION. Rejects bad input before the real work happens.
function withValidation(fn) {
return function (weightKg, zone) {
if (typeof weightKg !== "number" || weightKg <= 0) {
throw new Error("weightKg must be a positive number");
}
return fn(weightKg, zone); // hand over to whatever we wrapped
};
}
// DECORATOR 2 - CACHING (memoisation). The cache lives in a closure, so it
// survives between calls while staying unreachable from outside.
function withCache(fn) {
const cache = {};
return function (weightKg, zone) {
const key = weightKg + "|" + zone; // one string key per argument pair
if (key in cache) {
console.log(" (cache hit for " + key + ")");
return cache[key]; // the wrapped fn is never called
}
const result = fn(weightKg, zone);
cache[key] = result;
return result;
};
}
// DECORATOR 3 - LOGGING.
function withLogging(fn) {
return function (weightKg, zone) {
console.log("call: " + weightKg + "kg to " + zone);
const result = fn(weightKg, zone);
console.log(" -> " + result.toFixed(2));
return result;
};
}
// STACKING. Read it inside out: validation is innermost, logging outermost.
// A call travels logging -> cache -> validation -> shippingCost, and the
// answer travels back out through the same layers in reverse.
const quote = withLogging(withCache(withValidation(shippingCost)));
quote(2, "uk");
quote(2, "uk"); // same arguments, so the cache answers this time
quote(5, "eu");
// Validation still fires, from three layers down:
try {
quote(-1, "uk");
} catch (err) {
console.log("rejected: " + err.message);
}
// And the original is untouched, still usable on its own:
console.log("undecorated: " + shippingCost(2, "uk"));
// Expected output:
// call: 2kg to uk
// -> 7.00
// call: 2kg to uk
// (cache hit for 2|uk)
// -> 7.00
// call: 5kg to eu
// -> 30.00
// call: -1kg to uk
// rejected: weightKg must be a positive number
// undecorated: 7Look at the second call in the output. Logging still runs, because it is the outer layer, but the cache answers before shippingCost is ever reached. Swap the order to withCache(withLogging(...)) and the log line disappears on cache hits instead — same three decorators, different behaviour.
🎯 Your Turn — Write a Retry Decorator
Retry is the decorator you will actually reach for at work: network calls fail, and wrapping a function beats scattering try/catch through your app. Three blanks, everything else written for you.
// 🎯 YOUR TURN - write a retry decorator
// Everything is written for you except the three blanks marked ___
// A deliberately flaky function: it fails the first two times, then works.
let attempts = 0;
function fetchPrice(sku) {
attempts = attempts + 1;
if (attempts < 3) throw new Error("network glitch #" + attempts);
return "price for " + sku + " = 19.99";
}
function withRetry(fn, maxTries) {
return function (...args) {
let lastError;
for (let i = 1; i <= maxTries; i++) {
try {
// 👉 1) Call the wrapped function with the SAME arguments it was given,
// and return its answer immediately if it worked.
// Hint: ...args spreads the collected arguments back out.
return ___;
} catch (err) {
lastError = err; // remember it in case we run out of tries
console.log("attempt " + i + " failed: " + err.message);
}
}
// 👉 2) Every try has failed. Re-raise the last error so the caller finds out.
// Replace ___ with the keyword that raises it.
___ lastError;
};
}
// 👉 3) Wrap fetchPrice so it gets up to 4 tries. Decorators are just calls:
// something(theFunction, theNumber).
const safeFetchPrice = ___;
console.log(safeFetchPrice("SKU-1"));
// ✅ Expected output:
// attempt 1 failed: network glitch #1
// attempt 2 failed: network glitch #2
// price for SKU-1 = 19.99Blank 2 is the one people skip. If you leave the failure silent, the wrapped function returns undefined after every retry fails, and the bug surfaces somewhere far away with no mention of the network. A decorator should hide the retrying, not the failure.
⭐ 6. Facade Pattern
Simplifies complex systems behind a clean API. Hide ugly complexity from users of your code.
// Complex subsystems
class EmailService {
send(to, msg) { console.log("Email to", to + ":", msg); }
}
class SmsService {
send(to, msg) { console.log("SMS to", to + ":", msg); }
}
class PushService {
send(to, msg) { console.log("Push to", to + ":", msg); }
}
// Facade - clean interface
class NotificationFacade {
constructor() {
this.email = new EmailService();
this.sms = new SmsService();
this.push = new PushService();
}
notify(type, to, message) {
switch(type) {
case "email": this.email.send(to, message); break;
case "sms": this.sms.send(to, message); break;
case "push": this.push.send(to, message); break;
default: console.log("Unknown type");
}
}
notifyAll(to, message) {
this.email.send(to, message);
this.sms.send(to, message);
this.push.send(to, message);
}
}
// Simple usage - user doesn't know about subsystems
const notifier = new NotificationFacade();
notifier.notify("email", "[email protected]", "Welcome!");
notifier.notifyAll("admin", "System alert!");⭐ Real-World Example: Authentication System Using 4 Patterns
Combining Strategy + Factory + Decorator + Singleton for a professional login system.
// STRATEGY: Different login methods
class PasswordLogin {
login(data) {
console.log("Password login for:", data.email);
return { success: true, method: "password" };
}
}
class GoogleLogin {
login(data) {
console.log("Google OAuth for token:", data.token);
return { success: true, method: "google" };
}
}
class GithubLogin {
login(data) {
console.log("GitHub OAuth for token:", data.token);
return { success: true, method: "github" };
}
}
// FACTORY: Select the right strategy
class LoginFactory {
static get(method) {
const map = {
password: new PasswordLogin(),
google: new GoogleLogin(),
github: new GithubLogin(),
};
return map[method];
}
}
// DECORATOR: Add logging
function withLogging(strategy) {
return {
login(data) {
console.log("[AUTH_LOG]", strategy.constructor.name, data);
return strategy.login(data);
}
};
}
// SINGLETON: One AuthService
class AuthService {
constructor() {
if (AuthService.instance) return AuthService.instance;
AuthService.instance = this;
}
login(method, data) {
let strategy = LoginFactory.get(method);
if (!strategy) throw new Error("Unknown login method");
strategy = withLogging(strategy);
return strategy.login(data);
}
}
// Usage
const auth = new AuthService();
auth.login("google", { token: "abc123" });
auth.login("password", { email: "[email protected]", password: "***" });🏆 Mini-Challenge: One Observable Store
No blanks this time — a brief, an outline and a test harness. Combine two patterns you have now seen separately: Singleton, so there is exactly one store, and Observer, so any part of the app can react when it changes. Get this working and you have written the core of every state library you have ever used.
// 🎯 MINI-CHALLENGE: one observable store (Singleton + Observer together)
//
// Brief: a single shared state object that any part of the app can subscribe
// to. This is a stripped-down version of what Redux, Zustand and Pinia do.
//
// Outline - no logic filled in, that part is yours:
//
// class Store {
// constructor(initial) {
// 1. SINGLETON: if Store.instance already exists, return it immediately
// 2. otherwise set this.state = initial, this.subscribers = [],
// and record Store.instance = this
// }
// subscribe(fn) -> add fn to subscribers, and RETURN a function that
// removes it again (that returned function is the
// "unsubscribe" - a closure over fn)
// setState(patch) -> build a NEW state object by spreading the old one then
// the patch (never mutate the old object), store it, then
// call every subscriber with the new state
// get(key) -> read a single value out of state
// }
//
// Until you have written the class, running this throws
// "ReferenceError: Store is not defined" - that is expected, not a bug.
// your code here
// --- test harness: do not change anything below this line ---
const store = new Store({ user: "guest", cartCount: 0 });
const stopA = store.subscribe(s => console.log("header sees " + s.user));
store.subscribe(s => console.log("badge sees " + s.cartCount));
store.setState({ user: "Alice" });
store.setState({ cartCount: 3 });
stopA(); // the header unsubscribes
store.setState({ cartCount: 4 });
const sameStore = new Store({ user: "SHOULD BE IGNORED" });
console.log("singleton? " + (sameStore === store));
console.log("user is still " + store.get("user"));
// ✅ Expected output:
// header sees Alice
// badge sees 0
// header sees Alice
// badge sees 3
// badge sees 4
// singleton? true
// user is still AliceTwo details decide whether you got it right. subscribe must return the unsubscribe function, or stopA() will throw. And setState must build a new object rather than editing the old one — mutating shared state in place is the bug that state libraries exist to prevent.
⭐ Refactoring: Switch Statement → Patterns
Turning messy code into clean, extensible architecture.
// ❌ BEFORE: Messy switch statement
function handleEventBad(event) {
switch (event.type) {
case "order": console.log("Processing order..."); break;
case "refund": console.log("Processing refund..."); break;
case "cancel": console.log("Cancelling..."); break;
default: console.log("Unknown event");
}
}
// ✔ AFTER: Strategy + Factory pattern
class OrderEvent {
run(data) { console.log("Processing order:", data); }
}
class RefundEvent {
run(data) { console.log("Processing refund:", data); }
}
class CancelEvent {
run(data) { console.log("Cancelling order:", data); }
}
class EventFactory {
static create(type) {
const map = {
order: new OrderEvent(),
refund: new RefundEvent(),
cancel: new CancelEvent()
};
return map[type];
}
}
function handleEvent(event) {
const handler = EventFactory.create(event.type);
if (handler) {
handler.run(event.data);
} else {
console.log("Unknown event type");
}
}
// Clean usage
handleEvent({ type: "order", data: { id: 123 } });
handleEvent({ type: "refund", data: { id: 456 } });⭐ Pattern Comparison Table
| Pattern | When to Use | Avoid When |
|---|---|---|
| Factory | Creates different objects cleanly | Only one type exists |
| Singleton | Only one object must exist globally | Anything user-specific |
| Strategy | Many behaviours/algorithms | Only one behaviour needed |
| Observer | Many listeners for one event | Strict order/sequence needed |
| Decorator | Add features without touching code | Too many wrappers |
| Facade | Simplify a complex system | No actual complexity |
✅ Senior-Level Understanding
- • Patterns are mental tools, not copy/paste code
- • Factory: Creates objects without exposing creation logic
- • Singleton: One instance globally (config, logging, cache)
- • Strategy: Swap algorithms without rewriting code
- • Observer: One event, many listeners
- • Decorator: Add features by wrapping functions
- • Facade: Hide complexity behind clean APIs
- • Combine patterns for real-world systems
- • Use patterns only when they reduce complexity
Practice quiz
What are the three core categories of design patterns in this lesson?
- Frontend, Backend, Database
- Simple, Medium, Complex
- Creational, Structural, Behavioral
- Public, Private, Protected
Answer: Creational, Structural, Behavioral. The 3 core categories are Creational (object creation), Structural (object relationships), and Behavioral (object behaviour).
Which pattern creates objects without exposing the creation logic?
- Factory
- Singleton
- Observer
- Facade
Answer: Factory. The Factory pattern creates objects without exposing creation logic, useful when creation depends on input or conditions.
What does the Singleton pattern guarantee?
- Many instances for performance
- Objects are created lazily
- Each user gets their own instance
- Only one instance of a class exists throughout the app
Answer: Only one instance of a class exists throughout the app. Singleton ensures only one instance exists app-wide, perfect for config, database connections, logging, or global state.
The lesson warns NOT to use a Singleton for which kind of data?
- Global configuration
- User-specific data or UI state that should reset
- Logging
- Caching
Answer: User-specific data or UI state that should reset. The warning says don't use Singletons for user-specific data, UI state, or anything that should reset per page/component.
Which pattern lets you switch between algorithms and is called your 'anti-if/else superpower'?
- Strategy
- Decorator
- Facade
- Singleton
Answer: Strategy. The Strategy pattern switches between interchangeable algorithms (payment methods, sorting, filters) without long if/else chains.
What does the Observer pattern provide?
- One instance globally
- A simplified interface
- One event, many listeners
- Object creation logic
Answer: One event, many listeners. Observer means one event with many listeners, used in UI frameworks, WebSockets, pub/sub systems, and data stores.
How does the Decorator pattern add features in the lesson's examples?
- By editing the original function's source
- By wrapping a function to add behavior without changing its original code
- By creating a subclass
- By using a global variable
Answer: By wrapping a function to add behavior without changing its original code. Decorators like withLogging and withTiming wrap a function to add behavior (and can be stacked) without changing the original code.
What is the purpose of the Facade pattern?
- Create many object types
- Ensure a single instance
- Swap algorithms at runtime
- Simplify a complex system behind a clean API
Answer: Simplify a complex system behind a clean API. The Facade (e.g. NotificationFacade) hides complex subsystems behind a clean interface so callers don't deal with the complexity.
In the Factory example, what permissions does UserFactory.create('Alice', 'admin') assign?
- ['read']
- ['read', 'write', 'delete']
- ['read', 'write']
- []
Answer: ['read', 'write', 'delete']. The roleMap maps 'admin' to ['read', 'write', 'delete'], so the admin user receives all three permissions.
What is the lesson's overall guidance on when to use patterns?
- Always use as many as possible
- Avoid patterns entirely
- Use patterns only when they reduce complexity
- Use one pattern per file
Answer: Use patterns only when they reduce complexity. The senior-level takeaway: patterns are mental tools, not copy/paste code, and should be used only when they reduce complexity.
Continue this course
- Previous: Working with Dates, Timezones & Intl API
- Next: Writing Maintainable & Scalable Code Architecture — Structure large codebases with clear boundaries, naming, and conventions
- Quick reference: JavaScript cheat sheet