JavaScript Design Patterns
What Are Design Patterns?
Design patterns are proven, reusable solutions to common problems that occur in software design. They are not finished code that you copy and paste into your project. Instead, they are templates or blueprints that describe how to solve a particular design problem in a way that is flexible, maintainable, and scalable. Design patterns were popularized by the "Gang of Four" (GoF) book published in 1994, and they have since been adapted to every programming language, including JavaScript.
In JavaScript, design patterns are especially important because the language is extremely flexible. You can write JavaScript in procedural, object-oriented, or functional styles. This flexibility is powerful, but it also means that without patterns, codebases can quickly become disorganized and difficult to maintain. Patterns give your code structure and make your intent clear to other developers.
Design patterns fall into three categories:
- Creational Patterns -- Deal with object creation mechanisms, trying to create objects in a way that is suitable for the situation.
- Structural Patterns -- Deal with object composition, defining ways to assemble objects and classes into larger structures.
- Behavioral Patterns -- Deal with communication between objects, defining how objects interact and distribute responsibility.
Creational Pattern: Singleton
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. This is useful for shared resources like configuration objects, connection pools, logging services, or application state stores where having multiple instances would cause conflicts or waste resources.
Singleton with Module Pattern
In JavaScript, the simplest way to create a singleton is using the module pattern with an immediately invoked function expression (IIFE) or ES modules. Since ES modules are evaluated only once and cached, every file that imports the same module gets the same instance.
Example: Singleton Using Module Pattern (IIFE)
const AppConfig = (function() {
let instance = null;
function createInstance() {
// Private state
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
debug: false
};
return {
get(key) {
return config[key];
},
set(key, value) {
if (config.hasOwnProperty(key)) {
config[key] = value;
} else {
throw new Error("Unknown config key: " + key);
}
},
getAll() {
return { ...config }; // Return a copy
}
};
}
return {
getInstance() {
if (!instance) {
instance = createInstance();
}
return instance;
}
};
})();
// Usage -- both variables reference the same instance
const config1 = AppConfig.getInstance();
const config2 = AppConfig.getInstance();
config1.set("debug", true);
console.log(config2.get("debug")); // true
console.log(config1 === config2); // true
Singleton with ES6 Class
Example: Class-Based Singleton
class Logger {
constructor() {
if (Logger.instance) {
return Logger.instance;
}
this.logs = [];
this.level = "info";
Logger.instance = this;
}
setLevel(level) {
this.level = level;
}
log(message, level = "info") {
const entry = {
message,
level,
timestamp: new Date().toISOString()
};
this.logs.push(entry);
console.log("[" + entry.level.toUpperCase() + "] "
+ entry.timestamp + ": " + entry.message);
}
error(message) {
this.log(message, "error");
}
warn(message) {
this.log(message, "warn");
}
getHistory() {
return [...this.logs];
}
clear() {
this.logs = [];
}
}
// Both create the same instance
const logger1 = new Logger();
const logger2 = new Logger();
logger1.log("Application started");
logger2.error("Something went wrong");
console.log(logger1 === logger2); // true
console.log(logger1.getHistory().length); // 2
Creational Pattern: Factory
The Factory pattern provides an interface for creating objects without specifying their exact class. The factory decides which class to instantiate based on input parameters. This is useful when you have a family of related objects and want to centralize the creation logic.
Example: Factory Pattern
// Product classes
class TextNotification {
constructor(message) {
this.type = "text";
this.message = message;
}
send(recipient) {
console.log("Sending text to " + recipient + ": " + this.message);
}
}
class EmailNotification {
constructor(message, subject) {
this.type = "email";
this.message = message;
this.subject = subject || "Notification";
}
send(recipient) {
console.log("Sending email to " + recipient
+ " [" + this.subject + "]: " + this.message);
}
}
class PushNotification {
constructor(message, icon) {
this.type = "push";
this.message = message;
this.icon = icon || "default-icon.png";
}
send(recipient) {
console.log("Sending push to " + recipient + ": " + this.message);
}
}
// Factory
class NotificationFactory {
static create(type, options = {}) {
switch (type) {
case "text":
return new TextNotification(options.message);
case "email":
return new EmailNotification(options.message, options.subject);
case "push":
return new PushNotification(options.message, options.icon);
default:
throw new Error("Unknown notification type: " + type);
}
}
}
// Usage -- the caller does not need to know the specific classes
const notifications = [
NotificationFactory.create("text", { message: "Your code shipped!" }),
NotificationFactory.create("email", {
message: "Deployment complete.",
subject: "Deploy Status"
}),
NotificationFactory.create("push", { message: "New comment on your PR" })
];
notifications.forEach((n) => n.send("developer@example.com"));
Creational Pattern: Builder
The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations. It is ideal for objects with many optional parameters where a constructor with many arguments would be confusing.
Example: Builder Pattern
class RequestBuilder {
constructor(url) {
this.config = {
url: url,
method: "GET",
headers: {},
body: null,
timeout: 30000,
retries: 0,
cache: true
};
}
setMethod(method) {
this.config.method = method.toUpperCase();
return this; // Return this for chaining
}
setHeader(key, value) {
this.config.headers[key] = value;
return this;
}
setBody(body) {
this.config.body = typeof body === "string"
? body : JSON.stringify(body);
return this;
}
setTimeout(ms) {
this.config.timeout = ms;
return this;
}
setRetries(count) {
this.config.retries = count;
return this;
}
noCache() {
this.config.cache = false;
return this;
}
build() {
// Validate before building
if (!this.config.url) {
throw new Error("URL is required");
}
if (this.config.method === "GET" && this.config.body) {
throw new Error("GET requests cannot have a body");
}
// Return a frozen copy so it cannot be modified after building
return Object.freeze({ ...this.config });
}
}
// Usage with method chaining
const request = new RequestBuilder("https://api.example.com/users")
.setMethod("POST")
.setHeader("Content-Type", "application/json")
.setHeader("Authorization", "Bearer token123")
.setBody({ name: "Alice", role: "developer" })
.setTimeout(10000)
.setRetries(3)
.build();
console.log(request);
// { url, method: "POST", headers: {...}, body: "...", timeout: 10000, ... }
Structural Pattern: Module
The Module pattern encapsulates private state and exposes a public API. Before ES modules existed, this was the primary way to achieve encapsulation in JavaScript. It uses closures to create private scope and returns an object with public methods.
Example: Module Pattern
const ShoppingCart = (function() {
// Private state
let items = [];
let discount = 0;
// Private helper
function calculateSubtotal() {
return items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0);
}
// Public API
return {
addItem(name, price, quantity = 1) {
const existing = items.find((item) => item.name === name);
if (existing) {
existing.quantity += quantity;
} else {
items.push({ name, price, quantity });
}
return this;
},
removeItem(name) {
items = items.filter((item) => item.name !== name);
return this;
},
setDiscount(percent) {
if (percent < 0 || percent > 100) {
throw new Error("Discount must be between 0 and 100");
}
discount = percent;
return this;
},
getTotal() {
const subtotal = calculateSubtotal();
return subtotal - (subtotal * discount / 100);
},
getItems() {
return items.map((item) => ({ ...item })); // Return copies
},
getItemCount() {
return items.reduce((sum, item) => sum + item.quantity, 0);
},
clear() {
items = [];
discount = 0;
}
};
})();
// Usage
ShoppingCart.addItem("Laptop", 999, 1)
.addItem("Mouse", 29, 2)
.setDiscount(10);
console.log(ShoppingCart.getTotal()); // 951.3
console.log(ShoppingCart.getItemCount()); // 3
// items and discount are not accessible directly
Structural Pattern: Facade
The Facade pattern provides a simplified interface to a complex subsystem. It hides the complexity of multiple interacting classes or APIs behind a single, easy-to-use interface. This is one of the most commonly used patterns in JavaScript, especially when wrapping browser APIs or third-party libraries.
Example: Facade Pattern
// Complex subsystems
class AudioEngine {
constructor() { this.context = null; }
initialize() {
this.context = new (window.AudioContext
|| window.webkitAudioContext)();
console.log("Audio engine initialized");
}
setVolume(level) { console.log("Volume set to", level); }
play(buffer) { console.log("Playing audio buffer"); }
stop() { console.log("Audio stopped"); }
}
class VideoRenderer {
constructor(canvas) { this.canvas = canvas; }
initialize() { console.log("Video renderer initialized"); }
render(frame) { console.log("Rendering frame"); }
setResolution(w, h) { console.log("Resolution:", w + "x" + h); }
stop() { console.log("Video stopped"); }
}
class SubtitleManager {
constructor() { this.subtitles = []; }
load(url) { console.log("Loading subtitles from", url); }
show(time) { console.log("Showing subtitle at", time); }
hide() { console.log("Hiding subtitles"); }
setLanguage(lang) { console.log("Subtitle language:", lang); }
}
// Facade -- simple interface to the complex subsystems
class MediaPlayer {
constructor(canvasElement) {
this.audio = new AudioEngine();
this.video = new VideoRenderer(canvasElement);
this.subtitles = new SubtitleManager();
this.isPlaying = false;
}
initialize() {
this.audio.initialize();
this.video.initialize();
console.log("Media player ready");
}
play(mediaUrl, subtitleUrl, language) {
this.audio.play(mediaUrl);
this.video.render(mediaUrl);
if (subtitleUrl) {
this.subtitles.load(subtitleUrl);
this.subtitles.setLanguage(language || "en");
}
this.isPlaying = true;
console.log("Playing:", mediaUrl);
}
pause() {
this.audio.stop();
this.video.stop();
this.isPlaying = false;
}
setVolume(level) {
this.audio.setVolume(Math.max(0, Math.min(1, level)));
}
}
// The consumer only interacts with the simple Facade
const player = new MediaPlayer(document.querySelector("canvas"));
player.initialize();
player.play("/videos/intro.mp4", "/subs/intro.vtt", "en");
player.setVolume(0.8);
Structural Pattern: Decorator
The Decorator pattern attaches additional behavior to an object dynamically without modifying its structure. In JavaScript, decorators can be implemented by wrapping objects with new functionality. This is useful for extending behavior without creating complex inheritance hierarchies.
Example: Decorator Pattern
// Base component
class BasicCoffee {
cost() {
return 2.00;
}
description() {
return "Basic coffee";
}
}
// Decorator base -- wraps a coffee object
class CoffeeDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost();
}
description() {
return this.coffee.description();
}
}
// Concrete decorators
class MilkDecorator extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 0.50;
}
description() {
return this.coffee.description() + ", milk";
}
}
class SugarDecorator extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 0.25;
}
description() {
return this.coffee.description() + ", sugar";
}
}
class WhippedCreamDecorator extends CoffeeDecorator {
cost() {
return this.coffee.cost() + 0.75;
}
description() {
return this.coffee.description() + ", whipped cream";
}
}
// Usage -- stack decorators dynamically
let order = new BasicCoffee();
order = new MilkDecorator(order);
order = new SugarDecorator(order);
order = new WhippedCreamDecorator(order);
console.log(order.description()); // "Basic coffee, milk, sugar, whipped cream"
console.log(order.cost()); // 3.50
Structural Pattern: Proxy
The Proxy pattern provides a surrogate or placeholder for another object to control access to it. JavaScript has a built-in Proxy object (introduced in ES6) that makes implementing this pattern native and powerful. Proxies can intercept and redefine fundamental operations like property access, assignment, function calls, and more.
Example: Proxy Pattern with ES6 Proxy
// Validation Proxy -- enforces type checking on an object
function createValidatedObject(schema) {
const data = {};
return new Proxy(data, {
set(target, property, value) {
const validator = schema[property];
if (!validator) {
throw new Error("Unknown property: " + property);
}
if (typeof value !== validator.type) {
throw new TypeError(
property + " must be a " + validator.type
+ ", got " + typeof value
);
}
if (validator.min !== undefined && value < validator.min) {
throw new RangeError(
property + " must be at least " + validator.min
);
}
if (validator.max !== undefined && value > validator.max) {
throw new RangeError(
property + " must be at most " + validator.max
);
}
target[property] = value;
return true;
},
get(target, property) {
if (property in target) {
return target[property];
}
const validator = schema[property];
return validator ? validator.default : undefined;
}
});
}
// Define a schema
const userSchema = {
name: { type: "string", default: "" },
age: { type: "number", min: 0, max: 150, default: 0 },
email: { type: "string", default: "" }
};
const user = createValidatedObject(userSchema);
user.name = "Alice"; // Works
user.age = 30; // Works
console.log(user.name); // "Alice"
// user.age = -5; // RangeError: age must be at least 0
// user.age = "thirty"; // TypeError: age must be a number
// user.phone = "123"; // Error: Unknown property: phone
Example: Caching Proxy
// Proxy that caches expensive function results
function createCachingProxy(targetFunction, ttl = 60000) {
const cache = new Map();
return new Proxy(targetFunction, {
apply(target, thisArg, args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttl) {
console.log("Cache hit for:", key);
return cached.value;
}
console.log("Cache miss for:", key);
const result = target.apply(thisArg, args);
cache.set(key, { value: result, timestamp: Date.now() });
return result;
}
});
}
// Expensive function
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const cachedFib = createCachingProxy(fibonacci, 30000);
console.log(cachedFib(35)); // Slow first call
console.log(cachedFib(35)); // Instant from cache
Behavioral Pattern: Observer / PubSub
The Observer pattern defines a one-to-many dependency between objects. When one object (the subject) changes state, all its dependents (observers) are notified and updated automatically. The closely related Publish-Subscribe (PubSub) pattern adds a message broker between publishers and subscribers, decoupling them further.
Example: Observer Pattern
class EventEmitter {
constructor() {
this.events = new Map();
}
on(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
return this; // Allow chaining
}
off(event, callback) {
if (!this.events.has(event)) return this;
const listeners = this.events.get(event);
this.events.set(event,
listeners.filter((cb) => cb !== callback)
);
return this;
}
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
emit(event, ...args) {
if (!this.events.has(event)) return false;
this.events.get(event).forEach((cb) => {
try {
cb(...args);
} catch (error) {
console.error("Error in listener for " + event, error);
}
});
return true;
}
listenerCount(event) {
return this.events.has(event)
? this.events.get(event).length : 0;
}
removeAllListeners(event) {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
}
// Usage -- a store that notifies on state changes
class Store extends EventEmitter {
constructor(initialState = {}) {
super();
this.state = initialState;
}
setState(updates) {
const previous = { ...this.state };
this.state = { ...this.state, ...updates };
this.emit("change", this.state, previous);
// Emit specific property change events
for (const key of Object.keys(updates)) {
if (previous[key] !== updates[key]) {
this.emit("change:" + key, updates[key], previous[key]);
}
}
}
getState() {
return { ...this.state };
}
}
const store = new Store({ count: 0, user: null });
store.on("change:count", (newVal, oldVal) => {
console.log("Count changed from", oldVal, "to", newVal);
});
store.on("change:user", (user) => {
console.log("User logged in:", user);
});
store.setState({ count: 1 }); // "Count changed from 0 to 1"
store.setState({ user: "Alice" }); // "User logged in: Alice"
Behavioral Pattern: Mediator
The Mediator pattern defines an object that encapsulates how a set of objects interact. Instead of objects communicating directly with each other (creating tight coupling), they communicate through the mediator. This reduces dependencies between objects and makes the system easier to modify.
Example: Mediator Pattern (Chat Room)
class ChatRoom {
constructor(name) {
this.name = name;
this.users = new Map();
this.messageHistory = [];
}
join(user) {
this.users.set(user.name, user);
user.room = this;
this.broadcast(
"System",
user.name + " has joined the room."
);
}
leave(user) {
this.users.delete(user.name);
user.room = null;
this.broadcast(
"System",
user.name + " has left the room."
);
}
send(message, fromUser, toUserName) {
const entry = {
from: fromUser.name,
to: toUserName || "all",
message: message,
timestamp: new Date().toISOString()
};
this.messageHistory.push(entry);
if (toUserName) {
// Private message
const recipient = this.users.get(toUserName);
if (recipient) {
recipient.receive(message, fromUser.name, true);
}
} else {
// Broadcast to all except sender
this.broadcast(fromUser.name, message);
}
}
broadcast(senderName, message) {
this.users.forEach((user) => {
if (user.name !== senderName) {
user.receive(message, senderName, false);
}
});
}
}
class ChatUser {
constructor(name) {
this.name = name;
this.room = null;
this.inbox = [];
}
send(message, toUserName) {
if (!this.room) {
console.log(this.name + ": Not in a room!");
return;
}
this.room.send(message, this, toUserName);
}
receive(message, fromName, isPrivate) {
const prefix = isPrivate ? "[PM]" : "[Room]";
const formatted = prefix + " " + fromName + ": " + message;
this.inbox.push(formatted);
console.log(this.name + " received -- " + formatted);
}
}
// Usage -- users communicate through the mediator (ChatRoom)
const room = new ChatRoom("Developers");
const alice = new ChatUser("Alice");
const bob = new ChatUser("Bob");
const charlie = new ChatUser("Charlie");
room.join(alice);
room.join(bob);
room.join(charlie);
alice.send("Hello everyone!"); // Broadcast
bob.send("Hey Alice!", "Alice"); // Private to Alice
charlie.send("Working on the new API."); // Broadcast
Behavioral Pattern: Strategy
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from the clients that use it. This pattern is ideal for situations where you have multiple approaches to the same task and want to select one at runtime.
Example: Strategy Pattern
// Strategy interfaces (sorting algorithms)
const sortStrategies = {
bubble(arr) {
const result = [...arr];
for (let i = 0; i < result.length; i++) {
for (let j = 0; j < result.length - i - 1; j++) {
if (result[j] > result[j + 1]) {
[result[j], result[j + 1]] = [result[j + 1], result[j]];
}
}
}
return result;
},
quick(arr) {
if (arr.length <= 1) return [...arr];
const pivot = arr[Math.floor(arr.length / 2)];
const left = arr.filter((x) => x < pivot);
const middle = arr.filter((x) => x === pivot);
const right = arr.filter((x) => x > pivot);
return [...sortStrategies.quick(left), ...middle,
...sortStrategies.quick(right)];
},
merge(arr) {
if (arr.length <= 1) return [...arr];
const mid = Math.floor(arr.length / 2);
const left = sortStrategies.merge(arr.slice(0, mid));
const right = sortStrategies.merge(arr.slice(mid));
return mergeSorted(left, right);
}
};
function mergeSorted(left, right) {
const result = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result.push(left[i++]);
} else {
result.push(right[j++]);
}
}
return result.concat(left.slice(i)).concat(right.slice(j));
}
// Context class that uses a strategy
class Sorter {
constructor(strategy = "quick") {
this.setStrategy(strategy);
}
setStrategy(name) {
if (!sortStrategies[name]) {
throw new Error("Unknown strategy: " + name);
}
this.strategyName = name;
this.strategy = sortStrategies[name];
}
sort(data) {
console.log("Sorting with", this.strategyName, "strategy");
const start = performance.now();
const result = this.strategy(data);
const duration = performance.now() - start;
console.log("Sorted in", duration.toFixed(2), "ms");
return result;
}
}
// Usage -- swap strategies at runtime
const sorter = new Sorter("bubble");
const data = [64, 34, 25, 12, 22, 11, 90];
console.log(sorter.sort(data)); // Uses bubble sort
sorter.setStrategy("quick");
console.log(sorter.sort(data)); // Uses quick sort
sorter.setStrategy("merge");
console.log(sorter.sort(data)); // Uses merge sort
Behavioral Pattern: Command
The Command pattern encapsulates a request as an object, allowing you to parameterize operations, queue them, log them, and support undo/redo functionality. Each command object contains all the information needed to execute or reverse an action.
Example: Command Pattern with Undo/Redo
// Command interface
class Command {
execute() { throw new Error("execute() must be implemented"); }
undo() { throw new Error("undo() must be implemented"); }
describe() { return "Unknown command"; }
}
// Concrete commands
class AddTextCommand extends Command {
constructor(editor, text, position) {
super();
this.editor = editor;
this.text = text;
this.position = position;
}
execute() {
this.editor.insertAt(this.position, this.text);
}
undo() {
this.editor.deleteRange(this.position,
this.position + this.text.length);
}
describe() {
return "Add text: \"" + this.text + "\" at " + this.position;
}
}
class DeleteTextCommand extends Command {
constructor(editor, start, end) {
super();
this.editor = editor;
this.start = start;
this.end = end;
this.deletedText = "";
}
execute() {
this.deletedText = this.editor.getText(this.start, this.end);
this.editor.deleteRange(this.start, this.end);
}
undo() {
this.editor.insertAt(this.start, this.deletedText);
}
describe() {
return "Delete text from " + this.start + " to " + this.end;
}
}
// Receiver -- the actual text editor
class TextEditor {
constructor() {
this.content = "";
}
insertAt(position, text) {
this.content = this.content.slice(0, position)
+ text + this.content.slice(position);
}
deleteRange(start, end) {
this.content = this.content.slice(0, start)
+ this.content.slice(end);
}
getText(start, end) {
return this.content.slice(start, end);
}
toString() {
return this.content;
}
}
// Invoker -- manages command execution and history
class CommandManager {
constructor() {
this.history = [];
this.undone = [];
}
execute(command) {
command.execute();
this.history.push(command);
this.undone = []; // Clear redo stack on new action
}
undo() {
const command = this.history.pop();
if (command) {
command.undo();
this.undone.push(command);
console.log("Undone:", command.describe());
}
}
redo() {
const command = this.undone.pop();
if (command) {
command.execute();
this.history.push(command);
console.log("Redone:", command.describe());
}
}
canUndo() { return this.history.length > 0; }
canRedo() { return this.undone.length > 0; }
}
// Usage
const editor = new TextEditor();
const manager = new CommandManager();
manager.execute(new AddTextCommand(editor, "Hello ", 0));
manager.execute(new AddTextCommand(editor, "World!", 6));
console.log(editor.toString()); // "Hello World!"
manager.undo();
console.log(editor.toString()); // "Hello "
manager.redo();
console.log(editor.toString()); // "Hello World!"
manager.execute(new DeleteTextCommand(editor, 5, 12));
console.log(editor.toString()); // "Hello"
manager.undo();
console.log(editor.toString()); // "Hello World!"
Behavioral Pattern: Iterator
The Iterator pattern provides a way to access elements of a collection sequentially without exposing its underlying representation. JavaScript has built-in support for iterators through the Symbol.iterator protocol and generator functions.
Example: Custom Iterator with Symbol.iterator
class NumberRange {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
const step = this.step;
return {
next() {
if (current <= end) {
const value = current;
current += step;
return { value, done: false };
}
return { done: true };
}
};
}
}
// Works with for...of, spread, destructuring
const range = new NumberRange(1, 10, 2);
for (const num of range) {
console.log(num); // 1, 3, 5, 7, 9
}
console.log([...new NumberRange(0, 5)]); // [0, 1, 2, 3, 4, 5]
// Generator-based iterator for a tree structure
class TreeNode {
constructor(value, children = []) {
this.value = value;
this.children = children;
}
// Depth-first traversal using a generator
*[Symbol.iterator]() {
yield this.value;
for (const child of this.children) {
yield* child; // Delegate to child iterator
}
}
}
const tree = new TreeNode("root", [
new TreeNode("A", [
new TreeNode("A1"),
new TreeNode("A2")
]),
new TreeNode("B", [
new TreeNode("B1")
])
]);
console.log([...tree]); // ["root", "A", "A1", "A2", "B", "B1"]
MVC and MVVM Concepts
MVC (Model-View-Controller) and MVVM (Model-View-ViewModel) are architectural patterns that organize entire applications. They separate data, presentation, and logic into distinct layers. While modern frameworks implement these patterns internally, understanding them helps you write better-structured code.
Example: Simple MVC Implementation
// Model -- manages data and business logic
class TodoModel {
constructor() {
this.todos = [];
this.listeners = [];
}
subscribe(listener) {
this.listeners.push(listener);
}
notify() {
this.listeners.forEach((fn) => fn(this.todos));
}
addTodo(text) {
this.todos.push({ id: Date.now(), text, done: false });
this.notify();
}
toggleTodo(id) {
const todo = this.todos.find((t) => t.id === id);
if (todo) {
todo.done = !todo.done;
this.notify();
}
}
removeTodo(id) {
this.todos = this.todos.filter((t) => t.id !== id);
this.notify();
}
getAll() {
return [...this.todos];
}
}
// View -- handles display and user input
class TodoView {
constructor(container) {
this.container = container;
this.onAdd = null;
this.onToggle = null;
this.onRemove = null;
}
render(todos) {
this.container.innerHTML = "";
// Input form
const form = document.createElement("form");
const input = document.createElement("input");
input.placeholder = "Add a task...";
const button = document.createElement("button");
button.textContent = "Add";
form.appendChild(input);
form.appendChild(button);
form.addEventListener("submit", (e) => {
e.preventDefault();
if (input.value.trim() && this.onAdd) {
this.onAdd(input.value.trim());
input.value = "";
}
});
this.container.appendChild(form);
// Todo list
const list = document.createElement("ul");
todos.forEach((todo) => {
const li = document.createElement("li");
li.textContent = todo.text;
li.style.textDecoration = todo.done
? "line-through" : "none";
li.addEventListener("click", () => {
if (this.onToggle) this.onToggle(todo.id);
});
const removeBtn = document.createElement("button");
removeBtn.textContent = "X";
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.onRemove) this.onRemove(todo.id);
});
li.appendChild(removeBtn);
list.appendChild(li);
});
this.container.appendChild(list);
}
}
// Controller -- connects Model and View
class TodoController {
constructor(model, view) {
this.model = model;
this.view = view;
// View events call Model methods
this.view.onAdd = (text) => this.model.addTodo(text);
this.view.onToggle = (id) => this.model.toggleTodo(id);
this.view.onRemove = (id) => this.model.removeTodo(id);
// Model changes update the View
this.model.subscribe((todos) => {
this.view.render(todos);
});
// Initial render
this.view.render(this.model.getAll());
}
}
// Bootstrap the MVC application
// const app = new TodoController(
// new TodoModel(),
// new TodoView(document.getElementById("app"))
// );
When to Use Patterns
Design patterns are tools, not rules. Applying a pattern where it is not needed adds unnecessary complexity. Here is a practical guide for when each pattern is most useful:
- Singleton -- Use for genuinely shared resources: loggers, configuration, database connections, application-wide state. Avoid when you need testable, isolated instances.
- Factory -- Use when the creation logic is complex, when the exact type depends on runtime conditions, or when you want to decouple consumers from concrete classes.
- Builder -- Use when objects require many configuration options. If a constructor has more than three or four parameters, consider a builder.
- Module -- Use to encapsulate private state and expose a clean public API. In modern JavaScript, ES modules often serve this purpose natively.
- Facade -- Use to simplify complex subsystems or to provide a unified API over multiple libraries. Great for reducing coupling between application layers.
- Decorator -- Use to add behavior to objects without inheritance. Ideal for cross-cutting concerns like logging, caching, and validation.
- Proxy -- Use for lazy loading, access control, logging, validation, or caching. The ES6 Proxy object makes this pattern native in JavaScript.
- Observer -- Use when changes to one object should trigger updates in many others. Essential for event-driven architectures and reactive UIs.
- Mediator -- Use when many objects need to communicate and direct references create a tangled web of dependencies.
- Strategy -- Use when you have multiple algorithms for the same task and want to select one at runtime.
- Command -- Use when you need undo/redo, command queuing, logging of operations, or macro recording.
- Iterator -- Use when you need custom traversal over collections. Leverage JavaScript's built-in iterator protocol with Symbol.iterator.
Anti-Patterns to Avoid
Just as there are good patterns, there are anti-patterns -- common approaches that seem helpful but lead to problems. Recognizing these is just as important as knowing the correct patterns.
- God Object -- A single object or class that does everything. Break it into smaller, focused modules using the Single Responsibility Principle.
- Premature Optimization -- Adding caching, proxies, or complex patterns before measuring actual performance problems. Profile first, then optimize.
- Callback Hell -- Deeply nested callbacks that are hard to read and maintain. Refactor to Promises, async/await, or the Observer pattern.
- Spaghetti Code -- Code with no clear structure, where everything depends on everything else. Apply the Module and Mediator patterns to organize responsibilities.
- Golden Hammer -- Using the same pattern for every problem. Each pattern has specific use cases; match the pattern to the problem.
- Copy-Paste Programming -- Duplicating code instead of abstracting it. Use the Strategy or Template Method pattern to extract shared logic.
- Poltergeist Classes -- Classes that exist only to pass data between other classes without adding value. Remove them and let objects communicate directly or through a Mediator.
Refactoring to Patterns
You rarely start a project by choosing patterns upfront. Instead, patterns emerge as your code grows and you identify recurring problems. Here is how to recognize when your code needs a pattern and how to refactor toward one.
Example: Refactoring from Conditionals to Strategy
// BEFORE: Growing switch statement (code smell)
function calculateShipping(method, weight, distance) {
switch (method) {
case "standard":
return weight * 0.5 + distance * 0.1;
case "express":
return weight * 1.0 + distance * 0.3 + 5.00;
case "overnight":
return weight * 2.0 + distance * 0.5 + 15.00;
case "drone":
return weight * 3.0 + distance * 0.2 + 20.00;
// Every new method requires modifying this function...
default:
throw new Error("Unknown shipping method");
}
}
// AFTER: Refactored to Strategy pattern
const shippingStrategies = {
standard: (weight, distance) =>
weight * 0.5 + distance * 0.1,
express: (weight, distance) =>
weight * 1.0 + distance * 0.3 + 5.00,
overnight: (weight, distance) =>
weight * 2.0 + distance * 0.5 + 15.00,
drone: (weight, distance) =>
weight * 3.0 + distance * 0.2 + 20.00
};
// Adding a new strategy requires no changes to existing code
shippingStrategies.bicycle = (weight, distance) =>
weight * 0.3 + distance * 0.05;
function calculateShippingRefactored(method, weight, distance) {
const strategy = shippingStrategies[method];
if (!strategy) {
throw new Error("Unknown shipping method: " + method);
}
return strategy(weight, distance);
}
console.log(calculateShippingRefactored("express", 5, 100));
console.log(calculateShippingRefactored("bicycle", 2, 10));
Practice Exercise
Build a mini application that demonstrates multiple design patterns working together. Create a task management system with the following requirements: (1) Use the Singleton pattern for an AppState class that holds the global application state (task list, current user, settings). (2) Use the Factory pattern to create different task types: BasicTask, BugReport, and FeatureRequest, each with different properties and validation rules. (3) Use the Observer pattern (EventEmitter) so the AppState notifies the UI when tasks are added, removed, or updated. (4) Use the Command pattern to implement undo and redo for task operations (add, remove, toggle complete). Create AddTaskCommand, RemoveTaskCommand, and ToggleTaskCommand classes. (5) Use the Strategy pattern for task sorting: sort by priority, by due date, by creation date, or alphabetically, allowing the user to switch strategies at runtime. (6) Use the Proxy pattern to add validation to tasks -- ensure titles are not empty, due dates are in the future, and priority is between 1 and 5. (7) Use the Iterator pattern with a generator function that yields tasks filtered by status (all, completed, pending). Wire all the patterns together so that: creating a task uses the Factory, adding it goes through a validated Proxy, the add operation is wrapped in a Command for undo support, the state change notifies observers, and the display uses the current sort Strategy. Test each pattern individually and then test the integrated system.