JavaScript - OOP Patterns

Overview

Estimated time: 40–50 minutes

Use pragmatic OOP patterns in JavaScript to design flexible and testable systems. Favor composition over inheritance, inject dependencies, and use small patterns like factory, strategy, observer, and decorator.

Learning Objectives

  • Create objects with factories instead of complex constructors.
  • Swap behaviors with the Strategy pattern.
  • Notify subscribers with a minimal Observer (pub/sub).
  • Decorate objects to add cross-cutting features without inheritance.
  • Use dependency injection to improve testability.

Prerequisites

Factory

A factory function builds objects with a clear, stable API. Prefer factories when you don't need new or prototype inheritance.

function createPoint(x = 0, y = 0) {
  return {
    x, y,
    move(dx, dy) { this.x += dx; this.y += dy; return this; },
    toString() { return `(${this.x}, ${this.y})`; }
  };
}

const p = createPoint(1, 2).move(3, -1).toString(); // "(4, 1)"

Strategy

Encapsulate interchangeable algorithms behind a stable interface.

const numericAsc = (a, b) => a - b;
const stringLocale = (a, b) => String(a).localeCompare(String(b));

function sortWith(arr, strategy) {
  return [...arr].sort(strategy); // non-mutating clone + sort
}

sortWith([3,1,2], numericAsc);        // [1,2,3]
sortWith(['ä','a'], stringLocale);    // locale-aware

Observer (pub/sub)

A tiny event emitter for decoupled notifications.

function createEmitter() {
  const handlers = new Map(); // event -> Set(fn)
  return {
    on(event, fn) { (handlers.get(event) || handlers.set(event, new Set()).get(event)).add(fn); return () => this.off(event, fn); },
    off(event, fn) { handlers.get(event)?.delete(fn); },
    emit(event, payload) { for (const fn of handlers.get(event) || []) fn(payload); }
  };
}

const bus = createEmitter();
const off = bus.on('tick', x => console.log('tick', x));
bus.emit('tick', 1); // tick 1
off();

Decorator

Wrap an object to add cross-cutting behavior (e.g., logging, caching) without modifying the original implementation.

function withTimestamp(logger) {
  return {
    log(...args) { logger.log(new Date().toISOString(), ...args); }
  };
}

const baseLogger = { log: (...args) => console.log('[LOG]', ...args) };
const tsLogger = withTimestamp(baseLogger);
tsLogger.log('started');

Dependency Injection (DI)

Pass collaborators in from the outside for testability and reuse.

class UserService {
  constructor({ store, logger }) { this.store = store; this.logger = logger; }
  async save(user) { await this.store.put(user); this.logger.log('saved', user.id); }
}

const memoryStore = { async put(x){ this._ = x; } };
const logger = { log: (...a) => console.log('[UserService]', ...a) };
const svc = new UserService({ store: memoryStore, logger });
// Now easily swapped in tests: pass a fake store/logger.

Common Pitfalls

  • Overengineering: Prefer small functions and composition; only add patterns when they simplify code.
  • Hidden mutation: Decorators should avoid surprising state changes; document side effects.
  • Tight coupling: Strategies/observers should be small and focused; expose minimal interfaces.

Try it

Run to combine Strategy, Observer, Decorator, and DI in a tiny workflow: