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: