Design PatternsIntermediate 12 min Lesson 6 of 13

Decorator — Adding Behaviour by Wrapping

Wrap an object in another with the same interface to add logging, caching or retries, and stack the wrappers as needed.

Design Patterns · Lesson 6 of 13
0/13 done(0%)

What is it? #

The Decorator pattern wraps an object in another object with the same interface, adding behaviour before or after delegating to the original.

Because the wrapper implements the same interface, callers cannot tell the difference — and wrappers can be stacked.

It solves the subclass explosion problem. With logging, caching and retries as optional additions, inheritance needs a class per combination. Decorators need one class per feature.

Note that this pattern and Python's @decorator syntax are related in spirit but not the same thing. The syntax wraps functions; this pattern wraps objects.

Think of it like this #

A phone case. The phone works the same; the case adds protection. Add a screen protector and a grip ring and you have three layers, each doing one thing, all still letting you use the phone normally.

Nobody manufactures a separate phone model for every combination of accessories.

Simple example #

A storage service needs optional logging, caching and retry behaviour depending on the environment. Rather than one class with three flags, each concern is a wrapper.

Code #

PYTHON
import time
from abc import ABC, abstractmethod


class Storage(ABC):
    @abstractmethod
    def read(self, key: str) -> bytes: ...


class DiskStorage(Storage):
    def read(self, key):
        time.sleep(0.05)                       # pretend this is slow
        return f"contents-of-{key}".encode()


# --- Each decorator implements the same interface and wraps another Storage ---
class LoggingStorage(Storage):
    def __init__(self, inner: Storage):
        self._inner = inner

    def read(self, key):
        print(f"[log] reading {key}")
        result = self._inner.read(key)
        print(f"[log] got {len(result)} bytes")
        return result


class CachingStorage(Storage):
    def __init__(self, inner: Storage):
        self._inner = inner
        self._cache: dict[str, bytes] = {}

    def read(self, key):
        if key not in self._cache:
            self._cache[key] = self._inner.read(key)
        return self._cache[key]


class RetryingStorage(Storage):
    def __init__(self, inner: Storage, attempts: int = 3):
        self._inner, self._attempts = inner, attempts

    def read(self, key):
        last = None
        for attempt in range(self._attempts):
            try:
                return self._inner.read(key)
            except OSError as exc:
                last = exc
                time.sleep(0.1 * (attempt + 1))
        raise last


# --- Compose the behaviour you want, in the order you want ---
storage: Storage = DiskStorage()
storage = RetryingStorage(storage)         # innermost extra behaviour
storage = CachingStorage(storage)          # cache sits above retries
storage = LoggingStorage(storage)          # logging sees every call

storage.read("invoice.pdf")                # slow: reaches disk
storage.read("invoice.pdf")                # fast: served from cache

# Callers still just see a Storage
def render(storage: Storage, key: str) -> str:
    return storage.read(key).decode()
TEXT
Order matters

LoggingStorage(CachingStorage(DiskStorage()))
  -> logs every call, including cache hits

CachingStorage(LoggingStorage(DiskStorage()))
  -> logs only calls that miss the cache

How it works #

Every decorator implements Storage and holds another Storage. That dual role — same interface, wraps one — is what makes stacking work.

LoggingStorage prints, delegates, prints again. It adds nothing to the result.

CachingStorage checks its dictionary first and only delegates on a miss. This is where the real behaviour change happens: the second read never reaches the disk.

RetryingStorage catches a specific error and tries again with increasing delays, re-raising if all attempts fail.

The composition lines build the stack from the inside out. Reading it outwards: logging wraps caching, which wraps retrying, which wraps disk.

The order table matters in practice. Putting logging outermost shows every request including cache hits, which is what you usually want for request tracing. Putting it inside the cache shows only actual disk reads, which is what you want when measuring backend load.

Adding a fourth concern — metrics, encryption, compression — is one more small class, usable with any combination of the others.

Real-world use #

Web middleware is this pattern at framework scale: authentication, compression, CORS and request logging each wrap the handler, and the order in the stack determines behaviour.

HTTP clients are commonly built the same way, with retry, timeout, caching and tracing layers around a base transport.

In Python specifically, @lru_cache and custom function decorators express the same idea for functions rather than objects.

The practical warning is depth. A stack of six wrappers makes stack traces long and debugging harder, since a single call passes through many layers. Two or three is comfortable; beyond that, consider whether some concerns belong elsewhere.

Common mistakes #

  • Changing the interface in a decorator, so it can no longer be substituted or stacked.
  • Wrapping in the wrong order and, for example, caching failures or logging nothing useful.
  • Letting a decorator swallow exceptions, hiding real failures from the caller.
  • Stacking so many layers that a stack trace becomes unreadable.
  • Adding business logic in a decorator instead of a cross-cutting concern.

Practice #

Build a Repository interface with get(id). Write a base implementation plus three decorators: timing, caching and access logging. Compose them two different ways and describe in one sentence what each ordering reports differently.

Quick quiz

  1. 1. What makes decorators stackable?

  2. 2. Which problem does this pattern avoid?

  3. 3. Why does wrapping order matter?

  4. 4. What should a decorator not do?

  5. 5. What is the framework-scale version of this pattern?

Summary

  • A decorator wraps an object with the same interface and adds behaviour.
  • One class per concern replaces a class per combination.
  • Wrapping order changes what each layer observes.
  • Never change the interface or hide exceptions inside a wrapper.
  • Middleware stacks are this pattern at framework scale.