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:

  1. Component: The interface defining operations that can be decorated
  2. Concrete Component: The base implementation you’re decorating
  3. Base Decorator: Implements Component and holds a reference to a wrapped Component
  4. 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.