Decorator Pattern
The Decorator pattern attaches additional behavior to an object dynamically by wrapping it. Each decorator implements the same interface as the component it wraps, forwarding calls while adding its own logic before or after. You can stack multiple decorators to compose complex behavior from simple pieces.
Why this matters: When you need to add cross-cutting concerns (caching, logging, retry, compression, encryption) without polluting the core logic, Decorator is the cleanest approach. Multi-level caching is literally stacking decorators.
Prerequisites
- SOLID Principles โ Decorator satisfies OCP (add behavior without modifying existing code)
- Inheritance vs Composition โ Decorator favors composition
Class Diagram
classDiagram
class DataSource {
<<interface>>
+read(key) String
+write(key, value)
}
class DatabaseSource {
+read(key) String
+write(key, value)
}
class CachingDecorator {
-wrapped: DataSource
-cache: Map
+read(key) String
+write(key, value)
}
class LoggingDecorator {
-wrapped: DataSource
+read(key) String
+write(key, value)
}
class CompressionDecorator {
-wrapped: DataSource
+read(key) String
+write(key, value)
}
DataSource <|.. DatabaseSource
DataSource <|.. CachingDecorator
DataSource <|.. LoggingDecorator
DataSource <|.. CompressionDecorator
CachingDecorator --> DataSource
LoggingDecorator --> DataSource
CompressionDecorator --> DataSource
Real-World Example: Multi-Level Cache
// Component interface
public interface DataSource {
String read(String key);
void write(String key, String value);
}
// Concrete component: actual database
public class DatabaseSource implements DataSource {
private final JdbcTemplate db;
public DatabaseSource(JdbcTemplate db) { this.db = db; }
@Override
public String read(String key) {
return db.queryForObject("SELECT value FROM kv WHERE key = ?", String.class, key);
}
@Override
public void write(String key, String value) {
db.update("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT UPDATE SET value = ?",
key, value, value);
}
}
// Decorator: in-memory L1 cache
public class L1CacheDecorator implements DataSource {
private final DataSource wrapped;
private final Map<String, CacheEntry> cache;
private final Duration ttl;
public L1CacheDecorator(DataSource wrapped, int maxSize, Duration ttl) {
this.wrapped = wrapped;
this.cache = new LinkedHashMap<>(maxSize, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
return size() > maxSize;
}
};
this.ttl = ttl;
}
@Override
public String read(String key) {
CacheEntry entry = cache.get(key);
if (entry != null && !entry.isExpired(ttl)) {
return entry.getValue(); // L1 hit
}
String value = wrapped.read(key); // Delegate to next layer
if (value != null) {
cache.put(key, new CacheEntry(value, Instant.now()));
}
return value;
}
@Override
public void write(String key, String value) {
cache.remove(key); // Invalidate L1
wrapped.write(key, value); // Delegate
}
}
// Decorator: Redis L2 cache
public class L2RedisCacheDecorator implements DataSource {
private final DataSource wrapped;
private final RedisClient redis;
private final Duration ttl;
public L2RedisCacheDecorator(DataSource wrapped, RedisClient redis, Duration ttl) {
this.wrapped = wrapped;
this.redis = redis;
this.ttl = ttl;
}
@Override
public String read(String key) {
String cached = redis.get(key);
if (cached != null) return cached; // L2 hit
String value = wrapped.read(key); // Miss -- go to DB
if (value != null) {
redis.setex(key, ttl.getSeconds(), value);
}
return value;
}
@Override
public void write(String key, String value) {
redis.del(key); // Invalidate L2
wrapped.write(key, value);
}
}
// Decorator: logging
public class LoggingDecorator implements DataSource {
private final DataSource wrapped;
private final Logger logger;
public LoggingDecorator(DataSource wrapped, Logger logger) {
this.wrapped = wrapped;
this.logger = logger;
}
@Override
public String read(String key) {
long start = System.nanoTime();
String result = wrapped.read(key);
long elapsed = System.nanoTime() - start;
logger.debug("READ key={} elapsed={}ms hit={}", key, elapsed / 1_000_000, result != null);
return result;
}
@Override
public void write(String key, String value) {
wrapped.write(key, value);
logger.info("WRITE key={} size={}", key, value.length());
}
}
// Compose decorators: L1 -> L2 -> DB (with logging around the whole stack)
DataSource db = new DatabaseSource(jdbc);
DataSource withL2 = new L2RedisCacheDecorator(db, redis, Duration.ofMinutes(5));
DataSource withL1 = new L1CacheDecorator(withL2, 1000, Duration.ofSeconds(30));
DataSource dataSource = new LoggingDecorator(withL1, logger);
// Client uses dataSource -- doesn't know about caching layers
String value = dataSource.read("user:123");
from abc import ABC, abstractmethod
from functools import lru_cache
import time
class DataSource(ABC):
@abstractmethod
def read(self, key: str) -> str | None: ...
@abstractmethod
def write(self, key: str, value: str): ...
class DatabaseSource(DataSource):
def __init__(self, connection):
self._conn = connection
def read(self, key: str) -> str | None:
row = self._conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone()
return row[0] if row else None
def write(self, key: str, value: str):
self._conn.execute("INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)", (key, value))
class L1CacheDecorator(DataSource):
def __init__(self, wrapped: DataSource, max_size: int = 1000, ttl_seconds: float = 30):
self._wrapped = wrapped
self._cache: dict[str, tuple[str, float]] = {}
self._max_size = max_size
self._ttl = ttl_seconds
def read(self, key: str) -> str | None:
if key in self._cache:
value, timestamp = self._cache[key]
if time.time() - timestamp < self._ttl:
return value # L1 hit
del self._cache[key]
value = self._wrapped.read(key)
if value is not None:
if len(self._cache) >= self._max_size:
oldest = next(iter(self._cache))
del self._cache[oldest]
self._cache[key] = (value, time.time())
return value
def write(self, key: str, value: str):
self._cache.pop(key, None) # Invalidate
self._wrapped.write(key, value)
class LoggingDecorator(DataSource):
def __init__(self, wrapped: DataSource):
self._wrapped = wrapped
def read(self, key: str) -> str | None:
start = time.perf_counter()
result = self._wrapped.read(key)
elapsed = (time.perf_counter() - start) * 1000
print(f"READ key={key} elapsed={elapsed:.2f}ms hit={result is not None}")
return result
def write(self, key: str, value: str):
self._wrapped.write(key, value)
print(f"WRITE key={key} size={len(value)}")
# Compose: logging -> L1 cache -> database
db = DatabaseSource(connection)
cached = L1CacheDecorator(db, max_size=500, ttl_seconds=60)
data_source = LoggingDecorator(cached)
class DataSource {
public:
virtual ~DataSource() = default;
virtual optional<string> read(const string& key) = 0;
virtual void write(const string& key, const string& value) = 0;
};
class DatabaseSource : public DataSource {
public:
optional<string> read(const string& key) override {
// DB query
return db_.query(key);
}
void write(const string& key, const string& value) override {
db_.upsert(key, value);
}
};
class CachingDecorator : public DataSource {
unique_ptr<DataSource> wrapped_;
unordered_map<string, pair<string, TimePoint>> cache_;
chrono::seconds ttl_;
public:
CachingDecorator(unique_ptr<DataSource> wrapped, chrono::seconds ttl)
: wrapped_(std::move(wrapped)), ttl_(ttl) {}
optional<string> read(const string& key) override {
auto it = cache_.find(key);
if (it != cache_.end()) {
auto age = Clock::now() - it->second.second;
if (age < ttl_) return it->second.first;
cache_.erase(it);
}
auto value = wrapped_->read(key);
if (value) cache_[key] = {*value, Clock::now()};
return value;
}
void write(const string& key, const string& value) override {
cache_.erase(key);
wrapped_->write(key, value);
}
};
class LoggingDecorator : public DataSource {
unique_ptr<DataSource> wrapped_;
public:
explicit LoggingDecorator(unique_ptr<DataSource> wrapped)
: wrapped_(std::move(wrapped)) {}
optional<string> read(const string& key) override {
auto start = Clock::now();
auto result = wrapped_->read(key);
auto elapsed = chrono::duration_cast<chrono::milliseconds>(Clock::now() - start);
cout << "READ key=" << key << " elapsed=" << elapsed.count() << "ms" << endl;
return result;
}
void write(const string& key, const string& value) override {
wrapped_->write(key, value);
cout << "WRITE key=" << key << endl;
}
};
// Compose
auto source = make_unique<LoggingDecorator>(
make_unique<CachingDecorator>(
make_unique<DatabaseSource>(),
chrono::seconds(60)
)
);
Decorator vs Inheritance
| Decorator | Inheritance |
|---|---|
| Add behavior at runtime | Behavior fixed at compile time |
| Compose multiple behaviors freely | Leads to class explosion (LoggingCachingCompressingDB) |
| Same interface as wrapped object | Tightly coupled to parent class |
| Can be removed/replaced at runtime | Permanent once compiled |
When to Use vs When to Avoid
| Use Decorator When | Avoid When |
|---|---|
| Adding cross-cutting concerns (cache, log, retry, auth) | The core behavior is what changes (use Strategy instead) |
| Behaviors need to be composed in different combinations | Only one combination will ever exist |
| You want to add/remove behavior at runtime | Behavior is permanently required |
| Subclassing would create a combinatorial explosion | One or two fixed subclasses suffice |
Interview Questions
-
โDecorator vs Proxy โ whatโs the difference?โ โ Decorator adds new behavior. Proxy controls access to an existing object (lazy loading, access control, remote proxy). Similar structure, different intent.
-
โHow do you order decorators?โ โ Order matters.
Cache(Log(DB))logs cache misses only.Log(Cache(DB))logs everything. Think about what behavior you want at each layer. -
โDoesnโt this create too many small classes?โ โ Yes, thatโs the tradeoff. But each class is simple, testable, and reusable. Compare to one massive class that does caching + logging + compression.
-
โHow does this relate to Javaโs I/O streams?โ โ
BufferedInputStream(new FileInputStream(file))is exactly Decorator. Each stream wraps another, adding buffering, compression, or encryption.