Intent
Attach additional behavior to an object dynamically by wrapping it in another object that shares the same interface. Decorators give you a flexible alternative to subclassing for extending what an object does.
The Problem
You've got a small HttpClient class. It does one thing — send a request and return a response. Then the requirements show up.
"Add caching for GETs."
"Retry on 5xx, three times with backoff."
"Log every request and response for the audit trail."
"And while you're in there, redact authorization headers from the logs."
The naive path is to keep adding methods, then keep adding boolean flags to the constructor: new HttpClient({ cache: true, retries: 3, log: true, redact: true }). Six months later, the class is 800 lines of intermixed concerns and the test suite is a pyramid of if (this.cache && !this.retries && this.log) permutations.
Subclassing isn't the answer either — CachingRetryingLoggingHttpClient is a real class name people end up writing, and when you add the next concern you get a class hierarchy that no one can navigate.
The Solution
Decorator says: every "added behavior" is a separate object that wraps the original and shares its interface. Callers see one HttpClient and don't care how many layers are inside.
interface HttpClient
{
public function get(string $url): Response;
}
final class LoggingClient implements HttpClient
{
public function __construct(private HttpClient $inner) {}
public function get(string $url): Response
{
$this->log("GET $url");
$response = $this->inner->get($url);
$this->log("← {$response->status}");
return $response;
}
}
Now you compose the client like onion layers: new LoggingClient(new RetryClient(new CachingClient(new BasicClient()))). Caller code receives an HttpClient and never knows which layers are present. Adding a new concern is a new class — no edits, no flags, no permutation tests.
Real-World Analogy
Wrapping a gift. You start with the gift inside the box — that's the core object, and it's the only thing the recipient actually wanted. You add wrapping paper around it. You add a ribbon over the paper. You stick a bow on top. Each layer is optional, each layer is independent, and the gift inside is exactly the same gift whether it's wrapped or not.
The recipient unwraps it layer by layer; the giver added them layer by layer. Both sides only know about the layer they're currently dealing with.
Structure
Four roles you'll see in every Decorator implementation:
- Component — the interface every object in the chain agrees on. Here:
HttpClient. - Concrete Component — the original, unwrapped object. Here:
BasicHttpClient. - Decorator — an abstract base (or just a convention) that holds a reference to a wrapped Component and delegates to it.
- Concrete Decorator — a specific layer that adds behavior before, after, or instead of delegating. Here:
CachingClient,RetryClient,LoggingClient.
The defining property: the Decorator and its wrapped Component share exactly the same interface. That's why you can stack arbitrarily many of them and the caller never notices.
Code Examples
Here's an HTTP client wrapped in two decorators in five languages. Watch how each Decorator's get() method delegates to inner.get() while adding its own bit on top or beneath.
interface HttpClient {
get(url: string): Promise<Response>;
}
class BasicHttpClient implements HttpClient {
async get(url: string): Promise<Response> {
return fetch(url);
}
}
class CachingClient implements HttpClient {
private cache = new Map<string, Response>();
constructor(private inner: HttpClient) {}
async get(url: string): Promise<Response> {
const hit = this.cache.get(url);
if (hit) return hit;
const response = await this.inner.get(url);
this.cache.set(url, response);
return response;
}
}
class LoggingClient implements HttpClient {
constructor(private inner: HttpClient) {}
async get(url: string): Promise<Response> {
console.log(`GET ${url}`);
const response = await this.inner.get(url);
console.log(`← ${response.status}`);
return response;
}
}
// Compose: logs the cached calls too.
const client = new LoggingClient(new CachingClient(new BasicHttpClient()));
from abc import ABC, abstractmethod
class HttpClient(ABC):
@abstractmethod
def get(self, url): ...
class BasicHttpClient(HttpClient):
def get(self, url):
return requests.get(url)
class CachingClient(HttpClient):
def __init__(self, inner):
self._inner = inner
self._cache = {}
def get(self, url):
if url not in self._cache:
self._cache[url] = self._inner.get(url)
return self._cache[url]
class LoggingClient(HttpClient):
def __init__(self, inner):
self._inner = inner
def get(self, url):
print(f"GET {url}")
response = self._inner.get(url)
print(f"← {response.status_code}")
return response
client = LoggingClient(CachingClient(BasicHttpClient()))
public interface HttpClient {
Response get(String url);
}
public final class BasicHttpClient implements HttpClient {
@Override
public Response get(String url) { /* ... */ return new Response(); }
}
public final class CachingClient implements HttpClient {
private final HttpClient inner;
private final Map<String, Response> cache = new HashMap<>();
public CachingClient(HttpClient inner) { this.inner = inner; }
@Override
public Response get(String url) {
return cache.computeIfAbsent(url, inner::get);
}
}
public final class LoggingClient implements HttpClient {
private final HttpClient inner;
public LoggingClient(HttpClient inner) { this.inner = inner; }
@Override
public Response get(String url) {
System.out.println("GET " + url);
Response response = inner.get(url);
System.out.println("← " + response.status());
return response;
}
}
HttpClient client = new LoggingClient(new CachingClient(new BasicHttpClient()));
<?php
namespace App\Http;
interface HttpClient
{
public function get(string $url): Response;
}
final class BasicHttpClient implements HttpClient
{
public function get(string $url): Response { /* ... */ }
}
final class CachingClient implements HttpClient
{
private array $cache = [];
public function __construct(private HttpClient $inner) {}
public function get(string $url): Response
{
return $this->cache[$url] ??= $this->inner->get($url);
}
}
final class LoggingClient implements HttpClient
{
public function __construct(private HttpClient $inner, private LoggerInterface $log) {}
public function get(string $url): Response
{
$this->log->info("GET $url");
$response = $this->inner->get($url);
$this->log->info("← {$response->status}");
return $response;
}
}
$client = new LoggingClient(new CachingClient(new BasicHttpClient()), $logger);
package http
type Client interface {
Get(url string) (Response, error)
}
type BasicClient struct{}
func (BasicClient) Get(url string) (Response, error) { /* ... */ return Response{}, nil }
type CachingClient struct {
inner Client
cache map[string]Response
}
func NewCachingClient(inner Client) *CachingClient {
return &CachingClient{inner: inner, cache: map[string]Response{}}
}
func (c *CachingClient) Get(url string) (Response, error) {
if hit, ok := c.cache[url]; ok {
return hit, nil
}
resp, err := c.inner.Get(url)
if err == nil {
c.cache[url] = resp
}
return resp, err
}
type LoggingClient struct {
inner Client
logger Logger
}
func NewLoggingClient(inner Client, logger Logger) *LoggingClient {
return &LoggingClient{inner: inner, logger: logger}
}
func (c *LoggingClient) Get(url string) (Response, error) {
c.logger.Info("GET " + url)
resp, err := c.inner.Get(url)
c.logger.Info("← status")
return resp, err
}
// client := NewLoggingClient(NewCachingClient(BasicClient{}), logger)
The order matters. LoggingClient(CachingClient(BasicClient)) logs every call your code makes — including the ones served from cache. CachingClient(LoggingClient(BasicClient)) only logs misses. Same three components, two different behaviors. That's the whole point.
When to Use It
Reach for Decorator when you can answer "yes" to any of these:
- You want to add behavior to an object at runtime, not at class-definition time. Caching, retries, logging, instrumentation, rate-limiting, redaction — concerns that come and go based on environment or config.
- The behaviors are independent and can be combined freely. Caching doesn't know about retries, retries don't know about logging, but you can stack them in any order.
- Subclassing would explode the class hierarchy. If you'd otherwise need
CachingHttpClient,LoggingHttpClient,CachingLoggingHttpClient,RetryingCachingLoggingHttpClient— that's the smell Decorator removes. - You can't or shouldn't modify the original class. Third-party library, frozen API, stable core — Decorator wraps it from outside without touching it.
If you only ever have one wrapper and you'll never have more, just inline the behavior. The pattern earns its cost when combinations matter.
Pros and Cons
Pros
- Add or remove behavior at runtime without touching existing code.
- Each behavior is a small, focused class — easy to test on its own.
- Combine behaviors in any order. The number of configurations is the product of the number of decorators, but you only write one class per decorator.
- The Concrete Component stays pristine. No flags, no conditionals, no concerns it shouldn't know about.
Cons
- Stack traces and debugging get longer. A request through five decorators bounces through five
get()methods on its way to the bottom. - Order of composition matters and isn't always obvious. Document it.
- If your decorators need to share state (a shared cache, a shared rate-limit counter), you have to inject it in — which can feel awkward.
Pro Tips
- Keep each decorator single-purpose. "CachingClient" should only cache. If you want to add metrics for the cache, that's a separate
MetricsClientwrapping theCachingClient. Resist the urge to combine. - Make the inner reference explicit and immutable. Pass it through the constructor; never mutate it later. The decorator chain should be assembled once and never modified.
- Write a small factory for the assembled chain. Hand-rolling
new LoggingClient(new RetryClient(new CachingClient(new BasicHttpClient())))is fine in a test; in production code, hide it in aHttpClientFactory::default()so the order is set in one place. - Watch for "decorator" that's actually a Proxy. Decorator adds behavior to an interface; Proxy controls access to it (auth, lazy loading, remote calls). They look identical at a glance — separate them by intent.
Relations with Other Patterns
- Strategy replaces an algorithm; Decorator adds behavior on top of one. If you find yourself wrapping a strategy in another strategy, what you actually want is a Decorator.
- Composite has a similar tree shape — both compose objects sharing an interface — but Composite is about treating a group of objects uniformly with a single one; Decorator is about layering single behaviors.
- Proxy has the same structure as Decorator: a wrapper that shares the wrapped object's interface. The difference is intent: a Proxy controls access to its target (lazy load, security check, remote call), while a Decorator adds behavior. If your wrapper's job is "decide whether to let this through," it's a Proxy.
- Adapter changes an interface; Decorator preserves it. If the wrapper exposes the same methods, it's a Decorator. If it exposes different ones, it's an Adapter.
Final Tips
The Decorator that earned its keep for me was an HTTP client we used to talk to a flaky third-party API. We started with a BasicClient. Then a RetryClient with exponential backoff. Then an IdempotencyClient that added an Idempotency-Key header to every POST. Then a MetricsClient that emitted a histogram of latencies. Each one was 30 lines, each one was its own test file, and the order they composed in was documented in the factory.
When the third-party rate-limited us six months later, "add a RateLimitClient to the chain" was a 40-line PR that touched no existing code. That's the deep promise of Decorator — every concern is a layer, and layers come off as easily as they go on.


