paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

Implementing the GoF Design Patterns in JavaScript: A Practical Engineering Guide

How to apply all 23 classic Gang of Four design patterns-creational, structural, and behavioral-using idiomatic, modern JavaScript

Introduction

In 1994, Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides published Design Patterns: Elements of Reusable Object-Oriented Software, a catalog of 23 recurring solutions to common object-oriented design problems. The book's authors became known as the "Gang of Four" (GoF), and their patterns - organized into three categories: creational, structural, and behavioral - have shaped how engineers talk about software architecture for three decades. Most JavaScript tutorials that cover these patterns only scratch the surface, usually demonstrating three or four of the easiest ones (Singleton, Factory, Observer) with toy examples involving shapes or animals. This guide takes a more complete approach: it walks through all 23 patterns, grouped correctly by their original GoF category (five creational patterns, not three, and eleven behavioral patterns that rarely get covered at all), using examples drawn from problems engineers actually encounter.

The goal here isn't to convince you that every pattern belongs in every codebase. Some GoF patterns solve problems that JavaScript's language design already handles natively - first-class functions make Strategy nearly free, and generators make Iterator almost invisible. Others, like Builder or Chain of Responsibility, remain genuinely useful in a language without static typing or method overloading. Throughout this article, each pattern includes a realistic implementation, a note on where it fits in a typical JavaScript or Node.js codebase, and - where relevant - a callout for when the "pattern" is really just a native language feature wearing a formal name.

Why Design Patterns Still Matter in a Multi-Paradigm Language

JavaScript occupies an unusual position among the languages the GoF book was written for. C++ and Smalltalk, the reference languages of 1994, are class-based and statically structured in ways that make certain problems - like decoupling object creation from object use - genuinely hard without a formal pattern. JavaScript, by contrast, is prototype-based under the hood, supports first-class functions, and has closures that can encapsulate state without any class syntax at all. The class keyword introduced in ES2015 is largely syntactic sugar over the same prototype chain that existed since 1995. This matters because several GoF patterns exist specifically to work around limitations that JavaScript never had in the same form, which means their JavaScript implementations often look simpler, or collapse into idioms that don't feel like "patterns" at all.

At the same time, dismissing design patterns as irrelevant in JavaScript is a mistake many teams make and later regret. The patterns that address structural composition - Composite, Decorator, Adapter - and the ones that manage communication between loosely coupled objects - Observer, Mediator, Command - solve problems that are language-agnostic. A large Node.js service with dozens of middleware functions has a Chain of Responsibility problem whether or not anyone calls it that. A React component tree is a Composite structure. An event-driven microservice architecture is built on Observer. Recognizing the pattern gives you access to decades of accumulated knowledge about its trade-offs, failure modes, and testing strategies, rather than reinventing that knowledge from scratch under a different name.

This article treats each pattern as a tool with a specific job, not a checklist to complete. Where JavaScript's language features make a textbook implementation unnecessary or awkward, that gets called out explicitly rather than glossed over, because using a heavyweight class hierarchy to reproduce something a plain object or a callback already gives you for free is a common source of unnecessary complexity in JavaScript codebases influenced too heavily by Java and C# conventions.

Creational Patterns: Controlling How Objects Come Into Being

The GoF catalog defines five creational patterns, not three: Factory Method, Abstract Factory, Builder, Prototype, and Singleton. All five address a version of the same underlying problem - decoupling the code that needs an object from the code that knows how to construct it - but they differ in how much control they hand to the caller and how many related objects they need to produce together.

Factory Method

Factory Method defines an interface for creating an object but lets subclasses decide which concrete class to instantiate. In JavaScript, this is useful whenever you have a family of related classes that share a common interface but differ in construction logic, such as a notification system that needs to send email, SMS, or push notifications depending on runtime configuration.

class Notification {
  send(message) {
    throw new Error("send() must be implemented");
  }
}

class EmailNotification extends Notification {
  send(message) {
    return `Email sent: ${message}`;
  }
}

class SMSNotification extends Notification {
  send(message) {
    return `SMS sent: ${message}`;
  }
}

class NotifierFactory {
  createNotification() {
    throw new Error("createNotification() must be implemented");
  }
  notify(message) {
    return this.createNotification().send(message);
  }
}

