Table of Contents
Creational Patterns
Creational patterns focus on object creation mechanisms, ensuring objects are created in a way that aligns with the requirements of the system.
1. Singleton: Ensuring a Single Instance
What it is: Ensures a class has only one instance and provides a global point of access to it.
Real-World Scenario: A configuration manager that loads settings from a file. You don’t want multiple instances reloading the file (wasting resources) or holding conflicting configurations.
Example: App Configuration Manager
class ConfigManager:
_instance = None # Class-level variable to hold the single instance
def __new__(cls):
if cls._instance is None:
# Create the instance only if it doesn't exist
cls._instance = super().__new__(cls)
# Load configuration once during initialization
cls._instance.load_config()
return cls._instance
def load_config(self):
"""Load settings from a config file (e.g., JSON, YAML)."""
self.settings = {
"api_url": "https://api.example.com",
"timeout": 30,
"debug_mode": False
}
def get_setting(self, key):
return self.settings.get(key)
# Usage
config1 = ConfigManager()
config2 = ConfigManager()
print(config1 is config2) # Output: True (both are the same instance)
print(config1.get_setting("api_url")) # Output: https://api.example.com
Why It Works:
- The
__new__method controls instantiation, ensuring only one instance is created. - Global access avoids passing config objects between components, reducing complexity.
- Ideal for resources like database connections, loggers, or configuration managers where multiple instances cause issues.
2. Factory: Decoupling Object Creation
What it is: Defines an interface for creating objects but lets subclasses decide which class to instantiate. Decouples the client from concrete product classes.
Real-World Scenario: A payment processing system that supports multiple methods (Credit Card, PayPal, Bitcoin). The client shouldn’t need to know the details of each payment method—just request the type.
Example: Payment Processor Factory
from abc import ABC, abstractmethod
# Abstract Product: Defines the interface for payment methods
class PaymentMethod(ABC):
@abstractmethod
def process_payment(self, amount):
pass
# Concrete Products: Implement specific payment methods
class CreditCardPayment(PaymentMethod):
def process_payment(self, amount):
return f"Processing credit card payment of ${amount:.2f}"
class PayPalPayment(PaymentMethod):
def process_payment(self, amount):
return f"Processing PayPal payment of ${amount:.2f}"
class BitcoinPayment(PaymentMethod):
def process_payment(self, amount):
return f"Processing Bitcoin payment of ${amount:.2f}"
# Factory: Creates payment methods based on input
class PaymentFactory:
@staticmethod
def create_payment(method_type):
if method_type == "credit_card":
return CreditCardPayment()
elif method_type == "paypal":
return PayPalPayment()
elif method_type == "bitcoin":
return BitcoinPayment()
else:
raise ValueError(f"Unsupported payment method: {method_type}")
# Client Code: Uses the factory without knowing concrete classes
payment_method = PaymentFactory.create_payment("paypal")
print(payment_method.process_payment(99.99)) # Output: Processing PayPal payment of $99.99
Why It Works:
- The client depends on the abstract
PaymentMethodinterface, not concrete classes (e.g.,PayPalPayment). - Adding a new payment method (e.g., Stripe) only requires adding a new
PaymentMethodsubclass and updating the factory—no changes to client code. - Reduces tight coupling and simplifies maintenance.
3. Builder: Constructing Complex Objects Step-by-Step
What it is: Separates the construction of a complex object from its representation, allowing the same construction process to create different representations.
Real-World Scenario: Building a custom pizza with options for crust, sauce, cheese, and toppings. The process (select crust → add sauce → add cheese → add toppings) is the same, but the end result varies (e.g., Veggie Pizza vs. Meat Lovers).
Example: Pizza Builder
class Pizza:
def __init__(self):
self.crust = None
self.sauce = None
self.cheese = None
self.toppings = []
def __str__(self):
return (f"Pizza [Crust: {self.crust}, Sauce: {self.sauce}, Cheese: {self.cheese}, "
f"Toppings: {', '.join(self.toppings)}]")
# Builder: Defines steps to build a Pizza
class PizzaBuilder:
def __init__(self):
self.pizza = Pizza()
def set_crust(self, crust):
self.pizza.crust = crust
return self # Enable method chaining
def set_sauce(self, sauce):
self.pizza.sauce = sauce
return self
def set_cheese(self, cheese):
self.pizza.cheese = cheese
return self
def add_topping(self, topping):
self.pizza.toppings.append(topping)
return self
def build(self):
return self.pizza
# Director: Optional; orchestrates the building process for specific pizza types
class PizzaDirector:
@staticmethod
def build_veggie_pizza(builder):
return (builder.set_crust("thin")
.set_sauce("marinara")
.set_cheese("mozzarella")
.add_topping("mushrooms")
.add_topping("bell peppers")
.build())
# Usage
builder = PizzaBuilder()
veggie_pizza = PizzaDirector.build_veggie_pizza(builder)
print(veggie_pizza)
# Output: Pizza [Crust: thin, Sauce: marinara, Cheese: mozzarella, Toppings: mushrooms, bell peppers]
# Custom pizza (without director)
custom_pizza = (builder.set_crust("thick")
.set_sauce("bbq")
.set_cheese("cheddar")
.add_topping("pepperoni")
.build())
print(custom_pizza)
# Output: Pizza [Crust: thick, Sauce: bbq, Cheese: cheddar, Toppings: pepperoni]
Why It Works:
- Step-by-step construction lets clients customize objects without knowing internal details.
- The
Directorencapsulates predefined recipes (e.g., Veggie Pizza), ensuring consistency. - Avoids “telescoping constructors” (constructors with dozens of parameters) for complex objects.
Structural Patterns
Structural patterns focus on how classes and objects are composed to form larger structures.
4. Adapter: Bridging Incompatible Interfaces
What it is: Converts the interface of a class into another interface that clients expect. Lets classes work together that couldn’t otherwise due to incompatible interfaces.
Real-World Scenario: Integrating a legacy payment gateway (OldPaymentSystem) with a new e-commerce platform. The legacy system uses make_payment(amount, card_number) while the new platform expects process_payment(amount, security_code).
Example: Legacy Payment System Adapter
# Legacy Component: Incompatible with the new system
class OldPaymentSystem:
def make_payment(self, amount, card_number):
"""Legacy method: Requires card number (no security code)."""
return f"Old System: Charged ${amount} to card {card_number}"
# Target Interface: What the new system expects
class NewPaymentGateway(ABC):
@abstractmethod
def process_payment(self, amount, security_code):
pass
# Adapter: Wraps the legacy system to match the new interface
class PaymentAdapter(NewPaymentGateway):
def __init__(self, old_system: OldPaymentSystem, card_number: str):
self.old_system = old_system
self.card_number = card_number # Stored during setup (e.g., from user profile)
def process_payment(self, amount, security_code):
"""Adapts the new interface to the legacy method."""
# The legacy system doesn't use security codes, but we can log it for compliance
print(f"Verifying security code: {security_code}")
return self.old_system.make_payment(amount, self.card_number)
# Client Code: Uses the new interface, unaware of the legacy system
def new_ecommerce_checkout(gateway: NewPaymentGateway, amount: float, security_code: str):
result = gateway.process_payment(amount, security_code)
print(result)
# Usage
legacy_system = OldPaymentSystem()
# Wrap the legacy system with an adapter (card number stored during user onboarding)
adapter = PaymentAdapter(legacy_system, card_number="4111-1111-1111-1111")
# New system uses the adapter as if it's a NewPaymentGateway
new_ecommerce_checkout(adapter, amount=99.99, security_code="123")
# Output:
# Verifying security code: 123
# Old System: Charged $99.99 to card 4111-1111-1111-1111
Why It Works:
- Enables reuse of legacy code without modifying it (critical if you can’t update the legacy system).
- Clients interact with a familiar interface, reducing learning curve and errors.
- Acts as a translator between two incompatible systems during migration.
5. Decorator: Adding Behavior Dynamically
What it is: Attaches additional responsibilities to an object dynamically. Provides a flexible alternative to subclassing for extending functionality.
Real-World Scenario: Adding cross-cutting concerns (logging, caching, authentication) to API endpoints without cluttering the core logic. For example, logging the time taken to process a request.
Example: Timing Decorator for API Endpoints
import time
from functools import wraps
# Decorator: Adds timing logic to functions
def timing_decorator(func):
@wraps(func) # Preserves original function metadata (name, docstring)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs) # Call the original function
end_time = time.time()
duration = (end_time - start_time) * 1000 # Convert to milliseconds
print(f"{func.__name__} took {duration:.2f}ms to execute")
return result
return wrapper
# API Endpoint: Core logic (without timing)
@timing_decorator # Apply the decorator
def fetch_user_data(user_id):
"""Simulates fetching user data from a database."""
time.sleep(0.2) # Simulate network/database delay
return {"user_id": user_id, "name": "John Doe"}
# Usage
user = fetch_user_data(123)
print(user)
# Output:
# fetch_user_data took 200.12ms to execute
# {'user_id': 123, 'name': 'John Doe'}
Why It Works:
- Decorators add behavior (timing, logging, caching) without modifying the original function.
- Composable: Stack multiple decorators (e.g.,
@timing_decorator+@cache_decorator). - Python’s
functools.wrapsensures debugging tools and documentation work correctly.
6. Facade: Simplifying Complex Subsystems
What it is: Provides a unified interface to a set of interfaces in a subsystem. Defines a higher-level interface that makes the subsystem easier to use.
Real-World Scenario: A travel booking system with multiple subsystems: flight booking, hotel reservation, and car rental. Clients shouldn’t need to interact with each subsystem directly—just book a complete trip.
Example: Travel Booking Facade
# Complex Subsystems
class FlightBooking:
def search_flights(self, origin, destination, date):
return f"Flights from {origin} to {destination} on {date}: [FL123, FL456]"
def book_flight(self, flight_id, passenger_name):
return f"Booked flight {flight_id} for {passenger_name}"
class HotelReservation:
def search_hotels(self, location, check_in, check_out):
return f"Hotels in {location}: [Hotel A, Hotel B]"
def book_hotel(self, hotel_name, check_in, check_out):
return f"Booked {hotel_name} from {check_in} to {check_out}"
class CarRental:
def search_cars(self, location, start_date, end_date):
return f"Cars available in {location}: [SUV, Sedan]"
def rent_car(self, car_type, start_date, end_date):
return f"Rented {car_type} from {start_date} to {end_date}"
# Facade: Simplifies interaction with subsystems
class TravelFacade:
def __init__(self):
self.flights = FlightBooking()
self.hotels = HotelReservation()
self.cars = CarRental()
def book_trip(self, origin, destination, date, check_in, check_out):
"""Simplified interface for booking a complete trip."""
print("=== Booking Trip ===")
flights = self.flights.search_flights(origin, destination, date)
print(flights)
flight_booking = self.flights.book_flight("FL123", "Alice Smith")
print(flight_booking)
hotels = self.hotels.search_hotels(destination, check_in, check_out)
print(hotels)
hotel_booking = self.hotels.book_hotel("Hotel A", check_in, check_out)
print(hotel_booking)
car_rental = self.cars.rent_car("SUV", check_in, check_out)
print(car_rental)
return "Trip booked successfully!"
# Client Code: Uses the facade instead of subsystems directly
travel_agent = TravelFacade()
result = travel_agent.book_trip(
origin="NYC",
destination="LA",
date="2024-01-15",
check_in="2024-01-15",
check_out="2024-01-20"
)
print(result)
# Output:
# === Booking Trip ===
# Flights from NYC to LA on 2024-01-15: [FL123, FL456]
# Booked flight FL123 for Alice Smith
# Hotels in LA: [Hotel A, Hotel B]
# Booked Hotel A from 2024-01-15 to 2024-01-20
# Rented SUV from 2024-01-15 to 2024-01-20
# Trip booked successfully!
Why It Works:
- Reduces complexity by hiding subsystem details behind a simple interface (
book_trip). - Clients avoid tight coupling with subsystems, making the code easier to maintain.
- Simplifies testing: Test the facade instead of multiple subsystems.
Behavioral Patterns
Behavioral patterns focus on how objects interact and distribute responsibility.
7. Observer: Reacting to State Changes
What it is: Defines a one-to-many dependency between objects. When one object (subject) changes state, all its dependents (observers) are notified and updated automatically.
Real-World Scenario: A stock market where investors (observers) want to be notified when a stock’s price changes. The stock (subject) broadcasts updates to all registered investors.
Example: Stock Price Observer
from abc import ABC, abstractmethod
# Observer Interface: Defines the update method
class Observer(ABC):
@abstractmethod
def update(self, stock_name, price):
pass
# Subject Interface: Defines methods to manage observers
class Subject(ABC):
@abstractmethod
def attach(self, observer):
pass
@abstractmethod
def detach(self, observer):
pass
@abstractmethod
def notify(self):
pass
# Concrete Subject: Stock that notifies observers of price changes
class Stock(Subject):
def __init__(self, name, price):
self.name = name
self._price = price
self._observers = [] # List of registered observers
@property
def price(self):
return self._price
@price.setter
def price(self, new_price):
self._price = new_price
self.notify() # Notify observers when price changes
def attach(self, observer):
self._observers.append(observer)
def detach(self, observer):
self._observers.remove(observer)
def notify(self):
"""Notify all observers of the new price."""
for observer in self._observers:
observer.update(self.name, self.price)
# Concrete Observers: Investors who react to price changes
class Investor(Observer):
def __init__(self, name):
self.name = name
def update(self, stock_name, price):
print(f"Investor {self.name} notified: {stock_name} price is now ${price}")
# Usage
# Create a stock
apple_stock = Stock("AAPL", 150.0)
# Create investors (observers)
investor1 = Investor("Alice")
investor2 = Investor("Bob")
# Attach investors to the stock
apple_stock.attach(investor1)
apple_stock.attach(investor2)
# Change the stock price (triggers notification)
apple_stock.price = 155.5
# Output:
# Investor Alice notified: AAPL price is now $155.5
# Investor Bob notified: AAPL price is now $155.5
# Detach Bob
apple_stock.detach(investor2)
apple_stock.price = 160.0
# Output:
# Investor Alice notified: AAPL price is now $160.0
Why It Works:
- Decouples subjects and observers: The stock doesn’t need to know the type of investors.
- Supports dynamic relationships: Observers can attach/detach at runtime (e.g., investors buying/selling stocks).
- Used in event-driven systems (e.g., GUI frameworks, real-time data feeds).
8. Strategy: Swapping Algorithms at Runtime
What it is: Defines a family of interchangeable algorithms, encapsulates each, and makes them interchangeable. Lets the algorithm vary independently from clients that use it.
Real-World Scenario: A data processing tool that supports multiple sorting algorithms (Bubble Sort, Quick Sort, Merge Sort). The user can choose the algorithm based on data size (e.g., Quick Sort for large datasets, Bubble Sort for small ones).
Example: Sorting Strategy
from abc import ABC, abstractmethod
# Strategy Interface: Defines the sorting method
class SortStrategy(ABC):
@abstractmethod
def sort(self, data):
pass
# Concrete Strategies: Different sorting algorithms
class BubbleSort(SortStrategy):
def sort(self, data):
"""Simple but inefficient for large data."""
data_copy = data.copy()
n = len(data_copy)
for i in range(n):
for j in range(0, n-i-1):
if data_copy[j] > data_copy[j+1]:
data_copy[j], data_copy[j+1] = data_copy[j+1], data_copy[j]
return data_copy
class QuickSort(SortStrategy):
def sort(self, data):
"""Efficient for large data."""
if len(data) <= 1:
return data.copy()
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)
# Context: Uses a strategy to sort data
class DataProcessor:
def __init__(self, strategy: SortStrategy):
self._strategy = strategy
def set_strategy(self, strategy: SortStrategy):
"""Swap strategy at runtime."""
self._strategy = strategy
def process_data(self, data):
"""Uses the current strategy to sort data."""
return self._strategy.sort(data)
# Usage
data = [34, 12, 45, 6, 89, 23]
# Use Bubble Sort for small data
processor = DataProcessor(BubbleSort())
sorted_data_bubble = processor.process_data(data)
print("Bubble Sort Result:", sorted_data_bubble) # Output: [6, 12, 23, 34, 45, 89]
# Switch to Quick Sort for large data
processor.set_strategy(QuickSort())
sorted_data_quick = processor.process_data(data)
print("Quick Sort Result:", sorted_data_quick) # Output: [6, 12, 23, 34, 45, 89]
Why It Works:
- Algorithms are encapsulated, making them easy to replace (e.g., add a
MergeSortstrategy later). - Clients (e.g.,
DataProcessor) are decoupled from concrete algorithms, reducing code changes. - Ideal for scenarios where multiple algorithms exist for the same task (e.g., compression, validation).
9. Command: Encapsulating Actions as Objects
What it is: Encapsulates a request as an object, thereby letting you parameterize clients with queues, requests, or operations. Supports undoable operations.
Real-World Scenario: A smart home remote control that can execute commands (turn on light, adjust thermostat) and undo them. Each button press is a command object.
Example: Smart Home Remote Control
from abc import ABC, abstractmethod
# Command Interface: Defines execute and undo methods
class Command(ABC):
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass
# Concrete Commands: Encapsulate actions on receivers
class LightOnCommand(Command):
def __init__(self, light):
self.light = light # Receiver: The object being acted upon
def execute(self):
self.light.turn_on()
def undo(self):
self.light.turn_off()
class ThermostatUpCommand(Command):
def __init__(self, thermostat):
self.thermostat = thermostat
self.prev_temp = None # Track previous state for undo
def execute(self):
self.prev_temp = self.thermostat.temperature
self.thermostat.increase_temp()
def undo(self):
self.thermostat.set_temp(self.prev_temp)
# Receivers: Objects that perform the actual work
class Light:
def turn_on(self):
print("Light is ON")
def turn_off(self):
print("Light is OFF")
class Thermostat:
def __init__(self):
self.temperature = 20 # Default: 20°C
def increase_temp(self):
self.temperature += 1
print(f"Thermostat set to {self.temperature}°C")
def set_temp(self, temp):
self.temperature = temp
print(f"Thermostat set to {self.temperature}°C")
# Invoker: Sends commands to receivers (e.g., remote control)
class RemoteControl:
def __init__(self):
self._commands = {} # Maps buttons to commands
self._last_command = None # For undo
def set_command(self, button: str, command: Command):
self._commands[button] = command
def press_button(self, button: str):
if button in self._commands:
self._last_command = self._commands[button]
self._last_command.execute()
def press_undo(self):
if self._last_command:
self._last_command.undo()
self._last_command = None # Reset after undo
# Usage
# Create receivers
living_room_light = Light()
thermostat = Thermostat()
# Create commands
light_on_cmd = LightOnCommand(living_room_light)
thermostat_up_cmd = ThermostatUpCommand(thermostat)
# Configure remote (invoker)
remote = RemoteControl()
remote.set_command("button1", light_on_cmd)
remote.set_command("button2", thermostat_up_cmd)
# Press buttons
remote.press_button("button1") # Output: Light is ON
remote.press_button("button2") # Output: Thermostat set to 21°C
# Undo the last action (thermostat up)
remote.press_undo() # Output: Thermostat set to 20°C
Why It Works:
- Commands decouple the invoker (remote) from the receiver (light/thermostat).
- Supports undo/redo by storing command history and calling
undo(). - Enables queuing of commands (e.g., “macro” buttons that execute multiple commands).
Conclusion
Design patterns are not just theoretical—they are battle-tested solutions to common problems in software development. In Python, their flexibility and readability make them even more powerful. By understanding patterns like Singleton (for single instances), Decorator (for dynamic behavior), or Observer (for event handling), you can write code that is reusable, maintainable, and scalable.
The key is to recognize when to apply each pattern:
- Use Creational patterns when you need control over object creation.
- Use Structural patterns to compose objects into larger structures.
- Use Behavioral patterns to manage interactions between objects.
By incorporating these patterns into your workflow, you’ll build systems that are easier to debug, extend, and collaborate on.
References
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
- Real Python: Design Patterns in Python
- Python.org: functools Module (for decorators)
- Refactoring Guru: Design Patterns (visual examples and explanations)