OOP Design Patterns in JavaScript

advanced design-patterns singleton factory observer strategy decorator

Design patterns are proven solutions to problems that keep showing up in software. We don’t need to memorize all 23 GoF patterns, but knowing the most common ones helps us write cleaner code and recognize them in frameworks we already use. Let’s look at seven patterns that come up most in JavaScript.

Singleton — One Instance, Ever

A Singleton ensures only one instance of a class exists. Useful for things like a config manager, logger, or database connection pool.

In modern JS, the simplest Singleton is just a module-level instance — since ES modules are cached, the same instance is returned every time.

// logger.js
class Logger {
  #logs = [];
  log(msg) {
    this.#logs.push({ msg, time: Date.now() });
    console.log(`[LOG] ${msg}`);
  }
  getHistory() { return [...this.#logs]; }
}

export const logger = new Logger(); // single instance, module-cached

Anyone who imports logger gets the exact same object. No need for getInstance() gymnastics — the module system handles it.

If we need a class-based approach (for interviews):

class AppConfig {
  static #instance;
  #settings = {};

  static getInstance() {
    if (!AppConfig.#instance) AppConfig.#instance = new AppConfig();
    return AppConfig.#instance;
  }
  set(key, val) { this.#settings[key] = val; }
  get(key) { return this.#settings[key]; }
}

const a = AppConfig.getInstance();
const b = AppConfig.getInstance();
console.log(a === b); // true

Factory — Create Without Specifying the Exact Class

A Factory function creates objects without the caller knowing the exact class or construction details. The caller says what they want, and the factory figures out how to build it.

class PostgresDB {
  query(sql) { console.log(`Postgres: ${sql}`); }
}
class MongoDB {
  query(sql) { console.log(`Mongo: ${sql}`); }
}

function createDatabase(type) {
  if (type === "postgres") return new PostgresDB();
  if (type === "mongo") return new MongoDB();
  throw new Error(`Unknown database: ${type}`);
}

const db = createDatabase("postgres");
db.query("SELECT * FROM users"); // Postgres: SELECT * FROM users

The caller doesn’t import or know about PostgresDB or MongoDB directly. If we add MySQL later, only the factory changes. React.createElement() is a real-world factory.

Builder — Step-by-Step Construction

The Builder pattern builds complex objects step by step, usually with a fluent API (method chaining). Great when an object has many optional configurations.

class QueryBuilder {
  #table = "";
  #conditions = [];
  #limit = null;

  from(table) { this.#table = table; return this; }
  where(condition) { this.#conditions.push(condition); return this; }
  limit(n) { this.#limit = n; return this; }
  build() {
    let sql = `SELECT * FROM ${this.#table}`;
    if (this.#conditions.length) sql += ` WHERE ${this.#conditions.join(" AND ")}`;
    if (this.#limit) sql += ` LIMIT ${this.#limit}`;
    return sql;
  }
}

const query = new QueryBuilder()
  .from("users")
  .where("age > 18")
  .where("active = true")
  .limit(10)
  .build();
// "SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10"

Each method returns this so we can chain calls. The build() at the end produces the final result. Express middleware and Knex.js query builder use this pattern.

Observer — Subscribe to Changes

The Observer (or Pub/Sub) pattern lets objects subscribe to events and get notified when something happens. One object emits events, many objects listen.

class EventBus {
  #listeners = new Map();

  on(event, callback) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, []);
    this.#listeners.get(event).push(callback);
  }
  off(event, callback) {
    const cbs = this.#listeners.get(event) || [];
    this.#listeners.set(event, cbs.filter(cb => cb !== callback));
  }
  emit(event, data) {
    (this.#listeners.get(event) || []).forEach(cb => cb(data));
  }
}

const bus = new EventBus();
bus.on("user:login", (user) => console.log(`${user} logged in`));
bus.emit("user:login", "Manish"); // "Manish logged in"

This is everywhere: addEventListener in the DOM, Node’s EventEmitter, Redux subscriptions, and Vue/React state management all use Observer under the hood.

Strategy — Swap Algorithms at Runtime

The Strategy pattern lets us define a family of algorithms and swap them out without changing the code that uses them. In JS, we just pass functions around — first-class functions make this natural.

// Define strategies as simple functions
const strategies = {
  standard: (amount) => amount * 0.1,     // 10% tax
  reduced: (amount) => amount * 0.05,     // 5% tax
  exempt: () => 0                          // no tax
};

function calculateTax(amount, type = "standard") {
  const strategy = strategies[type];
  if (!strategy) throw new Error(`Unknown tax type: ${type}`);
  return strategy(amount);
}

console.log(calculateTax(100, "standard")); // 10
console.log(calculateTax(100, "reduced"));  // 5
console.log(calculateTax(100, "exempt"));   // 0

Adding a new tax type is just adding a new key to the strategies object. No if/else chains, no modifying existing code. This also ties directly to the Open/Closed principle from SOLID.

Decorator — Wrap to Add Behavior

The Decorator pattern wraps an object to add extra behavior without modifying the original. In JS, higher-order functions are the natural way to do this.

function withLogging(fn) {
  return function (...args) {
    console.log(`Calling ${fn.name} with`, args);
    const result = fn(...args);
    console.log(`Result:`, result);
    return result;
  };
}

function add(a, b) { return a + b; }

const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// Calling add with [2, 3]
// Result: 5

We can also decorate class instances:

function withTimestamp(instance) {
  const original = instance.save.bind(instance);
  instance.save = function (data) {
    return original({ ...data, updatedAt: new Date() });
  };
  return instance;
}

Express middleware is essentially a decorator chain — each middleware wraps the request/response to add behavior (logging, auth, CORS) before passing it along.

Proxy Pattern — Control Access

The Proxy pattern controls access to an object. JavaScript has this built into the language with the Proxy API, but the pattern itself is about adding a layer between the caller and the real object.

function createCachedAPI(apiClient) {
  const cache = new Map();

  return new Proxy(apiClient, {
    get(target, prop) {
      if (prop === "fetch") {
        return async (url) => {
          if (cache.has(url)) {
            console.log("Cache hit");
            return cache.get(url);
          }
          const result = await target.fetch(url);
          cache.set(url, result);
          return result;
        };
      }
      return Reflect.get(target, prop);
    }
  });
}

The proxy sits between the caller and the API client, transparently adding caching. The caller doesn’t know (or care) that caching is happening.

Quick Reference

PatternCategoryCore IdeaReal-World Example
SingletonCreationalOne instance globallyModule exports, Redux store
FactoryCreationalCreate without specifying classReact.createElement, express()
BuilderCreationalStep-by-step fluent constructionKnex.js, query builders
ObserverBehavioralSubscribe to eventsaddEventListener, EventEmitter
StrategyBehavioralSwap algorithms via functionsSorting comparators, tax rules
DecoratorStructuralWrap to add behaviorExpress middleware, HOCs
ProxyStructuralControl access to an objectCaching, validation, Vue reactivity

In simple language, design patterns aren’t rules we have to follow — they’re names for solutions we’ve probably already used without knowing it. Recognizing them helps us communicate with other developers and pick the right tool when we face a familiar problem.