class EmailNotifierFactory extends NotifierFactory {
  createNotification() {
    return new EmailNotification();
  }
}

const emailFactory = new EmailNotifierFactory();
console.log(emailFactory.notify("Server is down"));
// "Email sent: Server is down"

Abstract Factory

Abstract Factory goes a step further than Factory Method by producing entire families of related objects that are guaranteed to be compatible with each other. The classic use case in front-end work is a theming system, where a light-theme factory and a dark-theme factory each produce a matching set of UI components.

class LightButton { render() { return "Light button"; } }
class DarkButton { render() { return "Dark button"; } }
class LightCheckbox { render() { return "Light checkbox"; } }
class DarkCheckbox { render() { return "Dark checkbox"; } }

class UIFactory {
  createButton() { throw new Error("Not implemented"); }
  createCheckbox() { throw new Error("Not implemented"); }
}

class DarkThemeFactory extends UIFactory {
  createButton() { return new DarkButton(); }
  createCheckbox() { return new DarkCheckbox(); }
}

function renderForm(factory) {
  return `${factory.createButton().render()} + ${factory.createCheckbox().render()}`;
}

console.log(renderForm(new DarkThemeFactory()));
// "Dark button + Dark checkbox"

Builder

Builder separates the construction of a complex object from its representation, exposing a fluent, step-by-step interface. It's especially valuable in JavaScript for constructing configuration objects, HTTP requests, or SQL queries where many optional parameters would otherwise require an unwieldy constructor with a dozen positional arguments.

class HttpRequestBuilder {
  constructor(url) {
    this.url = url;
    this.method = "GET";
    this.headers = {};
    this.body = null;
  }
  setMethod(method) { this.method = method; return this; }
  setHeader(key, value) { this.headers[key] = value; return this; }
  setBody(body) { this.body = body; return this; }
  build() {
    return { url: this.url, method: this.method, headers: this.headers, body: this.body };
  }
}

const request = new HttpRequestBuilder("/api/orders")
  .setMethod("POST")
  .setHeader("Content-Type", "application/json")
  .setBody(JSON.stringify({ item: "widget" }))
  .build();

Prototype

Prototype creates new objects by copying an existing instance rather than building one from scratch, which is useful when object creation is expensive or when you need many variations of a baseline configuration. JavaScript's native structuredClone() function and object spread syntax already cover the simplest cases, but a dedicated clone() method still earns its place when an object needs custom copy semantics for nested references.

class ChartConfig {
  constructor(options) {
    this.type = options.type;
    this.colors = options.colors;
    this.axes = options.axes;
  }
  clone() {
    return new ChartConfig({
      type: this.type,
      colors: [...this.colors],
      axes: { ...this.axes },
    });
  }
}

const baseConfig = new ChartConfig({ type: "bar", colors: ["#4f46e5"], axes: { x: "category" } });
const revenueConfig = baseConfig.clone();
revenueConfig.type = "line";

Singleton

Singleton guarantees a class has exactly one instance and provides a global access point to it. It remains one of the most debated patterns because global mutable state makes testing harder, but it's still common for things like application configuration, logging, or a shared database connection pool.

