Decorator
Adding behaviour by wrapping, without subclass explosion.
01The intuition
The problem it solves
A coffee shop app prices drinks. A coffee can have milk, extra shots, syrup and whipped cream, in any combination. The first version tries to make a class per combination.
class Coffee:
def cost(self): return 100
class MilkCoffee:
def cost(self): return 120
class MilkSyrupCoffee:
def cost(self): return 140
class MilkSyrupCreamCoffee:
def cost(self): return 160
class SyrupCoffee:
def cost(self): return 120
# ... 2 to the power of 4 classes, and we have not added a second shot yet
Four add-ons give sixteen classes; a fifth add-on doubles that. The alternative people reach for is one Coffee class with a boolean per add-on, which means every new add-on edits Coffee and the cost method becomes a ladder of ifs.
A decorator is an object that wraps another object of the same interface and adds one thing to it. Because the wrapper has the same interface, you can wrap a wrapper, and combinations come for free.
Build it one piece at a time
Step 1: define the interface and one plain implementation.
from abc import ABC, abstractmethod
class Drink(ABC):
@abstractmethod
def cost(self) -> int: ...
@abstractmethod
def label(self) -> str: ...
class Coffee(Drink):
def cost(self) -> int: return 100
def label(self) -> str: return "coffee"
Step 2: write one wrapper that holds a Drink and is a Drink. It adds its own price to whatever it wraps.
class Milk(Drink):
def __init__(self, inner: Drink): self._inner = inner
def cost(self) -> int: return self._inner.cost() + 20
def label(self) -> str: return self._inner.label() + " + milk"
print(Milk(Coffee()).cost()) # 120
Step 3: add more wrappers, never more combinations. Each add-on is one small class. Stacking them is composition at the call site.
class Syrup(Drink):
def __init__(self, inner: Drink): self._inner = inner
def cost(self) -> int: return self._inner.cost() + 20
def label(self) -> str: return self._inner.label() + " + syrup"
class ExtraShot(Drink):
def __init__(self, inner: Drink): self._inner = inner
def cost(self) -> int: return self._inner.cost() + 30
def label(self) -> str: return self._inner.label() + " + shot"
order = ExtraShot(Syrup(Milk(Coffee())))
print(order.label(), order.cost()) # coffee + milk + syrup + shot 170
Step 4: use the same shape for behaviour, not just numbers. A decorator can add logging, caching or a size multiplier around any drink, and the drink does not know.
class Large(Drink):
def __init__(self, inner: Drink): self._inner = inner
def cost(self) -> int: return int(self._inner.cost() * 1.5)
def label(self) -> str: return "large " + self._inner.label()
| Step | Classes for 4 add-ons | Adding a 5th add-on | Milk twice |
|---|---|---|---|
| Start | 16 | 16 more classes | a 17th class |
| Boolean flags | 1 | edit Coffee and its cost ladder | a counter field |
| 2 and 3 | 5 | 1 new class | Milk(Milk(coffee)) |
Why it works
The trick is that the wrapper and the wrapped share an interface, so to the caller a decorated drink is just a drink. Each decorator owns exactly one responsibility and adds it by delegating to the inner object and adjusting the result. The number of classes grows with the number of add-ons, not with the number of combinations, and new add-ons never touch existing classes, which is the open-closed principle in action.
Where it breaks: order-dependent decorators (Large(Syrup(c)) and Syrup(Large(c)) price differently), so either document the order or make the multiplier a different mechanism; and identity checks, because isinstance(order, Coffee) is now false. Debugging a stack of six wrappers is also harder than reading one class, so give each a clear label or repr.
The trade-off you must say out loud
Decorators trade a flat, readable object for a chain you must unwind in your head. They are right when add-ons combine freely and independently; they are wrong when add-ons interact, where a single class with explicit rules is clearer. Say the test: "each add-on changes the result in a way that does not depend on the others". The follow-up is often "how do I remove milk later", and the answer is that decorators are built, not edited; rebuild the chain or keep a list of add-on choices and construct from it.
Where it shows up in the 100 Google problems
- Design a peeking iterator: it is a decorator over any iterator that adds
peekby holding one buffered element, and it is still an iterator. - Design a logging framework: a timestamp formatter, a JSON formatter and a rate-limited sink are decorators over the plain sink, stacked per configuration.
- Design a cache manager with pluggable eviction and TTL: TTL can be a decorator over any cache that checks expiry on
get, leaving the eviction policy alone. - Design a coffee machine: add-ons priced and labelled exactly as above, with the machine checking inventory per wrapper.