Episode 9 — System Design / 9.4 — Structural Design Patterns
9.4.d -- Decorator Pattern
In one sentence: The Decorator pattern lets you attach new behaviors to objects dynamically by wrapping them in special objects that add functionality -- like stacking toppings on a pizza where each topping wraps the previous layer without changing the pizza itself.
Navigation: <- 9.4.c -- Proxy | 9.4.e -- Composite ->
Table of Contents
- 1. What is the Decorator Pattern?
- 2. The Problem It Solves
- 3. Structure and Participants
- 4. Express Middleware as Decorators
- 5. Logging Decorator
- 6. Auth Decorator
- 7. Caching Decorator
- 8. Function Composition Approach in JavaScript
- 9. Before and After Comparison
- 10. Decorator vs Proxy vs Adapter
- 11. When to Use and When to Avoid
- 12. Key Takeaways
- 13. Explain-It Challenge
1. What is the Decorator Pattern?
The Decorator pattern attaches additional responsibilities to an object dynamically. It provides a flexible alternative to subclassing for extending functionality. Each decorator wraps the original object and adds behavior before or after delegating to it.
WITHOUT DECORATOR (inheritance explosion):
DataSource
|
+-- FileDataSource
+-- DatabaseDataSource
+-- EncryptedFileDataSource Too many
+-- CompressedFileDataSource subclasses!
+-- EncryptedCompressedFileDataSource
+-- LoggedEncryptedCompressedFileDataSource (explosion)
WITH DECORATOR (compose at runtime):
┌─────────────────────────────────────────────────────┐
│ LoggingDecorator │
│ ┌───────────────────────────────────────────────┐ │
│ │ EncryptionDecorator │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ CompressionDecorator │ │ │
│ │ │ ┌───────────────────────────────────┐ │ │ │
│ │ │ │ FileDataSource (core object) │ │ │ │
│ │ │ └───────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Each layer adds behavior. Stack any combination you need.
The key insight: each decorator has the same interface as the object it wraps, so decorators can be stacked infinitely.
2. The Problem It Solves
Imagine you have a notification system. You need to support:
- Email notifications
- SMS notifications
- Slack notifications
- Any combination: Email + SMS, Email + Slack, SMS + Slack, all three
With inheritance, you need a subclass for every combination:
Notifier
|-- EmailNotifier
|-- SMSNotifier
|-- SlackNotifier
|-- EmailSMSNotifier
|-- EmailSlackNotifier
|-- SMSSlackNotifier
|-- EmailSMSSlackNotifier
3 channels = 7 subclasses
4 channels = 15 subclasses
5 channels = 31 subclasses <-- combinatorial explosion!
With decorators, you have:
Notifier (base)
+ EmailDecorator
+ SMSDecorator
+ SlackDecorator
3 channels = 4 classes, combine however you want at runtime.
3. Structure and Participants
┌────────────────────────────┐
│ <<interface>> │
│ Component │
│────────────────────────────│
│ + execute(data) │
└──────────────┬─────────────┘
│
┌─────────┴──────────┐
│ │
┌────▼────────────┐ ┌───▼──────────────────┐
│ ConcreteComponent│ │ Decorator (base) │
│─────────────────│ │──────────────────────│
│ + execute(data) │ │ - wrapped: Component │
│ (core logic) │ │ + execute(data) { │
└─────────────────┘ │ wrapped.execute() │
│ } │
└───────┬──────────────┘
│
┌─────────────┼──────────────┐
│ │ │
┌──────▼──────┐ ┌───▼─────┐ ┌─────▼─────┐
│ LogDecorator│ │AuthDeco │ │CacheDeco │
│ + execute() │ │+execute │ │+execute() │
│ log() │ │ check()│ │ lookup() │
│ wrapped │ │ wrapped│ │ wrapped │
│ .execute()│ │ .exec()│ │ .exec() │
└─────────────┘ └─────────┘ └───────────┘
| Role | What it does |
|---|---|
| Component | Interface shared by core objects and decorators |
| ConcreteComponent | The original object being decorated |
| Decorator | Base class that holds a reference to a wrapped component |
| ConcreteDecorator | Adds specific behavior before/after delegating to wrapped component |
4. Express Middleware as Decorators
Express.js middleware is the decorator pattern in action. Each middleware wraps the request handler and adds behavior.
// ============================================================
// EXPRESS MIDDLEWARE AS DECORATORS -- FULL IMPLEMENTATION
// ============================================================
// Simplified Express-like request handler pipeline
class RequestHandler {
constructor(handler) {
this.handler = handler;
this.middlewares = [];
}
use(middleware) {
this.middlewares.push(middleware);
return this; // chainable
}
async handle(req, res) {
// Build the chain: each middleware wraps the next
let index = 0;
const middlewares = this.middlewares;
const finalHandler = this.handler;
const next = async () => {
if (index < middlewares.length) {
const middleware = middlewares[index++];
await middleware(req, res, next);
} else {
await finalHandler(req, res);
}
};
await next();
}
}
// ---- Middleware decorators ----
// Logging middleware (decorator)
function loggingMiddleware(req, res, next) {
const start = Date.now();
console.log(` [LOG] --> ${req.method} ${req.url}`);
await next();
console.log(` [LOG] <-- ${req.method} ${req.url} [${res.statusCode}] (${Date.now() - start}ms)`);
}
// Auth middleware (decorator)
function authMiddleware(req, res, next) {
const token = req.headers?.authorization;
if (!token || !token.startsWith('Bearer ')) {
res.statusCode = 401;
res.body = { error: 'Unauthorized' };
console.log(' [AUTH] Rejected -- no valid token');
return; // do NOT call next() -- stops the chain
}
req.user = { id: 'user_42', role: 'admin' }; // decoded from token
console.log(' [AUTH] Authenticated as user_42');
await next();
}
// Rate limiting middleware (decorator)
const rateLimitStore = new Map();
function rateLimitMiddleware(req, res, next) {
const ip = req.ip || '127.0.0.1';
const now = Date.now();
const windowMs = 60000; // 1 minute
const maxRequests = 5;
if (!rateLimitStore.has(ip)) {
rateLimitStore.set(ip, []);
}
const requests = rateLimitStore.get(ip).filter((t) => now - t < windowMs);
requests.push(now);
rateLimitStore.set(ip, requests);
if (requests.length > maxRequests) {
res.statusCode = 429;
res.body = { error: 'Too many requests' };
console.log(` [RATE] Blocked ${ip} (${requests.length} requests in window)`);
return;
}
console.log(` [RATE] ${ip} OK (${requests.length}/${maxRequests} in window)`);
await next();
}
// CORS middleware (decorator)
function corsMiddleware(req, res, next) {
res.headers = res.headers || {};
res.headers['Access-Control-Allow-Origin'] = '*';
res.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE';
console.log(' [CORS] Headers added');
await next();
}
// ---- The actual handler (core component) ----
async function getUserHandler(req, res) {
console.log(` [HANDLER] Fetching user data for ${req.user.id}`);
res.statusCode = 200;
res.body = { id: req.user.id, name: 'Alice', email: 'alice@example.com' };
}
// ---- Compose the decorators ----
const handler = new RequestHandler(getUserHandler);
handler
.use(loggingMiddleware) // outermost decorator
.use(corsMiddleware) // adds headers
.use(rateLimitMiddleware) // checks rate limits
.use(authMiddleware); // checks authentication
// Simulate a request
const req = { method: 'GET', url: '/api/users/42', headers: { authorization: 'Bearer abc123' }, ip: '192.168.1.1' };
const res = { statusCode: null, body: null, headers: {} };
console.log('=== Request with all middleware decorators ===');
handler.handle(req, res).then(() => {
console.log('\nFinal response:', res.body);
});
5. Logging Decorator
A pure class-based logging decorator that wraps any service.
// ============================================================
// LOGGING DECORATOR
// ============================================================
// ---- Base service interface ----
class UserService {
async getUser(id) {
return { id, name: 'Alice', email: 'alice@example.com' };
}
async createUser(data) {
return { id: 'new_' + Date.now(), ...data };
}
async deleteUser(id) {
return { deleted: true, id };
}
}
// ---- Logging Decorator ----
class LoggingDecorator {
constructor(service, serviceName = 'Service') {
this.service = service;
this.serviceName = serviceName;
// Dynamically wrap all methods
const methodNames = Object.getOwnPropertyNames(
Object.getPrototypeOf(service)
).filter((name) => name !== 'constructor' && typeof service[name] === 'function');
for (const method of methodNames) {
this[method] = async (...args) => {
const start = Date.now();
const argsStr = args.map((a) => JSON.stringify(a)).join(', ');
console.log(`[${this.serviceName}] ${method}(${argsStr}) -- called`);
try {
const result = await this.service[method](...args);
const duration = Date.now() - start;
console.log(`[${this.serviceName}] ${method} -- completed in ${duration}ms`);
console.log(`[${this.serviceName}] ${method} -- result:`, result);
return result;
} catch (error) {
const duration = Date.now() - start;
console.log(`[${this.serviceName}] ${method} -- FAILED in ${duration}ms: ${error.message}`);
throw error;
}
};
}
}
}
// ---- Usage ----
const userService = new UserService();
const loggedUserService = new LoggingDecorator(userService, 'UserService');
async function demo() {
await loggedUserService.getUser('42');
await loggedUserService.createUser({ name: 'Bob', email: 'bob@test.com' });
}
demo();
Output:
[UserService] getUser("42") -- called
[UserService] getUser -- completed in 0ms
[UserService] getUser -- result: { id: '42', name: 'Alice', email: 'alice@example.com' }
[UserService] createUser({"name":"Bob","email":"bob@test.com"}) -- called
[UserService] createUser -- completed in 0ms
[UserService] createUser -- result: { id: 'new_1712345678', name: 'Bob', email: 'bob@test.com' }
6. Auth Decorator
// ============================================================
// AUTH DECORATOR
// ============================================================
class OrderService {
async getOrder(orderId) {
return { id: orderId, items: ['Widget', 'Gadget'], total: 49.99 };
}
async cancelOrder(orderId) {
return { id: orderId, status: 'cancelled' };
}
async getAllOrders() {
return [
{ id: 'ORD-1', total: 29.99 },
{ id: 'ORD-2', total: 149.99 },
];
}
}
class AuthDecorator {
constructor(service, currentUser, permissions = {}) {
this.service = service;
this.currentUser = currentUser;
this.permissions = permissions;
// permissions example: { getOrder: ['user', 'admin'], cancelOrder: ['admin'], getAllOrders: ['admin'] }
// Wrap each method with auth check
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(service))
.filter((n) => n !== 'constructor' && typeof service[n] === 'function');
for (const method of methods) {
this[method] = async (...args) => {
const requiredRoles = this.permissions[method];
if (requiredRoles && !requiredRoles.includes(this.currentUser.role)) {
const msg = `Access denied: ${this.currentUser.role} cannot call ${method} (requires: ${requiredRoles.join(', ')})`;
console.log(` [Auth] ${msg}`);
throw new Error(msg);
}
console.log(` [Auth] ${this.currentUser.id} (${this.currentUser.role}) authorized for ${method}`);
return this.service[method](...args);
};
}
}
}
// ---- Usage: compose auth + logging ----
const orderService = new OrderService();
// Wrap with auth, then with logging
const authedOrders = new AuthDecorator(orderService, { id: 'user_1', role: 'user' }, {
getOrder: ['user', 'admin'],
cancelOrder: ['admin'],
getAllOrders: ['admin'],
});
const loggedAuthedOrders = new LoggingDecorator(authedOrders, 'OrderService');
async function demo() {
console.log('=== User can get orders ===');
await loggedAuthedOrders.getOrder('ORD-1');
console.log('\n=== User cannot cancel orders ===');
try {
await loggedAuthedOrders.cancelOrder('ORD-1');
} catch (e) {
console.log(` Caught: ${e.message}`);
}
}
demo();
7. Caching Decorator
// ============================================================
// CACHING DECORATOR
// ============================================================
class ProductService {
async getProduct(id) {
console.log(` [DB] Loading product ${id} from database...`);
// Simulate slow database query
await new Promise((r) => setTimeout(r, 100));
return { id, name: `Product ${id}`, price: 29.99, stock: 42 };
}
async searchProducts(query) {
console.log(` [DB] Searching for "${query}"...`);
await new Promise((r) => setTimeout(r, 200));
return [
{ id: '1', name: 'Widget', relevance: 0.95 },
{ id: '2', name: 'Widget Pro', relevance: 0.87 },
];
}
}
class CachingDecorator {
constructor(service, options = {}) {
this.service = service;
this.cache = new Map();
this.ttl = options.ttl || 30000; // 30 seconds
this.cachedMethods = new Set(options.methods || []); // which methods to cache
this.stats = { hits: 0, misses: 0 };
// Wrap methods
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(service))
.filter((n) => n !== 'constructor' && typeof service[n] === 'function');
for (const method of methods) {
if (this.cachedMethods.has(method)) {
this[method] = async (...args) => {
const key = `${method}:${JSON.stringify(args)}`;
const cached = this.cache.get(key);
if (cached && Date.now() - cached.time < this.ttl) {
this.stats.hits++;
console.log(` [Cache] HIT ${key}`);
return cached.data;
}
this.stats.misses++;
console.log(` [Cache] MISS ${key}`);
const result = await this.service[method](...args);
this.cache.set(key, { data: result, time: Date.now() });
return result;
};
} else {
// Non-cached methods pass through
this[method] = (...args) => this.service[method](...args);
}
}
}
clearCache() {
this.cache.clear();
console.log(' [Cache] Cleared');
}
getCacheStats() {
const total = this.stats.hits + this.stats.misses;
return {
...this.stats,
hitRate: total > 0 ? ((this.stats.hits / total) * 100).toFixed(1) + '%' : 'N/A',
};
}
}
// ---- Usage ----
async function demo() {
const productService = new ProductService();
const cachedProducts = new CachingDecorator(productService, {
ttl: 5000,
methods: ['getProduct', 'searchProducts'],
});
console.log('=== First call (miss) ===');
await cachedProducts.getProduct('42');
console.log('\n=== Second call (hit) ===');
await cachedProducts.getProduct('42');
console.log('\n=== Different args (miss) ===');
await cachedProducts.getProduct('99');
console.log('\n=== Stats ===');
console.log(cachedProducts.getCacheStats());
}
demo();
8. Function Composition Approach in JavaScript
In JavaScript, you do not always need classes for decorators. Higher-order functions (functions that wrap functions) are a natural and idiomatic approach.
// ============================================================
// FUNCTION COMPOSITION DECORATORS
// ============================================================
// ---- Base function ----
async function fetchUser(userId) {
console.log(` [fetch] Getting user ${userId} from DB`);
return { id: userId, name: 'Alice', email: 'alice@example.com' };
}
// ---- Decorator functions (higher-order functions) ----
function withLogging(fn, label = fn.name) {
return async function (...args) {
const start = Date.now();
console.log(`[LOG] ${label} called with:`, args);
try {
const result = await fn(...args);
console.log(`[LOG] ${label} returned in ${Date.now() - start}ms:`, result);
return result;
} catch (error) {
console.log(`[LOG] ${label} threw after ${Date.now() - start}ms:`, error.message);
throw error;
}
};
}
function withCache(fn, ttl = 5000) {
const cache = new Map();
return async function (...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttl) {
console.log(`[CACHE] Hit for ${key}`);
return cached.data;
}
console.log(`[CACHE] Miss for ${key}`);
const result = await fn(...args);
cache.set(key, { data: result, time: Date.now() });
return result;
};
}
function withRetry(fn, maxRetries = 3, delayMs = 1000) {
return async function (...args) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn(...args);
} catch (error) {
lastError = error;
console.log(`[RETRY] Attempt ${attempt}/${maxRetries} failed: ${error.message}`);
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
throw lastError;
};
}
function withTimeout(fn, ms = 5000) {
return async function (...args) {
return Promise.race([
fn(...args),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
),
]);
};
}
function withAuth(fn, requiredRole) {
return async function (context, ...args) {
if (!context?.user) {
throw new Error('Authentication required');
}
if (requiredRole && context.user.role !== requiredRole) {
throw new Error(`Role "${requiredRole}" required, got "${context.user.role}"`);
}
return fn(context, ...args);
};
}
// ---- Compose decorators ----
// Method 1: Manual wrapping (inside out)
const enhancedFetchUser = withLogging(withCache(withRetry(fetchUser)));
console.log('=== Composed function ===');
enhancedFetchUser('42').then(() => {
console.log('\n=== Second call (cached) ===');
return enhancedFetchUser('42');
});
// Method 2: compose utility
function compose(...decorators) {
return function (fn) {
return decorators.reduceRight((decorated, decorator) => decorator(decorated), fn);
};
}
const enhance = compose(
(fn) => withLogging(fn, 'fetchUser'),
(fn) => withCache(fn, 10000),
(fn) => withRetry(fn, 3),
(fn) => withTimeout(fn, 5000)
);
const superFetchUser = enhance(fetchUser);
// Method 3: pipe utility (left to right -- reads more naturally)
function pipe(...decorators) {
return function (fn) {
return decorators.reduce((decorated, decorator) => decorator(decorated), fn);
};
}
const pipeline = pipe(
(fn) => withTimeout(fn, 5000), // innermost: timeout
(fn) => withRetry(fn, 3), // then: retry
(fn) => withCache(fn, 10000), // then: cache
(fn) => withLogging(fn, 'User') // outermost: logging
);
const pipelinedFetchUser = pipeline(fetchUser);
COMPOSITION ORDER (inside out):
withLogging( <-- 4. logs the call
withCache( <-- 3. checks cache first
withRetry( <-- 2. retries on failure
withTimeout( <-- 1. enforces timeout
fetchUser <-- 0. actual logic
)
)
)
)
Call flow:
logging -> cache check -> retry wrapper -> timeout -> fetchUser
9. Before and After Comparison
Before (monolithic function with all concerns)
// BAD: All cross-cutting concerns mixed into one function
async function getUser(userId, currentUser) {
// Auth check
if (!currentUser) throw new Error('Not authenticated');
if (currentUser.role !== 'admin' && currentUser.id !== userId) {
throw new Error('Forbidden');
}
// Logging
console.log(`[${new Date().toISOString()}] getUser(${userId}) by ${currentUser.id}`);
const start = Date.now();
// Caching
const cacheKey = `user:${userId}`;
const cached = globalCache.get(cacheKey);
if (cached) return cached;
// Retry logic
let lastError;
for (let i = 0; i < 3; i++) {
try {
const user = await db.query('SELECT * FROM users WHERE id = ?', userId);
globalCache.set(cacheKey, user);
console.log(`[${new Date().toISOString()}] getUser completed in ${Date.now() - start}ms`);
return user;
} catch (e) {
lastError = e;
await sleep(1000);
}
}
throw lastError;
}
// Problems:
// - 40 lines for what should be a 3-line function
// - Cannot reuse caching logic for other functions
// - Cannot disable logging without editing code
// - Testing requires mocking everything at once
After (decorators separate concerns)
// GOOD: Each concern is a separate, reusable decorator
async function getUser(userId) {
return db.query('SELECT * FROM users WHERE id = ?', userId);
}
// Stack the exact behaviors you need
const enhancedGetUser = pipe(
fn => withTimeout(fn, 5000),
fn => withRetry(fn, 3),
fn => withCache(fn, 30000),
fn => withLogging(fn, 'getUser')
)(getUser);
// Each decorator is:
// - Independently testable
// - Reusable across any function
// - Removable without touching other code
// - Composable in any order
10. Decorator vs Proxy vs Adapter
| Aspect | Decorator | Proxy | Adapter |
|---|---|---|---|
| Interface | Same | Same | Different |
| Purpose | Add behavior | Control access | Translate interface |
| Client knows? | No | No | No |
| Stackable? | Yes (key feature) | Sometimes | Rarely |
| Lifecycle | Created by client | Often manages lifecycle | Wraps permanently |
| Example | Logging + caching + retry | Lazy loading, auth check | Stripe -> PaymentProcessor |
The gray area: a caching proxy and a caching decorator look nearly identical. The intent differs: a proxy says "I control whether you can access this," while a decorator says "I add caching behavior to this."
11. When to Use and When to Avoid
Use Decorator when:
| Scenario | Why Decorator helps |
|---|---|
| Adding cross-cutting concerns (logging, auth, caching) | Separates concerns cleanly |
| Need different combinations of behavior | Avoids inheritance explosion |
| Behavior should be toggleable at runtime | Stack/unstack decorators |
| Express/Koa middleware pipelines | Natural decorator composition |
| You want reusable behavior across many functions | Write once, apply anywhere |
Avoid Decorator when:
| Scenario | Why Decorator is wrong |
|---|---|
| Only one fixed combination is ever needed | Just put the logic in the function |
| Order of decorators matters and is confusing | Debug complexity increases |
| Deep stacks make stack traces unreadable | 10 layers of wrapping obscures the source |
| You need to modify the interface | Use Adapter instead |
| You need to control object creation | Use Proxy instead |
12. Key Takeaways
- The Decorator pattern adds behavior to objects dynamically by wrapping them -- without modifying the original object or using deep inheritance.
- Each decorator has the same interface as the component it wraps, enabling infinite stacking.
- Express/Koa middleware is the decorator pattern: each middleware wraps the handler and adds behavior (logging, auth, CORS, rate limiting).
- In JavaScript, higher-order functions are a natural way to implement decorators:
withLogging(withCache(withRetry(fetchUser))). - Use
composeorpipeutilities to make decorator stacking readable and maintainable. - Decorators follow the Open/Closed Principle: you extend behavior without modifying existing code.
- Watch out for decorator order --
logging(cache(fn))andcache(logging(fn))behave differently.
13. Explain-It Challenge
Without looking back, explain in your own words:
- Why does the Decorator pattern avoid the "inheritance explosion" problem?
- How does Express middleware demonstrate the decorator pattern?
- What is the difference between
withLogging(withCache(fn))andwithCache(withLogging(fn))? - When would you choose a class-based decorator over a function composition decorator?
- Write a
withTimeoutdecorator from memory that rejects after N milliseconds.
Navigation: <- 9.4.c -- Proxy | 9.4.e -- Composite ->