DocsHub
Advanced

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); // undefined

The 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 instance

3. 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 listener

The 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));             // quicksort

Real 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 350

Real 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: notvalid

We cover Proxy and Reflect in full depth in the next topic.


Choosing the Right Pattern

PatternUse when
ModuleEncapsulating related functionality with private state
SingletonExactly one instance needed — config, DB connection, store
ObserverOne-to-many notifications — UI updates, event systems
FactoryCreating objects of different types based on conditions
StrategySwitching algorithms or behaviors at runtime
DecoratorAdding behavior to objects without modifying their class
ProxyControlling 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

On this page