py4u guide

Real-World Examples of Python Design Patterns in Action

Design patterns are time-tested solutions to common software design problems. They provide reusable templates for structuring code, improving maintainability, scalability, and readability. Python, with its emphasis on simplicity and flexibility, is an excellent language for implementing these patterns. While theoretical knowledge of design patterns is valuable, understanding how they apply to real-world scenarios is key to mastering them. In this blog, we’ll explore **10 essential design patterns** across three categories—Creational, Structural, and Behavioral—with practical Python examples. Each section includes a real-world scenario, a code implementation, and an explanation of why the pattern is effective. By the end, you’ll recognize when and how to leverage these patterns in your own projects.

Table of Contents

  1. Creational Patterns

  2. Structural Patterns

  3. Behavioral Patterns

  4. Conclusion

  5. References

Creational Patterns

Creational patterns focus on object creation mechanisms, ensuring objects are created in a way that aligns with the requirements of the system.

1. Singleton: Ensuring a Single Instance

What it is: Ensures a class has only one instance and provides a global point of access to it.
Real-World Scenario: A configuration manager that loads settings from a file. You don’t want multiple instances reloading the file (wasting resources) or holding conflicting configurations.

Example: App Configuration Manager

class ConfigManager:
    _instance = None  # Class-level variable to hold the single instance

    def __new__(cls):
        if cls._instance is None:
            # Create the instance only if it doesn't exist
            cls._instance = super().__new__(cls)
            # Load configuration once during initialization
            cls._instance.load_config()
        return cls._instance

    def load_config(self):
        """Load settings from a config file (e.g., JSON, YAML)."""
        self.settings = {
            "api_url": "https://api.example.com",
            "timeout": 30,
            "debug_mode": False
        }

    def get_setting(self, key):
        return self.settings.get(key)

# Usage
config1 = ConfigManager()
config2 = ConfigManager()

print(config1 is config2)  # Output: True (both are the same instance)
print(config1.get_setting("api_url"))  # Output: https://api.example.com

Why It Works:

  • The __new__ method controls instantiation, ensuring only one instance is created.
  • Global access avoids passing config objects between components, reducing complexity.
  • Ideal for resources like database connections, loggers, or configuration managers where multiple instances cause issues.

2. Factory: Decoupling Object Creation

What it is: Defines an interface for creating objects but lets subclasses decide which class to instantiate. Decouples the client from concrete product classes.
Real-World Scenario: A payment processing system that supports multiple methods (Credit Card, PayPal, Bitcoin). The client shouldn’t need to know the details of each payment method—just request the type.

Example: Payment Processor Factory

from abc import ABC, abstractmethod

# Abstract Product: Defines the interface for payment methods
class PaymentMethod(ABC):
    @abstractmethod
    def process_payment(self, amount):
        pass

# Concrete Products: Implement specific payment methods
class CreditCardPayment(PaymentMethod):
    def process_payment(self, amount):
        return f"Processing credit card payment of ${amount:.2f}"

class PayPalPayment(PaymentMethod):
    def process_payment(self, amount):
        return f"Processing PayPal payment of ${amount:.2f}"

class BitcoinPayment(PaymentMethod):
    def process_payment(self, amount):
        return f"Processing Bitcoin payment of ${amount:.2f}"

# Factory: Creates payment methods based on input
class PaymentFactory:
    @staticmethod
    def create_payment(method_type):
        if method_type == "credit_card":
            return CreditCardPayment()
        elif method_type == "paypal":
            return PayPalPayment()
        elif method_type == "bitcoin":
            return BitcoinPayment()
        else:
            raise ValueError(f"Unsupported payment method: {method_type}")

# Client Code: Uses the factory without knowing concrete classes
payment_method = PaymentFactory.create_payment("paypal")
print(payment_method.process_payment(99.99))  # Output: Processing PayPal payment of $99.99

Why It Works:

  • The client depends on the abstract PaymentMethod interface, not concrete classes (e.g., PayPalPayment).
  • Adding a new payment method (e.g., Stripe) only requires adding a new PaymentMethod subclass and updating the factory—no changes to client code.
  • Reduces tight coupling and simplifies maintenance.

3. Builder: Constructing Complex Objects Step-by-Step

What it is: Separates the construction of a complex object from its representation, allowing the same construction process to create different representations.
Real-World Scenario: Building a custom pizza with options for crust, sauce, cheese, and toppings. The process (select crust → add sauce → add cheese → add toppings) is the same, but the end result varies (e.g., Veggie Pizza vs. Meat Lovers).