class AppConfig {
  static #instance;
  #settings;
  constructor(settings) {
    if (AppConfig.#instance) return AppConfig.#instance;
    this.#settings = settings;
    AppConfig.#instance = this;
  }
  get(key) { return this.#settings[key]; }
}

const configA = new AppConfig({ env: "production" });
const configB = new AppConfig({ env: "staging" });
console.log(configA === configB); // true

In practice, most JavaScript codebases achieve the same effect more simply: because ES module instances are cached by the module loader, exporting a single object literal from a module is a singleton, without any class or private static field required.

Structural Patterns: Composing Objects and Classes into Larger Structures

The seven structural patterns - Adapter, Bridge, Composite, Decorator, Facade, Flyweight, and Proxy - address how objects and classes combine into larger structures while keeping those structures flexible and efficient. These tend to translate cleanly into JavaScript because composition over inheritance is already the language's dominant idiom.

Adapter

Adapter converts the interface of an existing class into another interface that client code expects, which is invaluable when integrating third-party libraries whose APIs don't match your internal abstractions. A common real-world case is wrapping a payment provider's SDK behind your own PaymentProcessor interface so you can swap providers later without touching business logic.

class StripeClient {
  charge(amountInCents, currency) {
    return `Charged ${amountInCents / 100} ${currency} via Stripe`;
  }
}

class PaymentProcessor {
  pay(amount) { throw new Error("pay() must be implemented"); }
}

class StripeAdapter extends PaymentProcessor {
  constructor(stripeClient) { super(); this.stripeClient = stripeClient; }
  pay(amount) { return this.stripeClient.charge(Math.round(amount * 100), "USD"); }
}

const processor = new StripeAdapter(new StripeClient());
console.log(processor.pay(49.99)); // "Charged 49.99 USD via Stripe"

Bridge

Bridge decouples an abstraction from its implementation so the two can vary independently, which is useful when you expect multiple rendering backends or multiple platform-specific implementations behind a single API. A shape-drawing library that supports both Canvas and SVG output without duplicating shape logic is a textbook case.

class Renderer {
  renderCircle(radius) { throw new Error("Not implemented"); }
}
class SVGRenderer extends Renderer {
  renderCircle(radius) { return `<circle r="${radius}" />`; }
}

class Circle {
  constructor(renderer, radius) { this.renderer = renderer; this.radius = radius; }
  draw() { return this.renderer.renderCircle(this.radius); }
}

const svgCircle = new Circle(new SVGRenderer(), 10);
console.log(svgCircle.draw()); // '<circle r="10" />'

Composite

Composite lets you treat individual objects and groups of objects uniformly through a shared interface, which is the natural model for any tree-shaped data: file systems, DOM trees, or nested UI components.

class FileSystemNode {
  constructor(name) { this.name = name; }
  getSize() { throw new Error("Not implemented"); }
}
class File extends FileSystemNode {
  constructor(name, size) { super(name); this.size = size; }
  getSize() { return this.size; }
}
class Directory extends FileSystemNode {
  constructor(name) { super(name); this.children = []; }
  add(node) { this.children.push(node); return this; }
  getSize() { return this.children.reduce((sum, child) => sum + child.getSize(), 0); }
}

const project = new Directory("project")
  .add(new File("index.js", 2400))
  .add(new File("README.md", 500));
console.log(project.getSize()); // 2900

Decorator

Decorator attaches additional behavior to an object dynamically by wrapping it, without altering the objects it wraps or requiring subclassing. This is the pattern behind middleware stacks and behind libraries like Express, where each layer wraps the next.

class DataFetcher {
  fetch(id) { throw new Error("Not implemented"); }
}
class ApiDataFetcher extends DataFetcher {
  fetch(id) { return `data-for-${id}`; }
}
class CachingDecorator extends DataFetcher {
  constructor(wrapped) { super(); this.wrapped = wrapped; this.cache = new Map(); }
  fetch(id) {
    if (!this.cache.has(id)) this.cache.set(id, this.wrapped.fetch(id));
    return this.cache.get(id);
  }
}

const fetcher = new CachingDecorator(new ApiDataFetcher());
fetcher.fetch(42);
fetcher.fetch(42); // served from cache

Facade

Facade provides a single simplified interface over a set of complex subsystems, hiding their internal wiring from client code. Checkout flows are a common example, since they typically coordinate inventory, payment, and shipping subsystems behind one call.

class Inventory { reserve(sku, qty) { return `Reserved ${qty}x ${sku}`; } }
class Payment { charge(amount) { return `Charged $${amount}`; } }
class Shipping { schedule(address) { return `Shipping scheduled to ${address}`; } }

class CheckoutFacade {
  constructor() {
    this.inventory = new Inventory();
    this.payment = new Payment();
    this.shipping = new Shipping();
  }
  placeOrder(sku, qty, amount, address) {
    return [
      this.inventory.reserve(sku, qty),
      this.payment.charge(amount),
      this.shipping.schedule(address),
    ].join(" | ");
  }
}

Flyweight

Flyweight minimizes memory usage by sharing common state across many similar objects instead of duplicating it. This matters when rendering large numbers of similar entities, such as map markers or particle effects, where each instance only needs to store what's actually unique to it.

class MarkerStyleFactory {
  constructor() { this.styles = new Map(); }
  getStyle(icon, color) {
    const key = `${icon}-${color}`;
    if (!this.styles.has(key)) this.styles.set(key, { icon, color });
    return this.styles.get(key);
  }
}

const factory = new MarkerStyleFactory();
const styleA = factory.getStyle("pin", "red");
const styleB = factory.getStyle("pin", "red");
console.log(styleA === styleB); // true, shared style object

Proxy

Proxy provides a stand-in object that controls access to another object, commonly used for lazy loading, access control, or caching. JavaScript is somewhat unique among GoF-era languages in that it exposes a native Proxy object for intercepting fundamental operations like property access, but a plain wrapper class works just as well for coarser-grained cases like caching an expensive report generator.

class ReportGenerator {
  generate(id) {
    console.log(`Generating report ${id}...`);
    return `report-${id}-content`;
  }
}
class CachingReportProxy {
  constructor(realGenerator) { this.realGenerator = realGenerator; this.cache = new Map(); }
  generate(id) {
    if (!this.cache.has(id)) this.cache.set(id, this.realGenerator.generate(id));
    return this.cache.get(id);
  }
}

const proxy = new CachingReportProxy(new ReportGenerator());
proxy.generate(1); // logs "Generating report 1..."
proxy.generate(1); // no log, cached

Behavioral Patterns: Coordinating Behavior and Responsibility

The eleven behavioral patterns are the largest and most varied category in the GoF catalog, covering how objects communicate, delegate work, and manage state transitions: Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, and Visitor. Several of these map onto JavaScript idioms so directly that engineers use them daily without naming them.

Chain of Responsibility

Chain of Responsibility passes a request along a chain of handlers until one handles it, decoupling the sender from the specific handler that eventually processes the request. This is precisely the model behind Express-style middleware pipelines and request validation layers.

class Handler {
  setNext(handler) { this.next = handler; return handler; }
  handle(request) { return this.next ? this.next.handle(request) : null; }
}
class AuthHandler extends Handler {
  handle(request) { return request.token ? super.handle(request) : "Unauthorized"; }
}
class BusinessHandler extends Handler {
  handle(request) { return `Processed order ${request.body.id}`; }
}

const auth = new AuthHandler();
auth.setNext(new BusinessHandler());
console.log(auth.handle({ token: "abc", body: { id: 7 } }));

Command

Command encapsulates a request as an object, which makes it possible to queue, log, or undo operations. Text editors and any UI with undo/redo functionality rely on this pattern directly.

class InsertTextCommand {
  constructor(document, text, position) {
    this.document = document; this.text = text; this.position = position;
  }
  execute() {
    this.document.content =
      this.document.content.slice(0, this.position) + this.text + this.document.content.slice(this.position);
  }
  undo() {
    this.document.content =
      this.document.content.slice(0, this.position) + this.document.content.slice(this.position + this.text.length);
  }
}

class CommandHistory {
  constructor() { this.history = []; }
  execute(command) { command.execute(); this.history.push(command); }
  undoLast() { this.history.pop()?.undo(); }
}

Interpreter

Interpreter defines a grammar for a language and provides an interpreter that evaluates sentences in it. It's the least commonly used GoF pattern in typical application code, appearing mostly in configuration DSLs, rule engines, and expression evaluators rather than everyday business logic.

class NumberExpression {
  constructor(value) { this.value = value; }
  interpret() { return this.value; }
}
class AddExpression {
  constructor(left, right) { this.left = left; this.right = right; }
  interpret() { return this.left.interpret() + this.right.interpret(); }
}

const expr = new AddExpression(new NumberExpression(3), new NumberExpression(4));
console.log(expr.interpret()); // 7

Iterator

Iterator provides a way to traverse a collection's elements without exposing its underlying structure. JavaScript builds this directly into the language through the iterator protocol and Symbol.iterator, so implementing it manually is only necessary for custom traversal logic, such as paginating a large dataset.

class PagedCollection {
  constructor(items, pageSize) { this.items = items; this.pageSize = pageSize; }
  [Symbol.iterator]() {
    let index = 0;
    const { items, pageSize } = this;
    return {
      next() {
        if (index >= items.length) return { done: true, value: undefined };
        const page = items.slice(index, index += pageSize);
        return { done: false, value: page };
      },
    };
  }
}

for (const page of new PagedCollection([1, 2, 3, 4, 5], 2)) {
  console.log(page);
}

Mediator

Mediator centralizes communication between objects that would otherwise reference each other directly, reducing coupling in systems with many interacting components. Chat applications and complex form validation, where many fields need to react to each other's changes, are common use cases.

class ChatRoom {
  constructor() { this.users = new Map(); }
  register(user) { this.users.set(user.name, user); user.room = this; }
  send(message, from, to) { this.users.get(to)?.receive(message, from); }
}
class User {
  constructor(name) { this.name = name; }
  send(message, to) { this.room.send(message, this.name, to); }
  receive(message, from) { console.log(`${from} -> ${this.name}: ${message}`); }
}

Memento

Memento captures and externalizes an object's internal state so it can be restored later, without violating encapsulation. This underlies undo systems that need to snapshot state at specific points rather than replaying commands.

class Editor {
  constructor() { this.content = ""; }
  type(text) { this.content += text; }
  save() { return { content: this.content }; }
  restore(state) { this.content = state.content; }
}

const editor = new Editor();
editor.type("Hello");
const snapshot = editor.save();
editor.type(" world");
editor.restore(snapshot);
console.log(editor.content); // "Hello"

Observer

Observer defines a one-to-many dependency so that when one object changes state, all its dependents are notified automatically. This is the foundation of event-driven architecture in JavaScript, and it's built into the browser as EventTarget and into Node.js as EventEmitter.

class EventBus {
  constructor() { this.listeners = new Map(); }
  on(event, callback) {
    if (!this.listeners.has(event)) this.listeners.set(event, []);
    this.listeners.get(event).push(callback);
  }
  emit(event, payload) {
    (this.listeners.get(event) || []).forEach((cb) => cb(payload));
  }
}

const bus = new EventBus();
bus.on("order:placed", (order) => console.log(`Notify shipping: ${order.id}`));
bus.emit("order:placed", { id: 101 });

State

State allows an object to alter its behavior when its internal state changes, encapsulating each state's behavior in its own class rather than scattering conditionals throughout the object. Order lifecycle management is a natural fit.

class PendingState {
  name() { return "pending"; }
  next(order) { order.setState(new ShippedState()); }
}
class ShippedState {
  name() { return "shipped"; }
  next(order) { /* terminal for this example */ }
}
class Order {
  constructor() { this.state = new PendingState(); }
  setState(state) { this.state = state; }
  advance() { this.state.next(this); }
}

Strategy

Strategy defines a family of interchangeable algorithms and lets client code select one at runtime. Because JavaScript treats functions as first-class values, Strategy often needs no classes at all - a plain function reference does the job.

const pricingStrategies = {
  regular: (price) => price,
  student: (price) => price * 0.85,
  loyalty: (price) => price * 0.9,
};

function calculatePrice(price, strategy) {
  return strategy(price);
}

console.log(calculatePrice(100, pricingStrategies.student)); // 85

Template Method

Template Method defines the skeleton of an algorithm in a base class while letting subclasses override specific steps, without changing the algorithm's overall structure. Data processing pipelines with a fixed shape but variable analysis steps are a good fit.

class ReportGenerator {
  generate(data) {
    return this.format(this.analyze(this.clean(data)));
  }
  clean(data) { return data.filter(Boolean); }
  analyze(data) { throw new Error("analyze() must be implemented"); }
  format(result) { return JSON.stringify(result); }
}
class SalesReport extends ReportGenerator {
  analyze(data) { return { total: data.reduce((sum, n) => sum + n, 0) }; }
}

Visitor

Visitor separates an algorithm from the object structure it operates on by letting you add new operations without modifying the classes being visited. This is useful for AST traversal, serialization, and, in this example, computing geometric properties across a heterogeneous set of shapes.

class Circle {
  constructor(radius) { this.radius = radius; }
  accept(visitor) { return visitor.visitCircle(this); }
}
class Rectangle {
  constructor(w, h) { this.width = w; this.height = h; }
  accept(visitor) { return visitor.visitRectangle(this); }
}
class AreaVisitor {
  visitCircle(c) { return Math.PI * c.radius ** 2; }
  visitRectangle(r) { return r.width * r.height; }
}

const areas = [new Circle(3), new Rectangle(4, 5)].map((s) => s.accept(new AreaVisitor()));

Trade-offs and Common Pitfalls

The most common mistake teams make with GoF patterns in JavaScript is applying them prophylactically - building an Abstract Factory or a full Visitor hierarchy for a problem that only has one implementation today, on the theory that a second one might arrive eventually. This inverts the cost-benefit relationship the patterns are meant to provide. Patterns exist to manage complexity that has already arrived, not to pre-build flexibility for hypothetical future requirements. Codebases that reach for Builder, Abstract Factory, or Visitor before they have a second real use case tend to accumulate abstraction layers that make the code harder to read and modify than the plain object or function they replaced, without ever paying off the investment.

A second pitfall specific to JavaScript is fighting the language's own idioms in the name of pattern fidelity. Implementing Singleton with a class and a private static field, when a module-level export achieves the same guarantee more simply, or building a custom Iterator class when a generator function would do, adds ceremony without adding value. Similarly, deeply nested class hierarchies built for Template Method or Visitor can become difficult to trace through a debugger, especially when several levels of super calls are involved - a problem C++ and Java engineers have lived with for decades, but one that JavaScript's flatter, more functional style doesn't have to inherit.

Inheritance-heavy patterns also carry a testing cost that's easy to underestimate. Patterns like Template Method and Visitor rely on subclassing to vary behavior, which means unit tests for the base algorithm and tests for each variant are coupled through the class hierarchy: a change to the base class's method signature can silently break every subclass, and mocking a single step in isolation often requires instantiating the whole chain. Composition-based alternatives - passing in strategy functions or wrapping objects instead of subclassing them - tend to be easier to test in isolation because each unit of behavior is a standalone function or object with no hidden dependency on a parent class's internals.

Finally, several structural patterns introduce a real performance cost that's worth measuring rather than assuming. Flyweight is explicitly a memory optimization, and reaching for it before profiling shows a memory problem adds indirection for no measurable benefit. Proxy-based caching, similarly, trades memory for speed and can silently serve stale data if invalidation isn't handled carefully - a caching Proxy without a clear invalidation strategy is a common source of hard-to-reproduce bugs in production systems.

Best Practices for Applying Patterns in JavaScript

Start from the problem, not the pattern name. The most reliable way to use GoF patterns well in JavaScript is to notice a specific pain point - duplicated construction logic, a class doing too many unrelated things, a tangle of conditionals checking an object's internal mode - and then recognize which pattern addresses that specific shape of problem. Reaching for a pattern's implementation before the pain point exists is how codebases accumulate unnecessary class hierarchies that nobody can confidently delete later, because it's never clear whether some future caller depends on the extra flexibility.

Lean on TypeScript when a pattern's value comes from enforcing a contract. Many structural and behavioral patterns - Adapter, Bridge, Strategy, Visitor - depend on multiple classes correctly implementing the same interface. In plain JavaScript, that contract is enforced only by convention and a runtime error if someone forgets a method. TypeScript's interface and abstract class constructs turn that convention into a compile-time guarantee, which matters most in larger teams where the implementer of a new PaymentProcessor subclass isn't the same engineer who defined the interface.

interface PaymentProcessor {
  pay(amount: number): string;
}

class StripeAdapter implements PaymentProcessor {
  constructor(private client: { charge(cents: number, currency: string): string }) {}
  pay(amount: number): string {
    return this.client.charge(Math.round(amount * 100), "USD");
  }
}

Prefer composition and first-class functions over inheritance whenever a pattern's intent can be satisfied without a class hierarchy. Strategy, Command, and Observer in particular are almost always cleaner as plain functions and closures in JavaScript than as class hierarchies mirroring a Java implementation. Reserve class-based implementations for patterns like Composite and State, where the shared interface and internal state genuinely benefit from a formal object structure.

Key Takeaways

Design patterns are a shared vocabulary for solving recurring structural problems, and that vocabulary is valuable in JavaScript even though the language's flexibility means several patterns can be implemented more simply than their C++ or Java originals. The patterns worth learning first are the ones that map onto problems every non-trivial JavaScript codebase eventually faces: managing object construction complexity, composing behavior without deep inheritance, and coordinating loosely coupled parts of a system.

Use the following five steps as a practical checklist the next time a design decision feels like it might warrant a named pattern:

Analogies & Mental Models

A useful mental model for the creational patterns is a restaurant kitchen. A Factory Method is a station chef who knows how to prepare one dish and hands it off when asked, without the front-of-house staff needing to know the recipe. An Abstract Factory is closer to an entire themed menu - Italian night or Japanese night - where every dish produced that evening is guaranteed to belong to the same coherent set. Builder is the multi-course tasting menu assembled step by step, where the order of courses and their combination matters more than any single dish. Singleton is the one head chef in the kitchen - there's only one, and every station defers to that single point of authority whether or not that's actually a good idea for the restaurant's scalability.

For structural patterns, think of shipping containers. Adapter is the standardized container that lets cargo of wildly different shapes travel on the same ships and trains, regardless of what's inside. Decorator is stacking additional insulation or refrigeration units around a container without changing the container itself. Facade is the single shipping company you call, even though your cargo actually passes through a customs broker, a freight forwarder, and a trucking company behind the scenes - you never have to coordinate with all three directly.

Behavioral patterns map naturally onto how a well-run team communicates. Observer is a status update posted to a shared channel that anyone subscribed can react to, without the poster needing to know who's listening. Mediator is a project manager who routes information between team members instead of everyone messaging everyone else directly. Chain of Responsibility is an escalation path - a support ticket moves from tier-one support to tier-two to engineering until someone can actually resolve it, and each tier only needs to know the next one, not the entire chain.

The 80/20 of GoF Patterns in JavaScript

Not all 23 patterns pull equal weight in day-to-day JavaScript engineering, and recognizing which ones do is more useful than memorizing the full catalog. In practice, a small subset - Factory Method, Singleton (via modules), Observer, Strategy, Decorator, and Adapter - accounts for the overwhelming majority of pattern usage across typical web and Node.js codebases, because they map directly onto problems that show up constantly: constructing objects flexibly, sharing global state safely, reacting to events, swapping algorithms, layering behavior, and integrating third-party code.

The remaining patterns are not less valid, but they tend to cluster around more specialized problem domains. Composite and Visitor appear disproportionately in tools that manipulate tree-shaped data - compilers, UI frameworks, document processors - and rarely surface elsewhere. Interpreter is close to unused outside of DSL and rule-engine work. Memento and Command show up together almost exclusively in applications with undo/redo requirements, such as editors and drawing tools. Recognizing this distribution helps prioritize learning: understanding Factory Method, Observer, Strategy, Decorator, and Adapter deeply will serve most engineers far more often than a shallow familiarity with all 23.

This unevenness also explains why so many introductory JavaScript articles only cover a handful of patterns - those are, empirically, the ones engineers run into most. The risk in stopping there is not noticing a genuine Chain of Responsibility or Mediator problem when it appears, and either solving it with an ad hoc, unnamed structure that reinvents the pattern poorly, or overcomplicating a simple problem because the right, simpler pattern wasn't part of the mental toolkit.

Treat the 80/20 split as a learning order, not a permission to ignore the rest. A team building a rules engine will find Interpreter indispensable even though it's rare elsewhere; a team building a rich text editor will lean heavily on Command and Memento. The patterns that matter most are always a function of the problem domain, and the "core six" are simply the ones that generalize best across domains.

Conclusion

The GoF design patterns were never meant to be a checklist applied uniformly to every project, and that's especially true in JavaScript, where the language's flexibility means several patterns collapse into idioms - first-class functions, closures, native iterators, module-level singletons - that don't need a formal class-based implementation to work. Treating every pattern as equally load-bearing in JavaScript, or ignoring the catalog entirely because "JavaScript is different," are both mistakes that lead to weaker architecture: the first produces over-engineered code, the second produces ad hoc structures that reinvent well-understood solutions poorly.

The patterns that consistently earn their place - Factory Method for flexible construction, Observer for event-driven coordination, Strategy for swappable algorithms, Decorator for layered behavior, and Adapter for integration boundaries - do so because they solve problems that exist independently of language choice. Learning to recognize the shape of these problems, and reaching for the right tool only once that shape is clear, matters far more than memorizing all 23 names. Used this way, the GoF catalog remains exactly what it was designed to be three decades ago: a shared vocabulary that lets engineers describe a structural problem precisely enough to reach for a solution someone else has already tested at scale.

References

Resources