Design patterns are reusable solutions to common problems in software design. They represent best practices and can accelerate development by providing tested, proven approaches to solving recurring design problems. Three of the most fundamental and widely-used patterns are Singleton, Factory, and Observer.
This guide explores these patterns in depth, showing you not just how to implement them, but when and why to use them in your Python projects.
Understanding Design Patterns
Before diving into specific patterns, let’s establish why design patterns matter:
- Reusability: Proven solutions you can apply to similar problems
- Communication: Common vocabulary for discussing architecture
- Maintainability: Patterns make code easier to understand and modify
- Scalability: Patterns help structure code for growth
- Best Practices: Encapsulate years of collective experience
Design patterns are typically categorized into three types:
- Creational Patterns: Deal with object creation mechanisms
- Structural Patterns: Deal with object composition and relationships
- Behavioral Patterns: Deal with object collaboration and responsibility distribution
The three patterns we’ll explore include two creational patterns (Singleton and Factory) and one behavioral pattern (Observer).
Part 1: The Singleton Pattern
What is the Singleton Pattern?
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. It’s one of the simplest yet most misunderstood patterns.
Problem It Solves
Imagine you’re building a logging system. You want all parts of your application to write to the same log file. Without a Singleton, you might create multiple logger instances, each with its own file handle, leading to:
- Multiple file handles to the same file
- Inconsistent logging state
- Resource waste
- Potential data corruption
The Singleton pattern solves this by ensuring only one instance exists.
Basic Implementation
class Logger:
"""Basic Singleton implementation using a class variable"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.logs = []
return cls._instance
def log(self, message):
"""Add a message to the log"""
self.logs.append(message)
def get_logs(self):
"""Retrieve all logged messages"""
return self.logs
# Usage
logger1 = Logger()
logger2 = Logger()
logger1.log("First message")
logger2.log("Second message")
print(logger1 is logger2) # True - same instance
print(logger1.get_logs()) # ['First message', 'Second message']
Singleton Using a Decorator
A more Pythonic approach uses a decorator:
def singleton(cls):
"""Decorator to make a class a Singleton"""
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Database:
def __init__(self):
self.connection = None
def connect(self, url):
"""Establish database connection"""
self.connection = f"Connected to {url}"
print(self.connection)
def query(self, sql):
"""Execute a query"""
return f"Executing: {sql}"
# Usage
db1 = Database()
db2 = Database()
print(db1 is db2) # True
db1.connect("postgresql://localhost/mydb")
print(db2.query("SELECT * FROM users"))
Singleton Using a Metaclass
For more control, use a metaclass:
class SingletonMeta(type):
"""Metaclass for Singleton pattern"""
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class ConfigManager(metaclass=SingletonMeta):
def __init__(self):
self.config = {}
def set(self, key, value):
"""Set a configuration value"""
self.config[key] = value
def get(self, key, default=None):
"""Get a configuration value"""
return self.config.get(key, default)
# Usage
config1 = ConfigManager()
config2 = ConfigManager()
config1.set("debug", True)
print(config2.get("debug")) # True
print(config1 is config2) # True
Real-World Use Cases
1. Logging System
class Logger(metaclass=SingletonMeta):
def __init__(self):
self.logs = []
def log(self, level, message):
"""Log a message with a level"""
log_entry = f"[{level}] {message}"
self.logs.append(log_entry)
print(log_entry)
# Throughout your application
logger = Logger()
logger.log("INFO", "Application started")
logger.log("ERROR", "An error occurred")
2. Database Connection Pool
class DatabasePool(metaclass=SingletonMeta):
def __init__(self):
self.connections = []
self.max_connections = 10
def get_connection(self):
"""Get a connection from the pool"""
if self.connections:
return self.connections.pop()
elif len(self.connections) < self.max_connections:
return self._create_connection()
else:
raise Exception("No connections available")
def _create_connection(self):
"""Create a new database connection"""
return f"Connection-{len(self.connections)}"
def return_connection(self, conn):
"""Return a connection to the pool"""
self.connections.append(conn)
# Usage
pool = DatabasePool()
conn = pool.get_connection()
pool.return_connection(conn)
3. Application Settings
class Settings(metaclass=SingletonMeta):
def __init__(self):
self.settings = {
'debug': False,
'database_url': 'postgresql://localhost/db',
'secret_key': 'your-secret-key'
}
def get(self, key):
return self.settings.get(key)
def set(self, key, value):
self.settings[key] = value
# Usage
settings = Settings()
print(settings.get('debug'))
Advantages and Disadvantages
Advantages:
- Ensures single instance across application
- Lazy initialization possible
- Global access point
- Thread-safe (with proper implementation)
Disadvantages:
- Can hide dependencies
- Makes testing harder (global state)
- Can be overused
- May indicate design issues
Best Practices
- Use sparingly: Singletons can hide dependencies and make code harder to test
- Consider dependency injection: Often a better alternative
- Make thread-safe: Use metaclass or decorator approach
- Document clearly: Make it obvious why a Singleton is needed
- Provide a way to reset: Useful for testing
Part 2: The Factory Pattern
What is the Factory Pattern?
The Factory pattern provides an interface for creating objects without specifying their exact classes. Instead of directly instantiating classes, you use a factory method that returns the appropriate object.
Problem It Solves
Imagine you’re building a payment processing system. You need to support multiple payment methods: credit card, PayPal, cryptocurrency. Without a factory, your code might look like:
# โ Without Factory Pattern
if payment_method == 'credit_card':
processor = CreditCardProcessor()
elif payment_method == 'paypal':
processor = PayPalProcessor()
elif payment_method == 'crypto':
processor = CryptoProcessor()
else:
raise ValueError("Unknown payment method")
This approach has problems:
- Tight coupling to concrete classes
- Hard to add new payment methods
- Logic scattered throughout the code
- Violates the Open/Closed Principle
The Factory pattern centralizes object creation.
Simple Factory Implementation
class PaymentProcessor:
"""Base class for payment processors"""
def process(self, amount):
raise NotImplementedError
class CreditCardProcessor(PaymentProcessor):
def process(self, amount):
return f"Processing ${amount} via Credit Card"
class PayPalProcessor(PaymentProcessor):
def process(self, amount):
return f"Processing ${amount} via PayPal"
class CryptoProcessor(PaymentProcessor):
def process(self, amount):
return f"Processing ${amount} via Cryptocurrency"
class PaymentFactory:
"""Factory for creating payment processors"""
_processors = {
'credit_card': CreditCardProcessor,
'paypal': PayPalProcessor,
'crypto': CryptoProcessor
}
@classmethod
def create(cls, payment_method):
"""Create a payment processor"""
processor_class = cls._processors.get(payment_method)
if processor_class is None:
raise ValueError(f"Unknown payment method: {payment_method}")
return processor_class()
# Usage
factory = PaymentFactory()
processor = factory.create('credit_card')
print(processor.process(100)) # Processing $100 via Credit Card
Factory Method Pattern
The Factory Method pattern defines an interface for creating objects, but lets subclasses decide which class to instantiate:
from abc import ABC, abstractmethod
class Document(ABC):
"""Abstract base class for documents"""
@abstractmethod
def open(self):
pass
@abstractmethod
def save(self):
pass
class PDFDocument(Document):
def open(self):
return "Opening PDF document"
def save(self):
return "Saving PDF document"
class WordDocument(Document):
def open(self):
return "Opening Word document"
def save(self):
return "Saving Word document"
class DocumentCreator(ABC):
"""Abstract factory for creating documents"""
@abstractmethod
def create_document(self):
pass
def new_document(self):
"""Template method"""
doc = self.create_document()
print(doc.open())
return doc
class PDFCreator(DocumentCreator):
def create_document(self):
return PDFDocument()
class WordCreator(DocumentCreator):
def create_document(self):
return WordDocument()
# Usage
pdf_creator = PDFCreator()
pdf_doc = pdf_creator.new_document() # Opening PDF document
print(pdf_doc.save()) # Saving PDF document
Abstract Factory Pattern
The Abstract Factory pattern provides an interface for creating families of related objects:
from abc import ABC, abstractmethod
class Button(ABC):
@abstractmethod
def render(self):
pass
class Input(ABC):
@abstractmethod
def render(self):
pass
# Windows UI Components
class WindowsButton(Button):
def render(self):
return "Rendering Windows Button"
class WindowsInput(Input):
def render(self):
return "Rendering Windows Input"
# Mac UI Components
class MacButton(Button):
def render(self):
return "Rendering Mac Button"
class MacInput(Input):
def render(self):
return "Rendering Mac Input"
class UIFactory(ABC):
"""Abstract factory for UI components"""
@abstractmethod
def create_button(self):
pass
@abstractmethod
def create_input(self):
pass
class WindowsUIFactory(UIFactory):
def create_button(self):
return WindowsButton()
def create_input(self):
return WindowsInput()
class MacUIFactory(UIFactory):
def create_button(self):
return MacButton()
def create_input(self):
return MacInput()
def create_ui(os_type):
"""Factory function to create appropriate UI factory"""
if os_type == 'windows':
return WindowsUIFactory()
elif os_type == 'mac':
return MacUIFactory()
else:
raise ValueError(f"Unknown OS: {os_type}")
# Usage
factory = create_ui('windows')
button = factory.create_button()
input_field = factory.create_input()
print(button.render()) # Rendering Windows Button
print(input_field.render()) # Rendering Windows Input
Real-World Use Cases
1. Database Connection Factory
class DatabaseConnection(ABC):
@abstractmethod
def connect(self):
pass
class PostgreSQLConnection(DatabaseConnection):
def connect(self):
return "Connected to PostgreSQL"
class MySQLConnection(DatabaseConnection):
def connect(self):
return "Connected to MySQL"
class DatabaseFactory:
@staticmethod
def create_connection(db_type):
if db_type == 'postgresql':
return PostgreSQLConnection()
elif db_type == 'mysql':
return MySQLConnection()
else:
raise ValueError(f"Unknown database type: {db_type}")
# Usage
db = DatabaseFactory.create_connection('postgresql')
print(db.connect())
2. Logger Factory
class Logger(ABC):
@abstractmethod
def log(self, message):
pass
class FileLogger(Logger):
def log(self, message):
return f"Logging to file: {message}"
class ConsoleLogger(Logger):
def log(self, message):
return f"Logging to console: {message}"
class LoggerFactory:
@staticmethod
def create_logger(logger_type):
if logger_type == 'file':
return FileLogger()
elif logger_type == 'console':
return ConsoleLogger()
else:
raise ValueError(f"Unknown logger type: {logger_type}")
# Usage
logger = LoggerFactory.create_logger('console')
print(logger.log("Application started"))
3. Shape Factory
class Shape(ABC):
@abstractmethod
def draw(self):
pass
class Circle(Shape):
def draw(self):
return "Drawing Circle"
class Rectangle(Shape):
def draw(self):
return "Drawing Rectangle"
class Triangle(Shape):
def draw(self):
return "Drawing Triangle"
class ShapeFactory:
@staticmethod
def create_shape(shape_type):
shapes = {
'circle': Circle,
'rectangle': Rectangle,
'triangle': Triangle
}
shape_class = shapes.get(shape_type)
if shape_class is None:
raise ValueError(f"Unknown shape: {shape_type}")
return shape_class()
# Usage
for shape_type in ['circle', 'rectangle', 'triangle']:
shape = ShapeFactory.create_shape(shape_type)
print(shape.draw())
Advantages and Disadvantages
Advantages:
- Decouples object creation from usage
- Easy to add new types
- Centralizes creation logic
- Follows Open/Closed Principle
- Makes code more maintainable
Disadvantages:
- Can add unnecessary complexity for simple cases
- May create more classes than needed
- Requires careful design
Best Practices
- Use when you have multiple related types: Don’t use for single types
- Keep factories simple: Don’t add unnecessary logic
- Use type hints: Make it clear what types are returned
- Consider using a registry: For dynamic type registration
- Document supported types: Make it clear what can be created
Part 3: The Observer Pattern
What is the Observer Pattern?
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified automatically. It’s also known as the Publish-Subscribe pattern.
Problem It Solves
Imagine you’re building a stock trading application. When a stock price changes, multiple components need to be notified:
- A price display needs to update
- A trading algorithm needs to check conditions
- A notification system needs to alert users
- A logging system needs to record the change
Without the Observer pattern, the stock object would need to know about all these components and call them directly, creating tight coupling.
The Observer pattern decouples the stock from its observers.
Basic Implementation
from abc import ABC, abstractmethod
class Observer(ABC):
"""Abstract observer class"""
@abstractmethod
def update(self, subject):
pass
class Subject:
"""Subject that notifies observers"""
def __init__(self):
self._observers = []
def attach(self, observer):
"""Attach an observer"""
if observer not in self._observers:
self._observers.append(observer)
def detach(self, observer):
"""Detach an observer"""
if observer in self._observers:
self._observers.remove(observer)
def notify(self):
"""Notify all observers"""
for observer in self._observers:
observer.update(self)
class Stock(Subject):
"""Concrete subject"""
def __init__(self, symbol, price):
super().__init__()
self._symbol = symbol
self._price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
self._price = value
self.notify() # Notify observers when price changes
@property
def symbol(self):
return self._symbol
class PriceDisplay(Observer):
"""Concrete observer - displays price"""
def update(self, subject):
print(f"Price Display: {subject.symbol} is now ${subject.price}")
class TradingAlgorithm(Observer):
"""Concrete observer - executes trades"""
def update(self, subject):
if subject.price < 100:
print(f"Trading Algorithm: BUY {subject.symbol} at ${subject.price}")
elif subject.price > 200:
print(f"Trading Algorithm: SELL {subject.symbol} at ${subject.price}")
class NotificationService(Observer):
"""Concrete observer - sends notifications"""
def update(self, subject):
print(f"Notification: Alert! {subject.symbol} price changed to ${subject.price}")
# Usage
stock = Stock("AAPL", 150)
display = PriceDisplay()
algorithm = TradingAlgorithm()
notification = NotificationService()
stock.attach(display)
stock.attach(algorithm)
stock.attach(notification)
stock.price = 95 # Triggers all observers
# Output:
# Price Display: AAPL is now $95
# Trading Algorithm: BUY AAPL at $95
# Notification: Alert! AAPL price changed to $95
stock.price = 210 # Triggers all observers again
# Output:
# Price Display: AAPL is now $210
# Trading Algorithm: SELL AAPL at $210
# Notification: Alert! AAPL price changed to $210
Observer Using Callbacks
A simpler approach using callbacks:
class EventEmitter:
"""Simple event emitter using callbacks"""
def __init__(self):
self._callbacks = {}
def on(self, event, callback):
"""Register a callback for an event"""
if event not in self._callbacks:
self._callbacks[event] = []
self._callbacks[event].append(callback)
def off(self, event, callback):
"""Unregister a callback"""
if event in self._callbacks:
self._callbacks[event].remove(callback)
def emit(self, event, *args, **kwargs):
"""Emit an event"""
if event in self._callbacks:
for callback in self._callbacks[event]:
callback(*args, **kwargs)
class Button:
def __init__(self):
self.emitter = EventEmitter()
def click(self):
"""Simulate button click"""
self.emitter.emit('click')
def on_click(self, callback):
"""Register click handler"""
self.emitter.on('click', callback)
# Usage
button = Button()
def on_button_click():
print("Button was clicked!")
def log_click():
print("Logging click event...")
button.on_click(on_button_click)
button.on_click(log_click)
button.click()
# Output:
# Button was clicked!
# Logging click event...
Observer Using Properties and Decorators
class Observable:
"""Base class for observable objects"""
def __init__(self):
self._observers = {}
def observe(self, property_name, callback):
"""Observe changes to a property"""
if property_name not in self._observers:
self._observers[property_name] = []
self._observers[property_name].append(callback)
def _notify_observers(self, property_name, value):
"""Notify observers of property change"""
if property_name in self._observers:
for callback in self._observers[property_name]:
callback(value)
class User(Observable):
def __init__(self, name):
super().__init__()
self._name = name
self._email = None
@property
def name(self):
return self._name
@property
def email(self):
return self._email
@email.setter
def email(self, value):
self._email = value
self._notify_observers('email', value)
# Usage
user = User("Alice")
def on_email_changed(new_email):
print(f"Email changed to: {new_email}")
def log_email_change(new_email):
print(f"Logging: Email updated to {new_email}")
user.observe('email', on_email_changed)
user.observe('email', log_email_change)
user.email = "[email protected]"
# Output:
# Email changed to: [email protected]
# Logging: Email updated to [email protected]
Real-World Use Cases
1. Model-View Pattern
class Model(Subject):
"""Data model that notifies views of changes"""
def __init__(self):
super().__init__()
self._data = {}
def set_data(self, key, value):
self._data[key] = value
self.notify()
def get_data(self, key):
return self._data.get(key)
class View(Observer):
"""View that updates when model changes"""
def __init__(self, name):
self.name = name
def update(self, subject):
print(f"{self.name} View: Model updated - {subject._data}")
# Usage
model = Model()
view1 = View("View1")
view2 = View("View2")
model.attach(view1)
model.attach(view2)
model.set_data("user", "Alice")
# Output:
# View1 View: Model updated - {'user': 'Alice'}
# View2 View: Model updated - {'user': 'Alice'}
2. Event System
class EventSystem:
"""Global event system"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._listeners = {}
return cls._instance
def subscribe(self, event_type, listener):
"""Subscribe to an event"""
if event_type not in self._listeners:
self._listeners[event_type] = []
self._listeners[event_type].append(listener)
def unsubscribe(self, event_type, listener):
"""Unsubscribe from an event"""
if event_type in self._listeners:
self._listeners[event_type].remove(listener)
def publish(self, event_type, data):
"""Publish an event"""
if event_type in self._listeners:
for listener in self._listeners[event_type]:
listener(data)
# Usage
events = EventSystem()
def on_user_login(data):
print(f"User logged in: {data['username']}")
def log_login(data):
print(f"Logging login: {data['username']}")
events.subscribe('user_login', on_user_login)
events.subscribe('user_login', log_login)
events.publish('user_login', {'username': 'alice'})
# Output:
# User logged in: alice
# Logging login: alice
3. File System Watcher
class FileSystemWatcher(Subject):
"""Watches for file system changes"""
def __init__(self, path):
super().__init__()
self.path = path
def file_created(self, filename):
print(f"File created: {filename}")
self.notify()
def file_deleted(self, filename):
print(f"File deleted: {filename}")
self.notify()
class BackupService(Observer):
"""Backs up files when they change"""
def update(self, subject):
print(f"BackupService: Backing up {subject.path}")
class IndexService(Observer):
"""Indexes files when they change"""
def update(self, subject):
print(f"IndexService: Indexing {subject.path}")
# Usage
watcher = FileSystemWatcher("/home/user/documents")
backup = BackupService()
index = IndexService()
watcher.attach(backup)
watcher.attach(index)
watcher.file_created("document.txt")
# Output:
# File created: document.txt
# BackupService: Backing up /home/user/documents
# IndexService: Indexing /home/user/documents
Advantages and Disadvantages
Advantages:
- Loose coupling between subject and observers
- Dynamic relationships at runtime
- Supports broadcast communication
- Follows Open/Closed Principle
- Easy to add new observers
Disadvantages:
- Observers are notified in random order
- Memory leaks if observers aren’t unsubscribed
- Can be hard to debug
- Performance impact with many observers
Best Practices
- Always unsubscribe: Prevent memory leaks
- Use weak references: For automatic cleanup
- Keep observers simple: Don’t do heavy work in observers
- Document events: Make it clear what events are available
- Consider async notifications: For long-running operations
- Use type hints: Make observer signatures clear
Comparing the Three Patterns
| Pattern | Purpose | When to Use | Complexity |
|---|---|---|---|
| Singleton | Ensure single instance | Global state, resource pools | Low |
| Factory | Create objects | Multiple related types | Medium |
| Observer | Notify multiple objects | Event handling, MVC | Medium |
Conclusion
These three design patterns solve fundamental problems in software design:
Singleton ensures you have exactly one instance of a class. Use it for shared resources like loggers, configuration managers, and connection pools. However, use it sparinglyโit can hide dependencies and make testing harder.
Factory centralizes object creation and decouples creation from usage. Use it when you have multiple related types or when creation logic is complex. It makes adding new types easier and follows the Open/Closed Principle.
Observer decouples objects that need to communicate. Use it for event handling, MVC architectures, and any situation where multiple objects need to react to state changes. It enables loose coupling and dynamic relationships.
The key to using design patterns effectively is recognizing when a problem matches a pattern’s solution. Don’t force patterns where they’re not neededโsimplicity is often better than clever architecture. Start with the simplest solution, and introduce patterns when you encounter the problems they solve.
Remember: design patterns are tools, not rules. Use them to make your code clearer, more maintainable, and more flexible. When used appropriately, they’ll significantly improve your software design.
Comments