Design patterns represent time-tested solutions to recurring challenges in software development. In Python engineering, these reusable solutions help developers create more maintainable, flexible, and scalable code. Understanding and implementing design patterns effectively can transform how you approach software architecture, enabling you to write code that is not only functional but also elegant, reusable, and easier to maintain over time.
Design patterns serve as a vocabulary that allows engineers to communicate structural decisions concisely. When a senior developer suggests using a specific pattern, they're conveying an entire architectural approach in just a few words. This shared language accelerates team collaboration and ensures everyone understands the underlying structure of the codebase.
Understanding Design Patterns in Python Context
Python is a high-level programming language with dynamic typing and dynamic binding, making it a powerful, high-level dynamic language. This flexibility gives Python unique advantages when implementing design patterns, but it also means that some patterns need to be adapted to fit Python's idioms and capabilities.
Everything in Python is an object, including functions, which are first-class objects. This fundamental characteristic influences how design patterns are implemented in Python compared to more rigid, statically-typed languages. The dynamic nature of Python allows for more concise implementations of many patterns, while also introducing the need for disciplined coding practices.
Because Python is so powerful and flexible, we need some rules or patterns when programming in it. Without these guiding principles, codebases can quickly become unwieldy and difficult to maintain. Design patterns provide the structure needed to harness Python's vast potential while maintaining code quality and readability.
The Three Categories of Design Patterns
Design patterns are traditionally organized into three main categories, each addressing different aspects of software design. Understanding these categories helps developers select the appropriate pattern for their specific challenges.
Creational Patterns
Creational patterns focus on the process of object creation, providing mechanisms that increase flexibility and reuse of existing code. These patterns abstract the instantiation process, making systems independent of how objects are created, composed, and represented.
Instead of creating objects directly using a constructor, creational patterns provide more control and flexibility over the creation process. This approach is particularly valuable when the creation logic is complex, when you need to control which class gets instantiated, or when you want to manage resource allocation more efficiently.
Common creational patterns in Python include Singleton, Factory Method, Abstract Factory, Builder, and Prototype. Each serves a distinct purpose in managing object creation complexity.
Structural Patterns
Structural design patterns focus on the composition of classes or objects to form larger, more complex structures, helping organize and manage relationships between objects to achieve greater flexibility, reusability, and maintainability. These patterns are concerned with how classes and objects are composed to form larger structures while keeping these structures flexible and efficient.
Structural patterns help ensure that when one part of a system changes, the entire structure doesn't need to be modified. They facilitate the design of systems where components can be easily replaced or extended without affecting other parts of the application. Common structural patterns include Adapter, Bridge, Composite, Decorator, Facade, Flyweight, and Proxy.
Behavioral Patterns
Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. They describe not just patterns of objects or classes but also the patterns of communication between them. These patterns characterize complex control flow that's difficult to follow at runtime.
Observer addresses communication, allowing multiple parts of a system to react automatically to events or state changes. Other behavioral patterns include Strategy, Command, Iterator, Mediator, Memento, State, Template Method, Visitor, and Chain of Responsibility.
The Singleton Pattern: One Instance to Rule Them All
The Singleton Pattern ensures a class has only one instance throughout a program and provides a global access point, commonly used for managing shared resources like databases, logging systems, or file managers. This pattern is one of the most discussed and sometimes controversial patterns in software development.
When to Use Singleton
The Singleton pattern is particularly useful when you need exactly one instance of a class to control resources such as database connections, configuration managers, or logging systems. The pattern guarantees that all code using the class instance is working with the same object, ensuring consistency across the application.
Legitimate use cases for singletons in Python include:
- Hardware interfaces that represent unique physical resources, such as one camera, one printer, or one GPIO interface, where a singleton models this accurately
- Caching layers where you want a single shared cache across your application
- Thread pools or connection pools where you want to limit and share expensive resources, with the pool itself being a singleton though the resources it manages aren't
- Configuration management systems that need to maintain consistent settings throughout the application
- Logging systems where centralized log management is essential
Implementing Singleton in Python
The Singleton class can be implemented in different ways in Python, including base class, decorator, and metaclass approaches, with metaclass being best suited for this purpose. Each implementation method has its own advantages and trade-offs.
The classic implementation using the __new__ method looks like this:
class DatabaseConnection:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(DatabaseConnection, cls).__new__(cls)
cls._instance._initialize()
return cls._instance
def _initialize(self):
self.connection = None
self.host = "localhost"
self.port = 5432
def connect(self):
if not self.connection:
self.connection = f"Connected to {self.host}:{self.port}"
return self.connection
A thread-safe implementation uses a lock object that synchronizes threads during first access to the Singleton. This is crucial in multi-threaded applications where race conditions could lead to multiple instances being created:
from threading import Lock
class ThreadSafeSingleton:
_instance = None
_lock = Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
The Pythonic Alternative: Module-Level Singletons
Python's module system is itself a singleton mechanism: when you import a module, Python executes it once and caches the result in sys.modules, with every subsequent import returning the cached module object, not a new one. This makes modules a natural and Pythonic way to implement singleton behavior.
Python imports a module only once, making anything defined within a module effectively a singleton. This approach is often simpler and more maintainable than implementing a formal Singleton class:
# config.py
class _Config:
def __init__(self):
self.database_url = "postgresql://localhost/mydb"
self.api_key = "secret_key"
self.debug_mode = True
def update_setting(self, key, value):
setattr(self, key, value)
# Create the singleton instance
config = _Config()
# In other files, simply import:
# from config import config
The singleton pattern usually doesn't make sense in Python in its purest form; instead, it usually makes more sense to make a single instance of a class and assign that instance to a global variable in a module. This approach is more transparent, easier to test, and aligns better with Python's philosophy.
Singleton Drawbacks and Considerations
Many developers consider the Singleton pattern an antipattern, which is why its usage is on the decline in Python code. Several legitimate concerns exist:
- Due to its global state and tight coupling with other parts of the codebase, the Singleton Pattern can make unit testing challenging, with mocking or substituting the singleton instance being cumbersome
- In multithreaded environments, the Singleton Pattern can introduce concurrency issues if not implemented carefully, with multiple threads potentially creating multiple instances without proper synchronization
- Singletons create hidden dependencies that make code harder to understand and maintain
- They violate the Single Responsibility Principle by managing both their business logic and their instantiation
- Singletons make it difficult to extend or modify behavior through inheritance
Consider using dependency injection instead, as it could be cleaner, or use a module-level instance. These alternatives often provide the same benefits without the drawbacks.
Factory Pattern: Flexible Object Creation
The Factory pattern provides an interface for creating objects without specifying their exact classes, promoting loose coupling and making code more flexible to changes. This pattern is invaluable when you need to create objects but want to decouple the creation logic from the code that uses those objects.
Factory concentrates on how objects are created, hiding instantiation logic and reducing tight coupling between components. By centralizing object creation, the Factory pattern makes it easier to introduce new types without modifying existing code.
Simple Factory Implementation
The Simple Factory pattern encapsulates object creation in a dedicated function or class. Here's a practical example for creating different types of database connections:
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def connect(self):
pass
@abstractmethod
def query(self, sql):
pass
class PostgreSQLDatabase(Database):
def connect(self):
return "Connected to PostgreSQL"
def query(self, sql):
return f"PostgreSQL executing: {sql}"
class MySQLDatabase(Database):
def connect(self):
return "Connected to MySQL"
def query(self, sql):
return f"MySQL executing: {sql}"
class MongoDBDatabase(Database):
def connect(self):
return "Connected to MongoDB"
def query(self, sql):
return f"MongoDB executing: {sql}"
class DatabaseFactory:
@staticmethod
def create_database(db_type):
databases = {
'postgresql': PostgreSQLDatabase,
'mysql': MySQLDatabase,
'mongodb': MongoDBDatabase
}
db_class = databases.get(db_type.lower())
if not db_class:
raise ValueError(f"Unknown database type: {db_type}")
return db_class()
# Usage
db = DatabaseFactory.create_database('postgresql')
print(db.connect())
print(db.query("SELECT * FROM users"))
Factory Method Pattern
The Factory Method provides an interface for creating objects in a superclass but allows subclasses to alter the type of objects that will be created. This variation gives even more flexibility by delegating the instantiation to subclasses:
from abc import ABC, abstractmethod
class DocumentProcessor(ABC):
@abstractmethod
def create_parser(self):
"""Factory method to be implemented by subclasses"""
pass
def process_document(self, content):
parser = self.create_parser()
return parser.parse(content)
class Parser(ABC):
@abstractmethod
def parse(self, content):
pass
class JSONParser(Parser):
def parse(self, content):
return f"Parsing JSON: {content}"
class XMLParser(Parser):
def parse(self, content):
return f"Parsing XML: {content}"
class CSVParser(Parser):
def parse(self, content):
return f"Parsing CSV: {content}"
class JSONDocumentProcessor(DocumentProcessor):
def create_parser(self):
return JSONParser()
class XMLDocumentProcessor(DocumentProcessor):
def create_parser(self):
return XMLParser()
# Usage
json_processor = JSONDocumentProcessor()
result = json_processor.process_document('{"name": "John"}')
print(result)
Benefits of Factory Pattern
The Factory pattern offers several compelling advantages:
- Loose Coupling: Client code doesn't need to know the concrete classes being instantiated
- Single Responsibility: Object creation logic is centralized in one place
- Open/Closed Principle: New types can be added without modifying existing code
- Flexibility: Easy to switch between different implementations at runtime
- Testability: Mock objects can be easily injected for testing purposes
Observer Pattern: Event-Driven Architecture
The Observer pattern defines a subscription mechanism to notify multiple objects about any events that happen to the object they're observing. This pattern is fundamental to event-driven programming and is widely used in GUI frameworks, real-time systems, and reactive applications.
Understanding the Observer Pattern
The Observer pattern establishes a one-to-many dependency between objects. When the subject (observable) changes state, all its dependents (observers) are notified and updated automatically. This decouples the subject from its observers, allowing them to vary independently.
Here's a comprehensive implementation of the Observer pattern:
from abc import ABC, abstractmethod
from typing import List
class Observer(ABC):
@abstractmethod
def update(self, subject):
"""Receive update from subject"""
pass
class Subject(ABC):
def __init__(self):
self._observers: List[Observer] = []
def attach(self, observer: Observer):
"""Attach an observer to the subject"""
if observer not in self._observers:
self._observers.append(observer)
def detach(self, observer: Observer):
"""Detach an observer from the subject"""
try:
self._observers.remove(observer)
except ValueError:
pass
def notify(self):
"""Notify all observers about an event"""
for observer in self._observers:
observer.update(self)
class StockPrice(Subject):
def __init__(self, symbol: str, price: float):
super().__init__()
self._symbol = symbol
self._price = price
@property
def symbol(self):
return self._symbol
@property
def price(self):
return self._price
@price.setter
def price(self, new_price: float):
if new_price != self._price:
self._price = new_price
self.notify()
class StockDisplay(Observer):
def __init__(self, name: str):
self._name = name
def update(self, subject: StockPrice):
print(f"{self._name}: {subject.symbol} is now ${subject.price:.2f}")
class StockAlert(Observer):
def __init__(self, threshold: float):
self._threshold = threshold
def update(self, subject: StockPrice):
if subject.price > self._threshold:
print(f"ALERT: {subject.symbol} exceeded ${self._threshold}! Current: ${subject.price:.2f}")
# Usage
apple_stock = StockPrice("AAPL", 150.00)
display1 = StockDisplay("Display 1")
display2 = StockDisplay("Display 2")
alert = StockAlert(160.00)
apple_stock.attach(display1)
apple_stock.attach(display2)
apple_stock.attach(alert)
apple_stock.price = 155.50
apple_stock.price = 162.00
Real-World Applications
The Observer pattern is extensively used in modern software development:
- GUI Event Handling: Button clicks, mouse movements, and keyboard events
- Model-View-Controller (MVC): Views observe models for data changes
- Real-time Data Feeds: Stock prices, weather updates, social media notifications
- Logging Systems: Multiple log handlers observing application events
- Publish-Subscribe Systems: Message queues and event buses
Decorator Pattern: Extending Functionality Dynamically
The Decorator pattern allows adding behavior to an object—logging, caching, authentication, retrying—without modifying the object's class and without subclassing. This pattern provides a flexible alternative to subclassing for extending functionality.
Python Decorators vs Decorator Pattern
Python's @decorator syntax and the Decorator design pattern are conceptually the same—both wrap behavior around an existing callable without modifying it, with Python's syntax making the pattern a language-native feature. This makes Python particularly well-suited for implementing decorator functionality.
Here's an example using Python's decorator syntax:
import time
import functools
def timing_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
return result
return wrapper
def cache_decorator(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args in cache:
print(f"Returning cached result for {args}")
return cache[args]
result = func(*args)
cache[args] = result
return result
return wrapper
def retry_decorator(max_attempts=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
print(f"Attempt {attempt + 1} failed: {e}. Retrying...")
return None
return wrapper
return decorator
@timing_decorator
@cache_decorator
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
@retry_decorator(max_attempts=3)
def unreliable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("API temporarily unavailable")
return "Success!"
# Usage
print(fibonacci(10))
print(fibonacci(10)) # This will use cached result
Class-Based Decorator Pattern
The traditional Decorator pattern can also be implemented using classes, which is useful for more complex scenarios:
from abc import ABC, abstractmethod
class Coffee(ABC):
@abstractmethod
def cost(self):
pass
@abstractmethod
def description(self):
pass
class SimpleCoffee(Coffee):
def cost(self):
return 2.00
def description(self):
return "Simple coffee"
class CoffeeDecorator(Coffee):
def __init__(self, coffee: Coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost()
def description(self):
return self._coffee.description()
class MilkDecorator(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 0.50
def description(self):
return self._coffee.description() + ", milk"
class SugarDecorator(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 0.25
def description(self):
return self._coffee.description() + ", sugar"
class WhippedCreamDecorator(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 0.75
def description(self):
return self._coffee.description() + ", whipped cream"
# Usage
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 = WhippedCreamDecorator(SugarDecorator(MilkDecorator(SimpleCoffee())))
print(f"{fancy_coffee.description()}: ${fancy_coffee.cost():.2f}")
Strategy Pattern: Interchangeable Algorithms
The Strategy Pattern allows you to define a family of algorithms, encapsulate each one, and make them interchangeable at runtime, delegating behavior to separate strategy classes or functions instead of writing large conditional blocks. This pattern is essential for writing flexible, maintainable code that can adapt to different requirements.
The Strategy pattern defines a family of algorithms, puts each of them into a separate class, and makes their objects interchangeable. This enables selecting algorithms at runtime based on context or configuration.
Implementing Strategy Pattern
Here's a comprehensive example demonstrating the Strategy pattern for payment processing:
from abc import ABC, abstractmethod
from typing import Protocol
class PaymentStrategy(Protocol):
def pay(self, amount: float) -> str:
"""Process payment and return confirmation"""
...
class CreditCardPayment:
def __init__(self, card_number: str, cvv: str, expiry: str):
self.card_number = card_number
self.cvv = cvv
self.expiry = expiry
def pay(self, amount: float) -> str:
# Simulate payment processing
masked_card = f"****-****-****-{self.card_number[-4:]}"
return f"Paid ${amount:.2f} using Credit Card {masked_card}"
class PayPalPayment:
def __init__(self, email: str):
self.email = email
def pay(self, amount: float) -> str:
return f"Paid ${amount:.2f} using PayPal account {self.email}"
class CryptocurrencyPayment:
def __init__(self, wallet_address: str, currency: str = "BTC"):
self.wallet_address = wallet_address
self.currency = currency
def pay(self, amount: float) -> str:
return f"Paid ${amount:.2f} using {self.currency} to wallet {self.wallet_address[:10]}..."
class BankTransferPayment:
def __init__(self, account_number: str, routing_number: str):
self.account_number = account_number
self.routing_number = routing_number
def pay(self, amount: float) -> str:
masked_account = f"****{self.account_number[-4:]}"
return f"Paid ${amount:.2f} via bank transfer from account {masked_account}"
class ShoppingCart:
def __init__(self):
self.items = []
self.payment_strategy = None
def add_item(self, item: str, price: float):
self.items.append({'item': item, 'price': price})
def set_payment_strategy(self, strategy: PaymentStrategy):
self.payment_strategy = strategy
def calculate_total(self) -> float:
return sum(item['price'] for item in self.items)
def checkout(self) -> str:
if not self.payment_strategy:
raise ValueError("Payment strategy not set")
total = self.calculate_total()
return self.payment_strategy.pay(total)
# Usage
cart = ShoppingCart()
cart.add_item("Laptop", 999.99)
cart.add_item("Mouse", 29.99)
cart.add_item("Keyboard", 79.99)
# Pay with credit card
cart.set_payment_strategy(CreditCardPayment("1234567890123456", "123", "12/25"))
print(cart.checkout())
# Change strategy to PayPal
cart.set_payment_strategy(PayPalPayment("[email protected]"))
print(cart.checkout())
# Change strategy to Cryptocurrency
cart.set_payment_strategy(CryptocurrencyPayment("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"))
print(cart.checkout())
Pythonic Strategy with Functions
Python's first-class functions allow for a more concise implementation of the Strategy pattern:
def discount_none(total):
return total
def discount_percentage(percentage):
def apply_discount(total):
return total * (1 - percentage / 100)
return apply_discount
def discount_fixed_amount(amount):
def apply_discount(total):
return max(0, total - amount)
return apply_discount
def discount_bulk(threshold, discount_percentage):
def apply_discount(total):
if total >= threshold:
return total * (1 - discount_percentage / 100)
return total
return apply_discount
class Order:
def __init__(self, total: float):
self.total = total
self.discount_strategy = discount_none
def set_discount_strategy(self, strategy):
self.discount_strategy = strategy
def calculate_final_price(self):
return self.discount_strategy(self.total)
# Usage
order = Order(100.00)
print(f"No discount: ${order.calculate_final_price():.2f}")
order.set_discount_strategy(discount_percentage(10))
print(f"10% discount: ${order.calculate_final_price():.2f}")
order.set_discount_strategy(discount_fixed_amount(15))
print(f"$15 off: ${order.calculate_final_price():.2f}")
order.set_discount_strategy(discount_bulk(50, 20))
print(f"Bulk discount: ${order.calculate_final_price():.2f}")
Builder Pattern: Constructing Complex Objects
The Builder pattern lets you construct complex objects step by step, allowing you to produce different types and representations of an object using the same construction code. This pattern is particularly useful when an object requires numerous configuration options or when the construction process involves multiple steps.
When to Use Builder Pattern
The Builder pattern shines in scenarios where:
- Object construction requires many optional parameters
- The construction process must follow a specific sequence
- You need to create different representations of the same object
- Constructor parameters would create a "telescoping constructor" anti-pattern
- Object creation involves complex logic that should be separated from the object itself
Implementing Builder Pattern in Python
Here's a practical implementation for building database query objects:
from typing import List, Optional
from dataclasses import dataclass, field
@dataclass
class Query:
table: str
columns: List[str]
where_clauses: List[str]
joins: List[str]
order_by: Optional[str]
limit: Optional[int]
offset: Optional[int]
def to_sql(self) -> str:
# Select clause
cols = ", ".join(self.columns) if self.columns else "*"
sql = f"SELECT {cols} FROM {self.table}"
# Joins
if self.joins:
sql += " " + " ".join(self.joins)
# Where clause
if self.where_clauses:
sql += " WHERE " + " AND ".join(self.where_clauses)
# Order by
if self.order_by:
sql += f" ORDER BY {self.order_by}"
# Limit and offset
if self.limit:
sql += f" LIMIT {self.limit}"
if self.offset:
sql += f" OFFSET {self.offset}"
return sql
class QueryBuilder:
def __init__(self):
self._table: Optional[str] = None
self._columns: List[str] = []
self._where_clauses: List[str] = []
self._joins: List[str] = []
self._order_by: Optional[str] = None
self._limit: Optional[int] = None
self._offset: Optional[int] = None
def table(self, table_name: str) -> 'QueryBuilder':
self._table = table_name
return self
def select(self, *columns: str) -> 'QueryBuilder':
self._columns.extend(columns)
return self
def where(self, condition: str) -> 'QueryBuilder':
self._where_clauses.append(condition)
return self
def join(self, join_clause: str) -> 'QueryBuilder':
self._joins.append(join_clause)
return self
def order_by(self, column: str, direction: str = "ASC") -> 'QueryBuilder':
self._order_by = f"{column} {direction}"
return self
def limit(self, limit: int) -> 'QueryBuilder':
self._limit = limit
return self
def offset(self, offset: int) -> 'QueryBuilder':
self._offset = offset
return self
def build(self) -> Query:
if not self._table:
raise ValueError("Table name is required")
return Query(
table=self._table,
columns=self._columns,
where_clauses=self._where_clauses,
joins=self._joins,
order_by=self._order_by,
limit=self._limit,
offset=self._offset
)
def reset(self) -> 'QueryBuilder':
self.__init__()
return self
# Usage
builder = QueryBuilder()
# Build a simple query
query1 = (builder
.table("users")
.select("id", "name", "email")
.where("age > 18")
.where("status = 'active'")
.order_by("name", "ASC")
.limit(10)
.build())
print(query1.to_sql())
# Build a complex query with joins
builder.reset()
query2 = (builder
.table("orders")
.select("orders.id", "users.name", "products.title", "orders.total")
.join("INNER JOIN users ON orders.user_id = users.id")
.join("INNER JOIN products ON orders.product_id = products.id")
.where("orders.status = 'completed'")
.where("orders.total > 100")
.order_by("orders.created_at", "DESC")
.limit(20)
.offset(10)
.build())
print(query2.to_sql())
Fluent Interface Benefits
The Builder pattern often implements a fluent interface (method chaining), which provides several advantages:
- Readability: Code reads like natural language
- Flexibility: Easy to add or remove configuration steps
- Immutability: The final object can be immutable while the builder is mutable
- Validation: Construction logic can validate the object before creation
- Reusability: Builders can be reused to create multiple similar objects
Adapter Pattern: Making Incompatible Interfaces Work Together
The Adapter pattern allows objects with incompatible interfaces to collaborate. This structural pattern acts as a bridge between two incompatible interfaces, enabling classes to work together that couldn't otherwise due to incompatible interfaces.
Adapter Method is a structural design pattern that allows you to make two incompatible interfaces work together by creating a bridge between them. This is particularly valuable when integrating third-party libraries, legacy code, or external APIs into your application.
Real-World Adapter Implementation
Consider a scenario where you need to integrate multiple payment gateways with different interfaces:
from abc import ABC, abstractmethod
# Target interface that our application expects
class PaymentProcessor(ABC):
@abstractmethod
def process_payment(self, amount: float, currency: str) -> dict:
pass
# Adaptee 1: Stripe API (incompatible interface)
class StripeAPI:
def create_charge(self, amount_cents: int, currency_code: str, source: str):
return {
'id': 'ch_stripe_123',
'amount': amount_cents,
'currency': currency_code,
'status': 'succeeded'
}
# Adaptee 2: PayPal API (different incompatible interface)
class PayPalAPI:
def make_payment(self, total: float, currency_type: str, account: str):
return {
'transaction_id': 'pp_456',
'total_amount': total,
'currency': currency_type,
'state': 'approved'
}
# Adaptee 3: Square API (yet another incompatible interface)
class SquareAPI:
def charge_card(self, money_amount: dict, card_token: str):
return {
'payment_id': 'sq_789',
'amount_money': money_amount,
'status': 'COMPLETED'
}
# Adapter for Stripe
class StripeAdapter(PaymentProcessor):
def __init__(self, stripe_api: StripeAPI):
self.stripe = stripe_api
def process_payment(self, amount: float, currency: str) -> dict:
# Convert dollars to cents for Stripe
amount_cents = int(amount * 100)
# Call Stripe API with adapted parameters
result = self.stripe.create_charge(
amount_cents=amount_cents,
currency_code=currency.upper(),
source='tok_visa'
)
# Adapt the response to our standard format
return {
'success': result['status'] == 'succeeded',
'transaction_id': result['id'],
'amount': amount,
'currency': currency,
'provider': 'Stripe'
}
# Adapter for PayPal
class PayPalAdapter(PaymentProcessor):
def __init__(self, paypal_api: PayPalAPI):
self.paypal = paypal_api
def process_payment(self, amount: float, currency: str) -> dict:
result = self.paypal.make_payment(
total=amount,
currency_type=currency.upper(),
account='[email protected]'
)
return {
'success': result['state'] == 'approved',
'transaction_id': result['transaction_id'],
'amount': amount,
'currency': currency,
'provider': 'PayPal'
}
# Adapter for Square
class SquareAdapter(PaymentProcessor):
def __init__(self, square_api: SquareAPI):
self.square = square_api
def process_payment(self, amount: float, currency: str) -> dict:
money_amount = {
'amount': int(amount * 100),
'currency': currency.upper()
}
result = self.square.charge_card(
money_amount=money_amount,
card_token='cnon:card-token'
)
return {
'success': result['status'] == 'COMPLETED',
'transaction_id': result['payment_id'],
'amount': amount,
'currency': currency,
'provider': 'Square'
}
# Client code that works with the unified interface
class PaymentService:
def __init__(self, processor: PaymentProcessor):
self.processor = processor
def charge_customer(self, amount: float, currency: str = 'USD'):
result = self.processor.process_payment(amount, currency)
if result['success']:
print(f"✓ Payment successful via {result['provider']}")
print(f" Transaction ID: {result['transaction_id']}")
print(f" Amount: {result['amount']} {result['currency']}")
else:
print(f"✗ Payment failed")
return result
# Usage - all payment gateways work through the same interface
stripe_processor = StripeAdapter(StripeAPI())
paypal_processor = PayPalAdapter(PayPalAPI())
square_processor = SquareAdapter(SquareAPI())
# Process payments using different providers
service = PaymentService(stripe_processor)
service.charge_customer(99.99)
service = PaymentService(paypal_processor)
service.charge_customer(149.99)
service = PaymentService(square_processor)
service.charge_customer(199.99)
Template Method Pattern: Defining Algorithm Skeletons
The Template Method defines the skeleton of an algorithm in the superclass but lets subclasses override specific steps of the algorithm without changing its structure. This behavioral pattern is excellent for enforcing a consistent process while allowing customization at specific points.
Template Method Implementation
from abc import ABC, abstractmethod
import time
class DataProcessor(ABC):
"""Template class defining the data processing algorithm"""
def process(self, data):
"""Template method defining the algorithm structure"""
print("Starting data processing pipeline...")
# Step 1: Validate
if not self.validate(data):
raise ValueError("Data validation failed")
# Step 2: Extract
extracted = self.extract(data)
# Step 3: Transform
transformed = self.transform(extracted)
# Step 4: Load
result = self.load(transformed)
# Step 5: Cleanup (optional hook)
self.cleanup()
print("Data processing completed successfully")
return result
@abstractmethod
def validate(self, data) -> bool:
"""Validate input data - must be implemented by subclasses"""
pass
@abstractmethod
def extract(self, data):
"""Extract relevant data - must be implemented by subclasses"""
pass
@abstractmethod
def transform(self, data):
"""Transform data - must be implemented by subclasses"""
pass
@abstractmethod
def load(self, data):
"""Load processed data - must be implemented by subclasses"""
pass
def cleanup(self):
"""Optional hook method - can be overridden by subclasses"""
print("Performing default cleanup...")
class CSVDataProcessor(DataProcessor):
def validate(self, data) -> bool:
print("Validating CSV data...")
return isinstance(data, str) and len(data) > 0
def extract(self, data):
print("Extracting CSV data...")
lines = data.strip().split('n')
headers = lines[0].split(',')
rows = [line.split(',') for line in lines[1:]]
return {'headers': headers, 'rows': rows}
def transform(self, data):
print("Transforming CSV data...")
headers = data['headers']
rows = data['rows']
return [dict(zip(headers, row)) for row in rows]
def load(self, data):
print(f"Loading {len(data)} CSV records...")
return data
def cleanup(self):
print("Cleaning up CSV processing resources...")
class JSONDataProcessor(DataProcessor):
def validate(self, data) -> bool:
print("Validating JSON data...")
import json
try:
json.loads(data)
return True
except:
return False
def extract(self, data):
print("Extracting JSON data...")
import json
return json.loads(data)
def transform(self, data):
print("Transforming JSON data...")
if isinstance(data, list):
return [self._flatten_dict(item) for item in data]
return [self._flatten_dict(data)]
def _flatten_dict(self, d, parent_key='', sep='_'):
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
return dict(items)
def load(self, data):
print(f"Loading {len(data)} JSON records...")
return data
class XMLDataProcessor(DataProcessor):
def validate(self, data) -> bool:
print("Validating XML data...")
return data.strip().startswith('')
def extract(self, data):
print("Extracting XML data...")
# Simplified XML parsing
return {'xml_content': data}
def transform(self, data):
print("Transforming XML data...")
return {'processed_xml': data['xml_content']}
def load(self, data):
print("Loading XML data...")
return data
def cleanup(self):
print("Cleaning up XML processing resources...")
print("Closing XML parser...")
# Usage
csv_data = """name,age,city
John,30,New York
Jane,25,Los Angeles
Bob,35,Chicago"""
json_data = '''[
{"name": "John", "age": 30, "address": {"city": "New York"}},
{"name": "Jane", "age": 25, "address": {"city": "Los Angeles"}}
]'''
xml_data = "John30"
# Process CSV
csv_processor = CSVDataProcessor()
csv_result = csv_processor.process(csv_data)
print(f"CSV Result: {csv_result}n")
# Process JSON
json_processor = JSONDataProcessor()
json_result = json_processor.process(json_data)
print(f"JSON Result: {json_result}n")
# Process XML
xml_processor = XMLDataProcessor()
xml_result = xml_processor.process(xml_data)
print(f"XML Result: {xml_result}")
When to Use Design Patterns
Knowing when to use design patterns is crucial for effective software design, particularly when you encounter recurring design problems that have well-established solutions, as design patterns provide tested and proven approaches to common software design challenges.
Appropriate Use Cases
Use design patterns to promote code reusability, flexibility, and maintainability, as they help in structuring code in a way that makes it easier to modify and extend as requirements evolve. Consider implementing patterns when:
- You face a problem that matches a known pattern's intent
- The pattern provides clear benefits over a simpler solution
- Your team understands the pattern and can maintain it
- The added complexity is justified by improved flexibility or maintainability
- You want to improve communication among team members
When NOT to Use Patterns
The best code is the simplest code that correctly solves the problem—sometimes that's a well-placed design pattern, but often it's just a function and a dict. Avoid patterns when:
- A simpler solution would work just as well
- You're applying patterns for the sake of using patterns
- The pattern adds unnecessary complexity to straightforward code
- Your team isn't familiar with the pattern and documentation is lacking
- The problem doesn't actually match the pattern's intent
Each pattern has its own trade-offs, and you need to pay attention more to why you're choosing a certain pattern than to how to implement it. The decision to use a pattern should be driven by the problem at hand, not by a desire to demonstrate knowledge of patterns.
Anti-Patterns to Avoid in Python
Not all patterns make sense in Python's ecosystem. Python modules are already singletons—every module is imported only once, so explicit singleton classes add unnecessary complexity, with better alternatives being module-level variables or dependency injection.
Patterns That Don't Fit Python Well
- God Object: Centralizes too much logic in a single class, makes code harder to test and maintain, with the better alternative being to split functionality into smaller, cohesive classes
- Deep Inheritance Hierarchies: Deep inheritance trees make code brittle, so prefer composition and delegation
- Unnecessary Abstractions: Python's duck typing often eliminates the need for complex interface hierarchies
Modern Python Pattern Considerations
In modern Python (3.8+), prefer Protocol for structural subtyping as it doesn't require explicit inheritance, with classes satisfying a Protocol just by having the right methods, while using ABC when you want to enforce inheritance and provide default implementations.
Using Protocols for Duck Typing
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str:
...
class Circle:
def draw(self) -> str:
return "Drawing a circle"
class Square:
def draw(self) -> str:
return "Drawing a square"
def render(shape: Drawable) -> None:
print(shape.draw())
# Both Circle and Square satisfy the Drawable protocol
# without explicit inheritance
render(Circle())
render(Square())
Design Patterns in Production Python Applications
In 2026, Python sits at the intersection of AI and machine learning, scalable web product development, and enterprise data engineering, with its dominance reflecting a structural advantage that engineering teams discovered years ago: Python lets you move faster, integrate more easily, and build systems that are maintainable by teams, not just by the original author.
Framework-Specific Patterns
Django remains the most complete Python framework for teams building products with multi-user access, complex data models, admin interfaces, and authentication systems. Django extensively uses patterns like:
- Model-View-Template (MVT): Django's variation of MVC
- Active Record: Django's ORM models
- Template Method: Class-based views
- Middleware Chain: Request/response processing
FastAPI leverages modern Python features and patterns:
- Dependency Injection: Built-in DI system
- Decorator Pattern: Route decorators
- Strategy Pattern: Pluggable authentication
- Factory Pattern: Response model creation
Architectural Patterns
In 2026, the consensus among experienced Python engineering teams is clearer: start with a well-structured monolith, decompose into services when specific boundaries emerge from real usage, as a monolith is not a failure mode.
Instagram ran on a Django monolith well past 100 million users before decomposing specific high-load functions into services, with the key being building the monolith with service decomposition in mind from the start through clear module boundaries, minimal cross-module coupling, and event-driven communication patterns.
Testing Design Patterns
Design patterns should make code more testable, not less. Here are strategies for testing pattern implementations:
Testing Strategy Pattern
import unittest
from unittest.mock import Mock
class TestPaymentStrategies(unittest.TestCase):
def test_credit_card_payment(self):
strategy = CreditCardPayment("1234567890123456", "123", "12/25")
result = strategy.pay(100.00)
self.assertIn("Credit Card", result)
self.assertIn("100.00", result)
def test_paypal_payment(self):
strategy = PayPalPayment("[email protected]")
result = strategy.pay(50.00)
self.assertIn("PayPal", result)
self.assertIn("[email protected]", result)
def test_shopping_cart_with_different_strategies(self):
cart = ShoppingCart()
cart.add_item("Item 1", 50.00)
# Test with credit card
cart.set_payment_strategy(CreditCardPayment("1234", "123", "12/25"))
result1 = cart.checkout()
# Test with PayPal
cart.set_payment_strategy(PayPalPayment("[email protected]"))
result2 = cart.checkout()
self.assertIsNotNone(result1)
self.assertIsNotNone(result2)
Testing Observer Pattern
class TestObserverPattern(unittest.TestCase):
def test_observer_notification(self):
stock = StockPrice("AAPL", 150.00)
observer = Mock(spec=Observer)
stock.attach(observer)
stock.price = 155.00
observer.update.assert_called_once_with(stock)
def test_multiple_observers(self):
stock = StockPrice("GOOGL", 2800.00)
observer1 = Mock(spec=Observer)
observer2 = Mock(spec=Observer)
stock.attach(observer1)
stock.attach(observer2)
stock.price = 2850.00
observer1.update.assert_called_once()
observer2.update.assert_called_once()
def test_detach_observer(self):
stock = StockPrice("MSFT", 300.00)
observer = Mock(spec=Observer)
stock.attach(observer)
stock.detach(observer)
stock.price = 310.00
observer.update.assert_not_called()
Performance Considerations
While design patterns improve code organization and maintainability, they can introduce performance overhead if not implemented carefully. Consider these optimization strategies:
Lazy Initialization
The instance is created only when the get_instance() method is called for the first time, ensuring that resources are allocated only when needed. This is particularly important for resource-intensive objects:
class DatabaseConnection:
_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 DatabaseConnection._initialized:
self._connect()
DatabaseConnection._initialized = True
def _connect(self):
# Expensive connection operation
print("Establishing database connection...")
self.connection = "Connected"
Caching Decorator Results
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_computation(n):
"""Cached using built-in LRU cache"""
print(f"Computing for {n}...")
return sum(range(n))
# First call computes
result1 = expensive_computation(1000)
# Second call returns cached result
result2 = expensive_computation(1000)
Best Practices for Design Patterns in Python
Design patterns should simplify, not complicate, as Python design patterns are not about copying textbook diagrams—they are about solving real problems elegantly. Follow these guidelines for effective pattern implementation:
1. Favor Composition Over Inheritance
Python's dynamic nature makes composition particularly powerful. Instead of deep inheritance hierarchies, compose objects from smaller, focused components:
# Instead of inheritance
class EmailNotifier:
def send(self, message):
print(f"Sending email: {message}")
class SMSNotifier:
def send(self, message):
print(f"Sending SMS: {message}")
# Use composition
class NotificationService:
def __init__(self):
self.notifiers = []
def add_notifier(self, notifier):
self.notifiers.append(notifier)
def notify(self, message):
for notifier in self.notifiers:
notifier.send(message)
# Usage
service = NotificationService()
service.add_notifier(EmailNotifier())
service.add_notifier(SMSNotifier())
service.notify("Important update!")
2. Use Type Hints for Clarity
Type hints make pattern implementations more explicit and enable better IDE support:
from typing import Protocol, List
from abc import abstractmethod
class PaymentMethod(Protocol):
def process(self, amount: float) -> bool:
...
class PaymentProcessor:
def __init__(self, methods: List[PaymentMethod]):
self.methods = methods
def charge(self, amount: float) -> bool:
for method in self.methods:
if method.process(amount):
return True
return False
3. Document Pattern Usage
Always document which pattern you're using and why:
class DataExporter:
"""
Uses the Strategy pattern to support multiple export formats.
This allows adding new export formats without modifying existing code,
following the Open/Closed Principle.
Example:
exporter = DataExporter(JSONExportStrategy())
exporter.export(data)
"""
def __init__(self, strategy: ExportStrategy):
self._strategy = strategy
def export(self, data):
return self._strategy.export(data)
4. Keep It Simple
Don't over-engineer. Start simple and refactor to patterns when complexity justifies it:
# Simple is often better
def calculate_discount(price, customer_type):
discounts = {
'regular': 0,
'premium': 0.10,
'vip': 0.20
}
return price * (1 - discounts.get(customer_type, 0))
# Only introduce Strategy pattern when you need:
# - Runtime strategy switching
# - Complex discount logic
# - Multiple discount calculation methods
Resources for Learning Design Patterns
To deepen your understanding of design patterns in Python, explore these valuable resources:
- Refactoring.Guru: Comprehensive design pattern catalog with Python examples at https://refactoring.guru/design-patterns/python
- Python Patterns Guide: Brandon Rhodes' evolving guide at https://python-patterns.guide/
- GitHub Python Patterns: Community-driven pattern collection at https://github.com/faif/python-patterns
- Real Python: Practical tutorials on Python design patterns at https://realpython.com
- GeeksforGeeks Python Design Patterns: Comprehensive tutorials at https://www.geeksforgeeks.org/python-design-patterns/
Conclusion
Design patterns are powerful tools in a Python engineer's toolkit, but they should be applied judiciously. Strategy, Factory, and Observer patterns often appear together in well-architected Python systems, with Strategy focusing on selecting and swapping algorithms, Factory concentrating on how objects are created, and Observer addressing communication, with understanding these distinctions helping you choose the right pattern based on whether your challenge is about behavior, creation, or communication.
The key to successfully using design patterns in Python is understanding both the patterns themselves and Python's unique characteristics. Python's dynamic typing, first-class functions, and powerful standard library often allow for simpler implementations than in statically-typed languages. Always prioritize code clarity and maintainability over pattern purity.
Remember that patterns are means to an end, not ends in themselves. The goal is to write code that is easy to understand, test, and modify. When a pattern helps achieve that goal, use it. When a simpler solution suffices, embrace simplicity. As you gain experience, you'll develop an intuition for when patterns add value and when they add unnecessary complexity.
By mastering design patterns and understanding when to apply them, you'll be better equipped to build robust, scalable Python applications that stand the test of time and evolving requirements.