Example: Pizza Builder

class Pizza:
    def __init__(self):
        self.crust = None
        self.sauce = None
        self.cheese = None
        self.toppings = []

    def __str__(self):
        return (f"Pizza [Crust: {self.crust}, Sauce: {self.sauce}, Cheese: {self.cheese}, "
                f"Toppings: {', '.join(self.toppings)}]")

# Builder: Defines steps to build a Pizza
class PizzaBuilder:
    def __init__(self):
        self.pizza = Pizza()

    def set_crust(self, crust):
        self.pizza.crust = crust
        return self  # Enable method chaining

    def set_sauce(self, sauce):
        self.pizza.sauce = sauce
        return self

    def set_cheese(self, cheese):
        self.pizza.cheese = cheese
        return self

    def add_topping(self, topping):
        self.pizza.toppings.append(topping)
        return self

    def build(self):
        return self.pizza

# Director: Optional; orchestrates the building process for specific pizza types
class PizzaDirector:
    @staticmethod
    def build_veggie_pizza(builder):
        return (builder.set_crust("thin")
                       .set_sauce("marinara")
                       .set_cheese("mozzarella")
                       .add_topping("mushrooms")
                       .add_topping("bell peppers")
                       .build())

# Usage
builder = PizzaBuilder()
veggie_pizza = PizzaDirector.build_veggie_pizza(builder)
print(veggie_pizza)  
# Output: Pizza [Crust: thin, Sauce: marinara, Cheese: mozzarella, Toppings: mushrooms, bell peppers]

# Custom pizza (without director)
custom_pizza = (builder.set_crust("thick")
                       .set_sauce("bbq")
                       .set_cheese("cheddar")
                       .add_topping("pepperoni")
                       .build())
print(custom_pizza)  
# Output: Pizza [Crust: thick, Sauce: bbq, Cheese: cheddar, Toppings: pepperoni]

Why It Works:

  • Step-by-step construction lets clients customize objects without knowing internal details.
  • The Director encapsulates predefined recipes (e.g., Veggie Pizza), ensuring consistency.
  • Avoids “telescoping constructors” (constructors with dozens of parameters) for complex objects.

Structural Patterns

Structural patterns focus on how classes and objects are composed to form larger structures.

4. Adapter: Bridging Incompatible Interfaces

What it is: Converts the interface of a class into another interface that clients expect. Lets classes work together that couldn’t otherwise due to incompatible interfaces.
Real-World Scenario: Integrating a legacy payment gateway (OldPaymentSystem) with a new e-commerce platform. The legacy system uses make_payment(amount, card_number) while the new platform expects process_payment(amount, security_code).

Example: Legacy Payment System Adapter

# Legacy Component: Incompatible with the new system
class OldPaymentSystem:
    def make_payment(self, amount, card_number):
        """Legacy method: Requires card number (no security code)."""
        return f"Old System: Charged ${amount} to card {card_number}"

# Target Interface: What the new system expects
class NewPaymentGateway(ABC):
    @abstractmethod
    def process_payment(self, amount, security_code):
        pass

# Adapter: Wraps the legacy system to match the new interface
class PaymentAdapter(NewPaymentGateway):
    def __init__(self, old_system: OldPaymentSystem, card_number: str):
        self.old_system = old_system
        self.card_number = card_number  # Stored during setup (e.g., from user profile)

    def process_payment(self, amount, security_code):
        """Adapts the new interface to the legacy method."""
        # The legacy system doesn't use security codes, but we can log it for compliance
        print(f"Verifying security code: {security_code}")
        return self.old_system.make_payment(amount, self.card_number)

# Client Code: Uses the new interface, unaware of the legacy system
def new_ecommerce_checkout(gateway: NewPaymentGateway, amount: float, security_code: str):
    result = gateway.process_payment(amount, security_code)
    print(result)

# Usage
legacy_system = OldPaymentSystem()
# Wrap the legacy system with an adapter (card number stored during user onboarding)
adapter = PaymentAdapter(legacy_system, card_number="4111-1111-1111-1111")

# New system uses the adapter as if it's a NewPaymentGateway
new_ecommerce_checkout(adapter, amount=99.99, security_code="123")  
# Output:
# Verifying security code: 123
# Old System: Charged $99.99 to card 4111-1111-1111-1111

