Decorator pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- A decorator wraps an object in another object with the same interface, adding behaviour before or after delegating: caching, retries, metrics, authorisation, compression, buffering. Callers cannot tell the difference, which is the point.
- Decorators compose.
Cached(Retry(Client))andRetry(Cached(Client))are both valid and behave differently, so the order of wrapping is a design decision, not a detail. - The pattern replaces subclass explosions (
CachedRetryingMeteredClient) with small pieces that combine at wiring time. HTTP middleware, gRPC interceptors,bufio.Readerand Python's@functools.cacheare all decorators. - The costs: one indirect call per layer, lost identity (type checks and optional interfaces stop working through the wrapper), and stateful wrappers that need their own concurrency control.
- Agents tend to inline cross-cutting concerns (a retry loop pasted into every client method). In review, ask for one decorator per concern, a single place where they are stacked, and a test that pins the order.
How it works
The problem
A pricing service calls an upstream catalogue API to get a product's price. Over a year the client grows: a retry loop for timeouts, an in-memory cache, a latency histogram, a circuit breaker. Each lands inside CatalogClient.get_price, which is now 140 lines, and the next team that calls the same upstream from a batch job copies half of it. Turning off the cache in tests needs a flag. Nobody is sure whether a cache hit is counted in the latency metric.
The decorator answer: keep CatalogClient doing one thing (an HTTP call), and add each concern as a wrapper that implements the same PriceSource interface:
class Retry:
def __init__(self, inner, attempts):
self.inner, self.attempts = inner, attempts
def fetch(self, key):
for i in range(self.attempts):
try:
return self.inner.fetch(key)
except TransientError:
if i == self.attempts - 1:
raise
source = Metered(Cached(Retry(CatalogClient(url), 3), clock, ttl=30))Every wrapper is tested alone with a fake inner source. The batch job builds a different stack (no cache, more retries) from the same parts.
Order is behaviour
Read a decorator stack from the outside in: the outermost wrapper sees every call first. In Metered(Cached(Retry(client))) the histogram includes cache hits, so its p50 collapses toward zero when the hit ratio is high; in Cached(Metered(Retry(client))) it measures only upstream calls. Retry outside a cache means a miss is retried; retry inside means the cache never stores a failure, which is what you usually want. Authorisation must sit outside caching, or one user's cached response can be served to another. Write the order down in one wiring function and add a test for the property that depends on it.
Function decorators
Python's @decorator syntax applies the same idea to functions: @cache above def f is shorthand for f = cache(f). HTTP middleware in Go has the type func(http.Handler) http.Handler, Express middleware calls next(), Django middleware wraps get_response, and gRPC interceptors receive the next handler as an argument. These are decorators whose interface is "a function that handles a request". The same ordering rules apply.
Decorator versus inheritance
Subclassing adds behaviour too, but statically and one combination at a time. Three optional concerns give eight subclasses; with decorators they are three classes chosen at runtime. Decorators also work on objects you did not create, such as a client handed to you by a library. Inheritance remains fine when one variant is fixed and the base class was designed to be extended.
Decorator, proxy, adapter
A proxy has the same structure (a wrapper with the same interface) but controls access to the object rather than adding features: lazy loading, remote access, permission checks. Many real wrappers are both. An adapter changes the interface; a decorator must not. If your "decorator" adds a public method callers depend on, it has become something else and stacking breaks.
Reviewing agent-written code
Asked to "add retries to the catalogue client", an agent usually writes a for loop inside get_price, and does the same in get_stock and get_description on the next request. Steer it to one Retry wrapper over the interface. Then check what the wrapper retries (only errors marked transient, never a 400 or a validation error), whether the operation is idempotent, how many layers already retry, and that functools.wraps or an equivalent keeps names and docstrings for function decorators.
The same shape elsewhere
Decorators handle the overload and partial failure shapes as composable layers. The same layering appears in network stacks (TLS wraps TCP; HTTP/2 framing wraps TLS), in Linux's block layer (dm-crypt and dm-cache are device-mapper targets stacked over a disk, each presenting a block device) and in service meshes, where Envoy filter chains apply rate limiting, authentication and retries to every request in a configured order.
Down the stack
Layer 1The contract
A decorator promises to implement the wrapped interface's full contract and to change behaviour only in the documented way. A caching decorator promises results at most ttl old and that failures are not cached unless documented; a retrying decorator promises a maximum number of attempts and that it retries only idempotent operations on transient errors. What a decorator does not preserve must be stated: object identity changes, so isinstance, ==, Go type assertions and reflection see the wrapper, not the original.
Layer 2The mechanism
PEP 318 defines @d above a function as f = d(f) evaluated at definition time, and stacked decorators apply bottom-up, so the one nearest def is innermost. functools.wraps copies __module__, __name__, __qualname__, __doc__ and __dict__ from the wrapped function and sets __wrapped__, which inspect.signature follows to report the original signature. functools.lru_cache keeps a dict from argument key to a node in a circular doubly linked list for recency.
In Go, bufio.NewReader(r) returns a struct holding the inner io.Reader, a 4096-byte buffer and read and write offsets; gzip.NewReader, io.LimitReader and io.TeeReader stack the same way. HTTP middleware that wraps http.ResponseWriter to record the status code hits a known trap: the wrapper hides optional interfaces such as http.Flusher and http.Hijacker, so streaming and websockets break silently. Go 1.20 added http.ResponseController, which looks for an Unwrap() http.ResponseWriter method to reach the original writer through the decorators.
Layer 3The machine
Each layer costs a call frame and, in Go, an indirect call through an itab, about 1 to 2 ns when predicted. Ten middleware layers add perhaps 20 ns and ten stack frames, invisible next to a 1 ms request. In CPython a generic def wrapper(*args, **kwargs) builds a tuple and a dict on every call and costs roughly 100 ns per layer, which shows up in tight loops of millions of calls. Go closures that capture variables escape to the heap once, when the middleware is built, not per request. In V8, a call site that always sees the same wrapper class stays monomorphic and can be inlined.
Some decorators save far more than they cost. Reading a file one byte at a time through an unbuffered reader makes one read syscall per byte, roughly 200 to 500 ns each; bufio.Reader turns 4096 of those into one syscall and 4095 memory copies from the buffer. A cache decorator turns an 80 ms network call into a hash lookup of under 100 ns.
Stateful decorators carry concurrency costs. A cache needs a lock or a concurrent map, and without request coalescing a popular key that expires sends every concurrent caller upstream at once (a stampede). Go's singleflight package and similar tools let one caller fetch while others wait. Python's lru_cache keeps its internal structure consistent under threads but documents that the wrapped function may be called more than once for the same key when calls overlap.
Why it's built this way
- Cross-cutting concerns (caching, retries, metrics) apply to many components, so writing them inside each one duplicates code.
- Subclassing for every combination of concerns grows exponentially, so concerns become separate objects.
- Keeping the same interface lets any component be wrapped without its callers changing.
- Each wrapper is one indirect call, cheap next to the I/O most wrappers guard.
- Because order changes behaviour, the stack is built in one place and tested as a whole.
Diagram
sequenceDiagram
participant C as Caller
participant M as Metered
participant K as Cached
participant R as Retry
participant U as CatalogClient
C->>M: fetch("sku-1")
M->>K: fetch("sku-1")
K->>K: miss or expired
K->>R: fetch("sku-1")
R->>U: attempt 1
U-->>R: TransientError
R->>U: attempt 2
U-->>R: 1999
R-->>K: 1999
K->>K: store with timestamp
K-->>M: 1999
M-->>C: 1999 (latency recorded)Worked example
A pricing service handles 12,000 requests a second at peak, and each request needs prices for about 8 products. The catalogue upstream has a p99 of 80 ms and fails about 0.5% of calls with timeouts. The original client called upstream 96,000 times a second and retried inside every method.
The team split it into CatalogClient, Retry(attempts=3, transient only), Cached(ttl=30s) and Metered, wired as Metered(Cached(Retry(client))) with a separate upstream histogram inside the retry layer. The cache hit ratio settled at 93%, cutting upstream traffic to about 6,700 calls a second and request p99 from 95 ms to 22 ms.
Then an incident: the upstream degraded and error rates hit 40%. The pricing service retried 3 times, but so did the API gateway in front of it and the mobile client, so each user action could produce up to 27 upstream calls. The fix was a decorator, not a rewrite: a RetryBudget wrapper that allows retries only while they stay under 10% of recent requests, placed inside the cache, plus removing retries from the gateway. The order of layers was then pinned by a test that counts upstream calls for a scripted failure sequence.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Logic inside the class | Simple to read for one concern | Duplicated across classes; hard to turn off in tests | One component, one concern |
| Subclass per combination | Static and explicit | Combinatorial growth; fixed at compile time | One fixed variant of a class designed for extension |
| Object decorators | Compose at runtime; test each alone | Order bugs; identity lost; more objects | Several concerns over the same interface |
| Function decorators and middleware | Concise; framework support | Hidden control flow; signature loss without wraps | Request pipelines and pure functions |
| Sidecar or service mesh filters | Language-agnostic; ops-controlled | Network hop; config drift from code | Policy shared by many services |
Pitfalls in production
- Retries stacked at several layers multiplying load during an outage. Fix: retry in one layer, add a retry budget, and retry only idempotent operations.
- Caching above authorisation, serving one user's data to another. Fix: authorise outside the cache or include the principal in the cache key, and test it.
- Wrappers that hide optional interfaces (
http.Flusher,io.WriterTo), disabling streaming or zero-copy paths. Fix: implementUnwrap, or forward the optional interfaces explicitly. - Caching failures, so a 2-second blip becomes a 30-second outage. Fix: cache only successes, or cache errors for a much shorter time.
- Cache stampedes when a hot key expires. Fix: coalesce concurrent misses and add jitter to TTLs.
- Function decorators that drop names and signatures, breaking logs, docs and frameworks that inspect parameters. Fix:
functools.wraps.
Leadership lens
- Ask for one wiring function per client that shows the whole stack in order. It is the page an on-call engineer reads during an incident.
- Decide where retries live across the whole request path (client, gateway, service, library) and write it down. Retry amplification is an organisational problem before it is a code problem.
- Prefer shared, well-tested decorators for caching, retries and metrics over per-team versions. Reviewing one retry implementation is cheaper than reviewing twelve.
- Treat a decorator order change like a behaviour change: it needs a test and a release note.
Check yourself
Why does
Metered(Cached(x))report a lower median latency thanCached(Metered(x))?Show answer
In the first, the meter is outside the cache and records cache hits, which are nearly instant; in the second it records only calls that reached
x.Why should retry usually sit inside a cache rather than outside it?
Show answer
Inside, the cache stores only the final successful result and a hit skips retrying entirely; outside, a cached error or a miss can trigger retries of a layer that already handles freshness.
What does wrapping a Go
http.ResponseWriterbreak, and how is it fixed?Show answer
Type assertions for optional interfaces such as
http.Flusherfail on the wrapper. ImplementUnwrap()sohttp.ResponseControllercan reach the original, or forward the interfaces explicitly.When is a subclass better than a decorator?
Show answer
When there is one fixed variant, the base class was designed for extension, and the behaviour needs access to protected internals that a wrapper cannot reach.
Watch
- Decorator Pattern (Christopher Okhravi): the Gang of Four structure built up from a subclass explosion.
- 10 Design Patterns Explained in 10 Minutes (Fireship): decorator alongside the other common patterns, with JavaScript examples.
Free resources
- The Decorator Pattern (Brandon Rhodes, python-patterns.guide): the object pattern contrasted with Python's
@decoratorsyntax, and when each fits. - PEP 318: Decorators for Functions and Methods: the definition of Python's decorator syntax and its evaluation order.
- functools (Python docs):
wraps,cacheandlru_cache, including the thread-safety note. - bufio (Go docs): the buffering decorator over
io.Readerandio.Writer. - gRPC interceptors (gRPC docs): decorators for RPC clients and servers and how they are chained.
- Writing middleware (Express docs): the
next()style of request decorators and why order matters.