Skip to main content
JavaScript advanced Lesson 21 of 24

JavaScript Design Patterns

Practical implementations of the most useful JavaScript design patterns — Module, Singleton, Observer, Factory, and Command — with real-world use cases.

Design patterns are proven solutions to recurring design problems. They’re a shared vocabulary — saying “use the Observer pattern here” communicates more precisely than describing the wiring from scratch. This guide shows each pattern with a production-style implementation, not a toy example. The goal is to recognize the problem a pattern solves so you reach for it when it fits, not to apply patterns for their own sake.

Module Pattern

The Module pattern uses a closure to create private state — variables and functions that are inaccessible from outside the module, with only a deliberate public API exposed. This solves the problem of global scope pollution and prevents external code from depending on internal implementation details. The IIFE form was essential before ES modules; it’s still useful in environments without bundlers.

// IIFE-based module — creates a self-contained namespace with private state
const ShoppingCart = (() => {
  // Private state — inaccessible from outside the IIFE
  const items = [];
  let discountCode = null;

  // Private helper — not exposed, only used internally
  function calculateSubtotal() {
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  // Public API — only what's returned is accessible to callers
  return {
    addItem(product, quantity = 1) {
      const existing = items.find((i) => i.id === product.id);
      if (existing) {
        existing.quantity += quantity;
      } else {
        items.push({ ...product, quantity });
      }
    },

    removeItem(productId) {
      const index = items.findIndex((i) => i.id === productId);
      if (index !== -1) items.splice(index, 1);
    },

    applyDiscount(code) {
      discountCode = code;
    },

    getTotal() {
      const subtotal = calculateSubtotal();
      return discountCode === "SAVE10" ? subtotal * 0.9 : subtotal;
    },

    getItemCount() {
      return items.reduce((sum, i) => sum + i.quantity, 0);
    },
  };
})();

ShoppingCart.addItem({ id: 1, name: "Book", price: 29.99 }, 2);
ShoppingCart.applyDiscount("SAVE10");
console.log(ShoppingCart.getTotal()); // 53.982

With ES modules (modern approach), file scope provides the same encapsulation without the IIFE wrapper:

// cart.js — file scope is the module boundary
const items = []; // private — not exported, not accessible outside this file

export function addItem(product, qty = 1) { /* ... */ }
export function getTotal() { /* ... */ }

Singleton

The Singleton pattern ensures only one instance of a class exists across the entire application. This is useful for shared services like configuration, loggers, database connection pools, and caches — resources where having multiple independent instances would cause inconsistency or waste.

class AppConfig {
  #data = {};

  constructor(initialData = {}) {
    this.#data = initialData;
  }

  get(key) {
    return this.#data[key];
  }

  set(key, value) {
    this.#data[key] = value;
  }

  merge(newData) {
    Object.assign(this.#data, newData);
  }
}

// The Singleton wrapper — a module-level variable holds the single instance
let instance = null;

export function getConfig(initialData) {
  if (!instance) {
    instance = new AppConfig(initialData); // create only on first call
  }
  return instance;
}

// Usage across multiple files — always returns the same instance
import { getConfig } from "./config.js";

const config = getConfig({ apiUrl: "https://api.example.com", timeout: 5000 });
config.set("debug", true);

// Somewhere else in the codebase:
const config2 = getConfig(); // same instance — still has debug: true

When to avoid: Singletons make unit testing hard because they carry state between tests. Prefer passing the instance as a dependency (dependency injection) rather than importing the singleton directly in every module that needs it.

Observer / EventEmitter

The Observer pattern decouples publishers from subscribers. One part of your system emits events; other parts react to them — without either side needing a direct reference to the other. This is the foundation of event-driven architecture: the data store doesn’t know or care which UI components are listening, and components can subscribe or unsubscribe without modifying the store.

class EventEmitter {
  #listeners = new Map();

  on(event, handler) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, new Set());
    }
    this.#listeners.get(event).add(handler);
    // Return an unsubscribe function — cleaner than a separate off() call
    return () => this.off(event, handler);
  }

  once(event, handler) {
    // Wrap the handler so it removes itself after the first invocation
    const wrapper = (...args) => {
      handler(...args);
      this.off(event, wrapper);
    };
    return this.on(event, wrapper);
  }

  off(event, handler) {
    this.#listeners.get(event)?.delete(handler);
  }

  emit(event, ...args) {
    this.#listeners.get(event)?.forEach((handler) => handler(...args));
  }
}

// Real usage: a data store that notifies UI components when data changes
class UserStore extends EventEmitter {
  #users = [];

