Lesson 5: Design Patterns
Learning Objectives
By the end of this lesson, you will be able to:
- Define design patterns and explain their importance in professional software development
- Categorize patterns into creational, structural, and behavioral types with their distinct purposes
- Recognize common patterns and understand appropriate application contexts
- Implement basic patterns to address recurring design challenges in your code
- Assess when patterns provide value versus creating unnecessary complexity
- Apply multiple patterns together in real-world scenarios to build maintainable systems
Introduction
Imagine you’re building a notification system for a web application. Users need to receive alerts via email, SMS, and push notifications. How do you design this system so adding a new notification channel doesn’t require rewriting existing code? How do you ensure the system sends notifications reliably, even if one channel fails? These are exactly the types of problems design patterns solve.
Design patterns are proven solutions to recurring problems that developers encounter. Unlike finished code you can copy and paste, patterns serve as adaptable templates—blueprints that you customize to fit your specific context [1]. They represent decades of collective wisdom from software engineers who’ve solved similar problems countless times.
Why do design patterns matter? First, they accelerate development by providing ready-made architectural solutions rather than forcing you to reinvent the wheel. Second, they improve code clarity for developers familiar with these standard patterns—when you see a Singleton or Observer pattern, you immediately understand the design intent. Third, they help prevent subtle bugs by incorporating lessons learned from production systems. Finally, they establish a shared vocabulary that enables precise technical communication within teams [2].
The Gang of Four (Gamma, Helm, Johnson, and Vlissides) cataloged 23 fundamental patterns in their influential 1994 book “Design Patterns: Elements of Reusable Object-Oriented Software” [1]. While originally focused on object-oriented design, these patterns apply broadly across programming paradigms. Modern software engineering has expanded on these foundations with additional patterns for distributed systems, concurrent programming, and reactive architectures.
In this lesson, we’ll explore the most essential patterns every intermediate developer should master. You’ll learn when to apply each pattern, see practical implementations, and understand how to combine patterns to solve complex real-world problems.
Pattern Categories
Design patterns are organized into four main categories based on the problems they solve. Understanding these categories helps you quickly identify which patterns apply to your situation.
Creational Patterns
Creational patterns address object instantiation by abstracting creation mechanisms. They ensure systems remain independent of how objects are composed and represented [1]. These patterns answer the question: “How do I create objects flexibly without hardcoding specific classes?”
Common creational patterns include:
- Singleton: Ensures a class has only one instance with global access
- Factory Method: Delegates object creation to subclasses
- Abstract Factory: Creates families of related objects
- Builder: Constructs complex objects step by step
- Prototype: Creates objects by cloning existing instances
Structural Patterns
Structural patterns deal with object assembly into larger structures while maintaining flexibility and efficiency [1]. They define relationships between entities and answer: “How do I compose objects and classes into larger structures?”
Common structural patterns include:
- Adapter: Bridges incompatible interfaces
- Decorator: Adds functionality dynamically
- Facade: Provides a simplified interface to complex subsystems
- Proxy: Controls access to objects
- Composite: Treats individual objects and compositions uniformly
Behavioral Patterns
Behavioral patterns govern object communication and interaction mechanisms. They distribute responsibilities to maintain flexibility and supportability [1]. These patterns address: “How do objects interact and distribute responsibilities?”
Common behavioral patterns include:
- Observer: Establishes one-to-many dependencies with automatic notifications
- Strategy: Encapsulates interchangeable algorithms
- Command: Encapsulates requests as objects
- State: Changes object behavior when internal state changes
- Template Method: Defines algorithm skeletons with customizable steps
Concurrency Patterns
Concurrency patterns handle multi-threaded programming challenges like synchronization, deadlock prevention, and resource sharing. While beyond this lesson’s scope, they become crucial for building scalable systems.
Creational Patterns
Singleton Pattern
The Singleton pattern ensures a class has only one instance while providing global access to that instance [1]. This pattern is ideal for shared resources like database connections, configuration managers, or logging services where multiple instances would waste resources or cause conflicts.
When to Use Singleton:
- Managing shared resources (database connection pools, thread pools)
- Coordinating system-wide actions (configuration, logging)
- Caching expensive computations or data
Singleton Implementation:
import threading
class DatabaseConnection:
"""Thread-safe Singleton implementation using double-checked locking."""
_instance = None
_lock = threading.Lock()
def __new__(cls):
# First check (without locking for performance)
if cls._instance is None:
# Acquire lock for thread safety
with cls._lock:
# Second check (with locking to prevent race conditions)
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialize()
return cls._instance
def _initialize(self):
"""Initialize the database connection (called only once)."""
self.connection = "Database connection established"
print("DatabaseConnection instance created")
def query(self, sql):
"""Execute database query."""
return f"Executing: {sql}"
# Usage
db1 = DatabaseConnection()
db2 = DatabaseConnection()
print(db1 is db2) # True - both refer to the same instance
print(db1.query("SELECT * FROM users"))Benefits:
- Guarantees single instance across the application
- Provides global access point
- Lazy initialization (created only when first accessed)
- Thread-safe implementation prevents race conditions
Drawbacks:
- Creates hidden dependencies throughout code
- Complicates unit testing (difficult to mock or replace)
- Can become a global state container (violates single responsibility)
- Thread synchronization adds complexity
Best Practice: Use Singleton sparingly. Consider dependency injection as an alternative for better testability. Reserve Singleton for truly global, stateful resources.
Factory Method Pattern
The Factory Method pattern enables subclasses to determine which objects to instantiate. It decouples client code from concrete implementations, centralizes creation logic, and supports straightforward addition of new types [1].
When to Use Factory Method:
- Client code shouldn’t depend on concrete class implementations
- Object creation logic is complex or likely to change
- You need to centralize object creation for consistency
- You want to support extension without modifying existing code (Open/Closed Principle)
Factory Method Implementation:
from abc import ABC, abstractmethod
# Abstract base class - defines the contract
class NotificationSender(ABC):
"""Abstract notification sender interface."""
@abstractmethod
def send(self, recipient, message):
"""Send notification to recipient."""
pass
# Concrete implementations
class EmailSender(NotificationSender):
"""Sends notifications via email."""
def send(self, recipient, message):
print(f"📧 Sending email to {recipient}: {message}")
# Actual email sending logic here
return True
class SMSSender(NotificationSender):
"""Sends notifications via SMS."""
def send(self, recipient, message):
print(f"📱 Sending SMS to {recipient}: {message}")
# Actual SMS sending logic here
return True
class PushSender(NotificationSender):
"""Sends push notifications."""
def send(self, recipient, message):
print(f"🔔 Sending push notification to {recipient}: {message}")
# Actual push notification logic here
return True
# Factory class - encapsulates creation logic
class NotificationFactory:
"""Factory for creating notification senders."""
@staticmethod
def create_sender(notification_type):
"""
Create appropriate notification sender based on type.
Args:
notification_type: String specifying notification channel
Returns:
NotificationSender instance or None if type invalid
"""
senders = {
'email': EmailSender,
'sms': SMSSender,
'push': PushSender
}
sender_class = senders.get(notification_type.lower())
if sender_class:
return sender_class()
else:
raise ValueError(f"Unknown notification type: {notification_type}")
# Client code - works with abstraction, not concrete classes
class NotificationService:
"""High-level service using the factory."""
def notify_user(self, user_id, message, channel='email'):
"""
Send notification through specified channel.
Adding new channels requires no changes to this code!
"""
try:
sender = NotificationFactory.create_sender(channel)
sender.send(user_id, message)
print(f"✅ Notification sent successfully via {channel}")
except ValueError as e:
print(f"❌ Error: {e}")
# Usage
service = NotificationService()
service.notify_user("[email protected]", "Welcome to our platform!", "email")
service.notify_user("+1234567890", "Your order has shipped", "sms")
service.notify_user("device_token_123", "New message received", "push")
# Adding a new notification type requires only:
# 1. Create new class implementing NotificationSender
# 2. Register it in the factory's senders dictionary
# No changes to NotificationService or existing senders!Benefits:
- Decouples client code from concrete implementations
- Centralizes creation logic for maintainability
- Makes adding new types straightforward (just add new class)
- Follows Open/Closed Principle (open for extension, closed for modification)
Drawbacks:
- Increases number of classes (more files to maintain)
- Can be overkill for simple creation scenarios
- Adds indirection that may reduce code clarity for simple cases
Structural Patterns
Adapter Pattern
The Adapter pattern bridges incompatible interfaces, allowing classes with incompatible interfaces to work together [1]. It’s essential when integrating third-party libraries, migrating to new systems, or working with legacy code you can’t modify.
When to Use Adapter:
- Integrating third-party libraries with incompatible interfaces
- Making legacy code work with new systems
- Reusing existing classes that don’t match required interfaces
- Creating reusable libraries that work with various client expectations
Adapter Implementation:
# Legacy payment processor (old system we can't modify)
class OldPaymentProcessor:
"""Legacy payment system with incompatible interface."""
def make_payment(self, amount_cents):
"""Processes payment in cents."""
print(f"Processing ${amount_cents/100:.2f} via legacy system")
return {"transaction_id": "LEG-12345", "status": "completed"}
# Modern payment interface (what our new system expects)
class ModernPaymentProcessor(ABC):
"""Modern payment processor interface."""
@abstractmethod
def process_payment(self, amount_dollars):
"""Process payment in dollars."""
pass
# Adapter - translates between interfaces
class PaymentAdapter(ModernPaymentProcessor):
"""Adapts legacy payment processor to modern interface."""
def __init__(self, legacy_processor):
self.legacy_processor = legacy_processor
def process_payment(self, amount_dollars):
"""
Translates modern interface to legacy interface.
Converts dollars to cents and calls legacy method.
"""
amount_cents = int(amount_dollars * 100)
result = self.legacy_processor.make_payment(amount_cents)
# Translate legacy response format if needed
return {
'id': result['transaction_id'],
'success': result['status'] == 'completed'
}
# Client code - works with modern interface
class CheckoutService:
"""Modern checkout service expecting standard interface."""
def __init__(self, payment_processor):
self.payment_processor = payment_processor
def complete_purchase(self, amount):
"""Process purchase using modern interface."""
result = self.payment_processor.process_payment(amount)
if result['success']:
print(f"✅ Purchase completed! Transaction ID: {result['id']}")
else:
print("❌ Purchase failed")
# Usage - adapter makes legacy system work with modern code
legacy_system = OldPaymentProcessor()
adapted_processor = PaymentAdapter(legacy_system)
checkout = CheckoutService(adapted_processor)
checkout.complete_purchase(49.99)Benefits:
- Enables code reuse without modifying source
- Maintains consistent interfaces across codebase
- Facilitates gradual system migrations
- Allows integration of incompatible third-party libraries
Drawbacks:
- Adds extra layer of indirection
- Can accumulate if you create too many adapters
- May hide underlying complexity
Decorator Pattern
The Decorator pattern adds functionality dynamically without modifying object structure [1]. It enables flexible feature combinations and avoids complex inheritance hierarchies. This pattern is commonly used in I/O streams, middleware, and UI components.
When to Use Decorator:
- Adding responsibilities to objects dynamically
- Extending functionality without subclassing
- Combining features flexibly (stacking decorators)
- Following Single Responsibility Principle (each decorator does one thing)
Decorator Implementation:
from abc import ABC, abstractmethod
# Base component
class Coffee(ABC):
"""Abstract coffee base class."""
@abstractmethod
def cost(self):
"""Calculate total cost."""
pass
@abstractmethod
def description(self):
"""Get description of coffee."""
pass
# Concrete component
class SimpleCoffee(Coffee):
"""Basic coffee without additions."""
def cost(self):
return 2.00
def description(self):
return "Simple coffee"
# Decorator base class
class CoffeeDecorator(Coffee):
"""Base decorator for coffee additions."""
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost()
def description(self):
return self._coffee.description()
# Concrete decorators
class MilkDecorator(CoffeeDecorator):
"""Adds milk to coffee."""
def cost(self):
return self._coffee.cost() + 0.50
def description(self):
return self._coffee.description() + ", milk"
class SugarDecorator(CoffeeDecorator):
"""Adds sugar to coffee."""
def cost(self):
return self._coffee.cost() + 0.25
def description(self):
return self._coffee.description() + ", sugar"
class CaramelDecorator(CoffeeDecorator):
"""Adds caramel to coffee."""
def cost(self):
return self._coffee.cost() + 0.75
def description(self):
return self._coffee.description() + ", caramel"
# Usage - stack decorators for flexible combinations
coffee = SimpleCoffee()
print(f"{coffee.description()}: ${coffee.cost():.2f}")
coffee_with_milk = MilkDecorator(coffee)
print(f"{coffee_with_milk.description()}: ${coffee_with_milk.cost():.2f}")
fancy_coffee = CaramelDecorator(SugarDecorator(MilkDecorator(SimpleCoffee())))
print(f"{fancy_coffee.description()}: ${fancy_coffee.cost():.2f}")
# Output:
# Simple coffee: $2.00
# Simple coffee, milk: $2.50
# Simple coffee, milk, sugar, caramel: $3.50Benefits:
- More flexible than static inheritance
- Enables dynamic feature combinations
- Follows Single Responsibility (each decorator adds one feature)
- Supports unlimited decorator stacking
Drawbacks:
- Can create many small classes
- Order of decorator application may matter
- Debugging stacked decorators can be challenging
Behavioral Patterns
Observer Pattern
The Observer pattern establishes one-to-many dependencies where objects automatically receive notifications when the subject changes state [1]. This pattern is central to event-driven systems, GUI frameworks, and reactive programming.
When to Use Observer:
- One object’s state change should trigger updates in multiple dependents
- You need loose coupling between subjects and observers
- Building event-driven systems or reactive interfaces
- Implementing publish-subscribe mechanisms
Observer Implementation:
from abc import ABC, abstractmethod
from typing import List
# Observer interface
class Observer(ABC):
"""Abstract observer that receives updates."""
@abstractmethod
def update(self, subject):
"""Receive update notification from subject."""
pass
# Subject (Observable)
class StockPriceTracker:
"""Tracks stock price and notifies observers of changes."""
def __init__(self, stock_symbol):
self._observers: List[Observer] = []
self._stock_symbol = stock_symbol
self._price = 0.0
def attach(self, observer):
"""Register observer for notifications."""
if observer not in self._observers:
self._observers.append(observer)
print(f"📎 {observer.__class__.__name__} attached")
def detach(self, observer):
"""Unregister observer."""
if observer in self._observers:
self._observers.remove(observer)
print(f"📌 {observer.__class__.__name__} detached")
def notify_observers(self):
"""Notify all registered observers of state change."""
for observer in self._observers:
observer.update(self)
@property
def price(self):
return self._price
@price.setter
def price(self, new_price):
"""Update price and notify observers if changed."""
if new_price != self._price:
print(f"\n📊 {self._stock_symbol} price changed: ${self._price:.2f} → ${new_price:.2f}")
self._price = new_price
self.notify_observers()
@property
def stock_symbol(self):
return self._stock_symbol
# Concrete observers
class PriceAlertObserver(Observer):
"""Sends alert when price crosses threshold."""
def __init__(self, threshold):
self.threshold = threshold
def update(self, subject):
if subject.price > self.threshold:
print(f" 🚨 ALERT: {subject.stock_symbol} exceeded ${self.threshold:.2f}!")
class PortfolioObserver(Observer):
"""Updates portfolio value when stock price changes."""
def __init__(self, shares_owned):
self.shares_owned = shares_owned
def update(self, subject):
portfolio_value = subject.price * self.shares_owned
print(f" 💼 Portfolio value: ${portfolio_value:.2f} ({self.shares_owned} shares)")
class LoggingObserver(Observer):
"""Logs all price changes."""
def update(self, subject):
print(f" 📝 Log: {subject.stock_symbol} = ${subject.price:.2f}")
# Usage
stock_tracker = StockPriceTracker("AAPL")
# Attach observers
price_alert = PriceAlertObserver(threshold=150.0)
portfolio = PortfolioObserver(shares_owned=100)
logger = LoggingObserver()
stock_tracker.attach(price_alert)
stock_tracker.attach(portfolio)
stock_tracker.attach(logger)
# Price changes automatically notify all observers
stock_tracker.price = 145.00
stock_tracker.price = 152.00
# Can detach observers
stock_tracker.detach(price_alert)
stock_tracker.price = 155.00Benefits:
- Loose coupling between subjects and observers
- Dynamic relationships (attach/detach at runtime)
- Supports broadcast communication
- Follows Open/Closed Principle
Drawbacks:
- Observers notified in arbitrary order
- Can create memory leaks if observers aren’t detached
- Difficult to debug cascade of notifications
Strategy Pattern
The Strategy pattern encapsulates a family of algorithms, making them interchangeable [1]. It enables runtime algorithm selection, eliminates conditional statements, and is commonly used for payment methods, sorting algorithms, and validation rules.
When to Use Strategy:
- Multiple algorithms solve the same problem differently
- Need to switch algorithms at runtime
- Want to eliminate complex conditional logic
- Following Open/Closed Principle (add new strategies without modifying context)
Strategy Implementation:
from abc import ABC, abstractmethod
# Strategy interface
class PaymentStrategy(ABC):
"""Abstract payment strategy."""
@abstractmethod
def pay(self, amount):
"""Process payment using this strategy."""
pass
# Concrete strategies
class CreditCardPayment(PaymentStrategy):
"""Credit card payment implementation."""
def __init__(self, card_number, cvv):
self.card_number = card_number[-4:] # Store only last 4 digits
self.cvv = cvv
def pay(self, amount):
print(f"💳 Processing ${amount:.2f} via credit card ending in {self.card_number}")
# Actual payment processing logic here
return {'success': True, 'transaction_id': 'CC-12345'}
class PayPalPayment(PaymentStrategy):
"""PayPal payment implementation."""
def __init__(self, email):
self.email = email
def pay(self, amount):
print(f"🅿️ Processing ${amount:.2f} via PayPal ({self.email})")
# Actual PayPal API call here
return {'success': True, 'transaction_id': 'PP-67890'}
class CryptocurrencyPayment(PaymentStrategy):
"""Cryptocurrency payment implementation."""
def __init__(self, wallet_address):
self.wallet_address = wallet_address
def pay(self, amount):
print(f"₿ Processing ${amount:.2f} via cryptocurrency ({self.wallet_address[:10]}...)")
# Actual blockchain transaction here
return {'success': True, 'transaction_id': 'BTC-ABCDEF'}
# Context class that uses strategies
class ShoppingCart:
"""Shopping cart that can use different payment strategies."""
def __init__(self):
self._items = []
self._payment_strategy = None
def add_item(self, item, price):
"""Add item to cart."""
self._items.append({'item': item, 'price': price})
print(f"➕ Added {item} (${price:.2f}) to cart")
def calculate_total(self):
"""Calculate cart total."""
return sum(item['price'] for item in self._items)
def set_payment_strategy(self, strategy: PaymentStrategy):
"""Set payment method (strategy)."""
self._payment_strategy = strategy
print(f"✅ Payment method set: {strategy.__class__.__name__}")
def checkout(self):
"""Process payment using selected strategy."""
if not self._payment_strategy:
print("❌ Error: No payment method selected")
return False
total = self.calculate_total()
print(f"\n🛒 Cart total: ${total:.2f}")
result = self._payment_strategy.pay(total)
if result['success']:
print(f"✅ Payment successful! Transaction ID: {result['transaction_id']}\n")
self._items.clear()
return True
else:
print("❌ Payment failed\n")
return False
# Usage - same shopping cart, different payment strategies
cart = ShoppingCart()
cart.add_item("Laptop", 999.99)
cart.add_item("Mouse", 29.99)
# Pay with credit card
cart.set_payment_strategy(CreditCardPayment("1234-5678-9012-3456", "123"))
cart.checkout()
# Add more items and pay with PayPal
cart.add_item("Keyboard", 89.99)
cart.add_item("Monitor", 299.99)
cart.set_payment_strategy(PayPalPayment("[email protected]"))
cart.checkout()
# Add more items and pay with cryptocurrency
cart.add_item("Headphones", 149.99)
cart.set_payment_strategy(CryptocurrencyPayment("1A2B3C4D5E6F7G8H9I0J"))
cart.checkout()Benefits:
- Eliminates complex conditional logic
- Makes algorithms interchangeable at runtime
- Follows Open/Closed Principle (add new strategies without modifying context)
- Each strategy is independently testable
Drawbacks:
- Increases number of classes
- Clients must understand different strategies to choose appropriately
- May be overkill for simple conditional logic
Comprehensive Practical Example: Notification System
Let’s build a real-world notification system that integrates multiple design patterns. This example demonstrates how patterns work together synergistically to create maintainable, extensible systems.
Requirements:
- Support multiple notification channels (email, SMS, push)
- Add logging and retry capabilities to any channel
- Allow runtime channel selection
- Notify analytics and billing systems when notifications are sent
- Coordinate everything through a centralized service
Implementation combining Factory, Decorator, Strategy, Observer, and Singleton:
from abc import ABC, abstractmethod
from typing import List
import time
# 1. STRATEGY PATTERN - Define notification channel interface
class NotificationChannel(ABC):
"""Abstract notification channel."""
@abstractmethod
def send(self, recipient, message):
"""Send notification through this channel."""
pass
# Concrete channels
class EmailChannel(NotificationChannel):
def send(self, recipient, message):
print(f"📧 Email sent to {recipient}: {message}")
return True
class SMSChannel(NotificationChannel):
def send(self, recipient, message):
print(f"📱 SMS sent to {recipient}: {message}")
return True
class PushChannel(NotificationChannel):
def send(self, recipient, message):
print(f"🔔 Push notification sent to {recipient}: {message}")
return True
# 2. FACTORY PATTERN - Create channels
class ChannelFactory:
"""Factory for creating notification channels."""
@staticmethod
def create_channel(channel_type):
channels = {
'email': EmailChannel,
'sms': SMSChannel,
'push': PushChannel
}
channel_class = channels.get(channel_type.lower())
if not channel_class:
raise ValueError(f"Unknown channel: {channel_type}")
return channel_class()
# 3. DECORATOR PATTERN - Add logging and retry capabilities
class ChannelDecorator(NotificationChannel):
"""Base decorator for channels."""
def __init__(self, channel):
self._channel = channel
def send(self, recipient, message):
return self._channel.send(recipient, message)
class LoggingDecorator(ChannelDecorator):
"""Adds logging to any channel."""
def send(self, recipient, message):
print(f" [LOG] Attempting to send via {self._channel.__class__.__name__}")
result = self._channel.send(recipient, message)
print(f" [LOG] Send result: {'Success' if result else 'Failed'}")
return result
class RetryDecorator(ChannelDecorator):
"""Adds retry logic to any channel."""
def __init__(self, channel, max_retries=3):
super().__init__(channel)
self.max_retries = max_retries
def send(self, recipient, message):
for attempt in range(1, self.max_retries + 1):
print(f" [RETRY] Attempt {attempt}/{self.max_retries}")
if self._channel.send(recipient, message):
return True
if attempt < self.max_retries:
print(f" [RETRY] Failed, retrying in {attempt} second(s)...")
time.sleep(attempt)
print(f" [RETRY] All attempts failed")
return False
# 4. OBSERVER PATTERN - Notify other systems
class NotificationObserver(ABC):
"""Observer for notification events."""
@abstractmethod
def on_notification_sent(self, recipient, channel, message):
"""Called when notification is sent."""
pass
class AnalyticsObserver(NotificationObserver):
"""Tracks notification metrics."""
def on_notification_sent(self, recipient, channel, message):
print(f" 📊 [Analytics] Tracked: {channel} notification to {recipient}")
class BillingObserver(NotificationObserver):
"""Tracks notification costs."""
def __init__(self):
self.costs = {'email': 0.01, 'sms': 0.05, 'push': 0.02}
def on_notification_sent(self, recipient, channel, message):
cost = self.costs.get(channel.lower(), 0)
print(f" 💰 [Billing] Charged ${cost:.2f} for {channel} notification")
# 5. SINGLETON PATTERN - Centralized notification service
class NotificationService:
"""Singleton notification service coordinating everything."""
_instance = None
_observers: List[NotificationObserver] = []
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._observers = []
print("🎯 NotificationService initialized (Singleton)")
return cls._instance
def attach_observer(self, observer):
"""Attach observer for notification events."""
self._observers.append(observer)
def _notify_observers(self, recipient, channel_type, message):
"""Notify all observers."""
for observer in self._observers:
observer.on_notification_sent(recipient, channel_type, message)
def send_notification(self, recipient, message, channel_type='email',
enable_logging=True, enable_retry=False):
"""
Send notification with configurable features.
Demonstrates all patterns working together.
"""
print(f"\n{'='*60}")
print(f"Sending notification via {channel_type}")
print(f"{'='*60}")
# Use Factory to create channel
channel = ChannelFactory.create_channel(channel_type)
# Use Decorator to add features
if enable_logging:
channel = LoggingDecorator(channel)
if enable_retry:
channel = RetryDecorator(channel, max_retries=2)
# Send notification
success = channel.send(recipient, message)
# Use Observer to notify other systems
if success:
self._notify_observers(recipient, channel_type, message)
print(f"{'='*60}\n")
return success
# USAGE - All patterns working together
service = NotificationService()
# Attach observers (Observer Pattern)
service.attach_observer(AnalyticsObserver())
service.attach_observer(BillingObserver())
# Send notifications with different combinations of features
service.send_notification(
"[email protected]",
"Welcome to our platform!",
channel_type='email',
enable_logging=True
)
service.send_notification(
"+1234567890",
"Your verification code is 123456",
channel_type='sms',
enable_logging=True,
enable_retry=True
)
service.send_notification(
"device_token_xyz",
"New message received",
channel_type='push',
enable_logging=True
)
# Verify Singleton - both references point to same instance
service2 = NotificationService()
print(f"Same instance? {service is service2}") # TrueThis example demonstrates:
- Factory Pattern: Creates appropriate notification channels
- Decorator Pattern: Adds logging and retry capabilities flexibly
- Strategy Pattern: Enables runtime channel selection
- Observer Pattern: Notifies analytics and billing systems
- Singleton Pattern: Coordinates everything through centralized service
The power of design patterns emerges when they work together. Each pattern solves a specific problem, and their combination creates a flexible, maintainable system.
Common Pitfalls and How to Avoid Them
Pitfall 1: Over-Engineering with Patterns
The Problem: Forcing patterns into problems that don’t need them creates unnecessary complexity. A common example is using the Factory pattern when if/else suffices, or implementing Singleton for stateless utilities.
Why It Happens: Developers excited about learning patterns try to apply them everywhere. This “pattern enthusiasm” leads to over-abstraction.
How to Avoid It:
- Apply the “Rule of Three”: Wait until you’ve written similar code three times before introducing a pattern
- Ask: “Does this pattern simplify or complicate the code?”
- Remember YAGNI (You Aren’t Gonna Need It) - don’t build for hypothetical future needs
- Start simple, refactor to patterns when complexity justifies it
Example:
# Over-engineered - Factory for two options
class AnimalFactory:
@staticmethod
def create_animal(animal_type):
if animal_type == 'dog':
return Dog()
else:
return Cat()
# Simpler - direct instantiation
def create_animal(animal_type):
return Dog() if animal_type == 'dog' else Cat()Pitfall 2: Singleton Abuse
The Problem: Using Singleton for everything creates hidden dependencies and global state, making code difficult to test and understand.
Why It Happens: Global access is convenient, and Singleton seems like an elegant way to ensure single instances.
How to Avoid It:
- Reserve Singleton for truly global, stateful resources (logging, configuration, connection pools)
- Use dependency injection for most scenarios
- Ask: “Does this need to be globally accessible, or just single instance?”
- Consider making the single instance explicit rather than hidden in Singleton
Better Alternative:
# Instead of Singleton
class ConfigManager:
_instance = None
def __new__(cls):
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
# Use dependency injection
class ConfigManager:
def __init__(self, config_file):
self.config = load_config(config_file)
# Create one instance explicitly
config = ConfigManager('app.conf')
service = MyService(config) # Inject it where neededPitfall 3: Ignoring Context - Applying Patterns Inappropriately
The Problem: Patterns have specific contexts where they excel. Applying them outside those contexts creates confusion.
Why It Happens: Focusing on pattern mechanics without understanding their purpose and appropriate use cases.
How to Avoid It:
- Study the “Intent” and “Applicability” sections in pattern documentation
- Understand the problem before selecting a solution
- Consider alternatives - sometimes simple inheritance or composition suffices
- Look at real-world examples in established codebases
Example: Using Observer pattern for simple callback situations where a function parameter would suffice.
Pitfall 4: Mixing Pattern Responsibilities
The Problem: Creating classes that combine multiple pattern responsibilities, violating Single Responsibility Principle.
Why It Happens: Trying to minimize class count or not fully understanding pattern boundaries.
How to Avoid It:
- Keep patterns focused - each class should implement one pattern role
- Use composition to combine patterns rather than inheritance
- If a class is hard to name, it probably has too many responsibilities
- Maintain clear separation between pattern roles
Example:
# Mixed responsibilities - BAD
class NotificationFactoryObserver:
"""Trying to be both Factory and Observer - confusing!"""
def create_and_notify(self, type, observers):
notification = self.create(type)
for observer in observers:
observer.update(notification)
return notification
# Separated responsibilities - GOOD
factory = NotificationFactory()
subject = NotificationSubject()
notification = factory.create('email')
subject.notify_observers(notification)Pitfall 5: Not Documenting Pattern Usage
The Problem: Future developers (including yourself) don’t realize patterns are being used, leading to incorrect modifications.
Why It Happens: Assuming patterns are self-documenting or forgetting documentation in production pressure.
How to Avoid It:
- Add comments identifying which pattern you’re using and why
- Document pattern participants and their roles
- Include examples of correct usage in docstrings
- Reference pattern documentation for complex implementations
Example:
class NotificationService:
"""
Centralized notification service (SINGLETON PATTERN).
This class ensures only one notification service exists throughout
the application lifetime, coordinating all notification operations.
Thread-safe implementation uses double-checked locking.
Example:
service = NotificationService() # Creates or returns existing instance
service.send_notification("[email protected]", "Hello")
"""
_instance = None
_lock = threading.Lock()
# ... implementationComparison with Alternatives
When to Use Patterns vs. Simple Functions
Use patterns when:
- You anticipate changes or extensions
- Multiple implementations of the same concept exist
- You need runtime flexibility
- The problem matches a well-known pattern’s intent
Use simple functions when:
- The problem is straightforward and unlikely to change
- You’re building a prototype or proof-of-concept
- The overhead of pattern infrastructure outweighs benefits
- YAGNI applies - you don’t need future extensibility
Example: For a one-time data conversion script, a simple function beats a Strategy pattern. For a payment processing system supporting multiple payment methods, Strategy pattern provides essential flexibility.
Patterns vs. Language Features
Modern languages often provide built-in features that replace certain patterns:
Python Examples:
- Decorators (language feature) vs. Decorator pattern
- Generators and iterators vs. Iterator pattern
- Context managers vs. Resource Acquisition patterns
- First-class functions vs. Strategy pattern (for simple cases)
When to use language features: When they provide the same benefits with less code and better integration with the language ecosystem.
When to use explicit patterns: When you need:
- More control over behavior
- Additional capabilities beyond language features
- Clearer communication of design intent
- Cross-language consistency
Patterns vs. Frameworks
Many frameworks implement patterns internally:
Django/Flask: Factory pattern for view creation, Observer for signals React: Observer pattern for state management, Composite for component trees Spring: Factory pattern (ApplicationContext), Proxy (AOP), Singleton (beans)
When to use framework mechanisms: When building applications in that framework - leverage framework’s pattern implementations.
When to use custom patterns: When:
- Working outside framework scope
- Framework patterns don’t fit your use case
- Building framework-agnostic libraries
- Learning pattern fundamentals
Patterns vs. Simple Composition
Sometimes simple composition (combining objects) provides the flexibility you need without pattern overhead.
Use patterns when:
- You need formal structure and documentation
- Multiple developers need clear architectural guidance
- The problem maps cleanly to a pattern’s intent
- You’re building infrastructure or libraries
Use simple composition when:
- Straightforward object combination suffices
- The team is small and communicates frequently
- The system is simple and unlikely to scale
- You’re in exploratory phase
Example: Building a logging system with file and console output might use simple composition initially. As requirements grow (filtering, formatting, rotation), Observer pattern might become beneficial.
Key Takeaways
Design patterns are proven solutions to recurring problems, providing vocabulary and architectural templates. They’re not code to copy-paste but blueprints to adapt to your context [1][2].
Three main categories serve distinct purposes: Creational patterns address object creation, structural patterns handle object composition, and behavioral patterns manage object interaction and communication [1].
Patterns combine effectively in real applications. The notification system example demonstrated Factory, Decorator, Strategy, Observer, and Singleton patterns working together synergistically.
Apply patterns pragmatically, not dogmatically. Use the “Rule of Three” (wait for three instances before abstracting) and ask whether patterns simplify or complicate your specific situation [2].
Context determines appropriateness more than implementation. Singleton works well for connection pools but creates testing nightmares for business logic. Understanding when to apply patterns is more valuable than knowing implementation details [1].
Modern languages and frameworks often provide built-in alternatives to patterns. Use language features when they provide the same benefits with less code, and explicit patterns when you need more control or clearer design communication.
Document pattern usage clearly. Future developers (including yourself) need to understand which patterns you’re using and why, enabling correct modifications rather than inadvertent violations of pattern structure.
Practice Quiz
Question 1: Identifying Pattern Categories
You’re building a logging system that needs to send logs to multiple destinations (file, console, remote server) simultaneously. When a new log entry is created, all destinations should receive it automatically. Which pattern category best addresses this requirement?
a) Creational pattern - managing log object creation b) Structural pattern - composing log destinations c) Behavioral pattern - managing communication between logger and destinations d) Concurrency pattern - handling multi-threaded logging
Answer: c) Behavioral pattern
This scenario describes the Observer pattern (behavioral). The logger is the subject, and destinations (file, console, remote) are observers that need automatic notification when events occur. Behavioral patterns govern object interaction and communication—exactly what’s needed here.
While you might use structural patterns to compose destinations or concurrency patterns for thread safety, the core requirement (automatic notification of multiple dependents) is a behavioral concern.
Question 2: Singleton Appropriateness
Which of the following scenarios is an appropriate use case for the Singleton pattern?
a) A utility class with only static methods for string manipulation b) A database connection pool managing connections to a database c) A User class representing user accounts in the system d) A CalculationService class performing mathematical operations
Answer: b) A database connection pool
A database connection pool is an excellent Singleton candidate because:
- It manages a shared resource (database connections)
- Multiple instances would waste resources and cause conflicts
- It needs global access throughout the application
- It maintains state (available connections, connection limits)
Option a (utility class) doesn’t need Singleton—static methods suffice without instantiation. Option c (User class) should have multiple instances (one per user). Option d (CalculationService) should likely use dependency injection rather than Singleton, as it doesn’t manage shared resources and Singleton would complicate testing.
Question 3: Pattern Combination Understanding
In the comprehensive notification system example, why were both Factory pattern and Decorator pattern used together rather than choosing just one?
a) They’re always used together as a standard combination b) Factory creates notification channels; Decorator adds features (logging, retry) to those channels—they solve different problems c) Using more patterns makes the code more professional d) One could replace the other, but both were shown for educational purposes
Answer: b) Factory creates notification channels; Decorator adds features to those channels—they solve different problems
Factory and Decorator address different concerns:
- Factory pattern handles object creation (which notification channel to instantiate)
- Decorator pattern handles feature enhancement (adding logging or retry to any channel)
These are complementary, not redundant. You use Factory to get the right channel, then Decorator to add capabilities. Each pattern has a distinct responsibility, following Single Responsibility Principle.
This demonstrates that patterns aren’t mutually exclusive—real systems often combine multiple patterns, each solving a specific problem in the overall architecture.
Question 4: Avoiding Over-Application
Your team is building a simple utility to format dates in two formats: “MM/DD/YYYY” and “DD/MM/YYYY”. A junior developer proposes implementing the Strategy pattern with abstract DateFormatter interface, two concrete strategies, and a Context class. What’s the best feedback?
a) Great idea! Strategy pattern is perfect for multiple algorithms b) Add Factory pattern too for creating formatter instances c) This is over-engineering; a simple function parameter or conditional is sufficient d) Use Singleton to ensure only one formatter exists
Answer: c) This is over-engineering; a simple function parameter or conditional is sufficient
While Strategy pattern technically applies (multiple algorithms for the same problem), the overhead isn’t justified here:
- Only two simple formats (not complex algorithms)
- Unlikely to add many more formats
- No runtime algorithm selection based on complex conditions
- A function with a boolean parameter or simple conditional would be clearer and more maintainable
# Sufficient solution
def format_date(date, use_us_format=True):
return date.strftime("%m/%d/%Y" if use_us_format else "%d/%m/%Y")Apply the “Rule of Three”: wait until you’ve written similar code three times before introducing pattern abstractions. Patterns should simplify, not complicate.
Question 5: Pattern Selection
You’re integrating a third-party payment library that uses method processTransaction(amountCents, cardNumber), but your application uses makePayment(amountDollars, paymentDetails). You need to make the third-party library work with your existing code without modifying either. Which pattern best solves this?
a) Factory Method - create different payment types b) Adapter - translate between incompatible interfaces c) Decorator - add payment functionality d) Observer - notify when payments complete
Answer: b) Adapter - translate between incompatible interfaces
This scenario perfectly describes the Adapter pattern’s purpose: bridging incompatible interfaces. You have:
- Existing code expecting
makePayment(amountDollars, paymentDetails) - Third-party library providing
processTransaction(amountCents, cardNumber) - Cannot modify either system
An adapter class would implement your expected interface while internally calling the third-party library’s interface, translating parameters (dollars to cents) as needed.
Factory creates objects but doesn’t handle interface translation. Decorator adds functionality but doesn’t change interfaces. Observer handles notifications, not interface compatibility. Adapter is specifically designed for this exact situation.
References
[1] Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley Professional. URL: https://www.pearson.com/en-us/subject-catalog/p/design-patterns-elements-of-reusable-object-oriented-software/P200000003499
[2] Refactoring.Guru. (2024). Design Patterns. URL: https://refactoring.guru/design-patterns
[3] SourceMaking. (2024). Design Patterns. URL: https://sourcemaking.com/design_patterns
[4] Microsoft Learn. (2024). Cloud Design Patterns. URL: https://learn.microsoft.com/en-us/azure/architecture/patterns/
[5] Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall. URL: https://www.pearson.com/en-us/subject-catalog/p/clean-code-a-handbook-of-agile-software-craftsmanship/P200000009044
[6] Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd Edition). Addison-Wesley Professional. URL: https://martinfowler.com/books/refactoring.html