Design Patterns
Design patterns clicked for me when I realized they’re not rigid templates to follow blindly—they’re solutions to recurring problems that experienced developers have refined over time. In Python, many patterns that require complex implementations in other languages become surprisingly elegant thanks to features like decorators, first-class functions, and dynamic typing.
I’ve learned that the key to using patterns effectively is understanding the problem they solve, not just memorizing the implementation. Some patterns that are essential in Java or C++ are unnecessary in Python because the language provides simpler alternatives. Others become more powerful when adapted to Python’s strengths.
The Singleton Pattern: When You Need One Instance
The Singleton pattern ensures a class has only one instance and provides global access to it. While often overused, it’s genuinely useful for things like configuration managers, logging systems, or database connection pools. Python offers several elegant ways to implement singletons:
class DatabaseManager:
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not self._initialized:
self.connection_pool = []
self.max_connections = 10
self.active_connections = 0
self._initialized = True
def get_connection(self):
if self.active_connections < self.max_connections:
self.active_connections += 1
return f"Connection #{self.active_connections}"
return None
def release_connection(self, connection):
if self.active_connections > 0:
self.active_connections -= 1
return True
return False
# Multiple instantiations return the same object
db1 = DatabaseManager()
db2 = DatabaseManager()
print(db1 is db2) # True
conn1 = db1.get_connection()
conn2 = db2.get_connection() # Same instance, shared state
print(f"Active connections: {db1.active_connections}") # 2
A more Pythonic approach uses a decorator to convert any class into a singleton:
def singleton(cls):
"""Decorator to make any class a singleton"""
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class ConfigManager:
def __init__(self):
self.settings = {
'debug': False,
'database_url': 'sqlite:///app.db',
'cache_timeout': 300
}
def get(self, key, default=None):
return self.settings.get(key, default)
def set(self, key, value):
self.settings[key] = value
# Decorator approach is cleaner and more reusable
config1 = ConfigManager()
config2 = ConfigManager()
print(config1 is config2) # True
Factory Patterns: Creating Objects Intelligently
Factory patterns abstract object creation, making your code more flexible and easier to extend. They’re particularly useful when you need to create different types of objects based on runtime conditions:
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def process_payment(self, amount, currency="USD"):
pass
@abstractmethod
def get_fees(self, amount):
pass
class CreditCardProcessor(PaymentProcessor):
def __init__(self, merchant_id):
self.merchant_id = merchant_id
self.fee_rate = 0.029 # 2.9%
def process_payment(self, amount, currency="USD"):
fees = self.get_fees(amount)
net_amount = amount - fees
return {
'status': 'success',
'amount': amount,
'fees': fees,
'net_amount': net_amount,
'processor': 'credit_card'
}
def get_fees(self, amount):
return round(amount * self.fee_rate, 2)
class PayPalProcessor(PaymentProcessor):
def __init__(self, api_key):
self.api_key = api_key
self.fee_rate = 0.034 # 3.4%
def process_payment(self, amount, currency="USD"):
fees = self.get_fees(amount)
net_amount = amount - fees
return {
'status': 'success',
'amount': amount,
'fees': fees,
'net_amount': net_amount,
'processor': 'paypal'
}
def get_fees(self, amount):
return round(amount * self.fee_rate, 2)
class PaymentProcessorFactory:
_processors = {
'credit_card': CreditCardProcessor,
'paypal': PayPalProcessor
}
@classmethod
def create_processor(cls, processor_type, **kwargs):
if processor_type not in cls._processors:
raise ValueError(f"Unknown processor type: {processor_type}")
processor_class = cls._processors[processor_type]
return processor_class(**kwargs)
@classmethod
def register_processor(cls, name, processor_class):
"""Allow registration of new processor types"""
cls._processors[name] = processor_class
@classmethod
def get_available_processors(cls):
return list(cls._processors.keys())
# Factory creates appropriate objects based on type
processor = PaymentProcessorFactory.create_processor(
'credit_card',
merchant_id='MERCHANT_123'
)
result = processor.process_payment(100.00)
print(result) # Shows credit card processing result
# Easy to extend with new processor types
class CryptoProcessor(PaymentProcessor):
def __init__(self, wallet_address):
self.wallet_address = wallet_address
self.fee_rate = 0.01 # 1%
def process_payment(self, amount, currency="BTC"):
fees = self.get_fees(amount)
return {
'status': 'pending',
'amount': amount,
'fees': fees,
'processor': 'crypto',
'currency': currency
}
def get_fees(self, amount):
return round(amount * self.fee_rate, 4)
# Register new processor type
PaymentProcessorFactory.register_processor('crypto', CryptoProcessor)
crypto_processor = PaymentProcessorFactory.create_processor(
'crypto',
wallet_address='1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'
)
Observer Pattern: Decoupled Event Handling
The Observer pattern lets objects notify multiple other objects about state changes without tight coupling. It’s perfect for implementing event systems, model-view architectures, or any scenario where changes in one object should trigger actions in others:
class EventManager:
def __init__(self):
self._observers = {}
def subscribe(self, event_type, observer):
"""Subscribe an observer to an event type"""
if event_type not in self._observers:
self._observers[event_type] = []
self._observers[event_type].append(observer)
def unsubscribe(self, event_type, observer):
"""Unsubscribe an observer from an event type"""
if event_type in self._observers:
self._observers[event_type].remove(observer)
def notify(self, event_type, data=None):
"""Notify all observers of an event"""
if event_type in self._observers:
for observer in self._observers[event_type]:
observer.handle_event(event_type, data)
class ShoppingCart:
def __init__(self):
self.items = []
self.event_manager = EventManager()
def add_item(self, item, quantity=1):
self.items.append({'item': item, 'quantity': quantity})
self.event_manager.notify('item_added', {
'item': item,
'quantity': quantity,
'total_items': len(self.items)
})
def remove_item(self, item):
self.items = [i for i in self.items if i['item'] != item]
self.event_manager.notify('item_removed', {
'item': item,
'total_items': len(self.items)
})
def checkout(self):
total = sum(item['quantity'] for item in self.items)
self.event_manager.notify('checkout_started', {
'total_items': total,
'items': self.items.copy()
})
self.items.clear()
self.event_manager.notify('checkout_completed', {})
class InventoryManager:
def __init__(self):
self.stock = {'laptop': 10, 'mouse': 50, 'keyboard': 25}
def handle_event(self, event_type, data):
if event_type == 'item_added':
item = data['item']
if item in self.stock:
self.stock[item] -= data['quantity']
print(f"Inventory updated: {item} stock now {self.stock[item]}")
class EmailNotifier:
def handle_event(self, event_type, data):
if event_type == 'checkout_completed':
print("Sending order confirmation email...")
elif event_type == 'item_added':
print(f"Item added to cart: {data['item']}")
class AnalyticsTracker:
def __init__(self):
self.events = []
def handle_event(self, event_type, data):
self.events.append({
'event': event_type,
'data': data,
'timestamp': __import__('datetime').datetime.now()
})
print(f"Analytics: Tracked {event_type} event")
# Set up the observer system
cart = ShoppingCart()
inventory = InventoryManager()
email_notifier = EmailNotifier()
analytics = AnalyticsTracker()
# Subscribe observers to events
cart.event_manager.subscribe('item_added', inventory)
cart.event_manager.subscribe('item_added', email_notifier)
cart.event_manager.subscribe('item_added', analytics)
cart.event_manager.subscribe('checkout_completed', email_notifier)
cart.event_manager.subscribe('checkout_completed', analytics)
# Actions trigger notifications to all relevant observers
cart.add_item('laptop', 2)
cart.add_item('mouse', 1)
cart.checkout()
Strategy Pattern: Interchangeable Algorithms
The Strategy pattern lets you define a family of algorithms and make them interchangeable at runtime. It’s excellent for situations where you have multiple ways to accomplish the same task:
class SortingStrategy(ABC):
@abstractmethod
def sort(self, data):
pass
class BubbleSort(SortingStrategy):
def sort(self, data):
"""Simple bubble sort implementation"""
arr = data.copy()
n = len(arr)
for i in range(n):
for j in range(0, n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
class QuickSort(SortingStrategy):
def sort(self, data):
"""Quick sort implementation"""
if len(data) <= 1:
return data
pivot = data[len(data) // 2]
left = [x for x in data if x < pivot]
middle = [x for x in data if x == pivot]
right = [x for x in data if x > pivot]
return self.sort(left) + middle + self.sort(right)
class PythonSort(SortingStrategy):
def sort(self, data):
"""Use Python's built-in sort"""
return sorted(data)
class DataProcessor:
def __init__(self, sorting_strategy=None):
self.sorting_strategy = sorting_strategy or PythonSort()
def set_sorting_strategy(self, strategy):
"""Change sorting algorithm at runtime"""
self.sorting_strategy = strategy
def process_data(self, data):
"""Process data using the current sorting strategy"""
print(f"Sorting with {self.sorting_strategy.__class__.__name__}")
sorted_data = self.sorting_strategy.sort(data)
return {
'original': data,
'sorted': sorted_data,
'algorithm': self.sorting_strategy.__class__.__name__
}
# Strategy can be changed at runtime
processor = DataProcessor()
test_data = [64, 34, 25, 12, 22, 11, 90]
# Use default strategy
result1 = processor.process_data(test_data)
print(f"Result: {result1['sorted']}")
# Change strategy
processor.set_sorting_strategy(QuickSort())
result2 = processor.process_data(test_data)
print(f"Result: {result2['sorted']}")
# For small datasets, might prefer bubble sort
processor.set_sorting_strategy(BubbleSort())
result3 = processor.process_data(test_data)
print(f"Result: {result3['sorted']}")
Pythonic Pattern Adaptations
Python’s features often allow for more concise implementations of traditional patterns. For example, the Command pattern can be implemented elegantly using functions and closures:
class TextEditor:
def __init__(self):
self.content = ""
self.history = []
self.history_index = -1
def execute_command(self, command):
"""Execute a command and add it to history"""
command.execute()
# Remove any commands after current position (for redo functionality)
self.history = self.history[:self.history_index + 1]
self.history.append(command)
self.history_index += 1
def undo(self):
"""Undo the last command"""
if self.history_index >= 0:
command = self.history[self.history_index]
command.undo()
self.history_index -= 1
def redo(self):
"""Redo the next command"""
if self.history_index < len(self.history) - 1:
self.history_index += 1
command = self.history[self.history_index]
command.execute()
class Command:
def __init__(self, execute_func, undo_func):
self.execute_func = execute_func
self.undo_func = undo_func
def execute(self):
self.execute_func()
def undo(self):
self.undo_func()
# Factory functions for common commands
def create_insert_command(editor, text, position):
def execute():
editor.content = editor.content[:position] + text + editor.content[position:]
def undo():
editor.content = editor.content[:position] + editor.content[position + len(text):]
return Command(execute, undo)
def create_delete_command(editor, start, end):
deleted_text = editor.content[start:end]
def execute():
editor.content = editor.content[:start] + editor.content[end:]
def undo():
editor.content = editor.content[:start] + deleted_text + editor.content[start:]
return Command(execute, undo)
# Usage demonstrates the power of the command pattern
editor = TextEditor()
# Execute commands
insert_cmd = create_insert_command(editor, "Hello ", 0)
editor.execute_command(insert_cmd)
print(f"After insert: '{editor.content}'")
insert_cmd2 = create_insert_command(editor, "World!", 6)
editor.execute_command(insert_cmd2)
print(f"After second insert: '{editor.content}'")
# Undo operations
editor.undo()
print(f"After undo: '{editor.content}'")
editor.undo()
print(f"After second undo: '{editor.content}'")
# Redo operations
editor.redo()
print(f"After redo: '{editor.content}'")
Design patterns provide a shared vocabulary for discussing solutions to common problems. In Python, the key is adapting these patterns to leverage the language’s strengths rather than blindly copying implementations from other languages.
In the next part, we’ll explore advanced OOP concepts including metaclasses, descriptors, and dynamic class creation. These powerful features let you customize how classes themselves behave, opening up possibilities for frameworks, ORMs, and other sophisticated applications.