Decorator wraps an object in another object with the same interface, adding behaviour on the way in or out. It is the pattern that answers "how do I add retries, caching, logging and authorisation to this without editing it and without eight subclasses".
You have already used it extensively: HTTP middleware, stream wrappers and the interceptor chains in most RPC frameworks are all Decorator.
The problem inheritance cannot solve
A service call may need retries, a cache, timing metrics and an authorisation check. Any combination may be wanted.
class UserService {}
class CachedUserService extends UserService {}
class RetryingUserService extends UserService {}
class CachedRetryingUserService extends CachedUserService {}
// ...and RetryingCachedMetricUserService, and so onFour independent concerns produce sixteen subclasses. Five produce thirty-two. This is the combinatorial explosion that motivates composition over inheritance, in its purest form.
Diagram, as text
- Caller leads to withMetrics
- withMetrics leads to withRetry
- withRetry leads to withCache
- withCache leads to UserService the real work
- UserService the real work leads to withCache
- withCache leads to withRetry
- withRetry leads to withMetrics
- withMetrics leads to Caller
Each wrapper has the same interface as what it wraps, so the caller cannot tell how deep the stack is — and each layer is written and tested once, independently.
The implementation
In a language with first-class functions, a decorator is a function that returns a function:
const withRetry = (fn, attempts = 3) => async (...args) => {
for (let i = 0; i < attempts; i++) {
try { return await fn(...args); }
catch (err) {
if (i === attempts - 1 || !isRetryable(err)) throw err;
await sleep(backoffMs(i));
}
}
};
const withMetrics = (fn, name) => async (...args) => {
const start = performance.now();
try { return await fn(...args); }
finally { metrics.observe(name, performance.now() - start); }
};
const fetchUser = withMetrics(withRetry(rawFetchUser), 'fetch_user');In a language without them, the same structure is a class holding the wrapped object and forwarding each method. The structure is identical; only the ceremony differs.
Order is behaviour, not style
This is the part that is routinely got wrong, and it is the reason to read a decorator stack carefully.
withMetrics(withRetry(f)) — metrics is outermost, so it observes one duration covering all three attempts. You measure what the caller experienced.
withRetry(withMetrics(f)) — retry is outermost, so metrics records three separate durations. You measure what the downstream service did.
Neither is wrong; they answer different questions. But a team that believes it is measuring caller-visible latency while the stack is built the other way will have a dashboard that quietly disagrees with its users.
The same applies elsewhere:
- Cache inside retry means a retry can be served from cache — usually pointless, since a cached value would have succeeded the first time.
- Cache outside retry means a hit skips retrying entirely, which is what you want.
- Authorisation inside caching means an unauthorised caller can be served another user's cached result. That is a security bug produced purely by ordering.
Decorator versus middleware versus proxy
Middleware is Decorator applied to a request pipeline, usually assembled from configuration rather than in code. The next() function is the wrapped object.
Proxy has the same shape but a different intent: it controls access to a subject — lazily creating it, checking rights, forwarding it over a network — rather than adding behaviour around a call. In practice the boundary is fuzzy, and it matters less than naming what your wrapper does.
Aspect-oriented programming achieves similar ends by weaving behaviour in at compile or load time. It removes the visible wrapping, which is both its appeal and its problem — the behaviour applies without the reader seeing anything.
Where it goes wrong
Depth. Six wrappers deep, nobody can say what actually runs, and a stack trace is mostly framework frames. Three is comfortable; beyond five, treat it as a smell.
Silent behaviour changes. A decorator that swallows exceptions or returns a default on failure changes the contract without changing the interface. The type says nothing has changed and everything has.
Assembly scattered around. If wrappers are applied in different orders in different places, the ordering rules above are violated somewhere and you cannot tell where. Build the stack once, at the composition root.
Statefulness. A decorator holding a cache is stateful, and if the stack is rebuilt per request the cache is empty every time. Decorators that hold state must be constructed once and shared, which is a different lifetime from the stateless ones.
Check your understanding
Common mistakes
Assembling the stack in several places. Ordering rules then hold in some paths and not others, invisibly.
Putting a cache outside an authorisation check. A data-exposure bug created purely by arrangement.
Measuring inside a retry while claiming to measure caller latency. The dashboard understates real experience.
Decorators that swallow errors. The interface is unchanged and the contract is not — callers cannot see that failure now returns a default.
Stacking too deep. Past five layers, no reader can say what runs, and stack traces stop being useful.
Rebuilding stateful decorators per request. A cache constructed per call is not a cache.
Using Decorator for a concern that is not orthogonal. If two wrappers must know about each other, they are one thing wearing two names.
Practice
Design the decorator stack for an internal API client that needs authorisation, request-level caching, retries, a circuit breaker, timing metrics and structured logging. Give the order and justify each adjacency.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Work from the outside in, asking at each step what must not be skipped and what must not be repeated.
Show a strong answer
Order, outermost first: authorisation → logging → metrics → cache → circuit breaker → retry → transport.
Authorisation outermost. It must never be skipped, and every layer below can return a result without touching the transport. This is the only non-negotiable position in the stack.
Logging next. It should record the request as the caller made it, including requests that were served from cache or rejected by the breaker. Placed lower, cache hits become invisible and the logs suggest traffic that never left the process.
Metrics next. Outside cache and retry, so the recorded duration is what the caller actually waited — including cache hits, backoff and breaker rejections. I would additionally emit a low-level metric from inside retry, because the two numbers answer different questions: caller-visible latency and downstream service latency. Trying to get both from one position is the mistake behind most misleading dashboards.
Cache above the breaker and retry. A hit should skip both — there is nothing to retry and no reason to consult the breaker for a call that is not being made. Cache below them would mean serving from cache only after the failure machinery has run, which is exactly backwards.
Circuit breaker above retry. This adjacency is the one people get wrong, and the Circuit Breaker topic works through the breaker's own design. If retry is outside the breaker, each retry is a separate attempt that the breaker sees and rejects individually, and worse, the retry loop can hammer a service the breaker is trying to protect. Breaker outside means one rejection ends the call immediately, which is the point of having a breaker.
Retry innermost, above the transport. It should see only genuine transport-level failures, and only ones it can classify as retryable.
Two things I would insist on beyond the ordering:
- Build this in one line at the composition root, so a reviewer can check every adjacency above by reading it. Scattered assembly makes these rules unverifiable.
- Only retry idempotent operations. A retry decorator applied uniformly to an interface containing a non-idempotent write will duplicate charges. Either the interface carries the information, or the retry decorator is applied per operation rather than per client.
A colleague proposes a `withFallback` decorator that catches any error and returns a cached-or-empty result, so the service 'never fails'. Evaluate it.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask what the caller can distinguish after the wrapper is applied.
Show a strong answer
The mechanism is sound and the contract is the problem. The interface still says "returns a list of recommendations"; the behaviour now says "returns a list of recommendations, or an empty one, and you cannot tell which". Every caller's code is unchanged and every caller's meaning has changed.
What breaks:
- Empty becomes ambiguous. "This user has no recommendations" and "the recommendation service is down" are now the same value. A page renders "nothing for you" during an outage, which is a worse user experience than an error and, unlike an error, generates no alert.
- Failures stop being observable. Metrics count successes; the breaker never opens; the on-call dashboard is green through an outage. The wrapper does not remove failure, it removes evidence of failure.
- It composes badly upward. A caller that caches the response now caches the fallback. A caller that writes it back to a store persists emptiness as fact — and that is the version of this that causes real damage.
Where the idea is right. Graceful degradation is genuinely valuable for non-essential data: a recommendations strip, a "people also viewed" panel, a trending list. The failure there really is "show less", and users prefer that to an error page.
What I would ask for instead:
- Make the degradation visible in the return type.
{ items, degraded: true }, or a result type that distinguishes success from fallback. Callers that do not care ignore the flag; callers that must not persist a fallback can check it. This single change removes almost all the risk. - Emit a metric and keep the error flowing to the breaker. The fallback should suppress the failure for the user, not for the system.
- Apply it per operation, not to the whole client. A blanket wrapper will eventually cover an operation where an empty result is a correctness bug — a permission list, a balance, an inventory count. Those must fail loudly.
- Name it for what it does:
withEmptyOnFailureis honest;withFallbackinvites people to assume it returns something meaningful.
Verdict: accept it for explicitly non-essential reads, with a degraded flag, a metric and per-operation application. Reject it as a default wrapper on the whole client.
Key takeaways
- Decorator adds behaviour by wrapping an object with the same interface, so callers cannot tell how deep the stack is.
- It solves the combinatorial explosion that independent concerns cause under inheritance — linear wrappers instead of exponential subclasses.
- Order is behaviour. Metrics inside retry measures attempts; outside, it measures caller experience.
- Authorisation must sit outside anything that can return a result — cache, batching, memoisation. Getting this wrong exposes data.
- Circuit breaker outside retry, or the retry loop hammers a service the breaker is trying to protect.
- Assemble the stack once, in one visible line, so the ordering can be reviewed.
- Watch for depth beyond five layers, error-swallowing wrappers, and stateful decorators rebuilt per request.
Further reading
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
- Michael Nygard, Release It!, second edition (Pragmatic Bookshelf, 2018) — circuit breakers, timeouts and their interaction with retries.
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002).
- Gregor Kiczales and others, "Aspect-Oriented Programming" (ECOOP, 1997) — the alternative approach to cross-cutting concerns.
- Brendan Burns, Designing Distributed Systems (O'Reilly, 2018) — the sidecar as a decorator at the process level.