Decorator
A decorator wraps an object in another with the same interface that adds one behaviour, so behaviours can be stacked in any combination. This page wraps Pedal's remote fare service in logging, caching and retries, and shows why the order of the layers is a real decision.
1. The pain
Pedal's fares come from a pricing service across the network. Operations wants every quote logged. The service sometimes times out, so quotes should be retried. Riders ask for the same quote several times on the ride summary screen, so answers should be cached. Each of these is a small, separate concern, and different callers want different mixes.
Putting all three inside the fare service mixes network code with logging and caching, and every caller gets all of them whether it wants them or not. Subclassing for each mix is worse: three behaviours already make seven subclasses.
2. The idea in one picture
Decorator wraps an object in another object with the same interface that adds one behaviour and passes the call on. Because a decorator is itself a FareService, it can wrap another decorator, so behaviours stack: Logging(Caching(Retrying(remote))). Callers hold a FareService and never know how many layers there are.
Order matters. With logging outside the cache, every quote is logged, cached or not; with logging inside, only real lookups are. Neither is wrong, but you should choose. Section 6 lets you try orders.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Component | The interface both the real object and every decorator implement. | FareService.quote(ride_id) |
| Concrete component | The object that does the actual work. | RemoteFares |
| Decorator | Holds a component, adds one behaviour, delegates the rest. | Logging, Caching, Retrying |
| Client | Uses the interface; doesn't know about the layers. | The ride summary screen |
4. Before and after
The fare service both ways (Python sketch; the full program is in section 5).
Without the pattern
class RemoteFares:
def quote(self, ride_id):
if ride_id in self.cache:
return self.cache[ride_id]
for attempt in range(3):
try:
p = self.http_get(ride_id)
break
except Timeout:
...
self.cache[ride_id] = p
log(f"quote {ride_id} -> {p}p")
return pWith it
class Caching(FareService):
def __init__(self, inner):
self.inner, self.cache = inner, {}
def quote(self, ride_id):
if ride_id not in self.cache:
self.cache[ride_id] = \
self.inner.quote(ride_id)
return self.cache[ride_id]
fares = Logging(Caching(Retrying(RemoteFares())))| The next change | Without the pattern | With it |
|---|---|---|
| The admin tool wants fares without the cache | A flag inside quote(), or a copy of the class | Leave Caching out of its stack |
| Add timing metrics | Edit the one big method again | A Timing decorator; nothing else changes |
| Test the retry rule | Test it through the network code | Wrap a fake that fails twice |
Each concern is one small class, and each caller builds the stack it needs.
5. The code
Three decorators around a remote fare service that times out on its 1st and 3rd calls. The same three wrappers in a different order produce a different log.
from __future__ import annotations
from abc import ABC, abstractmethod
class FareService(ABC):
@abstractmethod
def quote(self, ride_id: str) -> int: ...
class FareTimeout(Exception):
pass
class RemoteFares(FareService):
"""The real thing: a pricing service across the network that times out on calls 1 and 3."""
def __init__(self) -> None:
self.prices = {"R-1": 280, "R-2": 120}
self.calls = 0
def quote(self, ride_id: str) -> int:
self.calls += 1
if self.calls in (1, 3):
raise FareTimeout(ride_id)
return self.prices[ride_id]
# ---- Decorators: each is a FareService that holds another FareService and adds one behaviour. ----
class Retrying(FareService):
def __init__(self, inner: FareService, attempts: int = 3) -> None:
self.inner, self.attempts = inner, attempts
def quote(self, ride_id: str) -> int:
for attempt in range(1, self.attempts + 1):
try:
return self.inner.quote(ride_id)
except FareTimeout:
if attempt == self.attempts:
raise
raise AssertionError("unreachable")
class Caching(FareService):
def __init__(self, inner: FareService) -> None:
self.inner = inner
self.cache: dict[str, int] = {}
def quote(self, ride_id: str) -> int:
if ride_id not in self.cache:
self.cache[ride_id] = self.inner.quote(ride_id)
return self.cache[ride_id]
class Logging(FareService):
def __init__(self, inner: FareService, log: list[str]) -> None:
self.inner, self.log = inner, log
def quote(self, ride_id: str) -> int:
pence = self.inner.quote(ride_id)
self.log.append(f"quote {ride_id} -> {pence}p")
return pence
if __name__ == "__main__":
log: list[str] = []
remote = RemoteFares()
fares: FareService = Logging(Caching(Retrying(remote)), log) # outermost first
for ride in ["R-1", "R-1", "R-2"]:
print(f"{ride}: {fares.quote(ride)}p")
print("log: " + " | ".join(log))
print(f"remote calls: {remote.calls} (two timeouts retried, one answer from the cache)")
log2: list[str] = []
swapped: FareService = Caching(Logging(Retrying(RemoteFares()), log2)) # same wrappers, another order
for ride in ["R-1", "R-1", "R-2"]:
swapped.quote(ride)
print(f"with logging inside the cache: {len(log2)} log lines for 3 quotes")
interface FareService { quote(rideId: string): number; }
class FareTimeout extends Error {}
// The real thing: a pricing service across the network that times out on calls 1 and 3.
class RemoteFares implements FareService {
private readonly prices: Record<string, number> = { "R-1": 280, "R-2": 120 };
calls = 0;
quote(rideId: string): number {
this.calls++;
if (this.calls === 1 || this.calls === 3) throw new FareTimeout(rideId);
return this.prices[rideId];
}
}
// ---- Decorators: each is a FareService that holds another FareService and adds one behaviour. ----
class Retrying implements FareService {
constructor(private readonly inner: FareService, private readonly attempts = 3) {}
quote(rideId: string): number {
for (let attempt = 1; ; attempt++) {
try {
return this.inner.quote(rideId);
} catch (e) {
if (!(e instanceof FareTimeout) || attempt === this.attempts) throw e;
}
}
}
}
class Caching implements FareService {
private readonly cache = new Map<string, number>();
constructor(private readonly inner: FareService) {}
quote(rideId: string): number {
if (!this.cache.has(rideId)) this.cache.set(rideId, this.inner.quote(rideId));
return this.cache.get(rideId)!;
}
}
class Logging implements FareService {
constructor(private readonly inner: FareService, private readonly log: string[]) {}
quote(rideId: string): number {
const pence = this.inner.quote(rideId);
this.log.push(`quote ${rideId} -> ${pence}p`);
return pence;
}
}
const log: string[] = [];
const remote = new RemoteFares();
const fares: FareService = new Logging(new Caching(new Retrying(remote)), log); // outermost first
for (const ride of ["R-1", "R-1", "R-2"]) console.log(`${ride}: ${fares.quote(ride)}p`);
console.log("log: " + log.join(" | "));
console.log(`remote calls: ${remote.calls} (two timeouts retried, one answer from the cache)`);
const log2: string[] = [];
const swapped: FareService = new Caching(new Logging(new Retrying(new RemoteFares()), log2)); // same wrappers, another order
for (const ride of ["R-1", "R-1", "R-2"]) swapped.quote(ride);
console.log(`with logging inside the cache: ${log2.length} log lines for 3 quotes`);import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Main {
interface FareService { int quote(String rideId); }
static class FareTimeout extends RuntimeException {
private static final long serialVersionUID = 1L;
FareTimeout(String rideId) { super(rideId); }
}
// The real thing: a pricing service across the network that times out on calls 1 and 3.
static class RemoteFares implements FareService {
private final Map<String, Integer> prices = Map.of("R-1", 280, "R-2", 120);
int calls;
public int quote(String rideId) {
calls++;
if (calls == 1 || calls == 3) throw new FareTimeout(rideId);
return prices.get(rideId);
}
}
// ---- Decorators: each is a FareService that holds another FareService and adds one behaviour. ----
static class Retrying implements FareService {
private final FareService inner;
private final int attempts;
Retrying(FareService inner, int attempts) { this.inner = inner; this.attempts = attempts; }
public int quote(String rideId) {
for (int attempt = 1; ; attempt++) {
try {
return inner.quote(rideId);
} catch (FareTimeout e) {
if (attempt == attempts) throw e;
}
}
}
}
static class Caching implements FareService {
private final FareService inner;
private final Map<String, Integer> cache = new HashMap<>();
Caching(FareService inner) { this.inner = inner; }
public int quote(String rideId) {
Integer hit = cache.get(rideId);
if (hit != null) return hit;
int pence = inner.quote(rideId);
cache.put(rideId, pence);
return pence;
}
}
static class Logging implements FareService {
private final FareService inner;
private final List<String> log;
Logging(FareService inner, List<String> log) { this.inner = inner; this.log = log; }
public int quote(String rideId) {
int pence = inner.quote(rideId);
log.add("quote " + rideId + " -> " + pence + "p");
return pence;
}
}
public static void main(String[] args) {
List<String> log = new ArrayList<>();
RemoteFares remote = new RemoteFares();
FareService fares = new Logging(new Caching(new Retrying(remote, 3)), log); // outermost first
for (String ride : List.of("R-1", "R-1", "R-2")) System.out.println(ride + ": " + fares.quote(ride) + "p");
System.out.println("log: " + String.join(" | ", log));
System.out.println("remote calls: " + remote.calls + " (two timeouts retried, one answer from the cache)");
List<String> log2 = new ArrayList<>();
FareService swapped = new Caching(new Logging(new Retrying(new RemoteFares(), 3), log2)); // same wrappers, another order
for (String ride : List.of("R-1", "R-1", "R-2")) swapped.quote(ride);
System.out.println("with logging inside the cache: " + log2.size() + " log lines for 3 quotes");
}
}Output (the same in all three languages)
R-1: 280p R-1: 280p R-2: 120p log: quote R-1 -> 280p | quote R-1 -> 280p | quote R-2 -> 120p remote calls: 4 (two timeouts retried, one answer from the cache) with logging inside the cache: 2 log lines for 3 quotes
6. Try it
Stack the wrappers
Three quotes run through your stack: R-1, R-1 again, then R-2. The remote service times out on its 1st and 3rd calls. Turn layers on or off and change their order.
The top of the list is the outermost layer: the first one a call meets.
7. When not to use it
What you get
- Behaviours mix and match: each caller stacks what it needs.
- Small classes: one concern each, easy to test.
- No edits to the real object: add behaviour around code you don't own.
- Runtime choice: build the stack from configuration.
What you pay
- Order is a hidden decision: the same layers in another order behave differently.
- Many tiny objects: debugging means stepping through layers.
- Identity is lost: the wrapper isn't the original object, so
ischecks and type checks fail. - Wide interfaces hurt: every decorator must forward every method.
| If… | Use instead |
|---|---|
| You need a different interface, not extra behaviour | 06 Adapter |
| You need to control access (laziness, permissions, remoteness) | 09 Proxy |
| The behaviour is one fixed step every call needs | Put it in the class |
| Your framework has middleware or interceptors | Use them: they're decorators already |
8. How it goes wrong
quote() of a two-method interface silently breaks the second. Generate or inherit the forwarding.Caching only works when Logging is outside it, they aren't independent layers any more.9. Remember it and say it
How this hook works: what “Parcel in layers” means
What the slogan means, phrase by phrase
- “Parcel”
- The interface: always a
FareService. - “in layers”
- Decorators wrapping decorators.
- “one protection”
- One concern per wrapper.
Why this picture fits: Packing makes layering physical: you can see that each layer is independent and that order matters (envelope outside, box inside).
The story → the concept
| The parcel | The code |
|---|---|
| The phone | RemoteFares |
| Box, bubble wrap, envelope | Retrying, Caching, Logging |
| The address label | The FareService interface |
| Repacking in another order | Changing what gets logged or retried |
Watch it on an example
- The screen asks for R-1's fare.
- Logging passes it in, Caching misses, Retrying calls the remote: a timeout, then 280p.
- The second R-1 stops at Caching: logged, but no remote call.
The trap it saves you from
Cramming logging, caching and retries into one class, or one subclass per combination.
It still works when…
Yes: Python's @functools.cache decorator, Java's BufferedInputStream, and HTTP middleware all wrap the same interface.
In an interview or code review
- Say the same-interface part: "Each wrapper is itself a
FareService, so they stack: logging around caching around retries." - State the order and why: "Cache outside the retries, so a cached answer never touches the network; logging outermost, so every request is recorded."
- Contrast: decorator adds behaviour with the same interface; adapter changes the interface; proxy controls access.
- Real examples: Java I/O streams, Python's
@functools.lru_cache, web middleware.
10. Check yourself
What must a decorator share with the object it wraps?
Logging(Caching(Retrying(remote))): the second quote for R-1. What happens?
Three independent behaviours via subclassing need how many classes for every mix?
Which change is a decorator's job?
What's a common decorator pitfall?
11. Go deeper
| Resource | Why |
|---|---|
Python functools.lru_cache | A caching decorator for functions, built into the language. |
Java BufferedInputStream | The classic standard-library decorator: buffering around any stream. |
| 09 Proxy | Same shape, different intent: controlling access. |
| Wikipedia: Decorator pattern | Structure and the comparison with subclassing. |