Skip to content

Decorator

Intent

Attach responsibilities to an object at runtime by wrapping it in another object that presents the same interface. Wrappers stack, so the combinations you would otherwise subclass (milk plus two extra shots plus syrup; retries plus auditing around a transport) are assembled by composition at the call site, one layer per responsibility.

When to use and when not to

Use it when

  • Responsibilities are optional and combinable. Three add-ons give seven non-empty combinations as subclasses, ignoring order and repetition, and "two extra shots" needs a subclass per count; as decorators they are three classes.
  • A cross-cutting behaviour must wrap an interface you do not want to touch: retries, caching, auditing, rate limiting or compression around any sender, cache or stream.
  • The stack differs per instance: this tenant gets retries, that one retries plus an audit trail, decided at wiring time, not in a hierarchy.

Leave it out when

  • The wrapper changes the interface: that is an Adapter, and it cannot stack.
  • The wrapper exists to control whether the real call happens (lazy load, permission check, cache in front of a remote): that is a Proxy.
  • One class needs one behaviour forever. Put it in the class; a single permanent layer is ceremony.
  • Callers depend on identity. isinstance(drink, Espresso) is false once wrapped, and __getattr__ forwarding skips dunder methods; code that needs the real object will be surprised.

Structure

The coffee form: a Component, one Concrete Component and a Decorator base that is itself a Component, so decorators can wrap decorators.

classDiagram
    class Beverage {
        <<abstract>>
        +description() str
        +cost() Money
    }
    class Espresso {
        +description() str
        +cost() Money
    }
    class AddOn {
        +inner: Beverage
        +description() str
        +cost() Money
    }
    class Milk {
        +description() str
        +cost() Money
    }
    class ExtraShot {
        +description() str
        +cost() Money
    }
    class Syrup {
        +flavour: str
        +description() str
        +cost() Money
    }
    Beverage <|-- Espresso
    Beverage <|-- AddOn
    AddOn <|-- Milk
    AddOn <|-- ExtraShot
    AddOn <|-- Syrup
    AddOn o-- Beverage : wraps

The infrastructure form: a Protocol instead of a base class, and each decorator holds a Sender and is a Sender.

classDiagram
    class Sender {
        <<interface>>
        +send(recipient: str, message: str) str
    }
    class SmtpSender {
        +send(recipient: str, message: str) str
    }
    class RetryingSender {
        -_inner: Sender
        -_attempts: int
        +send(recipient: str, message: str) str
    }
    class AuditingSender {
        -_inner: Sender
        -_sink: Callable
        +send(recipient: str, message: str) str
    }
    Sender <|.. SmtpSender
    Sender <|.. RetryingSender
    Sender <|.. AuditingSender
    RetryingSender o-- Sender : wraps
    AuditingSender o-- Sender : wraps

Both pictures share one shape: the arrow from the decorator back to the interface it implements.

Canonical example in Python

The beverage hierarchy first (code/patterns/decorator.py, tested by code/patterns/tests/test_decorator.py):

code/patterns/decorator.py — Component, Concrete Component and the add-ons
class Beverage(ABC):
    """The Component: what every drink, decorated or not, can be asked."""

    @abstractmethod
    def description(self) -> str: ...

    @abstractmethod
    def cost(self) -> Money: ...


@dataclass(frozen=True, slots=True)
class Espresso(Beverage):
    """A Concrete Component: the innermost object, the one that gets wrapped."""

    def description(self) -> str:
        return "espresso"

    def cost(self) -> Money:
        return ESPRESSO_PRICE


@dataclass(frozen=True, slots=True)
class AddOn(Beverage):
    """The Decorator base: holds *a* Beverage and forwards both calls unchanged.

    Subclasses override only what they change. Because an ``AddOn`` is itself a
    ``Beverage``, an add-on can wrap another add-on: that recursion is the pattern.
    """

    inner: Beverage

    def description(self) -> str:
        return self.inner.description()

    def cost(self) -> Money:
        return self.inner.cost()


@dataclass(frozen=True, slots=True)
class Milk(AddOn):
    def description(self) -> str:
        return f"{self.inner.description()}, milk"

    def cost(self) -> Money:
        return self.inner.cost() + MILK_PRICE


@dataclass(frozen=True, slots=True)
class ExtraShot(AddOn):
    def description(self) -> str:
        return f"{self.inner.description()}, extra shot"

    def cost(self) -> Money:
        return self.inner.cost() + SHOT_PRICE


@dataclass(frozen=True, slots=True)
class Syrup(AddOn):
    """A decorator with configuration of its own."""

    flavour: str = "vanilla"

    def description(self) -> str:
        return f"{self.inner.description()}, {self.flavour} syrup"

    def cost(self) -> Money:
        return self.inner.cost() + SYRUP_PRICE

