The Decorator Pattern in Python, Beyond @decorator Syntax

Your notification system started with one job. Send an email. Then product asked for SMS. Then email and SMS together. Then email and SMS and Slack, but only for critical alerts. If you modeled each of those as a subclass, you now have four classes and counting, and the count grows every single time someone asks for a new channel.

This is the exact problem the Decorator pattern exists to solve. It wraps an object inside another object that shares the same interface, so you can add behavior before or after the original call, without editing the original class and without a new subclass for every combination.

The old approach

Here’s the subclass version, and it’s worth seeing it fall apart before we fix it.

class Notifier:
    def send(self, message: str) -> None:
        print(f"Sending, {message}")

class EmailNotifier(Notifier):
    ...

class SMSNotifier(Notifier):
    ...

class EmailAndSMSNotifier(Notifier):
    ...

class EmailAndSMSAndSlackNotifier(Notifier):
    ...

Four channels, four classes already, and that is before anyone asks for a fifth channel or a combination nobody predicted. This is what subclass explosion actually looks like in a real codebase, not a slide about SOLID principles.

The pattern

The Decorator pattern fixes this by making each channel a thin wrapper around any other notifier, instead of a subclass of it. The shape has three parts, a component interface that both the original object and its decorators implement, a concrete component which is the plain undecorated object, and one or more decorators that hold a reference to a component and delegate to it while adding their own behavior around the call.

In Python, this is a good place to reach for Protocol instead of an abstract base class. You get structural typing, any object with a matching send method satisfies the interface, without forcing every notifier into a shared inheritance tree.

from typing import Protocol

class Notifier(Protocol):
    def send(self, message: str) -> None: ...

class BaseNotifier:
    def send(self, message: str) -> None:
        print(f"Sending, {message}")

class NotifierDecorator:
    """Wraps a notifier and forwards to it."""
    def __init__(self, wrapped: Notifier) -> None:
        self._wrapped = wrapped

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

class EmailDecorator(NotifierDecorator):
    def send(self, message: str) -> None:
        super().send(message)
        print(f"  -> Emailing, {message}")

class SlackDecorator(NotifierDecorator):
    def send(self, message: str) -> None:
        super().send(message)
        print(f"  -> Posting to Slack, {message}")

Channels now compose freely at runtime, with no new class for every combination.

notifier = SlackDecorator(EmailDecorator(BaseNotifier()))
notifier.send("Deploy finished")
# Sending, Deploy finished
#   -> Emailing, Deploy finished
#   -> Posting to Slack, Deploy finished

Need SMS too. Write an SMSDecorator and wrap it in, nothing else in the codebase changes. That is the open closed principle doing real work, open for extension, closed for modification, and it matters in practice specifically because the alternative is touching existing classes every time a new combination shows up.

Decorator pattern vs the @decorator syntax

Python’s function decorators are a language feature built on the same idea, applied to callables instead of objects.

import functools
import time

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timed
def fetch_report(report_id: int) -> dict:
    time.sleep(0.05)
    return {"id": report_id, "status": "ok"}

Structurally this is the same trick, wrapper holds a reference to func and calls it, adding behavior around the call. The difference is scope. The class based pattern wraps objects that share an interface, often across several methods. Python’s @decorator syntax wraps a single function. Use functools.wraps every time you write one, skipping it silently loses the wrapped function’s name, docstring, and signature, and that breaks introspection tools and makes stack traces confusing to read.

Most Python codebases reach for function decorators far more often than the full class based version, logging, caching, retries, and auth checks are usually simple enough for a function wrapper. When you’re wrapping a stateful object with multiple methods, a file handle, a database connection, an HTTP client, the class based version is usually the cleaner fit.

A production example, caching and retries on an API client

Here is a shape you will actually hit in real code, wrapping an API client with retry and caching behavior, without touching the client itself.

class APIClient:
    def get(self, path: str) -> dict:
        # real network call
        ...

class ClientDecorator:
    def __init__(self, client) -> None:
        self._client = client

    def get(self, path: str) -> dict:
        return self._client.get(path)

class RetryDecorator(ClientDecorator):
    def __init__(self, client, retries: int = 3) -> None:
        super().__init__(client)
        self._retries = retries

    def get(self, path: str) -> dict:
        last_exc = None
        for attempt in range(self._retries):
            try:
                return super().get(path)
            except ConnectionError as exc:
                last_exc = exc
        raise last_exc

class CachingDecorator(ClientDecorator):
    def __init__(self, client) -> None:
        super().__init__(client)
        self._cache: dict[str, dict] = {}

    def get(self, path: str) -> dict:
        if path not in self._cache:
            self._cache[path] = super().get(path)
        return self._cache[path]

client = CachingDecorator(RetryDecorator(APIClient()))

Every call goes through caching, then retry, then the real network call, and you can reorder or drop layers without touching APIClient at all. This is worth getting right early, because the order of decorators changes behavior, caching before retry means a failed call never gets cached, caching after retry means you retry before you even check the cache. That ordering bug is easy to miss in review and painful to debug in production.

When to reach for it, and when it is overkill

Good fits, you need optional behavior that combines freely, like logging, caching, retries, compression, or auth, on objects that share an interface, and you want that combination decided at runtime instead of baked in at class definition time.

Skip it when you only ever need one variant, a plain subclass or a function decorator reads easier. Also skip it once the wrapping chain runs past three or four layers, tracing through that many super().method() hops gets genuinely painful to debug, and a pipeline or an explicit list of steps usually reads clearer at that point. And skip it if you’re reaching for it just because it has a name, plain composition through functions or a list of callables is often the simpler answer for small cases.

What to do now

  • Search your codebase for classes named Manager or Handler with a stack of boolean constructor flags, send_email=True, send_sms=False, and so on. That is the subclass explosion smell this pattern fixes.
  • Wrapping a single function, use functools.wraps every time, no exceptions.
  • Wrapping a stateful object with several methods, write the class based version so every method delegates consistently, not just the one you need today.
  • Cap decorator chains at three or four layers. Past that, refactor into an explicit pipeline so the call order is visible in one place instead of buried in nested wrapping.
  • Pair this with the Strategy pattern when the wrapped behavior itself needs to be swappable, not just additive, the two compose well together.