Decorator
Add behaviour by wrapping rather than by editing. Excellent for logging, caching and retry; the cost is a stack trace nobody can read and behaviour that depends on wrapping order.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
I need logging, caching and retry around a client. Do I put them inside it, wrap it, or leave them at the call site?
The pricing client needs request logging for support, a short-lived cache because the same SKU is priced repeatedly in one request, and retry with backoff because the upstream is occasionally slow. None of the three is wanted in tests.
Put all three inside the client. It is one class, everything is in one place, and there is no wrapping order to reason about.
The client now has four reasons to change — pricing protocol, log format, cache policy, retry policy — and each one redeploys the other three (Divergent Change).
- The client now has four reasons to change — pricing protocol, log format, cache policy, retry policy — and each one redeploys the other three (Divergent Change).
- The reconciliation job cannot opt out of the cache without a flag, and the flag has to be threaded through every call (Boolean Parameters).
- Testing pricing logic now involves a cache and a retry loop, so tests are slow and occasionally flaky for reasons unrelated to what they assert (Testing as Design Feedback).
- Retry is applied uniformly, including to the one non-idempotent operation, and the bug is a duplicate charge that appears only under load (Partial Failure).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The pricing client is used by four callers and one of them — the reconciliation job — must not use the cache (Cache Invalidation, Stampedes and Hot Keys).
- Retry must not apply to non-idempotent operations, and the client has one of those (Idempotency by Design).
- Support needs the log line to include the caller, which the client does not know.
- A cached price is never returned for a request whose inputs differ, including currency and date (TTL and Expiry).
- Retry never turns one charge into two (Retries Are a Property of the Operation).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each concern owns itself: one wrapper for logging, one for caching, one for retry, each with one reason to change.
- The core client owns the protocol and nothing else — it should be the piece with no operational concerns in it (Functional Core, Imperative Shell).
- Composition order is owned by the wiring, which is where the decision "retry outside cache, not inside" is made and should be written down (Wiring and the Composition Root).
- The shared interface is the seam that makes wrapping possible, and every wrapper must be substitutable for the thing it wraps — this is the pattern's hard requirement (Liskov Substitution, Critically).
- The boundary is only as good as the interface's narrowness: wrapping a fourteen-method interface means writing fourteen pass-through methods per decorator (Interface Versus Implementation).
- Per-caller composition is the point. The reconciliation job gets a differently-wrapped instance, and nothing in the code branches on who is calling (Dependency Injection).
Three concerns, three wrappers, one interface
The requirement is what makes this the right pattern: three concerns that are genuinely orthogonal to pricing, that different callers want differently, and that nobody wants in a test. Any of those three conditions missing and the wrappers are ceremony.
Note that each wrapper is boring and short. A decorator with a branch in it is usually two decorators, and a decorator that reaches into the inner one's state is not a decorator.
1interface PricesSkus { price(sku: Sku, at: Date): Promise<Money> }2 3class CachingPrices implements PricesSkus {4 constructor(private inner: PricesSkus, private ttl: Duration) {}5 async price(sku: Sku, at: Date) {6 const key = `${sku}:${at.toISOString()}` // inputs, all of them7 return this.cache.getOrSet(key, this.ttl, () => this.inner.price(sku, at))8 }9}10 11class RetryingPrices implements PricesSkus { /* backoff around inner */ }12class LoggingPrices implements PricesSkus { /* one line in, one out */ }13 14// wiring/pricing.ts — the composition IS the decision15export const pricesFor = (caller: Caller) =>16 new LoggingPrices(caller,17 new RetryingPrices(18 caller === 'reconciliation'19 ? rawPrices // no cache for this one20 : new CachingPrices(rawPrices, minutes(5))))Retry sits outside the cache, so a retried call can be served from cache and failures are not cached. Reverse the two lines and both statements become false — with no other change and no test failure.
What a call actually goes through
This is the cost, drawn out. One logical call is four frames down and four frames back, each with its own failure behaviour, and a support question like "why did this price take nine seconds" cannot be answered from any single layer.
The mitigation is not to avoid decorators — it is to keep the stack shallow enough to hold in your head, and to make each layer say who it is in the one log line it emits (Stable Identifiers).
- 1LoggingPrices
Records the call with the caller id and a correlation id
fails by Logging the arguments including something sensitive, or emitting a line per retry and quadrupling log volume (Sensitive State).
- 2RetryingPrices
Calls inward; on a retryable failure, backs off and repeats
fails by Retrying a non-idempotent operation, or swallowing the final error so the caller sees a default price (Swallowed Errors).
- 3CachingPrices
Returns a stored value if the key matches and the TTL holds
fails by A key that omits an input — currency, date, tenant — so one caller gets another's answer (Cache Invalidation, Stampedes and Hot Keys).
- 4HttpPrices (the core)
Speaks the actual protocol and returns a price
fails by Timing out, which is the failure the three layers above exist to shape into something callers can handle.
- 5Unwinding
Value passes back up, each layer possibly transforming it
fails by A layer that transforms the value rather than observing it — this is where substitutability quietly breaks (Liskov Substitution, Critically).
Four layers is about the limit of what a person can reason about under incident pressure. The reason middleware frameworks present this as a flat ordered list rather than a nesting is precisely that the list can be read in one glance (Middleware Ordering Is a Correctness Decision).
Wrapping against the alternatives
The choice is rarely decorator-or-nothing. Four options do this job and the right one depends on how many callers need different combinations and how much you value being able to read the order.
The scores below are relative and deliberately unflattering to decorators on the axis that matters most in an incident.
| Option | Simplicity | Flexibility | Testability | Operational | Note |
|---|---|---|---|---|---|
| Inside the client | One place to look, four reasons to change, and no caller can opt out. Fine for exactly one caller with fixed policy. | ||||
| At each call site | Most readable and most debuggable — the whole behaviour is at the call. Diverges once there are more than about three call sites (Duplicate Knowledge). | ||||
| Decorator stack | Per-caller composition, one reason to change per wrapper, and a stack trace that costs you an afternoon during an incident. | ||||
| Middleware pipeline | Same composition, flat ordered list, one place to read it. The better shape where a framework offers it (The Middleware Pipeline). |
caveat The operational scores are the ones to take seriously and the hardest to justify numerically: they are a claim about how long a debugging session takes at 3am, which no benchmark measures. The simplicity scores also hide a threshold effect rather than a gradient — a two-layer stack is nearly free to read and a six-layer one is qualitatively different, and the score cannot express where between them your codebase sits. Finally, none of these numbers account for the framework you are already in: inside Express or ASP.NET the middleware option costs almost nothing extra, and outside one it means building the pipeline yourself (What a Framework Charges).
How to build it
Most important first.
- Wrap only cross-cutting concerns that are genuinely orthogonal to the core behaviour: logging, caching, retry, metrics, authorization checks.
- Keep the order explicit and written down. Retry outside cache means a retried call can hit the cache; cache outside retry means failures are never cached. Both are defensible and the difference is invisible in the code (Temporal Coupling).
- Give each wrapper the narrowest possible responsibility, so that reading its name tells you the whole of what it does.
- Make the composition visible in one place — the wiring — rather than assembled across a container's configuration where nobody can see the stack (Service Locator).
- For method-level variation (retry this call but not that one), decorators are the wrong tool: they apply to the whole interface. Split the interface or handle it at the call site (Interface Segregation, Critically).
- Consider the simpler alternative first: a higher-order function, or middleware in a pipeline, gets the same composition with a flatter stack and an explicit, readable order (The Middleware Pipeline).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Named change — "add metrics to the pricing client": one new wrapper, one wiring line, nothing existing opened. This is what the pattern buys and it is a genuine, repeated saving.
- Named change — "the reconciliation job must bypass the cache": one different composition in the wiring. Under the all-in-one-client design this is a flag threaded through every call.
- Named change — "add a method to the interface": now *every decorator* must implement it, usually as a pass-through. The cost of adding an operation grows linearly with the number of wrappers, and this is the pattern's hidden tax (API Stability).
- Named change — "find out why this call was slow": more expensive than before, permanently. Every debugging session crosses the stack, and no wrapper alone can answer the question (Local Reasoning).
- Traceability. This is the real price: deep stacks are hard to read, hard to debug and hard to profile, and no amount of naming discipline fully fixes it (Reading a Flame Graph).
- Every wrapper is a full implementation of the interface, so a wide interface makes decoration expensive to write and to maintain.
- Behaviour becomes a property of the wiring rather than of any class, which means the answer to "what does this do" is in a different file from everything else.
What can go wrong
- The stack gets deep. A five-layer stack means a stack trace with five near-identical frames and a
this.inner.inner.innerin the debugger, and finding which layer swallowed an error takes an afternoon (Debuggability by Design). - Order dependence bites: caching outside authorization means one user's price is served to another. This class of bug is severe, silent and entirely a product of composition order (Trust Boundaries).
- A decorator changes semantics rather than adding to them — a caching wrapper that returns stale data past its TTL, a retry wrapper that swallows the final error — and substitutability quietly fails (Swallowed Errors).
- The mitigation fails too: adding a "which layers are active" log line at every layer produces so much noise that the logs become unusable (Logging at Boundaries).
- Each decorator depends on the interface and on its own concern's machinery — a cache, a logger, a backoff policy — and on nothing else.
- Callers depend only on the interface and cannot tell how deep the stack is, which is the benefit and the debugging cost in the same sentence.
- The wiring depends on all of them, and the ordering decision lives there. That single file becomes surprisingly important and is rarely reviewed as such.
- "Decorators are a clean way to add features." They are a way to add *orthogonal* behaviour. Business behaviour added by wrapping is behaviour hidden from the reader of the core type, which is worse than editing it (Local Reasoning).
- "It follows open-closed, therefore it is better." Open-closed is a heuristic, not a justification. The question is still whether the next change is cheaper, and for a fourteen-method interface it may not be (Open/Closed, Critically).
- "Order does not matter much." Order decides whether authorization protects the cache, whether retries are cached, and whether metrics count retries as one call or four. It is the most consequential invisible decision in the pattern.
- "Use decorators instead of inheritance." Decorators require an interface and substitutability just as much as subclassing does; what they change is that composition happens at runtime and is per-instance (Composition Over Inheritance).
- divergent-change
- shotgun-surgery
Testing it, and how it ages
- Test each decorator against a stub inner: the retry wrapper with an inner that fails twice then succeeds; the cache wrapper with an inner that counts calls.
- Test the *composition* separately, because order-dependent behaviour is not visible in any single wrapper's tests — this is the test most teams skip and most need (Where a Test Must Be Real).
- A substitutability test: every wrapper must satisfy the same contract suite as the raw client (Contract Tests).
- Assert that retry is not applied to the non-idempotent operation, because that is a correctness invariant and not a performance detail.
- Stacks grow one wrapper at a time and are never pruned, because removing one requires knowing who relies on its side effects. Expect a five-year-old stack to have a layer nobody can explain.
- The common endgame is a move to middleware: same composition, explicit order, flat list, and one place to read the whole pipeline (Middleware Ordering Is a Correctness Decision).
- If two wrappers ever need to communicate — retry needs to know the cache was hit — the decoration model has run out and a pipeline with a shared context is the honest replacement.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- PARADIGM-SPECIFICWith first-class functions a decorator is a higher-order function —
withRetry(withCache(fetchPrice))— and the pattern is three lines with no classes and a visible order. Python's@decoratorsyntax is exactly this and is applied to functions rather than objects, which sidesteps the wide-interface problem entirely. - FRAMEWORK-SPECIFICMost frameworks provide this as middleware or interceptors (Express, ASP.NET, gRPC, Axum), where the order is a visible ordered list rather than a nesting expression. That is the same pattern with better ergonomics and better debuggability, and it is usually the right choice inside a framework that offers it (The Middleware Pipeline).
- CONTESTEDThe strongest argument against decorators for cross-cutting concerns is that they distribute one operational decision across N wrappers and a wiring file, and that a single explicit call site —
retry(() => cache(() => client.price(sku)))at the four places that need it — is more readable, more debuggable and no more code. The counter is that four call sites will diverge and the fifth will forget, which is the same duplicated-knowledge argument that motivates most of this domain (Duplicate Knowledge).
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — retry, timeout and circuit-breaking policy composed as wrappers is the most common real use of this pattern, and the place where composition order becomes a reliability decision rather than a stylistic one.