Why It Works:

  • Enables reuse of legacy code without modifying it (critical if you can’t update the legacy system).
  • Clients interact with a familiar interface, reducing learning curve and errors.
  • Acts as a translator between two incompatible systems during migration.

5. Decorator: Adding Behavior Dynamically

What it is: Attaches additional responsibilities to an object dynamically. Provides a flexible alternative to subclassing for extending functionality.
Real-World Scenario: Adding cross-cutting concerns (logging, caching, authentication) to API endpoints without cluttering the core logic. For example, logging the time taken to process a request.

Example: Timing Decorator for API Endpoints

import time
from functools import wraps

# Decorator: Adds timing logic to functions
def timing_decorator(func):
    @wraps(func)  # Preserves original function metadata (name, docstring)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)  # Call the original function
        end_time = time.time()
        duration = (end_time - start_time) * 1000  # Convert to milliseconds
        print(f"{func.__name__} took {duration:.2f}ms to execute")
        return result
    return wrapper

# API Endpoint: Core logic (without timing)
@timing_decorator  # Apply the decorator
def fetch_user_data(user_id):
    """Simulates fetching user data from a database."""
    time.sleep(0.2)  # Simulate network/database delay
    return {"user_id": user_id, "name": "John Doe"}

# Usage
user = fetch_user_data(123)
print(user)  
# Output:
# fetch_user_data took 200.12ms to execute
# {'user_id': 123, 'name': 'John Doe'}

Why It Works:

  • Decorators add behavior (timing, logging, caching) without modifying the original function.
  • Composable: Stack multiple decorators (e.g., @timing_decorator + @cache_decorator).
  • Python’s functools.wraps ensures debugging tools and documentation work correctly.

6. Facade: Simplifying Complex Subsystems

What it is: Provides a unified interface to a set of interfaces in a subsystem. Defines a higher-level interface that makes the subsystem easier to use.
Real-World Scenario: A travel booking system with multiple subsystems: flight booking, hotel reservation, and car rental. Clients shouldn’t need to interact with each subsystem directly—just book a complete trip.

Example: Travel Booking Facade

# Complex Subsystems
class FlightBooking:
    def search_flights(self, origin, destination, date):
        return f"Flights from {origin} to {destination} on {date}: [FL123, FL456]"

    def book_flight(self, flight_id, passenger_name):
        return f"Booked flight {flight_id} for {passenger_name}"

class HotelReservation:
    def search_hotels(self, location, check_in, check_out):
        return f"Hotels in {location}: [Hotel A, Hotel B]"

    def book_hotel(self, hotel_name, check_in, check_out):
        return f"Booked {hotel_name} from {check_in} to {check_out}"

class CarRental:
    def search_cars(self, location, start_date, end_date):
        return f"Cars available in {location}: [SUV, Sedan]"

    def rent_car(self, car_type, start_date, end_date):
        return f"Rented {car_type} from {start_date} to {end_date}"

# Facade: Simplifies interaction with subsystems
class TravelFacade:
    def __init__(self):
        self.flights = FlightBooking()
        self.hotels = HotelReservation()
        self.cars = CarRental()

    def book_trip(self, origin, destination, date, check_in, check_out):
        """Simplified interface for booking a complete trip."""
        print("=== Booking Trip ===")
        flights = self.flights.search_flights(origin, destination, date)
        print(flights)
        flight_booking = self.flights.book_flight("FL123", "Alice Smith")
        print(flight_booking)

        hotels = self.hotels.search_hotels(destination, check_in, check_out)
        print(hotels)
        hotel_booking = self.hotels.book_hotel("Hotel A", check_in, check_out)
        print(hotel_booking)

        car_rental = self.cars.rent_car("SUV", check_in, check_out)
        print(car_rental)
        return "Trip booked successfully!"

# Client Code: Uses the facade instead of subsystems directly
travel_agent = TravelFacade()
result = travel_agent.book_trip(
    origin="NYC",
    destination="LA",
    date="2024-01-15",
    check_in="2024-01-15",
    check_out="2024-01-20"
)
print(result)  
# Output:
# === Booking Trip ===
# Flights from NYC to LA on 2024-01-15: [FL123, FL456]
# Booked flight FL123 for Alice Smith
# Hotels in LA: [Hotel A, Hotel B]
# Booked Hotel A from 2024-01-15 to 2024-01-20
# Rented SUV from 2024-01-15 to 2024-01-20
# Trip booked successfully!

Why It Works:

  • Reduces complexity by hiding subsystem details behind a simple interface (book_trip).
  • Clients avoid tight coupling with subsystems, making the code easier to maintain.
  • Simplifies testing: Test the facade instead of multiple subsystems.

