Design Patterns, Explained Simply · Structural Patterns: Fitting Objects Together
Decorator
Add behaviour to an object, such as logging, caching or retries, by wrapping it in objects with the same interface. Stack the wrappers in any combination without subclassing.
The problem
A service fetches data over HTTP. Then you need logging. Then caching for some callers but not others. Then automatic retries, because the remote API fails occasionally. With subclasses you get LoggingFetcher, CachingFetcher, CachingLoggingFetcher, RetryingCachingLoggingFetcher... one class per combination, and the order of features (cache before retry? after?) is frozen into class names.
Real-world analogy
Getting dressed for cold weather: a shirt, then a sweater, then a raincoat. Each layer wraps the previous one and adds something (warmth, waterproofing), and you can choose which layers to wear today. You are still "a person wearing clothes" from the outside, however many layers there are.
Without the pattern
class Fetcher: ...
class LoggingFetcher(Fetcher): ...
class CachingFetcher(Fetcher): ...
class LoggingCachingFetcher(CachingFetcher): ... # one class per combination
class RetryingLoggingCachingFetcher(LoggingCachingFetcher): ...
The pattern
- Component interface: what clients call (
fetch(url)). - Concrete component: the real work (
HttpFetcher). - Decorators: implement the same interface, hold a wrapped component, and add behaviour before or after delegating to it (
LoggingFetcher,CachingFetcher,RetryFetcher). - Because a decorator is a component, decorators can wrap other decorators: you compose features by stacking wrappers, and the order is a runtime choice.
The code
FlakyHttp fails the first call, to show the retry decorator at work. The stack is: logging → cache → retry → the real fetcher.
class FlakyHttp: # concrete component: fails the first call
def __init__(self):
self.calls = 0
def fetch(self, url):
self.calls += 1
if self.calls == 1:
raise ConnectionError("timeout")
return f"<data from {url}>"
class RetryFetcher: # decorator: same interface, wraps another fetcher
def __init__(self, inner, attempts):
self.inner, self.attempts = inner, attempts
def fetch(self, url):
for attempt in range(1, self.attempts + 1):
try:
return self.inner.fetch(url)
except ConnectionError:
print(f" retry: attempt {attempt} failed")
raise ConnectionError("gave up")
class CachingFetcher:
def __init__(self, inner):
self.inner, self.cache = inner, {}
def fetch(self, url):
if url not in self.cache:
self.cache[url] = self.inner.fetch(url)
else:
print(" cache: hit")
return self.cache[url]
class LoggingFetcher:
def __init__(self, inner):
self.inner = inner
def fetch(self, url):
print(f"log: GET {url}")
return self.inner.fetch(url)
http = FlakyHttp()
fetcher = LoggingFetcher(CachingFetcher(RetryFetcher(http, attempts=3)))
print(fetcher.fetch("/users/1"))
print(fetcher.fetch("/users/1"))
print("real HTTP calls:", http.calls)
Output
log: GET /users/1 retry: attempt 1 failed <data from /users/1> log: GET /users/1 cache: hit <data from /users/1> real HTTP calls: 2
interface Fetcher {
fetch(url: string): string;
}
class FlakyHttp implements Fetcher { // concrete component: fails the first call
calls = 0;
fetch(url: string): string {
this.calls += 1;
if (this.calls === 1) throw new Error("timeout");
return `<data from ${url}>`;
}
}
class RetryFetcher implements Fetcher { // decorator: same interface, wraps another fetcher
constructor(private inner: Fetcher, private attempts: number) {}
fetch(url: string): string {
for (let attempt = 1; attempt <= this.attempts; attempt++) {
try {
return this.inner.fetch(url);
} catch {
console.log(` retry: attempt ${attempt} failed`);
}
}
throw new Error("gave up");
}
}
class CachingFetcher implements Fetcher {
private cache = new Map<string, string>();
constructor(private inner: Fetcher) {}
fetch(url: string): string {
if (!this.cache.has(url)) this.cache.set(url, this.inner.fetch(url));
else console.log(" cache: hit");
return this.cache.get(url)!;
}
}
class LoggingFetcher implements Fetcher {
constructor(private inner: Fetcher) {}
fetch(url: string): string {
console.log(`log: GET ${url}`);
return this.inner.fetch(url);
}
}
const http = new FlakyHttp();
const fetcher = new LoggingFetcher(new CachingFetcher(new RetryFetcher(http, 3)));
console.log(fetcher.fetch("/users/1"));
console.log(fetcher.fetch("/users/1"));
console.log("real HTTP calls:", http.calls);
Output
log: GET /users/1 retry: attempt 1 failed <data from /users/1> log: GET /users/1 cache: hit <data from /users/1> real HTTP calls: 2
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
FlakyHttp http = new FlakyHttp();
Fetcher fetcher = new LoggingFetcher(new CachingFetcher(new RetryFetcher(http, 3)));
System.out.println(fetcher.fetch("/users/1"));
System.out.println(fetcher.fetch("/users/1"));
System.out.println("real HTTP calls: " + http.calls);
}
}
interface Fetcher {
String fetch(String url);
}
class FlakyHttp implements Fetcher { // concrete component: fails the first call
int calls = 0;
public String fetch(String url) {
calls++;
if (calls == 1) throw new RuntimeException("timeout");
return "<data from " + url + ">";
}
}
class RetryFetcher implements Fetcher { // decorator: same interface, wraps another fetcher
private final Fetcher inner;
private final int attempts;
RetryFetcher(Fetcher inner, int attempts) { this.inner = inner; this.attempts = attempts; }
public String fetch(String url) {
for (int attempt = 1; attempt <= attempts; attempt++) {
try {
return inner.fetch(url);
} catch (RuntimeException e) {
System.out.println(" retry: attempt " + attempt + " failed");
}
}
throw new RuntimeException("gave up");
}
}
class CachingFetcher implements Fetcher {
private final Fetcher inner;
private final Map<String, String> cache = new HashMap<>();
CachingFetcher(Fetcher inner) { this.inner = inner; }
public String fetch(String url) {
if (!cache.containsKey(url)) cache.put(url, inner.fetch(url));
else System.out.println(" cache: hit");
return cache.get(url);
}
}
class LoggingFetcher implements Fetcher {
private final Fetcher inner;
LoggingFetcher(Fetcher inner) { this.inner = inner; }
public String fetch(String url) {
System.out.println("log: GET " + url);
return inner.fetch(url);
}
}
Output
log: GET /users/1 retry: attempt 1 failed <data from /users/1> log: GET /users/1 cache: hit <data from /users/1> real HTTP calls: 2
Run this on your computer — see “Get Started” for setup.
<?php
interface Fetcher {
public function fetch(string $url): string;
}
class FlakyHttp implements Fetcher { // concrete component: fails the first call
public int $calls = 0;
public function fetch(string $url): string {
$this->calls++;
if ($this->calls === 1) throw new RuntimeException("timeout");
return "<data from $url>";
}
}
class RetryFetcher implements Fetcher { // decorator: same interface, wraps another fetcher
public function __construct(private Fetcher $inner, private int $attempts) {}
public function fetch(string $url): string {
for ($attempt = 1; $attempt <= $this->attempts; $attempt++) {
try {
return $this->inner->fetch($url);
} catch (RuntimeException) {
echo " retry: attempt $attempt failed\n";
}
}
throw new RuntimeException("gave up");
}
}
class CachingFetcher implements Fetcher {
private array $cache = [];
public function __construct(private Fetcher $inner) {}
public function fetch(string $url): string {
if (!array_key_exists($url, $this->cache)) $this->cache[$url] = $this->inner->fetch($url);
else echo " cache: hit\n";
return $this->cache[$url];
}
}
class LoggingFetcher implements Fetcher {
public function __construct(private Fetcher $inner) {}
public function fetch(string $url): string {
echo "log: GET $url\n";
return $this->inner->fetch($url);
}
}
$http = new FlakyHttp();
$fetcher = new LoggingFetcher(new CachingFetcher(new RetryFetcher($http, 3)));
echo $fetcher->fetch("/users/1"), "\n";
echo $fetcher->fetch("/users/1"), "\n";
echo "real HTTP calls: ", $http->calls, "\n";
Output
log: GET /users/1 retry: attempt 1 failed <data from /users/1> log: GET /users/1 cache: hit <data from /users/1> real HTTP calls: 2
The first call was logged, retried once and cached; the second was served from the cache. Only two real HTTP calls happened. Changing the order (for example, logging inside the cache so cache hits aren't logged) is just a different wrapping order.
When to use it
- You want to add cross-cutting behaviour (logging, caching, retries, metrics, authorization, compression) to objects without changing their classes.
- Features must be combined in different ways for different callers, chosen at runtime.
- Subclassing would multiply classes, or the class is
final/ from a library.
When not to use it
- There is only one fixed combination. Putting the behaviour directly in the class may be simpler.
- Order matters, and many layers are hard to debug. Deep stacks of wrappers make stack traces long and behaviour harder to follow.
- Code that checks the concrete type (
instanceof HttpFetcher) breaks when the object is wrapped.
You've already used it
- Java I/O:
new BufferedReader(new InputStreamReader(new FileInputStream(f))), the textbook decorator stack. - Python: function decorators like
@functools.lru_cacheand@retrywrap a function with extra behaviour, the same idea applied to functions. - Middleware: Express, Laravel and ASP.NET middleware wrap a request handler with logging, auth and compression layers.
Key points
- A decorator wraps an object with the same interface and adds behaviour before or after delegating.
- Decorators stack in any order, avoiding a subclass for every combination.
- Ideal for cross-cutting concerns: logging, caching, retries, metrics, auth.
- Python's
@decoratorsyntax applies the same idea to functions. - Further reading: Refactoring Guru: Decorator and Python's functools.
Practice questions
How does the Decorator pattern differ from subclassing for adding behaviour?
Answer
Subclassing fixes a combination of features at compile time and needs a class per combination. Decorators wrap an object with the same interface at runtime, so features stack in any combination and order, and the original class stays unchanged.
Give a classic example of the Decorator pattern in the Java standard library.
Answer
Java I/O streams: new BufferedReader(new InputStreamReader(new FileInputStream(file))). Each layer implements a reader or stream interface and adds buffering, character decoding and so on to the one it wraps.
Is a Python `@decorator` the same as the Decorator pattern?
Answer
It is the same idea applied to functions: a function wraps another and adds behaviour while keeping the same call signature (@lru_cache, @retry). The GoF pattern wraps objects that share an interface. The syntax is different; the intent is the same.
Why does the order of decorators matter?
Answer
Each wrapper runs around the ones inside it. Logging outside a cache logs every call, including cache hits; logging inside it logs only real fetches. Retry outside a cache retries cache misses; retry inside protects only the real call.
Open in the app to mark lessons complete, take notes and track your progress.