AddOn forwards both calls unchanged; each add-on overrides what it changes and delegates the rest inward. The add-ons are frozen dataclasses, so a decorated drink is a value: Milk(Espresso()) == Milk(Espresso()), hashable, printable, safe to share.

The sender stack is the same shape with real stakes:

code/patterns/decorator.py — retries and auditing around any Sender
class SendError(HandbookError):
    """A transient transport failure: the kind a retry may cure."""


@runtime_checkable
class Sender(Protocol):
    """The Component interface as a Protocol: decorators and test fakes qualify by shape."""

    def send(self, recipient: str, message: str) -> str: ...


class SmtpSender:
    """The Concrete Component, standing in for a real transport.

    ``fail_first`` makes the first N calls raise, which lets the tests and the demo
    exercise the retry without a network and without sleeping.
    """

    def __init__(self, fail_first: int = 0) -> None:
        self._failures_left = fail_first
        self._ids = itertools.count(1)

    def send(self, recipient: str, message: str) -> str:
        if self._failures_left > 0:
            self._failures_left -= 1
            raise SendError("connection reset")
        return f"smtp-{next(self._ids)}"


class RetryingSender:
    """A Decorator: the same ``send``, plus retries with exponential backoff.

    The sleep is injected so tests never wait; the delays are still computed and
    handed to it, so a test can assert the schedule. A failure that outlives the
    budget propagates unchanged: the decorator adds behaviour, it does not
    translate errors.
    """

    def __init__(
        self,
        inner: Sender,
        attempts: int = 3,
        base_delay: float = 0.1,
        sleep: Callable[[float], None] = time.sleep,
    ) -> None:
        if attempts < 1:
            raise ValidationError("attempts must be at least 1")
        self._inner = inner
        self._attempts = attempts
        self._base_delay = base_delay
        self._sleep = sleep

    def send(self, recipient: str, message: str) -> str:
        attempt = 1
        while True:
            try:
                return self._inner.send(recipient, message)
            except SendError:
                if attempt == self._attempts:
                    raise
                self._sleep(self._base_delay * 2 ** (attempt - 1))
                attempt += 1


class AuditingSender:
    """A Decorator: the same ``send``, plus one audit line per call it sees, success or failure."""

    def __init__(self, inner: Sender, sink: Callable[[str], None]) -> None:
        self._inner = inner
        self._sink = sink

    def send(self, recipient: str, message: str) -> str:
        try:
            receipt = self._inner.send(recipient, message)
        except SendError as exc:
            self._sink(f"{recipient}: failed ({exc})")
            raise
        self._sink(f"{recipient}: ok ({receipt})")
        return receipt

Four decisions to say out loud:

  • Same interface in and out. RetryingSender.send takes and returns exactly what SmtpSender.send does, and the recording fake in the tests sees the original arguments untouched. A different signature means it is no longer a decorator.
  • Order is semantics. AuditingSender(RetryingSender(smtp)) records one line per message; RetryingSender(AuditingSender(smtp)) records every attempt. Neither is wrong; the stack is a design decision you state, not an accident of wiring.
  • Errors pass through. When the retry budget is spent, the original SendError propagates. A decorator adds behaviour; translating errors is the adapter's job.
  • Inject the sleep. The backoff schedule is computed and handed to a callable, so tests assert [0.1, 0.2] and never wait.

Running python -m patterns.decorator prints:

--- beverages: stack add-ons at runtime, the same add-on twice ---
espresso                                 2.00 USD
espresso, milk                           2.50 USD
espresso, milk, extra shot, extra shot   4.10 USD
espresso, milk, hazelnut syrup           2.90 USD
--- senders: same interface in and out, retries layered around a flaky transport ---
receipt smtp-1 after 2 retries, backoff [0.1, 0.2]
--- order matters: audit outside retry sees one call, inside sees every attempt ---
audit outside retry: ['user-42: ok (smtp-1)']
audit inside retry:  ['user-42: failed (connection reset)', 'user-42: failed (connection reset)', 'user-42: ok (smtp-1)']
--- when the budget runs out the original error surfaces, untranslated ---
SendError after 3 attempts: connection reset
--- function decorators: the same two wrappers for a callable ---
smtp-1 -> ['send_email: ok (smtp-1)']
wraps keeps the identity: name='send_email' doc='Send through the module transport.'

Pythonic variant

When the target is a callable, Python has syntax for the pattern: @retry(attempts=3) above def send_email means send_email = retry(attempts=3)(send_email), a function that takes a function and returns a same-signature function. The two sender wrappers, restated:

