Decorator Pattern: Dynamic Behavior Extension
You've got a notification system. It sends emails. Then you need SMS notifications. Then Slack. Then you need to log all notifications. Then you need to retry failed ones. Then you need rate limiting.
Key Insights
- Decorators solve the class explosion problem by allowing behavior composition at runtime instead of compile-time inheritance hierarchies
- The order of decorator stacking directly affects output—encryption then compression produces different results than compression then encryption
- Use decorators when you need transparent, stackable modifications to existing objects without altering their interface or requiring client code changes
The Problem with Static Inheritance
You’ve got a notification system. It sends emails. Then you need SMS notifications. Then Slack. Then you need to log all notifications. Then you need to retry failed ones. Then you need rate limiting.
If you reach for inheritance, you’re building a nightmare:
Notifier
├── EmailNotifier
├── SMSNotifier
├── SlackNotifier
├── LoggingEmailNotifier
├── LoggingSMSNotifier
├── RetryingLoggingEmailNotifier
├── RateLimitedRetryingLoggingEmailNotifier
└── ... (explosion continues)
Every combination of behaviors requires a new class. With 4 notification types and 3 cross-cutting concerns, you’re looking at potentially 32 classes. Add one more concern, and you double it.
The Decorator pattern offers a way out. Instead of baking every behavior combination into the class hierarchy, you wrap objects at runtime with layers of functionality. Each layer adds one responsibility, and you stack them as needed.
Pattern Anatomy
The Decorator pattern has four participants:
- Component: The interface defining operations that can be decorated
- Concrete Component: The base implementation you’re decorating
- Base Decorator: Implements Component and holds a reference to a wrapped Component
- Concrete Decorators: Extend the base decorator to add specific behaviors
The key insight is that decorators are components. They implement the same interface as what they wrap, making them interchangeable with the original object.
from abc import ABC, abstractmethod
# Component interface
class DataSource(ABC):
@abstractmethod
def write_data(self, data: str) -> None:
pass
@abstractmethod
def read_data(self) -> str:
pass
# Concrete Component
class FileDataSource(DataSource):
def __init__(self, filename: str):
self._filename = filename
self._data = ""
def write_data(self, data: str) -> None:
self._data = data
print(f"Writing to {self._filename}: {data[:50]}...")
def read_data(self) -> str:
return self._data
The FileDataSource handles basic file operations. It knows nothing about encryption, compression, or any other cross-cutting concern. That’s exactly how it should be.
Implementation Walkthrough
The base decorator establishes the delegation pattern. Every concrete decorator inherits from it:
# Base Decorator
class DataSourceDecorator(DataSource):
def __init__(self, source: DataSource):
self._wrapped = source
def write_data(self, data: str) -> None:
self._wrapped.write_data(data)
def read_data(self) -> str:
return self._wrapped.read_data()
By default, the base decorator just passes calls through. Concrete decorators override methods to inject behavior before or after delegation:
import base64
import zlib
# Concrete Decorator: Encryption
class EncryptionDecorator(DataSourceDecorator):
def write_data(self, data: str) -> None:
# Encrypt before passing down
encrypted = base64.b64encode(data.encode()).decode()
print(f"Encrypting data...")
super().write_data(encrypted)
def read_data(self) -> str:
# Decrypt after receiving from wrapped component
data = super().read_data()
return base64.b64decode(data.encode()).decode()
# Concrete Decorator: Compression
class CompressionDecorator(DataSourceDecorator):
def write_data(self, data: str) -> None:
# Compress before passing down
compressed = base64.b64encode(
zlib.compress(data.encode())
).decode()
print(f"Compressing data (ratio: {len(compressed)}/{len(data)})...")
super().write_data(compressed)
def read_data(self) -> str:
# Decompress after receiving
data = super().read_data()
return zlib.decompress(base64.b64decode(data.encode())).decode()
Client code composes these at runtime:
# Build the decorated data source
source = FileDataSource("data.txt")
source = CompressionDecorator(source)
source = EncryptionDecorator(source)
# Use it transparently
source.write_data("Sensitive information that needs protection")
print(source.read_data())
The client doesn’t know or care how many decorators are wrapped around the file source. It just calls write_data and read_data.
Stacking Decorators: Order Matters
Here’s where developers trip up. Decorator order isn’t arbitrary—it fundamentally changes behavior.
Consider the difference between these two stacks:
# Stack 1: Compress, then encrypt
source1 = FileDataSource("v1.txt")
source1 = CompressionDecorator(source1)
source1 = EncryptionDecorator(source1)
# Stack 2: Encrypt, then compress
source2 = FileDataSource("v2.txt")
source2 = EncryptionDecorator(source2)
source2 = CompressionDecorator(source2)
test_data = "Hello " * 100
source1.write_data(test_data)
source2.write_data(test_data)
Output:
# Stack 1
Encrypting data...
Compressing data (ratio: 120/816)...
Writing to v1.txt: eJxLzs8FAKEfAoE=...
# Stack 2
Compressing data (ratio: 816/600)...
Encrypting data...
Writing to v2.txt: ZUp4TExPenM4RkFL...
Stack 1 compresses first, achieving a good ratio because the plaintext has repetition. Then it encrypts the compressed output.
Stack 2 encrypts first, producing high-entropy output that compresses poorly. The compression decorator sees random-looking bytes and can’t find patterns.
Think of it as an onion. When writing, you go from outer layer to inner. When reading, you go from inner to outer. The outermost decorator processes data first on write and last on read.
Real-World Applications
HTTP Middleware
Web frameworks use decorators (often called middleware) extensively:
from typing import Callable, Dict
import time
# Component interface
Handler = Callable[[Dict], Dict]
# Concrete component
def api_handler(request: Dict) -> Dict:
return {"status": 200, "body": f"Processed: {request.get('path')}"}
# Decorator factory for logging
def logging_middleware(handler: Handler) -> Handler:
def wrapper(request: Dict) -> Dict:
print(f"[LOG] Incoming: {request.get('method')} {request.get('path')}")
response = handler(request)
print(f"[LOG] Response: {response.get('status')}")
return response
return wrapper
# Decorator factory for timing
def timing_middleware(handler: Handler) -> Handler:
def wrapper(request: Dict) -> Dict:
start = time.perf_counter()
response = handler(request)
duration = time.perf_counter() - start
response["x-duration-ms"] = duration * 1000
return response
return wrapper
# Decorator factory for authentication
def auth_middleware(handler: Handler) -> Handler:
def wrapper(request: Dict) -> Dict:
if not request.get("auth_token"):
return {"status": 401, "body": "Unauthorized"}
return handler(request)
return wrapper
# Compose the pipeline
pipeline = logging_middleware(
timing_middleware(
auth_middleware(api_handler)
)
)
# Use it
response = pipeline({"method": "GET", "path": "/users", "auth_token": "xyz"})
Repository Logging
Decorators work brilliantly for cross-cutting concerns in data access:
from abc import ABC, abstractmethod
from typing import Optional
import logging
class UserRepository(ABC):
@abstractmethod
def find_by_id(self, user_id: int) -> Optional[dict]:
pass
@abstractmethod
def save(self, user: dict) -> None:
pass
class PostgresUserRepository(UserRepository):
def find_by_id(self, user_id: int) -> Optional[dict]:
# Actual database query would go here
return {"id": user_id, "name": "Alice"}
def save(self, user: dict) -> None:
# Actual database insert/update
pass
class LoggingUserRepository(UserRepository):
def __init__(self, wrapped: UserRepository):
self._wrapped = wrapped
self._logger = logging.getLogger(__name__)
def find_by_id(self, user_id: int) -> Optional[dict]:
self._logger.info(f"Finding user by id: {user_id}")
result = self._wrapped.find_by_id(user_id)
self._logger.info(f"Found: {result is not None}")
return result
def save(self, user: dict) -> None:
self._logger.info(f"Saving user: {user.get('id')}")
self._wrapped.save(user)
self._logger.info("Save complete")
# Compose at application startup
repo = LoggingUserRepository(PostgresUserRepository())
Trade-offs and Alternatives
Decorators aren’t free. Know the costs:
Debugging complexity: Stack traces through five decorators are harder to follow. When something breaks in the middle of a chain, you’re unwrapping layers to find the culprit.
Identity issues: A decorated object isn’t equal to its unwrapped version. isinstance checks can behave unexpectedly. If code depends on concrete types, decorators break it.
Configuration overhead: Building decorator chains requires knowing what to stack and in what order. This configuration has to live somewhere.
Alternatives to consider:
- Strategy pattern: When you need to swap entire algorithms, not layer behaviors
- Mixins/Traits: When behaviors are known at compile time and you want static composition
- Aspect-Oriented Programming: When cross-cutting concerns span many unrelated classes
Guidelines for Adoption
Use decorators when:
- You need to add responsibilities to objects dynamically
- Extension by subclassing is impractical due to combinations
- You want transparent wrappers that preserve the interface
- Behaviors need to be stackable and reorderable
Keep them manageable:
- Limit chain depth to 3-4 decorators maximum
- Use factory methods or builders to construct common chains
- Document expected decorator order when it matters
Test decorators in isolation:
import unittest
from unittest.mock import Mock
class TestEncryptionDecorator(unittest.TestCase):
def test_encrypts_data_before_writing(self):
mock_source = Mock(spec=DataSource)
decorator = EncryptionDecorator(mock_source)
decorator.write_data("secret")
# Verify the wrapped source received encrypted data
mock_source.write_data.assert_called_once()
written_data = mock_source.write_data.call_args[0][0]
self.assertNotEqual(written_data, "secret")
self.assertEqual(
base64.b64decode(written_data.encode()).decode(),
"secret"
)
def test_decrypts_data_when_reading(self):
mock_source = Mock(spec=DataSource)
mock_source.read_data.return_value = base64.b64encode(b"secret").decode()
decorator = EncryptionDecorator(mock_source)
result = decorator.read_data()
self.assertEqual(result, "secret")
By mocking the wrapped component, you test each decorator’s transformation logic independently. Integration tests then verify the full chain works together.
The Decorator pattern shines when you embrace its core principle: single-responsibility wrappers that compose freely. Keep each decorator focused, test them in isolation, and let runtime composition handle the complexity that would otherwise explode your class hierarchy.