Behavioral Patterns

Behavioral patterns focus on how objects interact and distribute responsibility.

7. Observer: Reacting to State Changes

What it is: Defines a one-to-many dependency between objects. When one object (subject) changes state, all its dependents (observers) are notified and updated automatically.
Real-World Scenario: A stock market where investors (observers) want to be notified when a stock’s price changes. The stock (subject) broadcasts updates to all registered investors.

Example: Stock Price Observer

from abc import ABC, abstractmethod

# Observer Interface: Defines the update method
class Observer(ABC):
    @abstractmethod
    def update(self, stock_name, price):
        pass

# Subject Interface: Defines methods to manage observers
class Subject(ABC):
    @abstractmethod
    def attach(self, observer):
        pass

    @abstractmethod
    def detach(self, observer):
        pass

    @abstractmethod
    def notify(self):
        pass

# Concrete Subject: Stock that notifies observers of price changes
class Stock(Subject):
    def __init__(self, name, price):
        self.name = name
        self._price = price
        self._observers = []  # List of registered observers

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, new_price):
        self._price = new_price
        self.notify()  # Notify observers when price changes

    def attach(self, observer):
        self._observers.append(observer)

    def detach(self, observer):
        self._observers.remove(observer)

    def notify(self):
        """Notify all observers of the new price."""
        for observer in self._observers:
            observer.update(self.name, self.price)

# Concrete Observers: Investors who react to price changes
class Investor(Observer):
    def __init__(self, name):
        self.name = name

    def update(self, stock_name, price):
        print(f"Investor {self.name} notified: {stock_name} price is now ${price}")

# Usage
# Create a stock
apple_stock = Stock("AAPL", 150.0)

# Create investors (observers)
investor1 = Investor("Alice")
investor2 = Investor("Bob")

# Attach investors to the stock
apple_stock.attach(investor1)
apple_stock.attach(investor2)

# Change the stock price (triggers notification)
apple_stock.price = 155.5  
# Output:
# Investor Alice notified: AAPL price is now $155.5
# Investor Bob notified: AAPL price is now $155.5

# Detach Bob
apple_stock.detach(investor2)
apple_stock.price = 160.0  
# Output:
# Investor Alice notified: AAPL price is now $160.0

Why It Works:

  • Decouples subjects and observers: The stock doesn’t need to know the type of investors.
  • Supports dynamic relationships: Observers can attach/detach at runtime (e.g., investors buying/selling stocks).
  • Used in event-driven systems (e.g., GUI frameworks, real-time data feeds).

8. Strategy: Swapping Algorithms at Runtime

What it is: Defines a family of interchangeable algorithms, encapsulates each, and makes them interchangeable. Lets the algorithm vary independently from clients that use it.
Real-World Scenario: A data processing tool that supports multiple sorting algorithms (Bubble Sort, Quick Sort, Merge Sort). The user can choose the algorithm based on data size (e.g., Quick Sort for large datasets, Bubble Sort for small ones).

Example: Sorting Strategy

from abc import ABC, abstractmethod

# Strategy Interface: Defines the sorting method
class SortStrategy(ABC):
    @abstractmethod
    def sort(self, data):
        pass

# Concrete Strategies: Different sorting algorithms
class BubbleSort(SortStrategy):
    def sort(self, data):
        """Simple but inefficient for large data."""
        data_copy = data.copy()
        n = len(data_copy)
        for i in range(n):
            for j in range(0, n-i-1):
                if data_copy[j] > data_copy[j+1]:
                    data_copy[j], data_copy[j+1] = data_copy[j+1], data_copy[j]
        return data_copy