code/patterns/decorator.py — the same wrappers as function decorators
def retry[**P, R](
    attempts: int = 3, base_delay: float = 0.1, sleep: Callable[[float], None] = time.sleep
) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """``RetryingSender`` for any callable that raises ``SendError``.

    ``functools.wraps`` copies ``__name__``, ``__doc__`` and friends onto the
    wrapper and records the original as ``__wrapped__``, so logs, tracebacks and
    ``inspect`` still see the function you wrote rather than ``wrapper``.
    """
    if attempts < 1:
        raise ValidationError("attempts must be at least 1")

    def decorate(fn: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            attempt = 1
            while True:
                try:
                    return fn(*args, **kwargs)
                except SendError:
                    if attempt == attempts:
                        raise
                    sleep(base_delay * 2 ** (attempt - 1))
                    attempt += 1

        return wrapper

    return decorate


def audited[**P, R](sink: Callable[[str], None]) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """``AuditingSender`` for callables; stacks with ``retry`` in either order."""

    def decorate(fn: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            try:
                result = fn(*args, **kwargs)
            except SendError as exc:
                sink(f"{fn.__name__}: failed ({exc})")
                raise
            sink(f"{fn.__name__}: ok ({result})")
            return result

        return wrapper

    return decorate
  • functools.wraps is not optional. Without it the decorated function is called wrapper, its docstring is gone and inspect.signature lies; with it the original is reachable as __wrapped__, which is how unittest.mock and debuggers see through the layers.
  • Stacking reads bottom-up. @audited above @retry is audit outside retry: one line per call, exactly as with the classes.
  • Decorators with arguments are factories. retry(attempts=3) returns the decorator; validate the arguments there so misuse fails at import time.

When is each form right?

Reach for When
@decorator on a function The target is a callable, the stack is fixed when the code is written, and the behaviour is cross-cutting (retry, cache, timing, auth)
A contextlib.contextmanager or ContextDecorator The behaviour is "before and after" around a block and you want both with and @ forms
The object decorator The target has several methods that must stay consistent, the stack is assembled at runtime per instance, or layers must be inspected or removed
A middleware list (Pipeline) Many layers whose order is configuration rather than code

One naming trap: @property, @staticmethod, @dataclass and @app.route use the syntax but replace the function with a descriptor, transform a class or register a handler. Only a decorator that returns a same-signature wrapper is the GoF pattern.

Real-world usage

  • functools.lru_cache and functools.cache wrap a function with a cache behind the same signature; cache_info() and cache_clear() are the layer showing through.
  • io.BufferedReader and io.BufferedWriter wrap a raw stream with buffering behind the same read/write interface; gzip.GzipFile wraps any file object with compression; ssl.SSLSocket wraps a socket with TLS and the socket API.
  • logging.LoggerAdapter carries the adapter name but decorates: the same logging methods with extra context injected into every record.
  • unittest.mock.patch is both a decorator and a context manager, the ContextDecorator shape.
  • Frameworks: Flask and Django view decorators (@login_required, @cache_page) and Django's middleware chain.

The structural patterns all wrap something; classify by two questions: does the interface change, and why does the wrapper exist?

Looks like Decorator How to tell them apart
Adapter Changes the interface, adds no behaviour, never stacks. Adapt first, then decorate: RetryingProcessor(StripeAdapter(client)).
Proxy Same interface, but the wrapper exists to control access to one object: lazy creation, a cache, a permission check, a network hop. A proxy decides whether the real call happens and is usually one layer the client did not choose; a decorator always forwards, adds, and is stacked by the client.
Facade A new, simpler interface over a subsystem of several objects. Not interchangeable with what it hides, so it cannot be layered.
Composite Many children, there to aggregate. A decorator is a composite with exactly one child and a different purpose.
Strategy Swaps the algorithm inside the object; a decorator wraps the outside. DailyCapPricing on the Strategy page is half a decorator: same interface, but it wraps one specific type rather than any strategy.
Chain of Responsibility A handler may stop the chain and never forward; a decorator always delegates. A middleware pipeline is a chain of decorators built from a list.

Where it appears in LLD problems

Interview tips

Interview tip

Draw two boxes with the same interface and say the sentence that proves you understand the pattern: "same interface in and out, so I can stack them, and the order is a decision: audit outside retry records one line per message, inside records every attempt." Then offer @retry with functools.wraps and say when the function form is enough.

Common mistake

Changing the interface inside a decorator. A RetryingSender that returns a result object instead of the receipt, or swallows SendError and returns None, is an adapter with side effects and nothing can stack on it. Runner-up: forgetting functools.wraps, so every decorated function is called wrapper in tracebacks, and __getattr__ forwarding that misses the dunder methods the caller needed.