05 · Design Patterns in JavaScript¶
🎥 Video walkthrough¶
Design patterns are reusable solutions to recurring problems in software design. JavaScript's flexible object model lets you implement classic patterns in a few different ways — this module covers four of the most common: module, singleton, factory, and observer.
Module pattern¶
The goal of the module pattern is encapsulation — hiding internal state
and exposing only a small public interface. ES modules (export/import)
give you this for free at the file level, but the pattern also applies
inside a single file using closures.
// counter.js — ES module version: file-level encapsulation
let count = 0; // private — not exported, unreachable from outside this file
export function increment() {
count += 1;
return count;
}
export function reset() {
count = 0;
}
export function getCount() {
return count;
}
// Closure version: encapsulation without separate files
function createCounter() {
let count = 0; // private state, captured by the closures below
return {
increment() {
count += 1;
return count;
},
reset() {
count = 0;
},
getCount() {
return count;
},
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
// counter.count is not accessible — count only exists inside the closure
Singleton pattern¶
A singleton ensures a class or module has exactly one shared instance, and provides a global point of access to it — useful for things like a single database connection pool or a shared configuration object.
// connection.js
class DatabaseConnection {
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance; // return the existing instance instead of a new one
}
this.connectedAt = new Date();
this.queryCount = 0;
DatabaseConnection.instance = this;
}
query(sql) {
this.queryCount += 1;
return `executed: ${sql} (total queries: ${this.queryCount})`;
}
}
const connectionA = new DatabaseConnection();
const connectionB = new DatabaseConnection();
console.log(connectionA === connectionB); // true — same instance
console.log(connectionA.query("SELECT 1")); // executed: SELECT 1 (total queries: 1)
console.log(connectionB.query("SELECT 2")); // executed: SELECT 2 (total queries: 2) — shared state
Because ES modules are cached after their first import, a module that exports a single created instance is a simpler, idiomatic way to get singleton behavior in Node:
// config.js — the module itself acts as the singleton
const config = { env: "production", version: "1.0.0" };
export default config; // every importer receives the exact same object
Factory pattern¶
A factory function creates and returns objects without exposing the
new/class machinery to the caller — useful when object creation involves
branching logic or you want to hide the concrete type being constructed.
function createNotification(type, message) {
const base = { message, createdAt: new Date() };
switch (type) {
case "email":
return { ...base, kind: "email", send: () => `Emailing: ${message}` };
case "sms":
return { ...base, kind: "sms", send: () => `Texting: ${message}` };
case "push":
return { ...base, kind: "push", send: () => `Pushing: ${message}` };
default:
throw new Error(`unknown notification type: ${type}`);
}
}
const email = createNotification("email", "Your order shipped");
const sms = createNotification("sms", "Your code is 4821");
console.log(email.send()); // Emailing: Your order shipped
console.log(sms.send()); // Texting: Your code is 4821
console.log(email.kind); // email
Factories are especially handy alongside classes, when you want the constructor logic centralized but still hide class selection from callers:
class EmailNotification {
constructor(message) { this.message = message; }
send() { return `Emailing: ${this.message}`; }
}
class SmsNotification {
constructor(message) { this.message = message; }
send() { return `Texting: ${this.message}`; }
}
function notificationFactory(type, message) {
const classes = { email: EmailNotification, sms: SmsNotification };
const NotificationClass = classes[type];
if (!NotificationClass) throw new Error(`unknown type: ${type}`);
return new NotificationClass(message);
}
console.log(notificationFactory("sms", "Reminder: meeting at 3pm").send());
// Texting: Reminder: meeting at 3pm
Observer pattern¶
The observer pattern lets one object (the subject) notify a list of
dependent objects (observers) whenever something happens, without the
subject knowing any details about them. This is the foundation of DOM
events, Node's EventEmitter, and pub/sub systems in general.
class EventBus {
constructor() {
this.listeners = {}; // event name -> array of callback functions
}
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) {
(this.listeners[event] ?? []).forEach((callback) => callback(payload));
}
}
const bus = new EventBus();
function logOrder(order) {
console.log(`order placed: #${order.id} for $${order.total}`);
}
function emailReceipt(order) {
console.log(`emailing receipt for order #${order.id}`);
}
bus.on("order:placed", logOrder);
bus.on("order:placed", emailReceipt);
bus.emit("order:placed", { id: 101, total: 49.99 });
// order placed: #101 for $49.99
// emailing receipt for order #101
bus.off("order:placed", emailReceipt);
bus.emit("order:placed", { id: 102, total: 12.5 });
// order placed: #102 for $12.5
// (emailReceipt no longer fires)
Node's built-in EventEmitter (from node:events) is a ready-made,
battle-tested implementation of exactly this pattern:
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
emitter.on("greet", (name) => console.log(`Hello, ${name}!`));
emitter.emit("greet", "Ada"); // Hello, Ada!
Pattern cheat sheet¶
| Pattern | Problem it solves | JS building block |
|---|---|---|
| Module | hide internal state, expose a small API | ES modules / closures |
| Singleton | exactly one shared instance | cached module export / instance check |
| Factory | centralize object creation logic | a function returning different objects/classes |
| Observer | decouple "something happened" from "what to do about it" | listener arrays / EventEmitter |
How It Actually Works¶
The Module pattern (an IIFE returning an object) and ES modules solve the same problem
— avoiding global namespace pollution — through very different mechanisms. An IIFE
relies purely on closures: variables declared inside never escape the function's
lexical environment unless explicitly returned, so "private" state is really just
"unreachable from outside the closure," enforced by scope, not by any access-control
feature of the language. ES modules get real, engine-level isolation instead: each
module has its own top-level scope by specification, and nothing you declare at module
top level ever becomes a global unless you explicitly attach it to globalThis — no
IIFE wrapper needed because the module system itself provides the boundary.
The Observer pattern (pub/sub, EventEmitter) works because JS functions are first-class
values that can be stored in a plain array or Map keyed by event name; .emit() is just
a synchronous loop over that array calling each stored function in registration order —
there's no scheduling, no queue, it's a direct function call chain, which means a
listener that throws will halt the emit loop and any listeners registered after it
won't run (EventEmitter in Node re-throws synchronously unless you're on an 'error'
event, which gets special-cased specifically because an uncaught error there crashes
the process by design).
Exercise¶
Build a small TaskQueue using the observer pattern: it should support
.on("task:completed", callback) and .on("task:failed", callback), plus a
method runTask(fn) that calls fn(), catches any thrown error, and emits
the appropriate event with the result or the error. Then wrap creation of
TaskQueue instances in a factory function createQueue(type) that returns
either a "verbose" queue (logs every event to the console) or a "silent"
one (no logging), demonstrating factory + observer working together.