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 #
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()
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.