Decorator Pattern
The Decorator pattern allows you to add behavior to objects dynamically without modifying their code or using inheritance. This pattern is particularly valuable in legacy systems where you need to extend functionality without touching fragile existing code. It provides a flexible alternative to subclassing for extending functionality.
Video Summary
The video presentation explores the Decorator pattern in depth:
- What the Decorator pattern is and when to use it
- How decorators wrap objects to add behavior
- Advantages over inheritance for extending functionality
- Real-world examples in legacy modernization
- Implementing decorators in TypeScript
- Common use cases: logging, caching, validation, retry logic
- Composing multiple decorators for complex behavior
Key Concepts
1. Wrapping Objects to Add Behavior
The Decorator pattern wraps an object with another object that adds new behavior while preserving the original interface:
interface DataService {
getData(id: string): Promise<Data>;
}
// Original implementation
class APIDataService implements DataService {
async getData(id: string): Promise<Data> {
const response = await fetch(`/api/data/${id}`);
return response.json();
}
}
// Decorator adds caching
class CachingDataService implements DataService {
private cache = new Map<string, Data>();
constructor(private wrapped: DataService) {}
async getData(id: string): Promise<Data> {
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
const data = await this.wrapped.getData(id);
this.cache.set(id, data);
return data;
}
}
// Usage: wrap with decorator
const service = new CachingDataService(new APIDataService());
2. Composition Over Inheritance
Inheritance problems:
- Creates tight coupling
- Can't change behavior at runtime
- Leads to class explosion with combinations
Decorator advantages:
- Loose coupling
- Runtime composition
- Mix and match behaviors
3. Stacking Decorators
Multiple decorators can be composed:
const service = new RetryDecorator(
new LoggingDecorator(
new CachingDecorator(
new APIDataService()
)
)
);
// Order matters! This flows:
// Retry -> Logging -> Caching -> API
Real-World Applications
Example 1: Adding Logging to Legacy Service
// Legacy service we can't modify
class LegacyOrderService {
processOrder(order: Order): Promise<OrderResult> {
// Complex legacy logic
}
}
// Decorator adds logging without touching legacy code
class LoggingOrderService implements OrderService {
constructor(
private wrapped: OrderService,
private logger: Logger
) {}
async processOrder(order: Order): Promise<OrderResult> {
this.logger.info('Processing order', { orderId: order.id });
const startTime = Date.now();
try {
const result = await this.wrapped.processOrder(order);
this.logger.info('Order processed successfully', {
orderId: order.id,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
this.logger.error('Order processing failed', {
orderId: order.id,
error,
duration: Date.now() - startTime,
});
throw error;
}
}
}
// Use decorator
const orderService = new LoggingOrderService(
new LegacyOrderService(),
logger
);
Example 2: Adding Retry Logic
class RetryDecorator<T> {
constructor(
private wrapped: T,
private maxRetries: number = 3
) {}
// Intercept all method calls
[key: string]: any;
async getData(id: string): Promise<Data> {
let lastError;
for (let i = 0; i < this.maxRetries; i++) {
try {
return await (this.wrapped as any).getData(id);
} catch (error) {
lastError = error;
await this.delay(Math.pow(2, i) * 1000); // Exponential backoff
}
}
throw lastError;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
Example 3: Adding Validation
class ValidationDecorator implements UserService {
constructor(private wrapped: UserService) {}
async createUser(user: User): Promise<User> {
// Add validation before delegating
if (!user.email || !user.email.includes('@')) {
throw new Error('Invalid email');
}
if (!user.name || user.name.length < 2) {
throw new Error('Name too short');
}
// Delegate to wrapped service
return this.wrapped.createUser(user);
}
async updateUser(id: string, updates: Partial<User>): Promise<User> {
if (updates.email && !updates.email.includes('@')) {
throw new Error('Invalid email');
}
return this.wrapped.updateUser(id, updates);
}
}
Example 4: Circuit Breaker Pattern
class CircuitBreakerDecorator implements PaymentService {
private failures = 0;
private lastFailure: Date | null = null;
private readonly threshold = 5;
private readonly timeout = 60000; // 1 minute
constructor(private wrapped: PaymentService) {}
async processPayment(payment: Payment): Promise<PaymentResult> {
// Check if circuit is open
if (this.isCircuitOpen()) {
throw new Error('Circuit breaker is open - service unavailable');
}
try {
const result = await this.wrapped.processPayment(payment);
// Success: reset failure count
this.failures = 0;
return result;
} catch (error) {
// Failure: increment counter
this.failures++;
this.lastFailure = new Date();
throw error;
}
}
private isCircuitOpen(): boolean {
if (this.failures < this.threshold) {
return false;
}
const timeSinceLastFailure = Date.now() - (this.lastFailure?.getTime() || 0);
return timeSinceLastFailure < this.timeout;
}
}
Example 5: Metrics and Monitoring
class MetricsDecorator implements OrderService {
constructor(
private wrapped: OrderService,
private metrics: MetricsClient
) {}
async processOrder(order: Order): Promise<OrderResult> {
const startTime = Date.now();
try {
const result = await this.wrapped.processOrder(order);
this.metrics.timing('order.process.duration', Date.now() - startTime);
this.metrics.increment('order.process.success');
return result;
} catch (error) {
this.metrics.timing('order.process.duration', Date.now() - startTime);
this.metrics.increment('order.process.failure');
this.metrics.increment(`order.process.error.${error.constructor.name}`);
throw error;
}
}
}
Common Pitfalls
1. Interface Mismatch
Problem: Decorator doesn't implement full interface.
// Bad: Missing methods
class BadDecorator implements DataService {
constructor(private wrapped: DataService) {}
async getData(id: string) {
return this.wrapped.getData(id);
}
// Forgot to implement other methods!
}
Solution: Implement complete interface or use Proxy.
2. Too Many Layers
Problem: So many decorators it's hard to understand flow.
// Hard to debug!
const service = new RetryDecorator(
new MetricsDecorator(
new LoggingDecorator(
new CachingDecorator(
new ValidationDecorator(
new AuthDecorator(
new RateLimitDecorator(
new APIService()
)
)
)
)
)
)
);
Solution: Use composition root, document decorator chain.
3. Order Dependency
Problem: Decorators don't compose in all orders.
// These give different behavior!
const option1 = new CachingDecorator(new LoggingDecorator(service));
const option2 = new LoggingDecorator(new CachingDecorator(service));
Solution: Document required order, test composition.
Key Takeaways
- Decorator pattern adds behavior by wrapping objects
- Provides flexible alternative to inheritance
- Multiple decorators can be stacked for complex behavior
- Ideal for cross-cutting concerns: logging, caching, metrics, retry
- Particularly useful in legacy systems to avoid modifying fragile code
- Maintains original interface, making decorators transparent to callers
- Watch for too many layers or order dependencies
Further Reading
- Design Patterns - Gang of Four original pattern
- Decorators and Forwarding - JavaScript-specific implementation
- Working Effectively with Legacy Code - Using decorators to add tests