Table of Contents
- What Are Design Patterns?
- Why Use Design Patterns in Python?
- Creational Design Patterns
- Structural Design Patterns
- Behavioral Design Patterns
- When Not to Use Design Patterns
- Conclusion
- References
What Are Design Patterns?
Design patterns were popularized by the “Gang of Four” (GoF) in their 1994 book Design Patterns: Elements of Reusable Object-Oriented Software. They are categorized into three main types:
- Creational Patterns: Focus on object creation mechanisms, ensuring flexibility and reuse of existing code (e.g., Singleton, Factory Method).
- Structural Patterns: Deal with object composition, defining how classes/objects interact to form larger structures (e.g., Adapter, Decorator).
- Behavioral Patterns: Manage communication and responsibility between objects (e.g., Observer, Strategy).
Why Use Design Patterns in Python?
Python’s “batteries-included” philosophy and dynamic nature mean many problems can be solved with built-in tools (e.g., collections for data structures, itertools for iteration). However, design patterns add value by:
- Standardizing Solutions: Using patterns makes your code more readable to other developers familiar with OOP principles.
- Reducing Redundancy: Avoid reinventing the wheel for common problems like “how to ensure only one instance of a class exists” (Singleton).
- Improving Scalability: Patterns like Factory Method or Strategy make it easier to extend functionality without rewriting core code.
Creational Design Patterns
Creational patterns abstract the object creation process, decoupling the act of creating objects from the code that uses them. This flexibility is especially useful in Python, where dynamic object creation is common.
1. Singleton Pattern
Intent: Ensure a class has only one instance and provide a global point of access to it.
Problem: You need a single shared resource (e.g., a database connection pool, configuration manager) to avoid redundant setup/teardown or conflicting state.
Python Implementation:
Python has multiple ways to implement Singletons. The most robust is using a metaclass (since metaclasses control class creation).
class SingletonMeta(type):
"""Metaclass to enforce Singleton behavior."""
_instances = {} # Track instances of classes using this metaclass
def __call__(cls, *args, **kwargs):
"""Override the instance creation logic."""
if cls not in cls._instances:
# Create the instance only if it doesn't exist
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class DatabaseConfig(metaclass=SingletonMeta):
"""A Singleton class to manage database configuration."""
def __init__(self, host: str, port: int):
self.host = host
self.port = port
print(f"DatabaseConfig initialized with host={host}, port={port}")
def get_connection_string(self) -> str:
return f"postgresql://{self.host}:{self.port}/mydb"
# Usage
config1 = DatabaseConfig("localhost", 5432)
config2 = DatabaseConfig("example.com", 5433) # Ignored; uses existing instance
print(config1 is config2) # Output: True (both are the same instance)
print(config1.get_connection_string()) # Output: postgresql://localhost:5432/mydb
Key Notes:
- The
SingletonMetametaclass overrides__call__, ensuring only one instance is created. - A simpler alternative is using a module (since Python modules are singletons by default). For example, a
config.pymodule with global variables acts as a Singleton. - Pitfall: Singletons can make testing harder (due to shared state) and introduce hidden dependencies. Use them sparingly!
2. Factory Method Pattern
Intent: Define an interface for creating an object but let subclasses decide which class to instantiate.
Problem: You need to delegate object creation to subclasses to avoid tight coupling between a creator class and its products.
Example: A payment processing system that supports multiple gateways (Stripe, PayPal). The core logic shouldn’t hardcode gateway-specific classes.
Python Implementation:
from abc import ABC, abstractmethod
# ----------------------
# Product Interface: All payment gateways must implement this
# ----------------------
class PaymentGateway(ABC):
@abstractmethod
def process_payment(self, amount: float) -> str:
pass
# ----------------------
# Concrete Products: Stripe and PayPal implementations
# ----------------------
class StripeGateway(PaymentGateway):
def process_payment(self, amount: float) -> str:
return f"Stripe: Processing payment of ${amount:.2f}"
class PayPalGateway(PaymentGateway):
def process_payment(self, amount: float) -> str:
return f"PayPal: Processing payment of ${amount:.2f}"
# ----------------------
# Creator: Defines the Factory Method
# ----------------------
class PaymentProcessor(ABC):
@abstractmethod
def create_gateway(self) -> PaymentGateway:
"""Factory Method: Subclasses implement this to return a PaymentGateway."""
pass
def pay(self, amount: float) -> str:
"""Core logic that uses the Factory Method's product."""
gateway = self.create_gateway()
return gateway.process_payment(amount)
# ----------------------
# Concrete Creators: Stripe and PayPal processors
# ----------------------
class StripeProcessor(PaymentProcessor):
def create_gateway(self) -> PaymentGateway:
return StripeGateway()
class PayPalProcessor(PaymentProcessor):
def create_gateway(self) -> PaymentGateway:
return PayPalGateway()
# Usage
if __name__ == "__main__":
stripe_processor = StripeProcessor()
print(stripe_processor.pay(99.99)) # Output: Stripe: Processing payment of $99.99
paypal_processor = PayPalProcessor()
print(paypal_processor.pay(49.99)) # Output: PayPal: Processing payment of $49.99
Key Notes:
- The
PaymentProcessor(Creator) delegates gateway creation to subclasses viacreate_gateway(the Factory Method). - Adding a new gateway (e.g.,
BitcoinGateway) only requires:- A new
BitcoinGatewayclass implementingPaymentGateway. - A new
BitcoinProcessorsubclass ofPaymentProcessoroverridingcreate_gateway.
- A new
- Python’s dynamic typing simplifies this pattern—no need for strict interfaces (though
abc.ABCadds clarity).
3. Builder Pattern
Intent: Separate the construction of a complex object from its representation, allowing the same construction process to create different representations.
Problem: You need to build an object with many optional components (e.g., a Computer with CPU, RAM, storage, and optional GPU/keyboard). Constructing it via a constructor with 10+ parameters is messy.
Python Implementation:
class Computer:
"""Product: The complex object being built."""
def __init__(self):
self.cpu = None
self.ram = None
self.storage = None
self.gpu = None # Optional
self.keyboard = None # Optional
def __str__(self):
return (f"Computer Specs:\n"
f"CPU: {self.cpu}\n"
f"RAM: {self.ram}\n"
f"Storage: {self.storage}\n"
f"GPU: {self.gpu if self.gpu else 'None'}\n"
f"Keyboard: {self.keyboard if self.keyboard else 'None'}")
class ComputerBuilder:
"""Builder: Defines steps to build the Computer."""
def __init__(self):
self.computer = Computer() # Start with an empty product
def set_cpu(self, cpu: str) -> "ComputerBuilder":
self.computer.cpu = cpu
return self # Enable method chaining
def set_ram(self, ram: str) -> "ComputerBuilder":
self.computer.ram = ram
return self
def set_storage(self, storage: str) -> "ComputerBuilder":
self.computer.storage = storage
return self
def add_gpu(self, gpu: str) -> "ComputerBuilder":
self.computer.gpu = gpu
return self
def add_keyboard(self, keyboard: str) -> "ComputerBuilder":
self.computer.keyboard = keyboard
return self
def build(self) -> Computer:
"""Return the final product."""
return self.computer
# Usage
if __name__ == "__main__":
# Build a gaming PC with all options
gaming_pc = (ComputerBuilder()
.set_cpu("Intel i9")
.set_ram("32GB DDR5")
.set_storage("2TB NVMe")
.add_gpu("NVIDIA RTX 4090")
.add_keyboard("Mechanical RGB")
.build())
print(gaming_pc)
# Output:
# Computer Specs:
# CPU: Intel i9
# RAM: 32GB DDR5
# Storage: 2TB NVMe
# GPU: NVIDIA RTX 4090
# Keyboard: Mechanical RGB
Key Notes:
- The
ComputerBuilderprovides fluent interfaces (method chaining viareturn self), making construction readable. - A
Directorclass can be added to encapsulate common build sequences (e.g.,GamingPCDirectororOfficePCDirector). - Python’s keyword arguments (
**kwargs) are often used as a lightweight alternative, but the Builder pattern is better for objects with many optional steps.
Structural Design Patterns
Structural patterns focus on composing classes or objects to form larger structures, such as adapters for incompatible interfaces or decorators for dynamic functionality.
1. Adapter Pattern
Intent: Convert the interface of a class into another interface clients expect. Lets classes work together that couldn’t otherwise because of incompatible interfaces.
Problem: You need to integrate a legacy system (e.g., a LegacyPaymentProcessor with a charge_customer method) into a new system that expects a PaymentGateway interface with a process_payment method.
Python Implementation:
# ----------------------
# Legacy Component (Incompatible Interface)
# ----------------------
class LegacyPaymentProcessor:
"""Legacy system with a non-standard method name."""
def charge_customer(self, customer_id: int, amount: float) -> str:
return f"Legacy: Charged customer {customer_id} ${amount:.2f}"
# ----------------------
# Target Interface (What the new system expects)
# ----------------------
class PaymentGateway(ABC):
@abstractmethod
def process_payment(self, amount: float) -> str:
pass
# ----------------------
# Adapter: Wraps LegacyPaymentProcessor to match PaymentGateway
# ----------------------
class LegacyAdapter(PaymentGateway):
def __init__(self, legacy_processor: LegacyPaymentProcessor, customer_id: int):
self.legacy_processor = legacy_processor
self.customer_id = customer_id # Adapter-specific state
def process_payment(self, amount: float) -> str:
# Adapt the new interface to the legacy method
return self.legacy_processor.charge_customer(self.customer_id, amount)
# Usage
if __name__ == "__main__":
legacy_processor = LegacyPaymentProcessor()
adapter = LegacyAdapter(legacy_processor, customer_id=123) # Wrap legacy system
# New system calls process_payment (target interface)
result = adapter.process_payment(49.99)
print(result) # Output: Legacy: Charged customer 123 $49.99
Key Notes:
- The adapter acts as a middleman, translating calls between the target interface and the legacy component.
- Python’s dynamic typing makes adapters lightweight—no need for explicit interface inheritance (though using
ABCimproves clarity).
2. Decorator Pattern
Intent: Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
Problem: You need to add features to an object (e.g., logging, caching, or validation) without modifying its code or creating a bloated subclass hierarchy.
Python Implementation:
Python has built-in support for decorators via the @decorator syntax, but here we’ll implement the OOP version for clarity.
from abc import ABC, abstractmethod
# ----------------------
# Component Interface: Defines the core functionality
# ----------------------
class Coffee(ABC):
@abstractmethod
def cost(self) -> float:
pass
@abstractmethod
def description(self) -> str:
pass
# ----------------------
# Concrete Component: Base implementation
# ----------------------
class Espresso(Coffee):
def cost(self) -> float:
return 2.50
def description(self) -> str:
return "Espresso"
# ----------------------
# Decorator: Wraps a Coffee and adds functionality
# ----------------------
class CoffeeDecorator(Coffee):
def __init__(self, coffee: Coffee):
self._coffee = coffee # Wrap the coffee object
@abstractmethod
def cost(self) -> float:
pass
@abstractmethod
def description(self) -> str:
pass
# ----------------------
# Concrete Decorators: Add milk, sugar, etc.
# ----------------------
class MilkDecorator(CoffeeDecorator):
def cost(self) -> float:
return self._coffee.cost() + 0.50 # Add milk cost
def description(self) -> str:
return f"{self._coffee.description()}, Milk"
class SugarDecorator(CoffeeDecorator):
def cost(self) -> float:
return self._coffee.cost() + 0.25 # Add sugar cost
def description(self) -> str:
return f"{self._coffee.description()}, Sugar"
# Usage
if __name__ == "__main__":
# Start with a base espresso
coffee = Espresso()
print(f"Base: {coffee.description()} - ${coffee.cost():.2f}") # Output: Base: Espresso - $2.50
# Add milk
coffee_with_milk = MilkDecorator(coffee)
print(f"With Milk: {coffee_with_milk.description()} - ${coffee_with_milk.cost():.2f}") # Output: With Milk: Espresso, Milk - $3.00
# Add sugar to the milk coffee
coffee_with_milk_sugar = SugarDecorator(coffee_with_milk)
print(f"With Milk & Sugar: {coffee_with_milk_sugar.description()} - ${coffee_with_milk_sugar.cost():.2f}") # Output: With Milk & Sugar: Espresso, Milk, Sugar - $3.25
Key Notes:
- Python’s function decorators (e.g.,
@log,@cache) are a language-specific implementation of this pattern for functions/methods. - Decorators can be stacked (e.g.,
@log+@cache), making them ideal for adding layers of functionality dynamically.
3. Composite Pattern
Intent: Compose objects into tree structures to represent part-whole hierarchies. Clients treat individual objects and compositions uniformly.
Problem: You need to work with hierarchical data (e.g., file systems: files and directories) where both leaves (files) and composites (directories) should be treated the same.
Python Implementation:
from abc import ABC, abstractmethod
from typing import List
# ----------------------
# Component Interface: Files and directories share this
# ----------------------
class FileSystemComponent(ABC):
@abstractmethod
def get_size(self) -> int:
"""Return the size (in bytes) of the component."""
pass
@abstractmethod
def get_name(self) -> str:
"""Return the name of the component."""
pass
# ----------------------
# Leaf: Represents a file (no children)
# ----------------------
class File(FileSystemComponent):
def __init__(self, name: str, size: int):
self._name = name
self._size = size
def get_size(self) -> int:
return self._size
def get_name(self) -> str:
return self._name
# ----------------------
# Composite: Represents a directory (has children)
# ----------------------
class Directory(FileSystemComponent):
def __init__(self, name: str):
self._name = name
self._children: List[FileSystemComponent] = [] # Contains files/directories
def add_child(self, child: FileSystemComponent) -> None:
self._children.append(child)
def remove_child(self, child: FileSystemComponent) -> None:
self._children.remove(child)
def get_size(self) -> int:
"""Sum the sizes of all children (recursive)."""
return sum(child.get_size() for child in self._children)
def get_name(self) -> str:
return self._name
# Usage
if __name__ == "__main__":
# Create files
file1 = File("document.txt", 1024) # 1KB
file2 = File("image.jpg", 20480) # 20KB
# Create a subdirectory with a file
subdir = Directory("photos")
subdir.add_child(File("vacation.jpg", 51200)) # 50KB
# Create root directory and add components
root = Directory("root")
root.add_child(file1)
root.add_child(file2)
root.add_child(subdir)
# Calculate total size (uniform treatment of files/directories)
print(f"Root directory size: {root.get_size()} bytes") # Output: Root directory size: 72704 bytes
Key Notes:
- The
FileSystemComponentinterface ensures bothFile(leaf) andDirectory(composite) implementget_sizeandget_name. - Recursion is critical here: composites like
Directorydelegate work to their children.
Behavioral Design Patterns
Behavioral patterns manage communication and collaboration between objects, ensuring flexible and efficient interaction.
1. Observer Pattern
Intent: Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
Problem: You need to notify multiple objects (e.g., UI widgets, logs, analytics) when a central state changes (e.g., a sensor reading, stock price update).
Python Implementation:
from abc import ABC, abstractmethod
from typing import List
# ----------------------
# Subject Interface: Notifies observers of state changes
# ----------------------
class Subject(ABC):
@abstractmethod
def attach(self, observer: "Observer") -> None:
"""Add an observer to the list."""
pass
@abstractmethod
def detach(self, observer: "Observer") -> None:
"""Remove an observer from the list."""
pass
@abstractmethod
def notify(self) -> None:
"""Notify all observers of a state change."""
pass
# ----------------------
# Observer Interface: Defines a reaction to state changes
# ----------------------
class Observer(ABC):
@abstractmethod
def update(self, subject: Subject) -> None:
"""Update observer state based on the subject's new state."""
pass
# ----------------------
# Concrete Subject: Weather station with temperature data
# ----------------------
class WeatherStation(Subject):
def __init__(self):
self._temperature = 0.0
self._observers: List[Observer] = [] # Track attached observers
@property
def temperature(self) -> float:
return self._temperature
@temperature.setter
def temperature(self, value: float) -> None:
self._temperature = value
self.notify() # Notify observers when temperature changes
def attach(self, observer: Observer) -> None:
if observer not in self._observers:
self._observers.append(observer)
def detach(self, observer: Observer) -> None:
self._observers.remove(observer)
def notify(self) -> None:
"""Trigger update on all observers."""
for observer in self._observers:
observer.update(self)
# ----------------------
# Concrete Observers: Display devices
# ----------------------
class PhoneDisplay(Observer):
def update(self, subject: WeatherStation) -> None:
print(f"Phone Display: Current temp is {subject.temperature}°C")
class LaptopDisplay(Observer):
def update(self, subject: WeatherStation) -> None:
print(f"Laptop Display: Temperature updated to {subject.temperature}°C")
# Usage
if __name__ == "__main__":
station = WeatherStation()
phone = PhoneDisplay()
laptop = LaptopDisplay()
# Attach observers
station.attach(phone)
station.attach(laptop)
# Change temperature (triggers notifications)
station.temperature = 22.5
# Output:
# Phone Display: Current temp is 22.5°C
# Laptop Display: Temperature updated to 22.5°C
# Detach phone and update again
station.detach(phone)
station.temperature = 25.0
# Output:
# Laptop Display: Temperature updated to 25.0°C
Key Notes:
- The
WeatherStation(subject) notifies all attached observers vianotify()when itstemperaturechanges. - Python’s
typingmodule andABCensure type safety, but you could also use duck typing (e.g., checking ifobserverhas anupdatemethod).
2. Strategy Pattern
Intent: Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
Problem: You need to support multiple variants of an algorithm (e.g., sorting, payment processing) and switch between them dynamically.
Python Implementation:
from abc import ABC, abstractmethod
# ----------------------
# Strategy Interface: Defines the algorithm contract
# ----------------------
class SortStrategy(ABC):
@abstractmethod
def sort(self, data: list) -> list:
pass
# ----------------------
# Concrete Strategies: Different sorting algorithms
# ----------------------
class BubbleSort(SortStrategy):
def sort(self, data: list) -> list:
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: list) -> list:
data_copy = data.copy()
if len(data_copy) <= 1:
return data_copy
pivot = data_copy[len(data_copy) // 2]
left = [x for x in data_copy if x < pivot]
middle = [x for x in data_copy if x == pivot]
right = [x for x in data_copy if x > pivot]
return self.sort(left) + middle + self.sort(right)
# ----------------------
# Context: Uses a strategy to perform work
# ----------------------
class Sorter:
def __init__(self, strategy: SortStrategy):
self._strategy = strategy # Inject the strategy
def set_strategy(self, strategy: SortStrategy) -> None:
"""Dynamically change the strategy."""
self._strategy = strategy
def sort_data(self, data: list) -> list:
"""Delegate sorting to the strategy."""
return self._strategy.sort(data)
# Usage
if __name__ == "__main__":
data = [3, 1, 4, 1, 5, 9, 2, 6]
# Use Bubble Sort
bubble_sorter = Sorter(BubbleSort())
print("Bubble Sort:", bubble_sorter.sort_data(data)) # Output: Bubble Sort: [1, 1, 2, 3, 4, 5, 6, 9]
# Switch to QuickSort dynamically
bubble_sorter.set_strategy(QuickSort())
print("Quick Sort:", bubble_sorter.sort_data(data)) # Output: Quick Sort: [1, a1, 2, 3, 4, 5, 6, 9]
Key Notes:
- The
Sorter(context) is decoupled from the sorting logic, making it easy to add new strategies (e.g.,MergeSort). - Python’s first-class functions simplify this pattern: you can pass functions directly as strategies (no need for classes). For example:
def bubble_sort(data): ... def quick_sort(data): ... sorter = Sorter(strategy=quick_sort) # Use a function as the strategy
3. Command Pattern
Intent: Encapsulate a request as an object, thereby allowing for parameterization of clients with queues, requests, and operations.
Problem: You need to decouple the sender of a request (e.g., a button) from the receiver (e.g., a light, TV) and support features like undo/redo or logging requests.
Python Implementation:
from abc import ABC, abstractmethod
# ----------------------
# Command Interface: Defines the execute/undo contract
# ----------------------
class Command(ABC):
@abstractmethod
def execute(self) -> None:
pass
@abstractmethod
def undo(self) -> None:
pass
# ----------------------
# Receiver: The object that performs the actual work
# ----------------------
class Light:
def turn_on(self) -> None:
print("Light is ON")
def turn_off(self) -> None:
print("Light is OFF")
# ----------------------
# Concrete Commands: Encapsulate requests for the Light
# ----------------------
class LightOnCommand(Command):
def __init__(self, light: Light):
self._light = light # Reference to the receiver
def execute(self) -> None:
self._light.turn_on()
def undo(self) -> None:
self._light.turn_off() # Undo: reverse the execute action
class LightOffCommand(Command):
def __init__(self, light: Light):
self._light = light
def execute(self) -> None:
self._light.turn_off()
def undo(self) -> None:
self._light.turn_on()
# ----------------------
# Invoker: Sends commands to receivers (e.g., a remote control)
# ----------------------
class RemoteControl:
def __init__(self):
self._command = None # Currently selected command
def set_command(self, command: Command) -> None:
self._command = command
def press_button(self) -> None:
"""Execute the current command."""
if self._command:
self._command.execute()
def press_undo_button(self) -> None:
"""Undo the last command."""
if self._command:
self._command.undo()
# Usage
if __name__ == "__main__":
# Setup: Receiver (light) and commands
living_room_light = Light()
light_on = LightOnCommand(living_room_light)
light_off = LightOffCommand(living_room_light)
# Invoker: Remote control
remote = RemoteControl()
# Press "ON" button
remote.set_command(light_on)
remote.press_button() # Output: Light is ON
# Press "OFF" button
remote.set_command(light_off)
remote.press_button() # Output: Light is OFF
# Undo last action (turn off → turn on)
remote.press_undo_button() # Output: Light is ON
Key Notes:
- Commands encapsulate what to do (e.g.,
turn_on) and who to do it to (the receiver,Light). - This pattern enables advanced features like command queues (e.g., batch processing) or macros (sequences of commands).
When Not to Use Design Patterns
Design patterns are powerful, but they can be overused. Avoid them when:
- The problem is trivial (e.g., a simple script with 100 lines of code).
- They introduce unnecessary complexity (e.g., using Singleton for a stateless utility class).
- Python’s built-in features solve the problem better (e.g., using
collections.defaultdictinstead of a custom Factory pattern).
Conclusion
Design patterns are not silver bullets, but they are invaluable tools for solving recurring design problems. In Python, their implementation is often simplified by the language’s flexibility—whether through metaclasses for Singletons, decorators for dynamic behavior, or first-class functions for Strategies.
The key to mastering design patterns is practice: identify problems in your code, recognize which pattern applies, and refactor incrementally. Over time, you’ll develop an intuition for when to use patterns to write cleaner, more maintainable Python code.
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 Documentation: abc — Abstract Base Classes
- Refactoring Guru: Design Patterns in Python