JavaScript · lesson 21 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Also 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
| Problem | Pattern |
|---|---|
| Private state, clean public API | Module (or just an ES module file) |
| One shared instance across the app | Singleton — but prefer passing it in |
| Components react to events without coupling | Observer |
| Choose the concrete type from runtime data | Factory |
| Operations need undo/redo, queuing or logging | Command |
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.