Engineering Atlas › Low-Level Design

Decorator pattern

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

  1. Cross-cutting concerns (caching, retries, metrics) apply to many components, so writing them inside each one duplicates code.
  2. Subclassing for every combination of concerns grows exponentially, so concerns become separate objects.
  3. Keeping the same interface lets any component be wrapped without its callers changing.
  4. Each wrapper is one indirect call, cheap next to the I/O most wrappers guard.
  5. 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

OptionStrengthsWeaknessesChoose when
Logic inside the classSimple to read for one concernDuplicated across classes; hard to turn off in testsOne component, one concern
Subclass per combinationStatic and explicitCombinatorial growth; fixed at compile timeOne fixed variant of a class designed for extension
Object decoratorsCompose at runtime; test each aloneOrder bugs; identity lost; more objectsSeveral concerns over the same interface
Function decorators and middlewareConcise; framework supportHidden control flow; signature loss without wrapsRequest pipelines and pure functions
Sidecar or service mesh filtersLanguage-agnostic; ops-controlledNetwork hop; config drift from codePolicy shared by many services

Pitfalls in production

Leadership lens

Check yourself

  1. Why does Metered(Cached(x)) report a lower median latency than Cached(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.

  2. 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.

  3. What does wrapping a Go http.ResponseWriter break, and how is it fixed?

    Show answer

    Type assertions for optional interfaces such as http.Flusher fail on the wrapper. Implement Unwrap() so http.ResponseController can reach the original, or forward the interfaces explicitly.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →