This website uses cookies

Read our Privacy policy and Terms of use for more information.

Consider a payments client with a retry layer, a cache and request logging. During a refactor, someone swaps the order of two wrapper lines. Nothing fails and the tests pass. Two weeks later the logs say the upstream API is hit once a minute, while the vendor's dashboard says three times as often. Both numbers are correct. The logging wrapper moved from outside the retry layer to inside the cache, so it now counts only calls that survive the cache. I reproduced the mechanics below in thirty lines, so you can see exactly why.

That is the real subject of the Decorator pattern. The mechanics take five minutes to learn. The part that costs you is that a stack of decorators is an ordered pipeline, and the order is behavior. If you treat the stack as a bag of independent features, you will ship bugs that no unit test on a single decorator can catch.

The pattern in one paragraph

A decorator implements the same interface as the thing it wraps, holds a reference to that thing, and forwards calls to it, adding behavior before or after. Because the interface is identical, callers cannot tell a bare object from one wrapped five layers deep, and you can stack wrappers in any combination at construction time. The alternative is inheritance, where every combination of features needs a subclass. Retry, cache and logging give you eight combinations. Add timeouts and metrics and you are at thirty-two. The Decorator entry on Refactoring Guru states the trade clearly: inheritance is static and fixed at compile time, composition is chosen at runtime.

Notice what this is not. It is not the Python @ syntax, although @ is built to make one common case, function wrapping, short to write. PEP 318 introduced that syntax and is explicit that it is sugar for f = decorator(f). The pattern is older and more general than the syntax.

A real stack: retry, cache, log

Take an exchange-rate source with a flaky upstream. The interface is one method. I will use a Protocol so the wrappers stay structurally typed and nothing inherits from anything.

import time
from typing import Protocol

class RateSource(Protocol):
    def rate(self, pair: str) -> float: ...

class Retrying:
    def __init__(self, inner: RateSource, attempts: int = 3):
        self.inner, self.attempts = inner, attempts

    def rate(self, pair: str) -> float:
        for i in range(self.attempts):
            try:
                return self.inner.rate(pair)
            except TimeoutError:
                if i == self.attempts - 1:
                    raise

class Caching:
    def __init__(self, inner: RateSource, ttl: float = 30.0, clock=time.monotonic):
        self.inner, self.ttl, self.clock, self.store = inner, ttl, clock, {}

    def rate(self, pair: str) -> float:
        hit = self.store.get(pair)
        if hit and self.clock() - hit[0] < self.ttl:
            return hit[1]
        value = self.inner.rate(pair)
        self.store[pair] = (self.clock(), value)
        return value

class Logging:
    def __init__(self, inner: RateSource, log: list):
        self.inner, self.log = inner, log

    def rate(self, pair: str) -> float:
        self.log.append(f"rate({pair})")
        return self.inner.rate(pair)

Each class is about ten lines, knows nothing about the others, and can be tested alone with a fake inner. That is the payoff, and it is real. The clock parameter on Caching is the same trick you would use to test TTL expiry without sleeping.

Now the experiment. I ran this locally against a fake upstream that times out twice and succeeds on every third call. Compose the stack with logging on the outside, then ask for the same pair three times:

svc = Logging(Caching(Retrying(api)), log)
svc.rate("EUR/USD"); svc.rate("EUR/USD"); svc.rate("EUR/USD")
# log: 3 entries   api calls: 3

Three logged requests, three upstream calls: the first request retried its way to success, and the second and third came from the cache. The log shows what callers asked for. Now move logging inside the cache and leave everything else alone:

svc = Caching(Logging(Retrying(api), log))
svc.rate("EUR/USD"); svc.rate("EUR/USD"); svc.rate("EUR/USD")
# log: 1 entry    api calls: 3

Same behavior from the caller's side, same upstream traffic, and the log now has one line instead of three. The wrapper did not change. Its position changed what it can see. Move it one layer further in, inside the retry, and it logs every individual attempt against the upstream:

svc = Retrying(Logging(api, log))
svc.rate("EUR/USD")
# log: 3 entries   api calls: 3

Three placements, three different meanings for the word "call". None of them is wrong. All of them are wrong if the dashboard built on top of them assumes a different one. That is the payments story from the opening.

Order is a design decision, so write it down

Some orderings are not matters of taste. A few rules that survive contact with production:

Caching goes outside retry. A cache hit should skip the retry machinery entirely, and you do not want retry loops hammering a cache that cannot fail in the way an upstream does. The reverse composition works, but every retry then pays the cache lookup for nothing.

Timeouts go inside retry. The timeout bounds one attempt, and retry decides how many attempts you get. Put a timeout outside retry and you have a total deadline instead, which is a different and sometimes useful thing, but you should choose it on purpose.

Authentication goes inside retry for the opposite reason: if a token expires mid-stack, the retried attempt should re-sign the request, not replay a stale one.