class QuickSort(SortStrategy):
    def sort(self, data):
        """Efficient for large data."""
        if len(data) <= 1:
            return data.copy()
        pivot = data[len(data)//2]
        left = [x for x in data if x < pivot]
        middle = [x for x in data if x == pivot]
        right = [x for x in data if x > pivot]
        return self.sort(left) + middle + self.sort(right)

# Context: Uses a strategy to sort data
class DataProcessor:
    def __init__(self, strategy: SortStrategy):
        self._strategy = strategy

    def set_strategy(self, strategy: SortStrategy):
        """Swap strategy at runtime."""
        self._strategy = strategy

    def process_data(self, data):
        """Uses the current strategy to sort data."""
        return self._strategy.sort(data)

# Usage
data = [34, 12, 45, 6, 89, 23]

# Use Bubble Sort for small data
processor = DataProcessor(BubbleSort())
sorted_data_bubble = processor.process_data(data)
print("Bubble Sort Result:", sorted_data_bubble)  # Output: [6, 12, 23, 34, 45, 89]

# Switch to Quick Sort for large data
processor.set_strategy(QuickSort())
sorted_data_quick = processor.process_data(data)
print("Quick Sort Result:", sorted_data_quick)    # Output: [6, 12, 23, 34, 45, 89]

Why It Works:

  • Algorithms are encapsulated, making them easy to replace (e.g., add a MergeSort strategy later).
  • Clients (e.g., DataProcessor) are decoupled from concrete algorithms, reducing code changes.
  • Ideal for scenarios where multiple algorithms exist for the same task (e.g., compression, validation).

9. Command: Encapsulating Actions as Objects

What it is: Encapsulates a request as an object, thereby letting you parameterize clients with queues, requests, or operations. Supports undoable operations.
Real-World Scenario: A smart home remote control that can execute commands (turn on light, adjust thermostat) and undo them. Each button press is a command object.

Example: Smart Home Remote Control

from abc import ABC, abstractmethod

# Command Interface: Defines execute and undo methods
class Command(ABC):
    @abstractmethod
    def execute(self):
        pass

    @abstractmethod
    def undo(self):
        pass

# Concrete Commands: Encapsulate actions on receivers
class LightOnCommand(Command):
    def __init__(self, light):
        self.light = light  # Receiver: The object being acted upon

    def execute(self):
        self.light.turn_on()

    def undo(self):
        self.light.turn_off()

class ThermostatUpCommand(Command):
    def __init__(self, thermostat):
        self.thermostat = thermostat
        self.prev_temp = None  # Track previous state for undo

    def execute(self):
        self.prev_temp = self.thermostat.temperature
        self.thermostat.increase_temp()

    def undo(self):
        self.thermostat.set_temp(self.prev_temp)

# Receivers: Objects that perform the actual work
class Light:
    def turn_on(self):
        print("Light is ON")

    def turn_off(self):
        print("Light is OFF")

class Thermostat:
    def __init__(self):
        self.temperature = 20  # Default: 20°C

    def increase_temp(self):
        self.temperature += 1
        print(f"Thermostat set to {self.temperature}°C")

    def set_temp(self, temp):
        self.temperature = temp
        print(f"Thermostat set to {self.temperature}°C")

# Invoker: Sends commands to receivers (e.g., remote control)
class RemoteControl:
    def __init__(self):
        self._commands = {}  # Maps buttons to commands
        self._last_command = None  # For undo

    def set_command(self, button: str, command: Command):
        self._commands[button] = command

    def press_button(self, button: str):
        if button in self._commands:
            self._last_command = self._commands[button]
            self._last_command.execute()

    def press_undo(self):
        if self._last_command:
            self._last_command.undo()
            self._last_command = None  # Reset after undo

# Usage
# Create receivers
living_room_light = Light()
thermostat = Thermostat()

# Create commands
light_on_cmd = LightOnCommand(living_room_light)
thermostat_up_cmd = ThermostatUpCommand(thermostat)

# Configure remote (invoker)
remote = RemoteControl()
remote.set_command("button1", light_on_cmd)
remote.set_command("button2", thermostat_up_cmd)

# Press buttons
remote.press_button("button1")  # Output: Light is ON
remote.press_button("button2")  # Output: Thermostat set to 21°C

# Undo the last action (thermostat up)
remote.press_undo()  # Output: Thermostat set to 20°C

Why It Works:

  • Commands decouple the invoker (remote) from the receiver (light/thermostat).
  • Supports undo/redo by storing command history and calling undo().
  • Enables queuing of commands (e.g., “macro” buttons that execute multiple commands).

Conclusion

Design patterns are not just theoretical—they are battle-tested solutions to common problems in software development. In Python, their flexibility and readability make them even more powerful. By understanding patterns like Singleton (for single instances), Decorator (for dynamic behavior), or Observer (for event handling), you can write code that is reusable, maintainable, and scalable.

The key is to recognize when to apply each pattern:

  • Use Creational patterns when you need control over object creation.
  • Use Structural patterns to compose objects into larger structures.
  • Use Behavioral patterns to manage interactions between objects.

By incorporating these patterns into your workflow, you’ll build systems that are easier to debug, extend, and collaborate on.

References