Skip to main content
JavaScript · lesson 21 of 24
JavaScript advanced Lesson 21 of 24

Also part of: Low-Level Design

JavaScript Design Patterns

Runnable JavaScript design patterns — Module, Singleton, Observer, Factory and Command — each shown with real Node output and the problem it solves.

By SkillByExample · Updated

Design patterns are named solutions to recurring design problems — a shared vocabulary, so “use an Observer here” says more than describing the wiring. Each pattern below is a complete program you can run with node, followed by what it prints and what that shows.

Module Pattern

A closure keeps state private and exposes only a deliberate public API. The IIFE form predates ES modules and is still what you’ll find inside bundled code.

const ShoppingCart = (() => {
  const items = [];
  let discountCode = null;

  const subtotal = () => items.reduce((sum, i) => sum + i.price * i.quantity, 0);

  return {
    addItem(product, quantity = 1) {
      const existing = items.find((i) => i.id === product.id);
      if (existing) existing.quantity += quantity;
      else items.push({ ...product, quantity });
    },
    applyDiscount(code) { discountCode = code; },
    getTotal() { 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.addItem({ id: 1, name: "Book", price: 29.99 });
ShoppingCart.applyDiscount("SAVE10");
console.log(ShoppingCart.getItemCount());
console.log(ShoppingCart.getTotal());
console.log(ShoppingCart.getTotal().toFixed(2));
console.log(ShoppingCart.items, ShoppingCart.subtotal);
$ node cart.js
3
80.973
80.97
undefined undefined

Adding the same product twice merged into one line with quantity 3. items and subtotal are unreachable from outside — both print undefined. The raw total, 80.973, has a tenth of a cent in it: arithmetic on prices as floating-point numbers drifts off whole cents, so round with toFixed(2) for display, or better, store money as integer cents.

With ES modules, the file itself is the private scope; anything not exported is invisible to importers, no IIFE required.

Singleton

One shared instance for the whole app — config, a logger, a connection pool. With ES modules you get it almost for free, because a module is evaluated once and every importer receives the same exports.

// config.mjs
class AppConfig {
  #data;
  constructor(data) { this.#data = { ...data }; console.log("AppConfig created"); }
  get(key) { return this.#data[key]; }
  set(key, value) { this.#data[key] = value; }
}

let instance = null;
export function getConfig(initialData = {}) {
  instance ??= new AppConfig(initialData);
  return instance;
}
// main.mjs
import { getConfig } from "./config.mjs";
import "./worker.mjs";

const config = getConfig({ apiUrl: "https://api.example.com", timeout: 5000 });
config.set("debug", true);
console.log("main sees debug =", config.get("debug"));
console.log("same object:", config === getConfig({ apiUrl: "ignored" }));
console.log("apiUrl =", getConfig().get("apiUrl"));
// worker.mjs
import { getConfig } from "./config.mjs";
console.log("worker sees debug =", getConfig({ timeout: 1 }).get("debug"));
$ node main.mjs
AppConfig created
worker sees debug = undefined
main sees debug = true
same object: true
apiUrl = undefined

The surprise is in the first lines: worker.mjs ran first, because imports are evaluated before the importing module’s body. So the worker created the instance with { timeout: 1 }, and main’s { apiUrl: … } was silently ignored — apiUrl is undefined. That is the Singleton’s real cost: whoever touches it first decides its state, and that order depends on the import graph. It also carries state between unit tests. Prefer creating the instance once at startup and passing it to what needs it (dependency injection).

Observer / EventEmitter

Publishers emit events; subscribers react — neither holds a reference to the other. Here is a minimal emitter, and a store that uses it.

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 () => this.#listeners.get(event).delete(handler); // unsubscribe
  }
  emit(event, ...args) {
    this.#listeners.get(event)?.forEach((handler) => handler(...args));
  }
}

class UserStore extends EventEmitter {
  constructor(loadFn) { super(); this.loadFn = loadFn; }
  async load() {
    this.emit("loading");
    try { this.emit("loaded", await this.loadFn()); }
    catch (err) { this.emit("error", err); }
  }
}

const store = new UserStore(async () => ["alice", "bob"]);
store.on("loading", () => console.log("spinner on"));
const unsubscribe = store.on("loaded", (users) => console.log("render", users));
store.on("error", (err) => console.log("banner:", err.message));

await store.load();
unsubscribe();
await store.load();
store.loadFn = async () => { throw new Error("503 from /api/users"); };
await store.load();
$ node observer.mjs
spinner on
render [ 'alice', 'bob' ]
spinner on
spinner on
banner: 503 from /api/users

The store never mentions a spinner, a list or a banner — it only emits. After unsubscribe() the second load still shows the spinner but nothing renders. The third load fails and only the error subscriber reacts. on returning its own unsubscribe function is the modern convention (React effects, many stores) — it avoids needing to keep a reference to the handler for a later off call. Node ships a full implementation as node:events; the browser’s addEventListener is the same pattern.

Factory

A factory picks and constructs the concrete class from runtime data, so callers never import the concrete classes. A registry makes it open to new types without editing the factory.

class CsvExporter  { export(rows) { return rows.map((r) => r.join(",")).join("\n"); } }
class JsonExporter { export(rows) { return JSON.stringify(rows); } }

class ExporterFactory {
  static #registry = new Map();
  static register(format, Cls) { this.#registry.set(format.toLowerCase(), Cls); }
  static create(format) {
    const Cls = this.#registry.get(format.toLowerCase());
    if (!Cls) {
      const known = [...this.#registry.keys()].join(", ");
      throw new Error(`Unknown format "${format}". Registered: ${known}`);
    }
    return new Cls();
  }
}

ExporterFactory.register("csv", CsvExporter);
ExporterFactory.register("json", JsonExporter);

const rows = [["id", "name"], [1, "Alice"]];
for (const format of ["CSV", "json", "xml"]) {
  try {
    const exporter = ExporterFactory.create(format);
    console.log(`${format} -> ${exporter.constructor.name}:`, exporter.export(rows));
  } catch (err) {
    console.log(err.message);
  }
}
$ node factory.js
CSV -> CsvExporter: id,name
1,Alice
json -> JsonExporter: [["id","name"],[1,"Alice"]]
Unknown format "xml". Registered: csv, json

The loop only knows format names, which is what arrives from a config file or a ?format= query string. Lower-casing on both register and create made "CSV" work. The error lists what is registered — cheap to add, and it turns a support ticket into a self-explanatory message. Supporting XML means one new class and one register call; the factory and its callers don’t change.

Command Pattern

Wrap each operation in an object with execute and undo, and undo/redo becomes two stacks.

class Editor {
  text = "";
  insert(pos, s) { this.text = this.text.slice(0, pos) + s + this.text.slice(pos); }
  remove(pos, len) { const cut = this.text.slice(pos, pos + len);
    this.text = this.text.slice(0, pos) + this.text.slice(pos + len); return cut; }
}
const insert = (ed, pos, s) => ({ execute: () => ed.insert(pos, s), undo: () => ed.remove(pos, s.length) });
const remove = (ed, pos, len) => { let cut = "";
  return { execute: () => { cut = ed.remove(pos, len); }, undo: () => ed.insert(pos, cut) }; };

class History {
  #undo = []; #redo = [];
  run(cmd) { cmd.execute(); this.#undo.push(cmd); this.#redo = []; }
  undo() { const c = this.#undo.pop(); if (c) { c.undo(); this.#redo.push(c); } }
  redo() { const c = this.#redo.pop(); if (c) { c.execute(); this.#undo.push(c); } }
}

const ed = new Editor(), h = new History();
const log = (label) => console.log(label.padEnd(12), JSON.stringify(ed.text));
h.run(insert(ed, 0, "Hello, World!")); log("insert");
h.run(remove(ed, 5, 7));               log("remove");
h.undo();                              log("undo");
h.undo();                              log("undo");
h.redo();                              log("redo");
h.run(insert(ed, 13, "?"));            log("new edit");
h.redo();                              log("redo again");
$ node command.js
insert       "Hello, World!"
remove       "Hello!"
undo         "Hello, World!"
undo         ""
redo         "Hello, World!"
new edit     "Hello, World!?"
redo again   "Hello, World!?"

Each command remembers what it needs to reverse itself — remove captures the deleted text (", World") when it executes, so undo can put it back. The last line is the subtle part: running a new command clears the redo stack, so redo again does nothing. That matches every text editor you’ve used; without the this.#redo = [], redo would replay a stale command against text it no longer applies to. The same command objects can be queued, logged or retried.

Choosing the Right Pattern

ProblemPattern
Private state, clean public APIModule (or just an ES module file)
One shared instance across the appSingleton — but prefer passing it in
Components react to events without couplingObserver
Choose the concrete type from runtime dataFactory
Operations need undo/redo, queuing or loggingCommand

Start with the simplest code that works; reach for a pattern when its problem actually appears.

Practice

1. Add a once(event, handler) method to the EventEmitter that runs the handler for the first emit only.
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 () => this.#listeners.get(event).delete(handler);
  }
  once(event, handler) {
    const off = this.on(event, (...args) => { off(); handler(...args); });
    return off;
  }
  emit(event, ...args) {
    this.#listeners.get(event)?.forEach((h) => h(...args));
  }
}

const e = new EventEmitter();
e.once("ready", (n) => console.log("ready fired with", n));
e.emit("ready", 1);
e.emit("ready", 2);
console.log("done");
$ node p1.js
ready fired with 1
done

The wrapper unsubscribes itself before calling the handler, so even if the handler emits ready again it won’t recurse.

2. Register a TsvExporter with the factory without editing ExporterFactory.
class ExporterFactory {
  static #registry = new Map();
  static register(format, Cls) { this.#registry.set(format.toLowerCase(), Cls); }
  static create(format) { return new (this.#registry.get(format.toLowerCase()))(); }
}
class TsvExporter { export(rows) { return rows.map((r) => r.join("\t")).join("\n"); } }
ExporterFactory.register("tsv", TsvExporter);

console.log(ExporterFactory.create("TSV").export([["id", "name"], [1, "Alice"]]));
$ node p2.js
id	name
1	Alice

This is the open/closed principle in miniature: the factory is closed for modification but open for extension through register.

3. Replace the singleton with dependency injection: a createApp(config) that receives its config, and two apps with different configs side by side.
function createApp(config) {
  return { describe: () => `${config.env} -> ${config.apiUrl}` };
}

const prod = createApp({ env: "prod", apiUrl: "https://api.example.com" });
const test = createApp({ env: "test", apiUrl: "http://localhost:9999" });
console.log(prod.describe());
console.log(test.describe());
$ node p3.js
prod -> https://api.example.com
test -> http://localhost:9999

Two apps with different configs in one process is exactly what a module-level singleton makes impossible — and exactly what a test suite needs.

4. Add an uppercase(ed) command to the editor example that can be undone.
class Editor { text = "hello world"; }
const uppercase = (ed) => {
  let before = "";
  return {
    execute: () => { before = ed.text; ed.text = ed.text.toUpperCase(); },
    undo: () => { ed.text = before; },
  };
};

const ed = new Editor();
const cmd = uppercase(ed);
cmd.execute(); console.log(ed.text);
cmd.undo();    console.log(ed.text);
$ node p4.js
HELLO WORLD
hello world

When an operation isn’t mathematically reversible, the command saves a snapshot of what it overwrote — the same trick remove uses for deleted text.

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.