Metrics and tracing go as far outside as you can, so they observe the whole operation including retries, unless you specifically want per-attempt spans, in which case you want a second instance sitting inside.

Circuit breakers sit inside retry too. Otherwise the retry layer happily keeps knocking on a door the breaker has already closed. I will cover that pattern later in this series.

The cheapest defense is to build the stack in exactly one function and name it. Not scattered across call sites, not assembled by a dependency injection container from annotations whose ordering nobody reads.

def build_rate_source(api: RateSource, log: list) -> RateSource:
    # outermost first: what the caller asked for, then cache, then retries
    return Logging(Caching(Retrying(api)), log)

One function, one comment, one place to read the order. If you use a container, give the stack a single factory method and test the composed object against the behavior you care about: here, that three calls produce one upstream success, and a transient timeout never reaches the caller.

Where you are already using it

The pattern is everywhere because wrapping is the cheapest extension point in any language with interfaces.

Java's I/O library is the textbook case. FilterInputStream is a base class whose only job is to wrap another InputStream and delegate, and BufferedInputStream, DataInputStream and GZIPInputStream all build on it. new BufferedReader(new InputStreamReader(new GZIPInputStream(new FileInputStream(path)))) is a four-deep decorator stack that every Java developer has typed without naming it. It is also a fair warning about the pattern's cost: the nesting is unreadable, and the order matters here too, since buffering after decompression is a different performance profile from buffering before it.

Go reaches the same place through a one-method interface. http.RoundTripper takes a request and returns a response. A decorator is a RoundTripper that holds another RoundTripper:

type retryTransport struct {
    next     http.RoundTripper
    attempts int
}

func (t retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    var resp *http.Response
    var err error
    for i := 0; i < t.attempts; i++ {
        resp, err = t.next.RoundTrip(req)
        if err == nil && resp.StatusCode < 500 {
            return resp, nil
        }
    }
    return resp, err
}

client := &http.Client{Transport: retryTransport{next: http.DefaultTransport, attempts: 3}}

I did not compile that snippet in this run, so read it as a sketch, and note one real bug in it: requests with bodies cannot be blindly replayed, and a production version needs req.GetBody and must close the discarded response bodies. The shape, though, is how the OpenTelemetry HTTP instrumentation for Go works: it wraps your transport and gives you traces without touching the call sites.

Web frameworks use it under other names. Flask's @login_required, Django's @cache_page, and the middleware chain in Express or ASP.NET are all decorators over a handler. gRPC interceptors are decorators over a call. The retry library tenacity in Python is a decorator you apply with one line.

Function decorators and the one bug everyone ships

Python's @ is the pattern at function granularity, and it has a classic failure mode. A naive wrapper replaces the function's identity:

def naive(fn):
    def wrapper(*a, **kw):
        return fn(*a, **kw)
    return wrapper

@naive
def add(a, b):
    "adds"
    return a + b

add.__name__   # 'wrapper'
add.__doc__    # None

I ran this: the name is wrapper and the docstring is gone. That breaks introspection, which means it breaks things you did not know depended on it: stack traces, Flask's routing (two views both named wrapper collide on the same endpoint name), and documentation generators. The fix is one line from the standard library, functools.wraps, which copies the metadata onto the wrapper:

import functools, time

def timed(fn):
    @functools.wraps(fn)
    def wrapper(*a, **kw):
        t = time.perf_counter()
        try:
            return fn(*a, **kw)
        finally:
            wrapper.last = time.perf_counter() - t
    return wrapper

With wraps, add.__name__ is add again and the docstring survives. Use it every time, no exceptions. Stacked function decorators have the same order problem as the class version: the decorator closest to the function runs innermost, which is the reverse of how most people read the lines.

The yes, but

The strongest objection: wrapping hides the real type. Once an object is five decorators deep, isinstance checks fail, debuggers show you a chain of forwarding methods, and a stack trace for one failed call passes through every layer. This is true and it is the main reason the pattern gets abused. Decorators that all forward to an inner object also tend to duplicate the forwarding code for every method on a wide interface, and if the interface has twenty methods, every wrapper carries twenty trivial delegations. In that situation, either narrow the interface so wrappers only care about the one or two calls that matter, or reach for a generated proxy.

The second objection is that a language feature or a framework hook does it better. Often it does. If your framework has a middleware system, use it rather than building your own decorator chain beside it. The pattern earns its keep when you own the interface, the interface is small, and the behaviors need to combine in ways you cannot predict ahead of time.

What to do on Monday

Find one place in your codebase where cross-cutting behavior is applied: retries, caching, auth, metrics. Check whether the order is written in exactly one place and whether anyone can say, without reading the code, what the logging layer counts. If the answer is no, write one test that runs the full stack against a fake upstream and asserts the number you care about: upstream calls, log lines, or both. That test is worth more than a unit test per decorator, because the decorators are fine. The ordering is the part nobody is checking.

Sources