// ───────────────────────────────────────────────────────────────────────────── // DECORATOR [Structural] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Attach additional responsibilities to an object dynamically; a flexible alternative to // subclassing for extending functionality. // PROBLEM : You want logging + retry + caching around an HTTP client, in any combination and order, without // a `LoggingRetryingCachingHttpClient` subclass explosion. // SAY : Participants: Component (interface), ConcreteComponent (the real thing), Decorator (holds a // Component, implements the same interface), ConcreteDecorator (adds behavior before/after // delegating). Pitch: "same interface, wrapped — stack as many layers as you like." // SEEN IN : Higher-order functions: `memoize`, `debounce`, `throttle`, `once`, `withRetry`; Node // `util.deprecate(fn, msg)`; React HOCs (`connect()`, `withRouter`) and `React.memo`; Express // wrappers (`asyncHandler(fn)`); TS/JS `@decorators` (NestJS `@Injectable`, MobX `@observable`); // middleware-style wrapping of fetch (`withAuth(withTimeout(fetch))`). // VS : Adapter CHANGES the interface; Decorator keeps it. Proxy controls ACCESS (lazy/guard/cache, // usually one layer, manages the subject's lifecycle); Decorator ADDS behavior and stacks freely. // Composite has many children; Decorator has exactly one wrappee. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) // Component interface: get(url) -> Promise<{ status, body }> class FakeHttpClient { // ConcreteComponent (scripted responses) constructor(responses) { this.responses = responses; this.calls = 0; } async get() { this.calls += 1; const r = this.responses.shift(); if (r instanceof Error) throw r; return r; } } class HttpDecorator { // Decorator — same interface, holds the wrappee constructor(inner) { this.inner = inner; } get(url) { return this.inner.get(url); } } class LoggingClient extends HttpDecorator { // ConcreteDecorator constructor(inner, log) { super(inner); this.log = log; } async get(url) { this.log.push(`→ GET ${url}`); const res = await super.get(url); this.log.push(`← ${res.status} ${url}`); return res; } } class RetryClient extends HttpDecorator { // ConcreteDecorator constructor(inner, attempts) { super(inner); this.attempts = attempts; } async get(url) { let lastErr; for (let i = 0; i < this.attempts; i++) { try { return await super.get(url); } catch (e) { lastErr = e; } } throw lastErr; } } class CachingClient extends HttpDecorator { // ConcreteDecorator #cache = new Map(); async get(url) { if (!this.#cache.has(url)) this.#cache.set(url, await super.get(url)); return this.#cache.get(url); } } // ── Idiomatic JS shape: higher-order functions + compose (fn → fn, same signature) const compose = (...fns) => (x) => fns.reduceRight((acc, f) => f(acc), x); const withLogging = (log) => (fn) => async (...args) => { log.push(`call ${args[0]}`); const out = await fn(...args); log.push(`done ${args[0]}`); return out; }; const withRetry = (attempts) => (fn) => async (...args) => { let lastErr; for (let i = 0; i < attempts; i++) { try { return await fn(...args); } catch (e) { lastErr = e; } } throw lastErr; }; const withCache = () => (fn) => { const cache = new Map(); return async (key) => { if (!cache.has(key)) cache.set(key, await fn(key)); return cache.get(key); }; }; (async () => { // Classic: Caching(Logging(Retry(Fake))) — layers chosen and ordered at runtime const fake = new FakeHttpClient([new Error('ECONNRESET'), { status: 200, body: 'orders' }]); const log = []; const client = new CachingClient(new LoggingClient(new RetryClient(fake, 3), log)); const r1 = await client.get('/orders'); assert.deepEqual(r1, { status: 200, body: 'orders' }); assert.equal(fake.calls, 2); // retry absorbed the first failure assert.deepEqual(log, ['→ GET /orders', '← 200 /orders']); // logging sits OUTSIDE retry: one entry const r2 = await client.get('/orders'); assert.equal(r2, r1); assert.equal(fake.calls, 2); // cache: no extra network call assert.equal(log.length, 2); // ...and nothing logged const failing = new RetryClient(new FakeHttpClient([new Error('a'), new Error('b'), new Error('c')]), 3); await assert.rejects(failing.get('/x'), /c/); // gave up after 3; surfaced the last error // Idiomatic: the same stack as function composition let hits = 0; const flaky = async (sku) => { hits += 1; if (hits === 1) throw new Error('flaky'); return { sku, price: 999 }; }; const log2 = []; const getPrice = compose(withCache(), withLogging(log2), withRetry(2))(flaky); assert.deepEqual(await getPrice('A1'), { sku: 'A1', price: 999 }); assert.equal(hits, 2); assert.deepEqual(await getPrice('A1'), { sku: 'A1', price: 999 }); assert.equal(hits, 2); // cached assert.deepEqual(log2, ['call A1', 'done A1']); assert.equal(getPrice.length, 1); // still a plain function — callers can't tell console.log('ok: design-patterns/structural/decorator.js'); })();