Appearance
9.4.8 — Decorator
What the original Gang of Four book says: Attach extra responsibilities to an object at runtime. Decorators are a flexible alternative to subclassing for extending what an object can do.
What that means when you are actually writing code: When you need to add behaviour — retry, caching, logging, auth — in different combinations at runtime, wrap the object in a same-shaped layer, instead of exploding your class hierarchy or editing the original.
Decorator is how professionals add cross-cutting behaviour without touching the thing they are enhancing. It is the pattern under middleware, under stream pipelines, under resilient HTTP clients, and under React's higher-order components. Once you see the shape, you find it everywhere, because "wrap it to add one concern, keep the interface identical" is one of the most reusable moves in software.
1. The story: the class hierarchy that became a combinatorial bomb
You have an HttpClient with one method: get(url). Then the requirements arrive, one at a time, each reasonable on its own.
"Retry failed requests." So you write RetryingHttpClient. "Cache GET responses." So you write CachingHttpClient. "Log every request." So you write LoggingHttpClient.
Fine so far — three subclasses. Then it turns: "the payments client needs retry and logging"; "the search client needs caching and retry"; "the admin client needs all three, in a specific order." Now you are writing RetryingCachingHttpClient, CachingRetryingLoggingHttpClient, LoggingRetryingHttpClient, and the count is 2ⁿ: three concerns give up to 8 combinations, four give 16, and because order matters it is worse than that. Each new concern doubles the hierarchy, and each combination is a class you have to write, name, and maintain.
typescript
// ❌ the combinatorial explosion — one class per combination, order baked into the name
class RetryingCachingLoggingHttpClient extends HttpClient { … }
class CachingRetryingHttpClient extends HttpClient { … }
// …fourteen more…There is a second, subtler failure hiding here. Subclassing binds the combination at compile time. But "retry this endpoint, cache that one, log everything in staging only" is a runtime decision — it depends on config, environment, and which client you are building. Compile-time inheritance cannot express a runtime choice.
Decorator's answer is to make each concern a wrapper with the same interface as what it wraps, and compose them at runtime like nesting dolls. Three concerns become three small wrapper classes, and any combination in any order is an assembly expression, not a new class.
typescript
const client = // ← compose at runtime, any order
new LoggingClient(
new RetryingClient(
new CachingClient(
new FetchClient()), { max: 3 }));2. How you arrive at the pattern
Step 1 — Start naive. One class doing the core job. This is correct until extra, optional, combinable responsibilities appear.
Step 2 — Wait for the force. You need to add behaviour that is, first, cross-cutting (the same concern applies to many methods or many objects — retry, logging, caching, timing, auth), second, optional (not every use needs it), and third, combinable (different callers need different subsets, in different orders). Subclassing handles one concern; it collapses under combinations.
Step 3 — Draw the line between what varies and what stays fixed.
| What varies | which behaviours are layered on, and in what order — decided per assembly, at runtime |
| What stays fixed | the interface — every layer, and the core, expose exactly the same shape |
The fixed interface is what makes wrapping work: because a decorator has the same type as the thing it wraps, it can stand in for it, and be wrapped by the next decorator. That substitutability (9.3.7, Liskov) is the mechanical heart of the pattern.
Step 4 — Decide when the choice is made. Runtime composition. This is the crucial contrast with the inheritance the pattern replaces: you assemble the stack when you build the object, so the same code can build different stacks for different clients or environments.
Step 5 — Name the pattern and say what it costs. The name is Decorator. The costs are real. The behaviour of a wrapped object is no longer visible in one place — to understand what client.get() does, you have to read the whole stack. Debugging steps through many layers — a stack trace passes through every wrapper. Order is essential and easy to get wrong — caching-before-auth serves cached data to unauthorized users (section 6). And many small objects are allocated. The benefit is that each concern is written once, tested once, and combined freely — the exact thing subclassing could not do.
3. The mental model
In one sentence: a decorator is a coat. The person underneath is unchanged; each layer you put on adds warmth (behaviour) while still looking like a person (the same interface), and you can layer coats in any order.
The analogy that makes it stick — coffee with add-ons. A plain espresso is the core object. "Add milk", "add caramel", "add whipped cream" are decorators: each wraps the drink, adds its cost and its topping, and hands back a drink — still orderable, still priceable, still the same interface. You combine them per order at the counter (runtime), not by pre-defining a CaramelMilkWhippedEspresso class for every combination. Ask the price and the request cascades inward: whipped cream adds 50¢ to (caramel adds 40¢ to (milk adds 30¢ to (espresso's 200¢))) = 320¢. That cascade is the pattern.
When to reach for it. The signals: "add retry / caching / logging / auth / rate limiting — to this, and combine them differently per case" · "wrap the client so it also…" · "the same cross-cutting concern applies to lots of methods" · "we need this behaviour optionally, decided at runtime." The stream and pipeline flavour: "pipe the data through gzip, then encryption, then the file" — each .pipe() is a decorator on a stream.
The single sharpest distinction — Decorator versus inheritance. Both add behaviour to a base. Inheritance does it statically, once, at class-definition time; you get one fixed extension per subclass, and combinations explode. Decorator does it dynamically, per instance, at runtime; you compose behaviours like Lego. The Gang of Four's line is exact: Decorator is a flexible alternative to subclassing for extending functionality. Reach for it precisely when the extensions are optional and combinable.
4. Structure
The participants: the Component (the shared interface), the ConcreteComponent (the core object being decorated), the Decorator (implements Component and holds a Component — the wrapped inner object), and the ConcreteDecorators (each adds one behaviour around the delegated call).
5. The implementation, line by line
typescript
// ── The shared interface — the FIXED part. Core and every decorator implement it.
export interface HttpClient { // (1)
get(url: string): Promise<Response>;
}
// ── The core (ConcreteComponent)
export class FetchClient implements HttpClient { // (2)
async get(url: string) { return fetch(url); }
}
// ── A base decorator that just delegates — the boilerplate, written once
abstract class HttpDecorator implements HttpClient { // (3)
constructor(protected readonly inner: HttpClient) {} // (4) holds the wrapped Component
get(url: string) { return this.inner.get(url); } // default: pass through unchanged
}
// ── Concrete decorators: each overrides get() to add ONE behaviour
export class LoggingClient extends HttpDecorator {
async get(url: string) {
const t0 = performance.now();
try {
const res = await this.inner.get(url); // (5) act AFTER: measure + log
log.info({ url, ms: performance.now() - t0, status: res.status });
return res;
} catch (e) { log.error({ url, err: e }); throw e; } // (6) log then RE-THROW — don't swallow
}
}
export class CachingClient extends HttpDecorator {
#cache = new Map<string, Response>();
async get(url: string) {
const hit = this.#cache.get(url);
if (hit) return hit.clone(); // (7) act INSTEAD: short-circuit the core
const res = await this.inner.get(url);
this.#cache.set(url, res.clone());
return res;
}
}
export class RetryingClient extends HttpDecorator {
constructor(inner: HttpClient, private readonly max = 3) { super(inner); }
async get(url: string) {
let lastErr: unknown;
for (let attempt = 1; attempt <= this.max; attempt++) {
try { return await this.inner.get(url); } // (8) act AROUND: repeat the delegation
catch (e) { lastErr = e; await sleep(backoff(attempt)); }
}
throw lastErr;
}
}Now the numbered lines.
(1) The shared interface is the whole enabler. Because Component is one method here, the pattern is light. If it had ten methods, the base decorator's job — delegating all ten — is what lets concrete decorators override only the one they care about.
(2) The core does only the core job — no retry, no cache, no logging. This is 9.3.5's Single Responsibility: FetchClient has one reason to change, which is how HTTP is performed.
(3) The abstract base decorator removes boilerplate. Without it, every concrete decorator would have to re-implement every pass-through method. With it, a decorator overrides only what it changes and inherits pass-through for the rest — essential when the interface is wide.
(4) protected readonly inner is the wrapped object. Composition again: the decorator has-a Component, and because a decorator is a Component, inner may itself be another decorator. That is how the nesting works.
(5) Acting after delegation: LoggingClient calls inward, then records the outcome. Timing, metrics, and response transformation live here.
(6) Log then rethrow, never log then swallow. A decorator has to preserve the contract of the interface. Swallowing the error would make LoggingClient change behaviour, not just add to it — and that is the boundary between a decorator (transparent) and a different design.
(7) Acting instead of delegation: CachingClient may return without ever touching the core — a cache hit short-circuits. Note the .clone(): Response bodies are single-use streams (3.8.4), so returning the cached response directly would let the first caller consume the body and leave the second with an empty one — a genuine decorator bug.
(8) Acting around delegation: RetryingClient calls inward repeatedly. This is why order matters: retry outside caching retries cache misses; retry inside caching would cache a failure. Section 6 develops the ordering consequences.
Assembly and behaviour:
typescript
const client = new LoggingClient(new RetryingClient(new CachingClient(new FetchClient()), 3));
await client.get("/api/x");
// cache miss → fetch (retried up to 3× on failure) → cached → logged. All transparent to the caller.5.1 The functional spelling — decorators as higher-order functions
When the component is a single function, a decorator is a function that wraps a function and returns the same signature — no classes at all:
typescript
type Fetcher = (url: string) => Promise<Response>;
const withRetry = (max: number) => (next: Fetcher): Fetcher => async (url) => { // (1)
for (let i = 1; i <= max; i++) {
try { return await next(url); } catch (e) { if (i === max) throw e; await sleep(backoff(i)); }
}
throw new Error("unreachable");
};
const withLogging = (next: Fetcher): Fetcher => async (url) => {
const t0 = performance.now();
const res = await next(url);
log.info({ url, ms: performance.now() - t0 });
return res;
};
const fetcher = withLogging(withRetry(3)(rawFetch)); // (2) compose — same as the class version(1) Each decorator is (next) => (args) => … — it receives the next layer and returns a same-shaped function. This is the pattern with zero ceremony, and it is exactly how Redux middleware, Express-style handlers, and function-based interceptors are built.
(2) compose(withLogging, withRetry(3))(rawFetch) reads as a pipeline. This is the spelling to prefer in TypeScript when the interface is one function — 9.4.1's rule that the pattern is the role structure, not the class count.
5.2 Python — decorators are in the language
python
import functools, time
def with_retry(max_attempts=3): # a real Python @decorator
def wrap(fn):
@functools.wraps(fn) # preserve name/docstring — the wrapping discipline
def inner(*args, **kwargs):
for i in range(1, max_attempts + 1):
try: return fn(*args, **kwargs)
except Exception:
if i == max_attempts: raise
time.sleep(2 ** i * 0.1)
return inner
return wrap
@with_retry(3) # ← language syntax for the pattern
def fetch(url): ...Python's @decorator syntax is the Decorator pattern promoted to a language feature — which is the cleanest possible proof of 9.4.1's "patterns dissolve into features" point. functools.wraps is the discipline that keeps the wrapper transparent (name, docstring, and signature preserved) — the Python equivalent of "keep the same interface".
6. Order matters — the deep dive that separates seniors
Because decorators nest, the order of the stack changes behaviour, sometimes catastrophically. Three standard cases.
Caching then Auth, done wrong: Cache(Auth(core)). The cache sits outside auth, so a cached response is served before the auth check runs — and an unauthorized user receives another user's cached data. This is a real security-bug pattern. The correct order is Auth(Cache(core)) — authorize first, then consult the cache.
Retry then Logging. Log(Retry(core)) logs once per logical request; Retry(Log(core)) logs once per attempt. Both are valid — but you must choose deliberately, because they answer different operational questions (how many requests failed versus how many attempts were made).
Rate-limit then Retry. RateLimit(Retry(core)) counts one logical call against the limit; Retry(RateLimit(core)) counts every retry, so retries eat the client's own quota and a burst of failures self-throttles. Usually you want the former.
The rule to carry: in a decorator stack, outer layers see the "logical" operation and inner layers see the "physical" one. Decide each concern's position by asking whether it belongs to the logical request (auth, rate-limit, dedup, tracing) or the physical attempt (retry, timeout, connection handling). This exact lesson generalises to middleware ordering (Chain of Responsibility) — which is why Express security middleware must precede body parsing, which must precede the handlers.
7. Variants
| Variant | Shape | Notes |
|---|---|---|
| Class decorator | a wrapper class implementing the interface | the default for multi-method interfaces |
| Base-decorator + concretes | an abstract delegator plus thin overrides | removes pass-through boilerplate on wide interfaces |
| Functional decorator | (next) => (args) => … | the TS default for single-function components |
| Middleware | a list of decorators applied in sequence | Decorator with a runtime-ordered list (Chain of Responsibility) |
| Stream decorator | .pipe() transforms | Node streams, each transform wraps the previous |
| Mixin | a class factory adding behaviour | JS/TS decoration by inheritance-composition |
TS @decorator | annotation-based (Stage 3) | metadata plus wrapping; framework-heavy |
Two things called "decorator" in TypeScript
TypeScript's @decorator syntax (@Injectable(), @Get()) and the Decorator pattern are related but not identical. The syntax is a language mechanism for annotating and wrapping classes and methods; the pattern is the design idea of same-interface wrapping. Framework decorators (NestJS, Angular, class-validator) often implement the pattern, but the syntax is also used for pure metadata (registration, dependency injection) that is not wrapping at all. Know both meanings and which one is meant. (3.7.6 covers the syntax.)
8. Where you already use it
| What you have used | What each wrapper adds |
|---|---|
readStream.pipe(unzip).pipe(parser) | each .pipe wraps the previous stream (3.8.4) |
| Express middleware | each one wraps the handler, adding logging, auth or parsing |
| A retry wrapper around a network call | the call keeps its shape, gains repeats on failure |
| A timing wrapper that logs how long a function took | measurement, with the function untouched |
Object.freeze(obj) | a version of the same object that refuses writes |
Look at the first row as a picture. readStream produces compressed bytes. Wrap it in unzip and you have something that still produces bytes, just uncompressed ones. Wrap that in parser and you still have something producing data, now as objects. Each wrapper takes the same kind of thing it hands back, which is exactly why they stack in any order that makes sense, and why you can add a fourth without touching the first three.
9. Ways to get it wrong
The wrong order. Caching outside auth (a security bug), retry inside rate-limit (a quota bug).
The fix: section 6's logical-versus-physical rule; test the stack's ordering.
Breaking transparency. A decorator that swallows errors, changes the return type, or drops a method is not decorating — it changed the contract.
The fix: same interface in, same interface out; add, do not alter.
Sharing stateful decorators. A
CachingClientwith a shared cache used by two logically separate stacks leaks data between them (the multi-tenant cache bug).The fix: scope decorator state to the stack, or key it correctly.
The
Response/stream single-use trap. Returning a cached stream orResponsebody twice yields an empty body the second time.The fix:
.clone()(section 5 note 7).Deep stacks that hide behaviour. Eight layers and nobody can tell what
client.get()does.The fix: keep stacks shallow and named; a factory that builds the standard stack documents the intent.
A decorator where a parameter would do. Wrapping to toggle one boolean is heavier than a config flag.
The fix: decorators earn their keep with genuinely combinable, cross-cutting concerns.
Confusing Decorator with Proxy. Both keep the interface. Decorator adds behaviour a client asked for; Proxy controls access to something (often creating it lazily or guarding it). See section 11.
10. Decorator compared with its neighbours
| Compared with | The difference | Choose Decorator when |
|---|---|---|
| Proxy | same interface; Decorator adds behaviour, Proxy controls access | you are enhancing, not gatekeeping |
| Adapter | Adapter changes the interface; Decorator keeps it | you want more behaviour, same shape |
| Inheritance | subclassing is static and single-combination; Decorator is runtime and combinable | extensions are optional and combine |
| Chain of Responsibility | a chain passes a request along until one handler stops it; a decorator stack always flows through every layer | every layer participates, none "handles and stops" |
| Strategy | Strategy swaps the algorithm; Decorator layers around one | you are wrapping, not replacing |
Proxy versus Decorator is the one interviewers press, because the code looks identical (both wrap, both keep the interface). The distinction is intent: a Decorator's client wants the extra behaviour and composes it deliberately (retry, logging); a Proxy's client wants the same object, but the proxy controls whether and how the real call happens (lazy loading, access control, caching-as-gatekeeping, remoting). A cache can be either depending on the framing — as "I chose to add caching behaviour" it is a Decorator; as "access to the expensive resource is controlled and short-circuited" it is a Proxy. Say the framing, and you have answered correctly.
11. Interview calibration
The 45-second answer, in the order you would say it:
Decorator adds behaviour by wrapping an object in another object with the same interface, so I can compose concerns — retry, caching, logging, auth — at runtime in any combination, instead of subclassing, which explodes to 2ⁿ classes and binds the combination at compile time. Each decorator implements the shared interface, holds the wrapped object, and does its bit before, after, or instead of delegating inward.
The subtle part is order: caching outside auth serves cached data to unauthorized users, retry inside rate-limiting eats the client's quota — outer layers see the logical operation, inner layers the physical one. In TypeScript, when the thing is a single function, the decorator is just a higher-order function — which is exactly what Express and Redux middleware are.
Follow-up questions, with the seed of each answer:
- "Decorator versus Proxy?" — Same interface; Decorator adds behaviour you asked for, Proxy controls access. Framing decides borderline cases like caching.
- "Why not just subclass?" — Combinations explode (2ⁿ) and inheritance binds them at compile time; Decorator composes at runtime.
- "Where does order bite?" — Auth/cache, retry/rate-limit, retry/logging. Logical concerns outside, physical inside.
- "Is Express middleware Decorator?" — It is Decorator arranged as a runtime-ordered list — the border with Chain of Responsibility, which additionally lets a layer stop the chain.
- "Functional or class form?" — A function for single-method components (the TS default), a class for wide interfaces (with a base delegator).
Recall
- Decorator = wrap an object in a same-interface layer to add behaviour, composed at runtime. It replaces subclassing when extensions are optional and combinable — subclassing explodes to 2ⁿ classes and binds the combination at compile time; Decorator assembles any combination as an expression.
- The mechanism: a shared interface (the fixed part) means a decorator is the component, so it can wrap and be wrapped; each layer acts before, after, or instead of delegating inward (Caching short-circuits, Retry repeats, Logging measures). Transparency is the contract — add behaviour, never alter it: log then rethrow, never swallow.
- Order is essential: outer layers see the logical operation, inner layers the physical one. Auth must be outside Cache (or cached data reaches unauthorized users); rate-limit outside retry (or retries eat quota); choose the retry-versus-logging order deliberately (per request versus per attempt). Test the assembled stack's ordering.
- Spellings: a class decorator plus an abstract base delegator (wide interfaces) · a functional decorator
(next) => (args) => …(single-function components — the TS default, and what Express and Redux middleware are) · Python's@decorator, which is the pattern as a language feature. Beware that TS@decoratorsyntax also does non-wrapping metadata (DI, registration). - Misuse: the wrong order (security or quota bugs) · breaking transparency (swallowing errors, changing the return) · shared decorator state leaking across stacks · the
Response/stream single-use trap (.clone()) · stacks so deep the behaviour is invisible. Versus Proxy: same interface, but Decorator adds behaviour asked for, Proxy controls access.
Self-test: Why does subclassing fail where Decorator succeeds, in one number? What are the three positions a layer can act in relative to delegation? Give two ordering bugs and the logical-versus-physical rule that prevents them. What is the transparency contract? When do you write the functional form instead of the class form?
Quiz Bank
FoundationalDerive Decorator from a class hierarchy and explain the combinatorial explosion precisely.
Naive: an HttpClient with get().
The force: cross-cutting concerns arrive — retry, caching, logging — that are optional (not every client needs each) and combinable (different clients need different subsets in different orders).
What breaks with subclassing: one subclass per concern is fine (RetryingHttpClient), but combinations require a class per subset, and because order matters, the count grows as 2ⁿ or worse — three concerns give up to eight combination classes, four give sixteen, each new concern doubles the hierarchy, and every combination is a class to write, name, and maintain. Worse, subclassing binds the combination at compile time, but "retry this endpoint, cache that one, log only in staging" is a runtime decision that inheritance cannot express.
The varies/fixed line: what varies is which behaviours are layered and in what order (runtime, per assembly); what is fixed is the interface — every layer and the core share exactly one shape.
The pattern: each concern becomes a wrapper implementing the shared interface and holding the wrapped object; the shared interface makes a wrapper substitutable for what it wraps, so wrappers nest arbitrarily. Now three concerns are three small classes, and any combination in any order is an assembly expression (new Log(new Retry(new Cache(core))))).
The cost: behaviour is spread across the stack rather than visible in one place, debugging steps through the layers, order is easy to get wrong, and many small objects are allocated. The trade is decisive: each concern written and tested once, combined freely.
FoundationalExplain why decorator order matters, with two concrete bugs and the rule that prevents them.
Decorators nest, so a call cascades from the outermost layer inward, and each layer runs relative to that cascade — which means the stacking order determines what each layer sees and whether it runs at all.
Bug one — caching outside auth: Cache(Auth(core)). The cache is consulted before the auth decorator runs, so a cache hit returns another user's data to an unauthorized caller without any authorization check — a genuine security vulnerability. The fix is Auth(Cache(core)): authorize first, then consult the cache.
Bug two — retry inside rate-limiting: Retry(RateLimit(core)). Every retry attempt passes through the rate limiter and counts against the client's quota, so a burst of transient failures makes the client self-throttle and consume its own allowance on attempts that were never real requests. The fix is RateLimit(Retry(core)): one logical call counts once, and retries happen beneath the limit.
The rule: outer layers see the logical operation, inner layers see the physical one — so place each concern by asking which it belongs to. Logical concerns (authorization, rate-limiting, deduplication, request tracing, idempotency) go outside; physical concerns (retry, timeout, connection handling, per-attempt logging) go inside.
A corollary that trips people: retry-versus-logging is not a bug either way but a choice — Log(Retry(core)) logs once per logical request, Retry(Log(core)) logs once per attempt, and you pick based on which operational question you are answering. Because order is behaviour, at least one test should assert the assembled stack's ordering, not just each decorator in isolation.
AppliedShow the functional form of Decorator and say when you prefer it over the class form.
When the component is a single function, a decorator is a higher-order function that takes the next layer and returns a function of the same signature: const withRetry = (max) => (next) => async (...args) => { for (let i = 1; i <= max; i++) { try { return await next(...args); } catch (e) { if (i === max) throw e; await sleep(backoff(i)); } } }. Composition is then compose(withLogging, withRetry(3), withTimeout(5000))(rawFetch), which reads as a pipeline and produces a function with the original signature.
Prefer the functional form when the interface is one function and the decorators are stateless or close over their own state — it has zero class ceremony, the composition is a data operation you can build from a runtime-ordered array (middlewares.reduceRight((next, m) => m(next), core)), and it is exactly the shape of Express handlers, Koa middleware, Redux middleware, and function interceptors, so it matches idioms your teammates already know.
Prefer the class form when the component interface has several methods (a base decorator delegating all of them lets each concrete decorator override only the one it changes — without it, every functional wrapper would have to re-wrap every method), when decorators carry non-trivial mutable state with a lifecycle (a cache with eviction, a circuit breaker with a state machine), or when the codebase's discovery conventions lean on named classes.
The deciding question is 9.4.1's: the pattern is the role structure (same-interface wrapping), not the class count — so use whichever spelling makes the wrapping visible with the least ceremony for the interface you actually have. In TypeScript that is usually the function for one-method components and the class for many-method ones.
InterviewDecorator and Proxy both wrap an object and keep its interface. How do you tell them apart, and when is a cache which?
They are structurally identical — a wrapper that implements the same interface as the object it holds — and they differ purely in intent, which is why the distinction is a favourite probe.
Decorator adds behaviour the client deliberately composed. The client wants retry, logging, and metrics, and assembles them; each layer augments the operation while still performing it.
Proxy controls access to the real object. The client wants the same object and the same behaviour; the proxy decides whether, when, and for whom the real call happens — lazy initialization (create the expensive object on first use), access control (check permission before delegating), remoting (the real object is on another machine), or protective short-circuiting.
The tells: a Decorator is usually one of several stacked layers chosen by the assembler; a Proxy usually stands alone and often manages the lifecycle of the thing it wraps (it may create it).
A cache can be either, and the framing decides. Framed as "I chose to add caching as one of several cross-cutting behaviours on this client", it is a Decorator — it sits in a stack alongside retry and logging. Framed as "access to this expensive resource is mediated, and the mediator returns a stored result instead of hitting the resource", it is a Proxy — specifically a caching proxy, whose job is controlling access to the costly backend.
In an interview, the correct move is not to pick one label dogmatically but to state the framing: "as a composable behaviour it's a Decorator; as access control over an expensive resource it's a Proxy — and here I'd call it a caching proxy because its purpose is to guard the backend, not to enhance the client." That answer demonstrates you understand both patterns are about intent, not structure.
StaffDesign a resilient outbound-call layer for a service that calls 15 downstream APIs, each needing some combination of timeout, retry with backoff, circuit breaking, rate limiting, caching, request logging, and distributed tracing — with different combinations and thresholds per downstream. Use Decorator, handle ordering, and say how you keep 15 configurations maintainable.
Decorator is the right spine, because the requirements are the pattern's exact tension: cross-cutting concerns, optional per downstream, combinable in different orders with different thresholds. Each concern is one decorator over a type Call = (req: Request) => Promise<Response>, implemented as functional decorators (section 5.1) since the component is a single async function — withTimeout(ms), withRetry(policy), withCircuitBreaker(policy), withRateLimit(policy), withCache(policy), withLogging(), withTracing().
Ordering is fixed by the logical-versus-physical rule (section 6) and encoded once, not left to each caller — because ad hoc per-downstream ordering is how the security and quota bugs of section 6 get shipped. The standard outbound order, outermost to innermost: tracing → logging → cache → rate-limit → circuit-breaker → retry → timeout → raw call. The reasoning per boundary: tracing outermost so the span covers everything including waits; cache above rate-limit so a cache hit costs no quota; rate-limit above circuit-breaker and retry so one logical call counts once and retries do not consume quota; circuit-breaker above retry so an open breaker fails fast without retrying a downstream that is known to be down (retrying into an open breaker is a classic amplification bug — 10.9); timeout innermost, wrapping the raw call, so each attempt is individually bounded and retry can react to a timeout.
Maintaining 15 configurations: do not hand-assemble 15 stacks. Build a single factory that takes a per-downstream config and produces the stack — createClient(config: DownstreamConfig): Call — applying the fixed order and skipping layers whose config is absent (config.cache ? withCache(config.cache) : identity). The 15 downstreams become 15 data entries (Record<Downstream, DownstreamConfig>), each declaring its thresholds — retry counts, breaker error rates, cache TTLs, rate limits — and the assembly logic exists once. This turns "add a downstream" into a data edit and "change the standard resilience order" into a one-function change affecting all 15 consistently.
Per-downstream state isolation is critical and easy to get wrong: the circuit breaker, the cache, and the rate-limiter each hold state, and that state must be per downstream (one breaker per API, not shared) but shared across all callers of that downstream (so the breaker sees the aggregate error rate). So the stateful decorators are constructed once per downstream at the composition root and captured in the factory's closure, never per request.
Observability falls out because tracing and logging are already layers: every call emits a span with the downstream name and the outcome of each layer (cache hit or miss, breaker state, attempt count), which is exactly the data you need to tune the thresholds.
The design sentence: the concerns are decorators, the order is fixed once by the logical-versus-physical rule, the 15 downstreams are configuration not code, and the stateful layers are constructed per downstream at startup so their state is shared across callers but isolated across downstreams.
Flashcards
FlashDecorator in one line
Wrap an object in a same-interface layer to add behaviour; compose concerns at runtime in any combination. Replaces 2ⁿ subclasses.
FlashThree positions a layer acts in
Before delegating (validate, auth), after (log, transform, measure), or instead of (a cache hit short-circuits the core). Retry acts around (repeats).
FlashThe order rule
Outer = logical operation, inner = physical. Auth/rate-limit/dedup outside; retry/timeout inside. Cache outside auth = data leak; retry inside rate-limit = quota bug.
FlashTransparency contract
Add behaviour, never alter it. Same interface in and out; log then rethrow, never swallow; don't change the return type or drop methods.
FlashFunctional decorator
(next) => (args) => { ...before; const r = await next(args); ...after; return r }. Express/Redux middleware are exactly this. Prefer for single-function components.
FlashDecorator vs Proxy
Same interface. Decorator adds behaviour the client composed; Proxy controls access (lazy, auth, remoting). A cache is either — state the framing.
Scenario Drill
DrillA data pipeline reads 10 GB CSV files from S3, decompresses, validates each row, transforms, and writes to a database, and must handle files that don't fit in memory, report progress, be resumable, and let new transform steps be added without rewriting the pipeline. Design it with Decorator (stream decorators), and say how this differs from the HTTP-client decorator case.
This is Decorator in its streaming form, and the framing that makes it click is that a Node stream pipeline is a decorator stack where each layer wraps the previous stream and the data flows through as chunks rather than as one call. The pipeline is assembled as pipeline(source, ...transforms, sink) where each stage implements the same stream interface (Readable, Transform, Writable — 3.8.4): s3ReadStream(key) → gunzip() → csvParse() → validateRows() → transform() → dbWriteStream(). Each transform is a decorator over the stream — it consumes chunks from the layer before, does its bit, and emits to the layer after, exactly the before/after/instead structure of section 5 but applied per chunk.
The memory constraint is why it must be streaming decorators rather than the HTTP-client shape, and this is the key difference from the HTTP case: the HTTP decorators wrap a single request/response call and the whole response fits in memory; here the data is 10 GB and never materialises fully — the decorator stack processes a bounded window of chunks at a time, and backpressure propagates through the stack automatically (if the database write is slow, the sink's buffer fills, which pauses the transform, which pauses the parser, which pauses the S3 read — so memory stays bounded without any layer coordinating explicitly). That automatic backpressure is a property streams give the decorator stack that the HTTP call decorators do not need and do not have.
Progress reporting is itself a decorator: a pass-through Transform that counts bytes and rows and emits progress events without altering the data — a textbook transparent decorator (adds behaviour, changes nothing).
Resumability is handled by making the source decorator range-aware: s3ReadStream(key, { startByte }) resumes from a checkpoint the sink records (the last committed offset), so a crash restarts mid-file rather than from zero — the decorator's configurability carrying a real operational requirement.
Extensibility — "add transform steps without rewriting" — is the pattern's core payoff: a new step (say, PII redaction, or enrichment from a lookup table) is a new Transform decorator inserted into the pipeline(...) array at the right position; no existing stage changes, because every stage shares the stream interface.
Ordering still matters, the same rule as section 6: decompress before parse (you cannot parse gzip), validate before transform (do not transform garbage), redact before write (never persist unredacted PII) — logical-versus-physical reasoning applied to data flow.
Error handling differs from the HTTP case in an important way: pipeline() (not manual .pipe()) is mandatory, because it propagates errors and destroys every stream in the stack on failure — a decorator that threw without cleanup would leak file handles and sockets; the transparency contract here includes resource cleanup, which the one-shot HTTP decorators did not have to worry about.
The comparison sentence: the HTTP client and this pipeline are the same pattern — same-interface wrappers composed at runtime — but the streaming form adds two properties the call form lacks: data flows as chunks so nothing must fit in memory, and backpressure plus resource cleanup propagate through the stack automatically, which is exactly why large-data work reaches for stream decorators rather than wrapping one big call.