Table of Contents
- What Are Design Patterns?
- Creational Patterns
- Structural Patterns
- Behavioral Patterns
- Conclusion
- References
What Are Design Patterns?
Design patterns are not code snippets or libraries; they are templates for solving specific design problems. They focus on how to structure classes and objects to interact effectively. Patterns are categorized into three types:
- Creational: Handle object creation mechanisms, ensuring flexibility and reuse.
- Structural: Deal with object composition, simplifying relationships between classes.
- Behavioral: Manage communication and interaction between objects.
Patterns are not one-size-fits-all. Overusing them can complicate code, so they should be applied only when the problem they solve is clearly present.
Creational Patterns
Creational patterns abstract the instantiation process, making a system independent of how its objects are created, composed, and represented.
1. Singleton
Overview
Ensures a class has only one instance and provides a global point of access to it.
Problem
You need a single instance of a class (e.g., a database connection pool, logger, or configuration manager) to avoid redundant resource usage or inconsistent state.
Solution
Restrict the class constructor to prevent multiple instantiations. Provide a static method to return the single instance.
Python Implementation
In Python, the Singleton can be implemented using a metaclass (the class of a class), which controls class instantiation:
class SingletonMeta(type):
"""Metaclass to enforce Singleton behavior."""
_instances = {} # Stores instances of classes using this metaclass
def __call__(cls, *args, **kwargs):
"""Controls instantiation: returns existing instance or creates a new one."""
if cls not in cls._instances:
# Create a new instance if none exists
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class DatabaseConnection(metaclass=SingletonMeta):
"""Example class using Singleton pattern for a database connection."""
def __init__(self, connection_string):
self.connection_string = connection_string
print(f"Initializing connection with: {connection_string}")
def query(self, sql):
print(f"Executing query: {sql}")
# Usage
db1 = DatabaseConnection("postgresql://user:pass@localhost/db")
db2 = DatabaseConnection("mysql://user:pass@localhost/db") # Ignored; uses existing instance
print(db1 is db2) # Output: True (both are the same instance)
Use Cases
- Configuration managers (ensure all parts of the app use the same config).
- Loggers (centralize log output to a single file).
- Thread pools or connection pools (avoid excessive resource creation).
Pros & Cons
- Pros: Controlled access to a single instance; reduces resource usage.
- Cons: Global state can make testing harder (tight coupling); violates Single Responsibility Principle (controls both its logic and instantiation).
2. Factory Method
Overview
Defines an interface for creating objects but lets subclasses decide which class to instantiate.
Problem
A class cannot anticipate the type of objects it needs to create. For example, a UI framework may need to create buttons, but the button type (Windows, macOS) depends on the operating system.
Solution
Delegate object creation to subclasses via a “factory method.” The base class declares the method, and subclasses override it to produce specific objects.
Python Implementation
Example: A pizza shop where different regions (NY, Chicago) make distinct pizza styles:
from abc import ABC, abstractmethod
# Product: Pizza (interface)
class Pizza(ABC):
@abstractmethod
def prepare(self):
pass
@abstractmethod
def bake(self):
pass
# Concrete Products
class NYStyleCheesePizza(Pizza):
def prepare(self):
print("Preparing NY Style Cheese Pizza (thin crust, tomato sauce)")
def bake(self):
print("Baking at 350°F for 25 minutes")
class ChicagoStyleCheesePizza(Pizza):
def prepare(self):
print("Preparing Chicago Style Cheese Pizza (deep dish, thick crust)")
def bake(self):
print("Baking at 400°F for 40 minutes")
# Creator: Pizza Store (interface with factory method)
class PizzaStore(ABC):
def order_pizza(self):
# Let subclass decide pizza type via factory_method
pizza = self.create_pizza()
pizza.prepare()
pizza.bake()
return pizza
@abstractmethod
def create_pizza(self): # Factory Method
pass
# Concrete Creators
class NYPizzaStore(PizzaStore):
def create_pizza(self):
return NYStyleCheesePizza()
class ChicagoPizzaStore(PizzaStore):
def create_pizza(self):
return ChicagoStyleCheesePizza()
# Usage
ny_store = NYPizzaStore()
chicago_store = ChicagoPizzaStore()
ny_pizza = ny_store.order_pizza() # Prepares and bakes NY-style pizza
chicago_pizza = chicago_store.order_pizza() # Prepares and bakes Chicago-style pizza
Use Cases
- Libraries/Frameworks (e.g., SQLAlchemy using
create_engineto return database-specific engines). - Plugins (e.g., a text editor allowing plugins to register new file format handlers via a factory).
Pros & Cons
- Pros: Decouples object creation from usage; easy to add new product types (just add a new subclass of the creator).
- Cons: Requires creating a new subclass for each product type, which can lead to class explosion.
3. Builder
Overview
Separates object construction from its representation, allowing the same construction process to create different representations.
Problem
Creating complex objects with many optional components (e.g., a meal with a main dish, side, drink, and dessert). Direct instantiation with a constructor leads to bloated, unreadable code (e.g., Meal("Burger", "Fries", "Coke", "Ice Cream", True, False)).
Solution
Use a “builder” class to construct the object step-by-step. A “director” class controls the construction process, using the builder to assemble the object.
Python Implementation
Example: Building a customizable meal (veggie/non-veggie) with optional items:
from abc import ABC, abstractmethod
# Product: Meal
class Meal:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def show_items(self):
print("Meal Items:")
for item in self.items:
print(f"- {item.name}, Price: ${item.price}")
# Components of the Product (items in the meal)
class Item(ABC):
@property
@abstractmethod
def name(self):
pass
@property
@abstractmethod
def price(self):
pass
class Burger(Item):
pass
class VegBurger(Burger):
@property
def name(self):
return "Veg Burger"
@property
def price(self):
return 3.50
class ChickenBurger(Burger):
@property
def name(self):
return "Chicken Burger"
@property
def price(self):
return 4.50
# Builder: Defines steps to build a Meal
class MealBuilder(ABC):
@abstractmethod
def add_burger(self):
pass
@abstractmethod
def add_drink(self):
pass
@abstractmethod
def get_meal(self):
pass
# Concrete Builders
class VegMealBuilder(MealBuilder):
def __init__(self):
self.meal = Meal()
def add_burger(self):
self.meal.add_item(VegBurger())
def add_drink(self):
self.meal.add_item(Coke()) # Assume Coke is a Drink Item
def get_meal(self):
return self.meal
class NonVegMealBuilder(MealBuilder):
def __init__(self):
self.meal = Meal()
def add_burger(self):
self.meal.add_item(ChickenBurger())
def add_drink(self):
self.meal.add_item(Pepsi()) # Assume Pepsi is a Drink Item
def get_meal(self):
return self.meal
# Director: Controls the construction process
class Waiter:
def __init__(self, builder):
self.builder = builder
def construct_meal(self):
self.builder.add_burger()
self.builder.add_drink()
def get_meal(self):
return self.builder.get_meal()
# Usage
veg_builder = VegMealBuilder()
waiter = Waiter(veg_builder)
waiter.construct_meal()
veg_meal = waiter.get_meal()
veg_meal.show_items() # Output: Veg Burger ($3.50), Coke ($1.50)
Use Cases
- Complex object creation (e.g., JSON/XML parsers, where the same parsing logic can build different data structures).
- Meal kits, car configurators (build a car with optional features like GPS, sunroof).
Pros & Cons
- Pros: Separates construction from representation; allows fine-grained control over object creation.
- Cons: Adds complexity (requires builder and director classes); overkill for simple objects.
Structural Patterns
Structural patterns organize classes and objects to form larger structures while keeping them flexible and efficient.
1. Adapter
Overview
Converts the interface of a class into another interface that clients expect, enabling incompatible classes to work together.
Problem
A legacy system has a useful class, but its interface doesn’t match the new code’s requirements. For example, a European electrical device (220V) needs to work with a US socket (110V).
Solution
Wrap the legacy class in an “adapter” that translates its interface to the one the client expects.
Python Implementation
Example: A legacy EuropeanPlug with a provide_220v() method, adapted to work with a USSocket expecting provide_110v():
# Target: Interface client expects
class USSocket:
def provide_110v(self):
return "110V power"
# Adaptee: Legacy class with incompatible interface
class EuropeanPlug:
def provide_220v(self):
return "220V power"
# Adapter: Wraps Adaptee to match Target interface
class PlugAdapter(USSocket):
def __init__(self, european_plug):
self.european_plug = european_plug
def provide_110v(self):
# Convert 220V to 110V (simulated here)
power = self.european_plug.provide_220v()
return power.replace("220V", "110V") # Simulate voltage conversion
# Usage
euro_plug = EuropeanPlug()
adapter = PlugAdapter(euro_plug)
print(adapter.provide_110v()) # Output: "110V power" (adapted from 220V)
Use Cases
- Integrating legacy code with modern systems (e.g., using a Python 2 library in Python 3 via an adapter).
- Third-party API integration (e.g., adapting Stripe’s API to match your internal payment processor interface).
Pros & Cons
- Pros: Reuses existing code without modifying it; decouples client from adaptee.
- Cons: Adds a layer of indirection; can introduce performance overhead for simple adaptations.
2. Decorator
Overview
Dynamically adds new responsibilities to objects without altering their structure.
Problem
You need to add features to an object (e.g., logging, caching) without subclassing every possible combination (leading to a “class explosion”).
Solution
Wrap the object in “decorator” classes that add behavior before/after delegating to the wrapped object.
Python Implementation
Example: Adding toppings to a pizza (base pizza + cheese + pepperoni, each decorator adds cost and description):
from abc import ABC, abstractmethod
# Component: Pizza (interface)
class Pizza(ABC):
@abstractmethod
def get_description(self):
pass
@abstractmethod
def get_cost(self):
pass
# Concrete Component: Base pizza
class MargheritaPizza(Pizza):
def get_description(self):
return "Margherita Pizza"
def get_cost(self):
return 8.99
# Decorator: Wraps Pizza to add toppings
class ToppingDecorator(Pizza):
def __init__(self, pizza):
self.pizza = pizza # Wrapped pizza
@abstractmethod
def get_description(self):
pass
@abstractmethod
def get_cost(self):
pass
# Concrete Decorators
class CheeseTopping(ToppingDecorator):
def get_description(self):
return f"{self.pizza.get_description()}, Extra Cheese"
def get_cost(self):
return self.pizza.get_cost() + 1.50
class PepperoniTopping(ToppingDecorator):
def get_description(self):
return f"{self.pizza.get_description()}, Pepperoni"
def get_cost(self):
return self.pizza.get_cost() + 2.00
# Usage
pizza = MargheritaPizza()
print(pizza.get_description()) # "Margherita Pizza"
print(pizza.get_cost()) # $8.99
pizza = CheeseTopping(pizza) # Add cheese
print(pizza.get_description()) # "Margherita Pizza, Extra Cheese"
print(pizza.get_cost()) # $10.49
pizza = PepperoniTopping(pizza) # Add pepperoni
print(pizza.get_description()) # "Margherita Pizza, Extra Cheese, Pepperoni"
print(pizza.get_cost()) # $12.49
Use Cases
- Adding features dynamically (e.g., logging, compression, encryption in web frameworks like Flask/Django).
- GUI toolkits (adding borders, shadows to buttons without subclassing).
Pros & Cons
- Pros: Adds responsibilities dynamically; avoids subclassing for every combination.
- Cons: Can lead to a large number of small decorator classes; debugging is harder (multiple layers of wrapping).
2. Decorator (Python’s Built-in vs. Pattern)
Note: Python’s @decorator syntax is a language feature for wrapping functions/methods, but the Decorator Pattern focuses on object composition (as above). Both share the goal of adding behavior dynamically.
3. Composite
Overview
Treats individual objects and collections of objects uniformly. Clients interact with a single object or a group of objects the same way.
Problem
You need to represent hierarchical structures (e.g., file systems, organization charts) where elements can be either leaves (individual items) or composites (collections of items).
Solution
Define a common interface for leaves and composites. Composites contain child elements (leaves or other composites) and delegate operations to them.
Python Implementation
Example: A file system with File (leaf) and Directory (composite) elements, both with a get_size() method:
from abc import ABC, abstractmethod
# Component: Common interface for leaves and composites
class FileSystemElement(ABC):
@abstractmethod
def get_size(self):
pass
@abstractmethod
def get_name(self):
pass
# Leaf: Individual element (File)
class File(FileSystemElement):
def __init__(self, name, size):
self.name = name
self.size = size
def get_size(self):
return self.size
def get_name(self):
return self.name
# Composite: Collection of elements (Directory)
class Directory(FileSystemElement):
def __init__(self, name):
self.name = name
self.children = [] # Contains Files or Directories
def add_child(self, element):
self.children.append(element)
def remove_child(self, element):
self.children.remove(element)
def get_size(self):
# Sum size of all children (recursively)
return sum(child.get_size() for child in self.children)
def get_name(self):
return self.name
def list_contents(self, indent=0):
print(" " * indent + f"- {self.name}/ (Size: {self.get_size()}B)")
for child in self.children:
if isinstance(child, Directory):
child.list_contents(indent + 1)
else:
print(" " * (indent + 1) + f"- {child.get_name()} (Size: {child.get_size()}B)")
# Usage
# Create files
file1 = File("notes.txt", 1024) # 1KB
file2 = File("image.jpg", 204800) # 200KB
# Create directories
docs_dir = Directory("Documents")
docs_dir.add_child(file1)
photos_dir = Directory("Photos")
photos_dir.add_child(file2)
root_dir = Directory("Root")
root_dir.add_child(docs_dir)
root_dir.add_child(photos_dir)
# List contents and total size
root_dir.list_contents()
# Output:
# - Root/ (Size: 205824B)
# - Documents/ (Size: 1024B)
# - notes.txt (Size: 1024B)
# - Photos/ (Size: 204800B)
# - image.jpg (Size: 204800B)
Use Cases
- File systems, organization charts, XML/HTML DOM (elements can contain text or other elements).
- UI components (menus with submenus, where both menus and menu items have
render()methods).
Pros & Cons
- Pros: Clients treat leaves and composites uniformly; simplifies traversal of hierarchical structures.
- Cons: Hard to restrict composite contents (e.g., a directory can’t contain a video file in some systems); can make type checks necessary.
Behavioral Patterns
Behavioral patterns focus on communication between objects and how they distribute responsibility.
1. Observer
Overview
Defines a one-to-many dependency between objects: when one object (subject) changes state, all its dependents (observers) are notified and updated automatically.
Problem
A weather station collects data (temperature, humidity) and needs to update multiple displays (e.g., phone app, web dashboard, LCD screen) in real time.
Solution
The subject maintains a list of observers, notifying them of state changes via a common interface.
Python Implementation
Example: A weather station notifying displays of temperature updates:
from abc import ABC, abstractmethod
# Observer: Interface for objects that need updates
class Observer(ABC):
@abstractmethod
def update(self, temperature):
pass
# Subject: Interface for objects being observed
class Subject(ABC):
@abstractmethod
def attach(self, observer):
pass
@abstractmethod
def detach(self, observer):
pass
@abstractmethod
def notify(self):
pass
# Concrete Subject: Weather Station
class WeatherStation(Subject):
def __init__(self):
self.observers = []
self.temperature = 0.0
def attach(self, observer):
self.observers.append(observer)
def detach(self, observer):
self.observers.remove(observer)
def notify(self):
# Notify all observers of new temperature
for observer in self.observers:
observer.update(self.temperature)
def set_temperature(self, new_temp):
print(f"Weather Station: Temperature updated to {new_temp}°C")
self.temperature = new_temp
self.notify() # Trigger updates
# Concrete Observers
class PhoneDisplay(Observer):
def update(self, temperature):
print(f"Phone Display: Current Temp = {temperature}°C")
class WebDashboard(Observer):
def update(self, temperature):
print(f"Web Dashboard: Temp Alert! {temperature}°C (High: 30°C)")
# Usage
station = WeatherStation()
phone_display = PhoneDisplay()
web_dashboard = WebDashboard()
# Attach observers
station.attach(phone_display)
station.attach(web_dashboard)
# Update temperature (triggers notifications)
station.set_temperature(25.5)
# Output:
# Weather Station: Temperature updated to 25.5°C
# Phone Display: Current Temp = 25.5°C
# Web Dashboard: Temp Alert! 25.5°C (High: 30°C)
# Detach web dashboard
station.detach(web_dashboard)
station.set_temperature(30.0)
# Output:
# Weather Station: Temperature updated to 30.0°C
# Phone Display: Current Temp = 30.0°C (Web Dashboard no longer notified)
Use Cases
- Event-driven systems (e.g., GUI frameworks, where buttons notify listeners on click).
- Stock tickers (notifying investors of price changes), chat apps (notifying users of new messages).
Pros & Cons
- Pros: Decouples subject and observers; supports dynamic attachment/detachment.
- Cons: Observers may receive unnecessary updates; can lead to memory leaks if observers are not detached properly.
2. Strategy
Overview
Defines a family of interchangeable algorithms and encapsulates each, allowing them to be swapped dynamically.
Problem
An object needs to use different variants of an algorithm (e.g., sorting, payment processing) based on context. For example, an e-commerce app may support credit card, PayPal, or Bitcoin payments.
Solution
Encapsulate each algorithm in a “strategy” class, and make the context object use a strategy to delegate the work.
Python Implementation
Example: Order processing with interchangeable payment strategies:
from abc import ABC, abstractmethod
# Strategy: Payment algorithm interface
class PaymentStrategy(ABC):
@abstractmethod
def pay(self, amount):
pass
# Concrete Strategies
class CreditCardPayment(PaymentStrategy):
def __init__(self, card_number, cvv):
self.card_number = card_number
self.cvv = cvv
def pay(self, amount):
print(f"Paid ${amount} via Credit Card (****-****-****-{self.card_number[-4:]})")
class PayPalPayment(PaymentStrategy):
def __init__(self, email):
self.email = email
def pay(self, amount):
print(f"Paid ${amount} via PayPal (Account: {self.email})")
# Context: Uses a strategy to perform work
class Order:
def __init__(self, total_amount):
self.total_amount = total_amount
self.payment_strategy = None # Strategy to use
def set_payment_strategy(self, strategy):
self.payment_strategy = strategy
def process_payment(self):
if not self.payment_strategy:
raise ValueError("No payment strategy set!")
self.payment_strategy.pay(self.total_amount)
# Usage
order = Order(99.99)
# Pay with Credit Card
cc_strategy = CreditCardPayment("4111-1111-1111-1234", "123")
order.set_payment_strategy(cc_strategy)
order.process_payment() # Output: Paid $99.99 via Credit Card (****-****-****-1234)
# Switch to PayPal
paypal_strategy = PayPalPayment("[email protected]")
order.set_payment_strategy(paypal_strategy)
order.process_payment() # Output: Paid $99.99 via PayPal (Account: [email protected])
Use Cases
- Sorting algorithms (swap between quicksort, mergesort based on data size).
- Shipping calculators (standard, expedited, international shipping).
Pros & Cons
- Pros: Encapsulates algorithms; allows runtime switching; open/closed principle (add new strategies without changing context).
- Cons: Requires clients to be aware of different strategies; increases number of classes.
3. Command
Overview
Encapsulates a request as an object, allowing parameterization of clients with different requests, queuing of requests, and support for undo/redo.
Problem
You need to decouple the object that issues a request (client) from the object that performs it (receiver). For example, a remote control should operate multiple devices (TV, lights) without knowing their internal logic.
Solution
Wrap each request in a “command” object that contains all information needed to execute it. The client triggers the command, which delegates to the receiver.
Python Implementation
Example: A remote control with buttons that execute commands (turn TV on/off, dim lights):
from abc import ABC, abstractmethod
# Command: Interface for all commands
class Command(ABC):
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass
# Receiver: The object that performs the work
class TV:
def on(self):
print("TV is ON")
def off(self):
print("TV is OFF")
class Light:
def __init__(self):
self.brightness = 0 # 0-100
def dim(self, level):
self.brightness = level
print(f"Light dimmed to {self.brightness}%")
# Concrete Commands
class TVOnCommand(Command):
def __init__(self, tv):
self.tv = tv # Receiver
def execute(self):
self.tv.on()
def undo(self):
self.tv.off()
class TVOffCommand(Command):
def __init__(self, tv):
self.tv = tv
def execute(self):
self.tv.off()
def undo(self):
self.tv.on()
class LightDimCommand(Command):
def __init__(self, light, level):
self.light = light # Receiver
self.target_level = level
self.prev_level = light.brightness # Store previous state for undo
def execute(self):
self.prev_level = self.light.brightness # Update before dimming
self.light.dim(self.target_level)
def undo(self):
self.light.dim(self.prev_level)
# Invoker: Triggers commands (remote control)
class RemoteControl:
def __init__(self):
self.commands = {} # Maps button names to commands
self.last_command = None # For undo
def set_command(self, button_name, command):
self.commands[button_name] = command
def press_button(self, button_name):
if button_name in self.commands:
command = self.commands[button_name]
command.execute()
self.last_command = command # Track last command for undo
def press_undo(self):
if self.last_command:
print("Undoing last action...")
self.last_command.undo()
# Usage
# Create receivers
tv = TV()
light = Light()
# Create commands
tv_on_cmd = TVOnCommand(tv)
tv_off_cmd = TVOffCommand(tv)
light_dim_cmd = LightDimCommand(light, 75) # Dim to 75%
# Configure remote
remote = RemoteControl()
remote.set_command("TV On", tv_on_cmd)
remote.set_command("TV Off", tv_off_cmd)
remote.set_command("Dim Light", light_dim_cmd)
# Press buttons
remote.press_button("TV On") # TV is ON
remote.press_button("Dim Light") # Light dimmed to 75%
remote.press_undo() # Undo: Light dimmed to 0% (previous level)
remote.press_button("TV Off") # TV is OFF
remote.press_undo() # Undo: TV is ON
Use Cases
- GUI buttons, menu items (each triggers a command).
- Task queues, undo/redo functionality (e.g., text editors), macro recording.
Pros & Cons
- Pros: Decouples sender and receiver; supports undo/redo, queuing, logging of requests.
- Cons: Increases number of classes (one command per action); overhead for simple requests.
Conclusion
Design patterns are powerful tools for writing clean, maintainable, and scalable Python code. By understanding when and how to apply them—Singleton for single instances, Factory Method for flexible object creation, Observer for event handling—you can solve complex problems with proven solutions.
Remember: Patterns are guidelines, not rules. Overusing them can make code harder to read. Always prioritize simplicity, and reach for a pattern only when it clearly solves a problem you’re facing.
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