OOP Design Patterns Every Developer Should Know in Python
Master essential OOP design patterns in Python with practical examples. Learn Singleton, Factory, Observer, Strategy, Decorator patterns and more to write professional, maintainable code

Every "Clever Solution" I Invented Already Had a Name
Three years into my career, I was rewriting the same code patterns over and over:
- Creating single database connections
- Building objects with complex initialization
- Notifying multiple components about changes
I thought I was being creative. Then my senior showed me the Gang of Four book.
"These problems were solved 30 years ago," he said. "Learn design patterns."
Mind blown. Every "clever solution" I'd invented already had a name, proven implementation, and best practices.
Today, I'll teach you the most essential OOP design patterns in Pythonβthe ones you'll actually use in real projects.
What Are Design Patterns?
Design patterns are proven solutions to common software design problems. They're like blueprints you can customize to solve recurring design challenges.
Benefits:
- β Proven solutions
- β Common vocabulary (team communication)
- β Faster development
- β Fewer bugs
Three categories:
- Creational - Object creation
- Structural - Object composition
- Behavioral - Object interaction
1. Singleton Pattern (Creational)
Problem: Need exactly one instance of a class (database connection, logger, config).
Solution: Ensure class has only one instance and provide global access point.
class DatabaseConnection:
"""Singleton: Only one database connection"""
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, connection_string):
if self._initialized:
return
self.connection_string = connection_string
self._initialized = True
print(f"β
Database connection created: {connection_string}")
def query(self, sql):
print(f"π Executing: {sql}")
return []
# Create "multiple" connections
db1 = DatabaseConnection("mysql://localhost:3306")
db2 = DatabaseConnection("postgresql://localhost:5432")
# Both are the same instance!
print(db1 is db2) # True
print(db1.connection_string) # mysql://localhost:3306 (first one wins)
db1.query("SELECT * FROM users")
db2.query("SELECT * FROM products") # Uses same connection
When to use:
- β Database connections
- β Logger instances
- β Configuration managers
- β Thread pools
2. Factory Pattern (Creational)
Problem: Complex object creation logic scattered throughout code.
Solution: Centralize object creation in a factory class.
from abc import ABC, abstractmethod
# Product interface
class Database(ABC):
@abstractmethod
def connect(self):
pass
@abstractmethod
def query(self, sql):
pass
# Concrete products
class MySQLDatabase(Database):
def connect(self):
print("π¬ Connecting to MySQL...")
def query(self, sql):
print(f"MySQL: {sql}")
class PostgreSQLDatabase(Database):
def connect(self):
print("π Connecting to PostgreSQL...")
def query(self, sql):
print(f"PostgreSQL: {sql}")
class MongoDatabase(Database):
def connect(self):
print("π Connecting to MongoDB...")
def query(self, query):
print(f"MongoDB: {query}")
# Factory
class DatabaseFactory:
"""Creates database objects based on type"""
@staticmethod
def create_database(db_type: str) -> Database:
databases = {
"mysql": MySQLDatabase,
"postgresql": PostgreSQLDatabase,
"mongo": MongoDatabase
}
db_class = databases.get(db_type.lower())
if db_class is None:
raise ValueError(f"Unknown database type: {db_type}")
return db_class()
# Usage
config_db_type = "mysql" # From config file
# No need to know which class to instantiate!
db = DatabaseFactory.create_database(config_db_type)
db.connect()
db.query("SELECT * FROM users")
# Easy to switch database
db = DatabaseFactory.create_database("postgresql")
db.connect()
db.query("SELECT * FROM users")
When to use:
- β Object creation is complex
- β Need to switch implementations easily
- β Centralizing creation logic
- β Plugin systems
3. Observer Pattern (Behavioral)
Problem: Need to notify multiple objects when one object changes state.
Solution: Define subscription mechanism to notify multiple objects.
from abc import ABC, abstractmethod
# Observer interface
class Observer(ABC):
@abstractmethod
def update(self, subject):
pass
# Subject (Observable)
class Stock:
"""Stock price that observers can watch"""
def __init__(self, symbol, price):
self.symbol = symbol
self._price = price
self._observers = []
def attach(self, observer):
"""Subscribe an observer"""
if observer not in self._observers:
self._observers.append(observer)
print(f"β
{observer.__class__.__name__} subscribed to {self.symbol}")
def detach(self, observer):
"""Unsubscribe an observer"""
if observer in self._observers:
self._observers.remove(observer)
print(f"β {observer.__class__.__name__} unsubscribed from {self.symbol}")
def notify(self):
"""Notify all observers of change"""
print(f"\nπ’ Notifying observers about {self.symbol} price change...")
for observer in self._observers:
observer.update(self)
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value != self._price:
old_price = self._price
self._price = value
print(f"\nπ° {self.symbol} price changed: ${old_price} β ${value}")
self.notify() # Notify observers
# Concrete observers
class EmailAlert(Observer):
def __init__(self, email):
self.email = email
def update(self, stock):
print(f"π§ Email alert to {self.email}: {stock.symbol} is now ${stock.price}")
class SMSAlert(Observer):
def __init__(self, phone):
self.phone = phone
def update(self, stock):
print(f"π± SMS to {phone}: {stock.symbol}: ${stock.price}")
class Dashboard(Observer):
def update(self, stock):
print(f"π Dashboard updated: {stock.symbol} = ${stock.price}")
# Usage
apple_stock = Stock("AAPL", 150.00)
# Subscribe observers
email_alert = EmailAlert("investor@email.com")
sms_alert = SMSAlert("+1-555-1234")
dashboard = Dashboard()
apple_stock.attach(email_alert)
apple_stock.attach(sms_alert)
apple_stock.attach(dashboard)
# Change price (all observers get notified)
apple_stock.price = 155.00
apple_stock.price = 152.50
# Unsubscribe one observer
apple_stock.detach(sms_alert)
apple_stock.price = 160.00 # SMS won't be notified
When to use:
- β Event systems
- β UI updates (MVC pattern)
- β Pub/sub systems
- β Real-time data feeds
4. Strategy Pattern (Behavioral)
Problem: Multiple algorithms for same task; need to switch at runtime.
Solution: Define family of dsa, encapsulate each, make them interchangeable.
from abc import ABC, abstractmethod
# Strategy interface
class PaymentStrategy(ABC):
@abstractmethod
def pay(self, amount):
pass
# Concrete strategies
class CreditCardPayment(PaymentStrategy):
def __init__(self, card_number):
self.card_number = card_number
def pay(self, amount):
print(f"π³ Paying ${amount} with credit card ****{self.card_number[-4:]}")
class PayPalPayment(PaymentStrategy):
def __init__(self, email):
self.email = email
def pay(self, amount):
print(f"π
ΏοΈ Paying ${amount} with PayPal ({self.email})")
class CryptoPayment(PaymentStrategy):
def __init__(self, wallet_address):
self.wallet_address = wallet_address
def pay(self, amount):
print(f"βΏ Paying ${amount} with Bitcoin ({self.wallet_address[:10]}...)")
# Context
class ShoppingCart:
def __init__(self):
self.items = []
self.payment_strategy = None
def add_item(self, name, price):
self.items.append({"name": name, "price": price})
print(f"β
Added {name} (${price}) to cart")
def set_payment_strategy(self, strategy: PaymentStrategy):
"""Switch payment method at runtime"""
self.payment_strategy = strategy
print(f"π° Payment method set to {strategy.__class__.__name__}")
def checkout(self):
if not self.payment_strategy:
raise ValueError("Payment method not set!")
total = sum(item["price"] for item in self.items)
print(f"\n{'='*50}")
print(f"Total: ${total}")
self.payment_strategy.pay(total)
print(f"{'='*50}\n")
# Usage
cart = ShoppingCart()
cart.add_item("Laptop", 999.99)
cart.add_item("Mouse", 29.99)
# Pay with credit card
cart.set_payment_strategy(CreditCardPayment("4532-1234-5678-9012"))
cart.checkout()
# Same cart, different payment method
cart.set_payment_strategy(PayPalPayment("user@email.com"))
cart.checkout()
# Another payment method
cart.set_payment_strategy(CryptoPayment("1A1zP1eP5QGefi2DMPTfTL"))
cart.checkout()
When to use:
- β Multiple ways to do same thing
- β Swapping algorithms at runtime
- β Eliminating conditional statements
- β Payment systems, compression, sorting
5. Decorator Pattern (Structural)
Problem: Need to add functionality to objects dynamically without modifying their code.
Solution: Wrap objects in decorator objects that add new behavior.
from abc import ABC, abstractmethod
# Component interface
class Coffee(ABC):
@abstractmethod
def cost(self):
pass
@abstractmethod
def description(self):
pass
# Concrete component
class SimpleCoffee(Coffee):
def cost(self):
return 2.0
def description(self):
return "Simple Coffee"
# Decorator base class
class CoffeeDecorator(Coffee):
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost()
def description(self):
return self._coffee.description()
# Concrete decorators
class Milk(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 0.5
def description(self):
return self._coffee.description() + " + Milk"
class Sugar(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 0.2
def description(self):
return self._coffee.description() + " + Sugar"
class WhippedCream(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 1.0
def description(self):
return self._coffee.description() + " + Whipped Cream"
# Usage
coffee = SimpleCoffee()
print(f"{coffee.description()}: ${coffee.cost()}")
# Add decorators dynamically
coffee = Milk(coffee)
print(f"{coffee.description()}: ${coffee.cost()}")
coffee = Sugar(coffee)
print(f"{coffee.description()}: ${coffee.cost()}")
coffee = WhippedCream(coffee)
print(f"{coffee.description()}: ${coffee.cost()}")
# Or all at once
fancy_coffee = WhippedCream(Sugar(Milk(SimpleCoffee())))
print(f"\n{fancy_coffee.description()}: ${fancy_coffee.cost()}")
When to use:
- β Add responsibilities dynamically
- β Avoid subclass explosion
- β Wrapping objects with behavior
- β UI components, streams, middleware
6. Adapter Pattern (Structural)
Problem: Incompatible interfaces need to work together.
Solution: Create adapter that converts one interface to another.
# Target interface (what we want)
class MediaPlayer:
def play(self, filename):
pass
# Adaptee (existing incompatible interface)
class VLCPlayer:
def play_vlc(self, filename):
print(f"π¬ VLC playing: {filename}")
class MP3Player:
def play_mp3(self, filename):
print(f"π΅ MP3 playing: {filename}")
# Adapters
class VLCAdapter(MediaPlayer):
def __init__(self):
self.vlc_player = VLCPlayer()
def play(self, filename):
self.vlc_player.play_vlc(filename)
class MP3Adapter(MediaPlayer):
def __init__(self):
self.mp3_player = MP3Player()
def play(self, filename):
self.mp3_player.play_mp3(filename)
# Client code
class AudioPlayer:
def __init__(self):
self.players = {
"vlc": VLCAdapter(),
"mp3": MP3Adapter()
}
def play(self, file_type, filename):
player = self.players.get(file_type)
if player:
player.play(filename)
else:
print(f"β Unsupported format: {file_type}")
# Usage
audio = AudioPlayer()
audio.play("vlc", "movie.vlc")
audio.play("mp3", "song.mp3")
audio.play("avi", "video.avi") # Unsupported
When to use:
- β Integrating third-party libraries
- β Legacy code integration
- β API compatibility
- β Wrapping external services
Real-World Example: E-Commerce System
Combining multiple patterns:
from abc import ABC, abstractmethod
from datetime import datetime
# Singleton: Configuration
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.settings = {
"tax_rate": 0.08,
"shipping_cost": 5.99
}
return cls._instance
# Factory: Payment methods
class PaymentFactory:
@staticmethod
def create_payment(payment_type, **kwargs):
payments = {
"credit_card": CreditCardPayment,
"paypal": PayPalPayment
}
return payments[payment_type](**kwargs)
# Strategy: Shipping methods
class ShippingStrategy(ABC):
@abstractmethod
def calculate_cost(self, weight):
pass
class StandardShipping(ShippingStrategy):
def calculate_cost(self, weight):
return 5.99
class ExpressShipping(ShippingStrategy):
def calculate_cost(self, weight):
return 15.99
# Observer: Order status updates
class OrderObserver(ABC):
@abstractmethod
def update(self, order):
pass
class EmailNotification(OrderObserver):
def update(self, order):
print(f"π§ Email: Order #{order.id} status = {order.status}")
class SMSNotification(OrderObserver):
def update(self, order):
print(f"π± SMS: Order #{order.id} status = {order.status}")
# Main order class
class Order:
_id_counter = 1
def __init__(self):
self.id = Order._id_counter
Order._id_counter += 1
self.items = []
self.status = "created"
self._observers = []
self.shipping_strategy = None
self.payment_method = None
def attach_observer(self, observer):
self._observers.append(observer)
def set_status(self, status):
self.status = status
self._notify_observers()
def _notify_observers(self):
for observer in self._observers:
observer.update(self)
def add_item(self, name, price):
self.items.append({"name": name, "price": price})
def set_shipping(self, strategy):
self.shipping_strategy = strategy
def set_payment(self, payment):
self.payment_method = payment
def process(self):
config = Config()
subtotal = sum(item["price"] for item in self.items)
tax = subtotal * config.settings["tax_rate"]
shipping = self.shipping_strategy.calculate_cost(0)
total = subtotal + tax + shipping
print(f"\n{'='*50}")
print(f"Order #{self.id}")
print(f"Subtotal: ${subtotal:.2f}")
print(f"Tax: ${tax:.2f}")
print(f"Shipping: ${shipping:.2f}")
print(f"Total: ${total:.2f}")
print(f"{'='*50}")
self.set_status("processing")
self.payment_method.pay(total)
self.set_status("shipped")
# Usage
order = Order()
# Attach observers
order.attach_observer(EmailNotification())
order.attach_observer(SMSNotification())
# Add items
order.add_item("Laptop", 999.99)
order.add_item("Mouse", 29.99)
# Set shipping strategy
order.set_shipping(ExpressShipping())
# Set payment method
order.set_payment(PaymentFactory.create_payment("credit_card", card_number="4532-1234"))
# Process order
order.process()
Pattern Selection Guide
| When You Need... | Use Pattern |
|---|---|
| Only one instance | Singleton |
| Create objects without specifying exact class | Factory |
| Notify multiple objects of changes | Observer |
| Switch algorithms at runtime | Strategy |
| Add functionality dynamically | Decorator |
| Make incompatible interfaces work | Adapter |
Common Mistakes to Avoid
1. Overusing Patterns
Don't force patterns where simple code works:
# Bad - unnecessary pattern
class SimpleCalculatorFactory:
@staticmethod
def create(operation):
# Too complex for simple addition!
pass
# Good - just use a function
def add(a, b):
return a + b
2. Implementing Before Understanding
Learn the pattern thoroughly before applying it. Understand when and why it's needed, not just how to implement it.
3. Ignoring Python's Built-in Features
Python has built-in decorators, context managers, and other features. Don't reinvent the wheel when Python provides elegant solutions!
Conclusion: Patterns Make You a Better Developer
Benefits:
- β Proven solutions to common problems
- β Common vocabulary with team
- β Faster development
- β More maintainable code
Remember:
- Don't force patterns where they don't fit
- Start simple, refactor to patterns when needed
- Understand the problem before applying pattern
- Patterns should make code simpler, not complex
Next steps:
- Practice implementing these patterns
- Identify patterns in code you read
- Learn more advanced patterns
- Apply patterns in your projects
Start recognizing and applying these patterns in your Python projects today!
Building Python applications with design patterns? I'd love to hear about your experience! Connect with me on Twitter or LinkedIn!
Support My Work
If this guide helped you master OOP design patterns in Python, implement design patterns in your projects, or write more maintainable code, I'd really appreciate your support! Creating comprehensive, practical content like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for Python developers.
β Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!