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:

Master Factory, Singleton, Strategy, Observer, Decorator, and Facade patterns for scalable applications

What You'll Learn

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.

⭐ 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: 7

Look 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.99

Blank 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 Alice

Two 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

PatternWhen to UseAvoid When
FactoryCreates different objects cleanlyOnly one type exists
SingletonOnly one object must exist globallyAnything user-specific
StrategyMany behaviours/algorithmsOnly one behaviour needed
ObserverMany listeners for one eventStrict order/sequence needed
DecoratorAdd features without touching codeToo many wrappers
FacadeSimplify a complex systemNo actual complexity

✅ Senior-Level Understanding

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