  async loadUsers() {
    this.emit("loading"); // UI can show a spinner
    try {
      const res = await fetch("/api/users");
      this.#users = await res.json();
      this.emit("loaded", this.#users); // UI renders the list
    } catch (err) {
      this.emit("error", err); // UI shows an error message
    }
  }

  getUsers() {
    return [...this.#users]; // return a copy to prevent external mutation
  }
}

const store = new UserStore();

// Subscribe — returns an unsubscribe function
const unsubscribe = store.on("loaded", (users) => {
  renderUserList(users);
});

store.on("error", (err) => {
  showErrorBanner(err.message);
});

store.loadUsers();

// Later — clean up to prevent memory leaks (e.g., on component unmount)
unsubscribe();

Factory

Factories centralize object creation logic, hiding the choice of which concrete class to instantiate from callers. This is valuable when the exact type to create depends on runtime data (a format string, a config value, a user preference), or when construction involves complex setup that callers shouldn’t need to know about. Adding a new type never requires touching existing code — just register it.

// Without Factory — callers must import and know about every concrete class
import { PNGExporter, JPEGExporter, WebPExporter } from "./exporters.js";
const exporter = new PNGExporter(options); // caller is tightly coupled to concrete class

// With Factory — callers specify what they want, not how to build it
class ExporterFactory {
  static #registry = new Map();

  static register(format, ExporterClass) {
    this.#registry.set(format.toLowerCase(), ExporterClass);
  }

  static create(format, options = {}) {
    const ExporterClass = this.#registry.get(format.toLowerCase());
    if (!ExporterClass) {
      throw new Error(
        `Unknown export format: "${format}". Registered: ${[...this.#registry.keys()].join(", ")}`
      );
    }
    return new ExporterClass(options);
  }
}

// Register formats — each format module registers itself (plugin-style)
ExporterFactory.register("png", PNGExporter);
ExporterFactory.register("jpeg", JPEGExporter);
ExporterFactory.register("webp", WebPExporter);

// Usage — no import of concrete classes at the call site
async function exportImage(canvas, format, quality) {
  const exporter = ExporterFactory.create(format, { quality });
  const blob = await exporter.export(canvas);
  return blob;
}

// Adding a new format never touches existing code — just register it
ExporterFactory.register("avif", AVIFExporter);

Command Pattern

The Command pattern encapsulates an operation as an object. Each command has an execute method and, optionally, an undo method. This decoupling gives you undo/redo, operation queuing, logging, and retry logic essentially for free — features that would be very hard to bolt on after the fact without a command abstraction.

// Each command encapsulates one reversible operation
class Command {
  execute() { throw new Error("Not implemented"); }
  undo() { throw new Error("Not implemented"); }
}

class InsertTextCommand extends Command {
  constructor(editor, position, text) {
    super();
    this.editor = editor;
    this.position = position;
    this.text = text;
  }

  execute() {
    this.editor.insert(this.position, this.text);
  }

  undo() {
    // Reverse the insert by deleting the same range
    this.editor.delete(this.position, this.text.length);
  }
}

class DeleteTextCommand extends Command {
  constructor(editor, position, length) {
    super();
    this.editor = editor;
    this.position = position;
    this.length = length;
    this.deletedText = ""; // captured during execute so undo can restore it
  }

  execute() {
    this.deletedText = this.editor.read(this.position, this.length);
    this.editor.delete(this.position, this.length);
  }

  undo() {
    this.editor.insert(this.position, this.deletedText);
  }
}

// CommandHistory manages undo/redo stacks — works with any Command subclass
class CommandHistory {
  #undoStack = [];
  #redoStack = [];

  execute(command) {
    command.execute();
    this.#undoStack.push(command);
    this.#redoStack = []; // a new action invalidates the redo history
  }

  undo() {
    const command = this.#undoStack.pop();
    if (!command) return;
    command.undo();
    this.#redoStack.push(command);
  }

  redo() {
    const command = this.#redoStack.pop();
    if (!command) return;
    command.execute();
    this.#undoStack.push(command);
  }

  canUndo() { return this.#undoStack.length > 0; }
  canRedo() { return this.#redoStack.length > 0; }
}

// Usage
const history = new CommandHistory();

history.execute(new InsertTextCommand(editor, 0, "Hello, World!"));
history.execute(new InsertTextCommand(editor, 13, " How are you?"));
history.undo(); // removes " How are you?"
history.redo(); // puts it back

Choosing the Right Pattern

ProblemPattern
Private state, clean public APIModule
One shared instance across the appSingleton
Components react to events without couplingObserver
Defer or vary object constructionFactory
Operations need undo/redo or queuingCommand

Patterns are tools, not rules. Start with the simplest code that works. Reach for a pattern when a known problem appears — not before.

Frequently Asked Questions

Do I need to memorize every design pattern?
No. Focus on understanding the problem each pattern solves. Observer, Module, and Factory cover the vast majority of real-world needs. The others are worth recognizing so you can apply them when the problem fits, not to force them onto code that doesn't need them.
Are ES modules a replacement for the Module pattern?
For new code, yes. ES modules (import/export) give you encapsulation, tree-shaking, and explicit dependencies without an IIFE wrapper. The IIFE-based Module pattern is still relevant when reading legacy code or working in environments without a module bundler.
What is the difference between Observer and EventEmitter?
They solve the same problem: decoupled pub/sub communication. Observer is the general pattern name. EventEmitter is a concrete implementation of Observer — it's the Node.js standard library's version. The browser's addEventListener/dispatchEvent is another EventEmitter-style implementation.