JavaScript Design Patterns: Singleton, Observer, Factory, Module and More
Design patterns are reusable solutions to commonly occurring problems in software design. They are not templates you copy and paste β they are concepts that, once understood, you will recognize and apply naturally. In JavaScript interviews, knowing patterns demonstrates that you write intentional, maintainable code.
Why Design Patterns Matter
Without patterns, developers solve the same problems repeatedly in inconsistent ways. Patterns give teams a shared vocabulary β saying "let's use an Observer here" is faster than explaining the concept from scratch. They also represent decades of battle-tested wisdom about what works.
Creational Patterns
Creational patterns deal with object creation.
Singleton
Ensures a class has only one instance and provides a global access point to it.
javascriptclass DatabaseConnection { constructor(url) { if (DatabaseConnection.instance) { return DatabaseConnection.instance; } this.url = url; this.connected = false; DatabaseConnection.instance = this; } connect() { if (!this.connected) { console.log(`Connecting to ${this.url}`); this.connected = true; } return this; } query(sql) { if (!this.connected) throw new Error("Not connected"); return `Result of: ${sql}`; } } const db1 = new DatabaseConnection("postgres://localhost/mydb"); const db2 = new DatabaseConnection("postgres://other/db"); console.log(db1 === db2); // true -- same instance console.log(db2.url); // "postgres://localhost/mydb"
Modern JavaScript often uses module-level singletons instead:
javascript// db.js -- the module is loaded once; this is the singleton import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); export default pool; // Any file that imports pool gets the same instance import pool from "./db.js";
Use when: You need exactly one instance of something β a connection pool, a configuration object, a logger.
Avoid when: It introduces global state that makes testing hard. Consider dependency injection instead.
Factory
Creates objects without specifying their exact class, delegating the instantiation logic to a factory function or method.
javascriptclass Button { constructor(text, style) { this.text = text; this.style = style; } render() { return `<button class="${this.style}">${this.text}</button>`; } } class IconButton extends Button { constructor(text, icon) { super(text, "icon-btn"); this.icon = icon; } render() { return `<button class="${this.style}"><span>${this.icon}</span> ${this.text}</button>`; } } class LinkButton extends Button { constructor(text, href) { super(text, "link-btn"); this.href = href; } render() { return `<a href="${this.href}" class="${this.style}">${this.text}</a>`; } } // Factory function function createButton(type, options) { switch (type) { case "icon": return new IconButton(options.text, options.icon); case "link": return new LinkButton(options.text, options.href); default: return new Button(options.text, options.style ?? "btn"); } } const btn = createButton("icon", { text: "Save", icon: "πΎ" }); const link = createButton("link", { text: "Learn more", href: "/docs" });
Use when: Object creation logic is complex, varies by type, or you want to hide implementation details from callers.
Builder
Constructs complex objects step by step, separating construction from representation.
javascriptclass QueryBuilder { #table = ""; #conditions = []; #selectedCols = ["*"]; #limitVal = null; #orderByCol = null; from(table) { this.#table = table; return this; // enable chaining } select(...cols) { this.#selectedCols = cols; return this; } where(condition) { this.#conditions.push(condition); return this; } limit(n) { this.#limitVal = n; return this; } orderBy(col) { this.#orderByCol = col; return this; } build() { let query = `SELECT ${this.#selectedCols.join(", ")} FROM ${this.#table}`; if (this.#conditions.length) { query += ` WHERE ${this.#conditions.join(" AND ")}`; } if (this.#orderByCol) query += ` ORDER BY ${this.#orderByCol}`; if (this.#limitVal) query += ` LIMIT ${this.#limitVal}`; return query; } } const query = new QueryBuilder() .from("users") .select("id", "name", "email") .where("active = true") .where("age >= 18") .orderBy("name") .limit(20) .build(); // SELECT id, name, email FROM users WHERE active = true AND age >= 18 ORDER BY name LIMIT 20
Structural Patterns
Structural patterns deal with object composition.
Module Pattern
Encapsulates related code with private state. The foundation of JavaScript modularity before ES modules.
javascriptconst CartModule = (function() { // Private state const items = []; let discount = 0; // Private function function calculateSubtotal() { return items.reduce((sum, item) => sum + item.price * item.qty, 0); } // Public API return { addItem(item) { const existing = items.find(i => i.id === item.id); if (existing) { existing.qty += item.qty; } else { items.push({ ...item }); } }, removeItem(id) { const index = items.findIndex(i => i.id === id); if (index !== -1) items.splice(index, 1); }, setDiscount(pct) { discount = pct; }, getTotal() { return calculateSubtotal() * (1 - discount / 100); }, getItems() { return [...items]; // return copy, not reference }, }; })(); CartModule.addItem({ id: 1, name: "Laptop", price: 999, qty: 1 }); CartModule.setDiscount(10); console.log(CartModule.getTotal()); // 899.1 console.log(CartModule.items); // undefined -- private
Decorator
Adds behavior to objects dynamically without modifying their class.
javascriptclass Coffee { cost() { return 2; } description() { return "Basic coffee"; } } class MilkDecorator { constructor(coffee) { this.coffee = coffee; } cost() { return this.coffee.cost() + 0.5; } description() { return this.coffee.description() + ", milk"; } } class SyrupDecorator { constructor(coffee) { this.coffee = coffee; } cost() { return this.coffee.cost() + 0.75; } description() { return this.coffee.description() + ", syrup"; } } class WhipDecorator { constructor(coffee) { this.coffee = coffee; } cost() { return this.coffee.cost() + 0.6; } description() { return this.coffee.description() + ", whip"; } } let order = new Coffee(); order = new MilkDecorator(order); order = new SyrupDecorator(order); order = new WhipDecorator(order); console.log(order.description()); // Basic coffee, milk, syrup, whip console.log(order.cost()); // 3.85
Behavioral Patterns
Behavioral patterns deal with communication between objects.
Observer (Publish/Subscribe)
Defines a one-to-many dependency β when one object changes state, all dependents are notified.
javascriptclass EventEmitter { #listeners = new Map(); on(event, listener) { if (!this.#listeners.has(event)) { this.#listeners.set(event, new Set()); } this.#listeners.get(event).add(listener); return () => this.off(event, listener); // return unsubscribe fn } off(event, listener) { this.#listeners.get(event)?.delete(listener); } emit(event, ...args) { this.#listeners.get(event)?.forEach(listener => listener(...args)); } once(event, listener) { const wrapper = (...args) => { listener(...args); this.off(event, wrapper); }; return this.on(event, wrapper); } } const store = new EventEmitter(); const unsubscribe = store.on("userLoggedIn", (user) => { console.log(`Welcome, ${user.name}!`); }); store.on("userLoggedIn", (user) => { analytics.track("login", { userId: user.id }); }); store.emit("userLoggedIn", { id: 1, name: "Alice" }); unsubscribe(); // clean up first listener
Strategy
Defines a family of algorithms, encapsulates each one, and makes them interchangeable.
javascriptconst sortStrategies = { bubble(arr) { const a = [...arr]; for (let i = 0; i < a.length; i++) for (let j = 0; j < a.length - i - 1; j++) if (a[j] > a[j + 1]) [a[j], a[j + 1]] = [a[j + 1], a[j]]; return a; }, 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 [...sortStrategies.quick(left), pivot, ...sortStrategies.quick(right)]; }, native(arr) { return [...arr].sort((a, b) => a - b); }, }; class Sorter { constructor(strategy = "native") { this.strategy = sortStrategies[strategy]; } setStrategy(strategy) { this.strategy = sortStrategies[strategy]; } sort(arr) { return this.strategy(arr); } } const sorter = new Sorter("quick"); sorter.sort([5, 3, 8, 1, 9, 2]); // [1, 2, 3, 5, 8, 9] sorter.setStrategy("native"); sorter.sort([5, 3, 8, 1, 9, 2]); // same result, different algorithm
Common Interview Questions
Q: What is the difference between the Factory and Builder patterns?
Factory creates an object in one step, hiding the creation logic. Builder constructs a complex object step by step with a fluent interface β useful when there are many optional configurations. Use Factory when creation is simple but varies by type; use Builder when constructing complex objects with many parts.
Q: What problem does the Observer pattern solve?
It decouples the subject (producer) from its observers (consumers). The subject does not need to know who is observing or what they do with the data. Observers can subscribe and unsubscribe at runtime. This is the foundation of event systems, reactive programming, and pub/sub messaging.
Q: Is Singleton an anti-pattern?
It can be. Singletons introduce global mutable state that makes code harder to test (you cannot easily swap the singleton for a mock) and creates hidden dependencies. Module-level exports are a cleaner alternative in JavaScript. Use Singletons deliberately and sparingly.
Practice JavaScript on Froquiz
Design pattern questions appear in mid-level to senior JavaScript interviews. Test your JavaScript knowledge on Froquiz across all difficulty levels.
Summary
- Singleton β one instance only; use module exports in modern JavaScript
- Factory β create objects by type without exposing class details
- Builder β construct complex objects step by step with chaining
- Module β private state with a public API via IIFE or ES modules
- Decorator β add behavior dynamically by wrapping objects
- Observer β one-to-many notification without tight coupling
- Strategy β swap algorithms at runtime without changing callers