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

πŸ“… Published: February 10, 2025 ✏️ Updated: March 5, 2025 By Ojaswi Athghara
#design #patterns #python #oop #software

OOP Design Patterns Every Developer Should Know in Python

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:

  1. Creational - Object creation
  2. Structural - Object composition
  3. 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 instanceSingleton
Create objects without specifying exact classFactory
Notify multiple objects of changesObserver
Switch algorithms at runtimeStrategy
Add functionality dynamicallyDecorator
Make incompatible interfaces workAdapter

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:

  1. Practice implementing these patterns
  2. Identify patterns in code you read
  3. Learn more advanced patterns
  4. 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!


Cover image by UX Store on Unsplash

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Β© ojaswiat.com 2025-2027