Lesson 5: Design Patterns

Learning Objectives

By the end of this lesson, you will be able to:

  1. Define design patterns and explain their importance in professional software development
  2. Categorize patterns into creational, structural, and behavioral types with their distinct purposes
  3. Recognize common patterns and understand appropriate application contexts
  4. Implement basic patterns to address recurring design challenges in your code
  5. Assess when patterns provide value versus creating unnecessary complexity
  6. Apply multiple patterns together in real-world scenarios to build maintainable systems

Introduction

Imagine you’re building a notification system for a web application. Users need to receive alerts via email, SMS, and push notifications. How do you design this system so adding a new notification channel doesn’t require rewriting existing code? How do you ensure the system sends notifications reliably, even if one channel fails? These are exactly the types of problems design patterns solve.

Design patterns are proven solutions to recurring problems that developers encounter. Unlike finished code you can copy and paste, patterns serve as adaptable templates—blueprints that you customize to fit your specific context [1]. They represent decades of collective wisdom from software engineers who’ve solved similar problems countless times.

Why do design patterns matter? First, they accelerate development by providing ready-made architectural solutions rather than forcing you to reinvent the wheel. Second, they improve code clarity for developers familiar with these standard patterns—when you see a Singleton or Observer pattern, you immediately understand the design intent. Third, they help prevent subtle bugs by incorporating lessons learned from production systems. Finally, they establish a shared vocabulary that enables precise technical communication within teams [2].

The Gang of Four (Gamma, Helm, Johnson, and Vlissides) cataloged 23 fundamental patterns in their influential 1994 book “Design Patterns: Elements of Reusable Object-Oriented Software” [1]. While originally focused on object-oriented design, these patterns apply broadly across programming paradigms. Modern software engineering has expanded on these foundations with additional patterns for distributed systems, concurrent programming, and reactive architectures.

In this lesson, we’ll explore the most essential patterns every intermediate developer should master. You’ll learn when to apply each pattern, see practical implementations, and understand how to combine patterns to solve complex real-world problems.


Pattern Categories

Design patterns are organized into four main categories based on the problems they solve. Understanding these categories helps you quickly identify which patterns apply to your situation.

Creational Patterns

Creational patterns address object instantiation by abstracting creation mechanisms. They ensure systems remain independent of how objects are composed and represented [1]. These patterns answer the question: “How do I create objects flexibly without hardcoding specific classes?”

Common creational patterns include:

  • Singleton: Ensures a class has only one instance with global access
  • Factory Method: Delegates object creation to subclasses
  • Abstract Factory: Creates families of related objects
  • Builder: Constructs complex objects step by step
  • Prototype: Creates objects by cloning existing instances

Structural Patterns

Structural patterns deal with object assembly into larger structures while maintaining flexibility and efficiency [1]. They define relationships between entities and answer: “How do I compose objects and classes into larger structures?”

Common structural patterns include:

  • Adapter: Bridges incompatible interfaces
  • Decorator: Adds functionality dynamically
  • Facade: Provides a simplified interface to complex subsystems
  • Proxy: Controls access to objects
  • Composite: Treats individual objects and compositions uniformly

Behavioral Patterns

Behavioral patterns govern object communication and interaction mechanisms. They distribute responsibilities to maintain flexibility and supportability [1]. These patterns address: “How do objects interact and distribute responsibilities?”

Common behavioral patterns include:

  • Observer: Establishes one-to-many dependencies with automatic notifications
  • Strategy: Encapsulates interchangeable algorithms
  • Command: Encapsulates requests as objects
  • State: Changes object behavior when internal state changes
  • Template Method: Defines algorithm skeletons with customizable steps

Concurrency Patterns

Concurrency patterns handle multi-threaded programming challenges like synchronization, deadlock prevention, and resource sharing. While beyond this lesson’s scope, they become crucial for building scalable systems.


Creational Patterns

Singleton Pattern

The Singleton pattern ensures a class has only one instance while providing global access to that instance [1]. This pattern is ideal for shared resources like database connections, configuration managers, or logging services where multiple instances would waste resources or cause conflicts.

When to Use Singleton:

  • Managing shared resources (database connection pools, thread pools)
  • Coordinating system-wide actions (configuration, logging)
  • Caching expensive computations or data

Singleton Implementation:

import threading

class DatabaseConnection:
    """Thread-safe Singleton implementation using double-checked locking."""

    _instance = None
    _lock = threading.Lock()

    def __new__(cls):
        # First check (without locking for performance)
        if cls._instance is None:
            # Acquire lock for thread safety
            with cls._lock:
                # Second check (with locking to prevent race conditions)
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._initialize()
        return cls._instance

    def _initialize(self):
        """Initialize the database connection (called only once)."""
        self.connection = "Database connection established"
        print("DatabaseConnection instance created")

    def query(self, sql):
        """Execute database query."""
        return f"Executing: {sql}"

# Usage
db1 = DatabaseConnection()
db2 = DatabaseConnection()

print(db1 is db2)  # True - both refer to the same instance
print(db1.query("SELECT * FROM users"))

Benefits:

  • Guarantees single instance across the application
  • Provides global access point
  • Lazy initialization (created only when first accessed)
  • Thread-safe implementation prevents race conditions

Drawbacks:

  • Creates hidden dependencies throughout code
  • Complicates unit testing (difficult to mock or replace)
  • Can become a global state container (violates single responsibility)
  • Thread synchronization adds complexity

Best Practice: Use Singleton sparingly. Consider dependency injection as an alternative for better testability. Reserve Singleton for truly global, stateful resources.

Factory Method Pattern

The Factory Method pattern enables subclasses to determine which objects to instantiate. It decouples client code from concrete implementations, centralizes creation logic, and supports straightforward addition of new types [1].

When to Use Factory Method:

  • Client code shouldn’t depend on concrete class implementations
  • Object creation logic is complex or likely to change
  • You need to centralize object creation for consistency
  • You want to support extension without modifying existing code (Open/Closed Principle)

Factory Method Implementation:

from abc import ABC, abstractmethod

# Abstract base class - defines the contract
class NotificationSender(ABC):
    """Abstract notification sender interface."""

    @abstractmethod
    def send(self, recipient, message):
        """Send notification to recipient."""
        pass

# Concrete implementations
class EmailSender(NotificationSender):
    """Sends notifications via email."""

    def send(self, recipient, message):
        print(f"📧 Sending email to {recipient}: {message}")
        # Actual email sending logic here
        return True

class SMSSender(NotificationSender):
    """Sends notifications via SMS."""

    def send(self, recipient, message):
        print(f"📱 Sending SMS to {recipient}: {message}")
        # Actual SMS sending logic here
        return True

class PushSender(NotificationSender):
    """Sends push notifications."""

    def send(self, recipient, message):
        print(f"🔔 Sending push notification to {recipient}: {message}")
        # Actual push notification logic here
        return True

# Factory class - encapsulates creation logic
class NotificationFactory:
    """Factory for creating notification senders."""

    @staticmethod
    def create_sender(notification_type):
        """
        Create appropriate notification sender based on type.

        Args:
            notification_type: String specifying notification channel

        Returns:
            NotificationSender instance or None if type invalid
        """
        senders = {
            'email': EmailSender,
            'sms': SMSSender,
            'push': PushSender
        }

        sender_class = senders.get(notification_type.lower())
        if sender_class:
            return sender_class()
        else:
            raise ValueError(f"Unknown notification type: {notification_type}")

# Client code - works with abstraction, not concrete classes
class NotificationService:
    """High-level service using the factory."""

    def notify_user(self, user_id, message, channel='email'):
        """
        Send notification through specified channel.

        Adding new channels requires no changes to this code!
        """
        try:
            sender = NotificationFactory.create_sender(channel)
            sender.send(user_id, message)
            print(f"✅ Notification sent successfully via {channel}")
        except ValueError as e:
            print(f"❌ Error: {e}")

# Usage
service = NotificationService()
service.notify_user("[email protected]", "Welcome to our platform!", "email")
service.notify_user("+1234567890", "Your order has shipped", "sms")
service.notify_user("device_token_123", "New message received", "push")

# Adding a new notification type requires only:
# 1. Create new class implementing NotificationSender
# 2. Register it in the factory's senders dictionary
# No changes to NotificationService or existing senders!

Benefits:

  • Decouples client code from concrete implementations
  • Centralizes creation logic for maintainability
  • Makes adding new types straightforward (just add new class)
  • Follows Open/Closed Principle (open for extension, closed for modification)

Drawbacks:

  • Increases number of classes (more files to maintain)
  • Can be overkill for simple creation scenarios
  • Adds indirection that may reduce code clarity for simple cases

Structural Patterns

Adapter Pattern

The Adapter pattern bridges incompatible interfaces, allowing classes with incompatible interfaces to work together [1]. It’s essential when integrating third-party libraries, migrating to new systems, or working with legacy code you can’t modify.

When to Use Adapter:

  • Integrating third-party libraries with incompatible interfaces
  • Making legacy code work with new systems
  • Reusing existing classes that don’t match required interfaces
  • Creating reusable libraries that work with various client expectations

Adapter Implementation:

# Legacy payment processor (old system we can't modify)
class OldPaymentProcessor:
    """Legacy payment system with incompatible interface."""

    def make_payment(self, amount_cents):
        """Processes payment in cents."""
        print(f"Processing ${amount_cents/100:.2f} via legacy system")
        return {"transaction_id": "LEG-12345", "status": "completed"}

# Modern payment interface (what our new system expects)
class ModernPaymentProcessor(ABC):
    """Modern payment processor interface."""

    @abstractmethod
    def process_payment(self, amount_dollars):
        """Process payment in dollars."""
        pass

# Adapter - translates between interfaces
class PaymentAdapter(ModernPaymentProcessor):
    """Adapts legacy payment processor to modern interface."""

    def __init__(self, legacy_processor):
        self.legacy_processor = legacy_processor

    def process_payment(self, amount_dollars):
        """
        Translates modern interface to legacy interface.
        Converts dollars to cents and calls legacy method.
        """
        amount_cents = int(amount_dollars * 100)
        result = self.legacy_processor.make_payment(amount_cents)

        # Translate legacy response format if needed
        return {
            'id': result['transaction_id'],
            'success': result['status'] == 'completed'
        }

# Client code - works with modern interface
class CheckoutService:
    """Modern checkout service expecting standard interface."""

    def __init__(self, payment_processor):
        self.payment_processor = payment_processor

    def complete_purchase(self, amount):
        """Process purchase using modern interface."""
        result = self.payment_processor.process_payment(amount)
        if result['success']:
            print(f"✅ Purchase completed! Transaction ID: {result['id']}")
        else:
            print("❌ Purchase failed")

# Usage - adapter makes legacy system work with modern code
legacy_system = OldPaymentProcessor()
adapted_processor = PaymentAdapter(legacy_system)
checkout = CheckoutService(adapted_processor)

checkout.complete_purchase(49.99)

Benefits:

  • Enables code reuse without modifying source
  • Maintains consistent interfaces across codebase
  • Facilitates gradual system migrations
  • Allows integration of incompatible third-party libraries

Drawbacks:

  • Adds extra layer of indirection
  • Can accumulate if you create too many adapters
  • May hide underlying complexity

Decorator Pattern

The Decorator pattern adds functionality dynamically without modifying object structure [1]. It enables flexible feature combinations and avoids complex inheritance hierarchies. This pattern is commonly used in I/O streams, middleware, and UI components.

When to Use Decorator:

  • Adding responsibilities to objects dynamically
  • Extending functionality without subclassing
  • Combining features flexibly (stacking decorators)
  • Following Single Responsibility Principle (each decorator does one thing)

Decorator Implementation:

from abc import ABC, abstractmethod

# Base component
class Coffee(ABC):
    """Abstract coffee base class."""

    @abstractmethod
    def cost(self):
        """Calculate total cost."""
        pass

    @abstractmethod
    def description(self):
        """Get description of coffee."""
        pass

# Concrete component
class SimpleCoffee(Coffee):
    """Basic coffee without additions."""

    def cost(self):
        return 2.00

    def description(self):
        return "Simple coffee"

# Decorator base class
class CoffeeDecorator(Coffee):
    """Base decorator for coffee additions."""

    def __init__(self, coffee):
        self._coffee = coffee

    def cost(self):
        return self._coffee.cost()

    def description(self):
        return self._coffee.description()

# Concrete decorators
class MilkDecorator(CoffeeDecorator):
    """Adds milk to coffee."""

    def cost(self):
        return self._coffee.cost() + 0.50

    def description(self):
        return self._coffee.description() + ", milk"

class SugarDecorator(CoffeeDecorator):
    """Adds sugar to coffee."""

    def cost(self):
        return self._coffee.cost() + 0.25

    def description(self):
        return self._coffee.description() + ", sugar"

class CaramelDecorator(CoffeeDecorator):
    """Adds caramel to coffee."""

    def cost(self):
        return self._coffee.cost() + 0.75

    def description(self):
        return self._coffee.description() + ", caramel"

# Usage - stack decorators for flexible combinations
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 = CaramelDecorator(SugarDecorator(MilkDecorator(SimpleCoffee())))
print(f"{fancy_coffee.description()}: ${fancy_coffee.cost():.2f}")

# Output:
# Simple coffee: $2.00
# Simple coffee, milk: $2.50
# Simple coffee, milk, sugar, caramel: $3.50

Benefits:

  • More flexible than static inheritance
  • Enables dynamic feature combinations
  • Follows Single Responsibility (each decorator adds one feature)
  • Supports unlimited decorator stacking

Drawbacks:

  • Can create many small classes
  • Order of decorator application may matter
  • Debugging stacked decorators can be challenging

Behavioral Patterns

Observer Pattern

The Observer pattern establishes one-to-many dependencies where objects automatically receive notifications when the subject changes state [1]. This pattern is central to event-driven systems, GUI frameworks, and reactive programming.

When to Use Observer:

  • One object’s state change should trigger updates in multiple dependents
  • You need loose coupling between subjects and observers
  • Building event-driven systems or reactive interfaces
  • Implementing publish-subscribe mechanisms

Observer Implementation:

from abc import ABC, abstractmethod
from typing import List

# Observer interface
class Observer(ABC):
    """Abstract observer that receives updates."""

    @abstractmethod
    def update(self, subject):
        """Receive update notification from subject."""
        pass

# Subject (Observable)
class StockPriceTracker:
    """Tracks stock price and notifies observers of changes."""

    def __init__(self, stock_symbol):
        self._observers: List[Observer] = []
        self._stock_symbol = stock_symbol
        self._price = 0.0

    def attach(self, observer):
        """Register observer for notifications."""
        if observer not in self._observers:
            self._observers.append(observer)
            print(f"📎 {observer.__class__.__name__} attached")

    def detach(self, observer):
        """Unregister observer."""
        if observer in self._observers:
            self._observers.remove(observer)
            print(f"📌 {observer.__class__.__name__} detached")

    def notify_observers(self):
        """Notify all registered observers of state change."""
        for observer in self._observers:
            observer.update(self)

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

    @price.setter
    def price(self, new_price):
        """Update price and notify observers if changed."""
        if new_price != self._price:
            print(f"\n📊 {self._stock_symbol} price changed: ${self._price:.2f} → ${new_price:.2f}")
            self._price = new_price
            self.notify_observers()

    @property
    def stock_symbol(self):
        return self._stock_symbol

# Concrete observers
class PriceAlertObserver(Observer):
    """Sends alert when price crosses threshold."""

    def __init__(self, threshold):
        self.threshold = threshold

    def update(self, subject):
        if subject.price > self.threshold:
            print(f"  🚨 ALERT: {subject.stock_symbol} exceeded ${self.threshold:.2f}!")

class PortfolioObserver(Observer):
    """Updates portfolio value when stock price changes."""

    def __init__(self, shares_owned):
        self.shares_owned = shares_owned

    def update(self, subject):
        portfolio_value = subject.price * self.shares_owned
        print(f"  💼 Portfolio value: ${portfolio_value:.2f} ({self.shares_owned} shares)")

class LoggingObserver(Observer):
    """Logs all price changes."""

    def update(self, subject):
        print(f"  📝 Log: {subject.stock_symbol} = ${subject.price:.2f}")

# Usage
stock_tracker = StockPriceTracker("AAPL")

# Attach observers
price_alert = PriceAlertObserver(threshold=150.0)
portfolio = PortfolioObserver(shares_owned=100)
logger = LoggingObserver()

stock_tracker.attach(price_alert)
stock_tracker.attach(portfolio)
stock_tracker.attach(logger)

# Price changes automatically notify all observers
stock_tracker.price = 145.00
stock_tracker.price = 152.00

# Can detach observers
stock_tracker.detach(price_alert)
stock_tracker.price = 155.00

Benefits:

  • Loose coupling between subjects and observers
  • Dynamic relationships (attach/detach at runtime)
  • Supports broadcast communication
  • Follows Open/Closed Principle

Drawbacks:

  • Observers notified in arbitrary order
  • Can create memory leaks if observers aren’t detached
  • Difficult to debug cascade of notifications

Strategy Pattern

The Strategy pattern encapsulates a family of algorithms, making them interchangeable [1]. It enables runtime algorithm selection, eliminates conditional statements, and is commonly used for payment methods, sorting algorithms, and validation rules.

When to Use Strategy:

  • Multiple algorithms solve the same problem differently
  • Need to switch algorithms at runtime
  • Want to eliminate complex conditional logic
  • Following Open/Closed Principle (add new strategies without modifying context)

Strategy Implementation:

from abc import ABC, abstractmethod

# Strategy interface
class PaymentStrategy(ABC):
    """Abstract payment strategy."""

    @abstractmethod
    def pay(self, amount):
        """Process payment using this strategy."""
        pass

# Concrete strategies
class CreditCardPayment(PaymentStrategy):
    """Credit card payment implementation."""

    def __init__(self, card_number, cvv):
        self.card_number = card_number[-4:]  # Store only last 4 digits
        self.cvv = cvv

    def pay(self, amount):
        print(f"💳 Processing ${amount:.2f} via credit card ending in {self.card_number}")
        # Actual payment processing logic here
        return {'success': True, 'transaction_id': 'CC-12345'}

class PayPalPayment(PaymentStrategy):
    """PayPal payment implementation."""

    def __init__(self, email):
        self.email = email

    def pay(self, amount):
        print(f"🅿️  Processing ${amount:.2f} via PayPal ({self.email})")
        # Actual PayPal API call here
        return {'success': True, 'transaction_id': 'PP-67890'}

class CryptocurrencyPayment(PaymentStrategy):
    """Cryptocurrency payment implementation."""

    def __init__(self, wallet_address):
        self.wallet_address = wallet_address

    def pay(self, amount):
        print(f"₿ Processing ${amount:.2f} via cryptocurrency ({self.wallet_address[:10]}...)")
        # Actual blockchain transaction here
        return {'success': True, 'transaction_id': 'BTC-ABCDEF'}

# Context class that uses strategies
class ShoppingCart:
    """Shopping cart that can use different payment strategies."""

    def __init__(self):
        self._items = []
        self._payment_strategy = None

    def add_item(self, item, price):
        """Add item to cart."""
        self._items.append({'item': item, 'price': price})
        print(f"➕ Added {item} (${price:.2f}) to cart")

    def calculate_total(self):
        """Calculate cart total."""
        return sum(item['price'] for item in self._items)

    def set_payment_strategy(self, strategy: PaymentStrategy):
        """Set payment method (strategy)."""
        self._payment_strategy = strategy
        print(f"✅ Payment method set: {strategy.__class__.__name__}")

    def checkout(self):
        """Process payment using selected strategy."""
        if not self._payment_strategy:
            print("❌ Error: No payment method selected")
            return False

        total = self.calculate_total()
        print(f"\n🛒 Cart total: ${total:.2f}")

        result = self._payment_strategy.pay(total)
        if result['success']:
            print(f"✅ Payment successful! Transaction ID: {result['transaction_id']}\n")
            self._items.clear()
            return True
        else:
            print("❌ Payment failed\n")
            return False

# Usage - same shopping cart, different payment strategies
cart = ShoppingCart()
cart.add_item("Laptop", 999.99)
cart.add_item("Mouse", 29.99)

# Pay with credit card
cart.set_payment_strategy(CreditCardPayment("1234-5678-9012-3456", "123"))
cart.checkout()

# Add more items and pay with PayPal
cart.add_item("Keyboard", 89.99)
cart.add_item("Monitor", 299.99)
cart.set_payment_strategy(PayPalPayment("[email protected]"))
cart.checkout()

# Add more items and pay with cryptocurrency
cart.add_item("Headphones", 149.99)
cart.set_payment_strategy(CryptocurrencyPayment("1A2B3C4D5E6F7G8H9I0J"))
cart.checkout()

Benefits:

  • Eliminates complex conditional logic
  • Makes algorithms interchangeable at runtime
  • Follows Open/Closed Principle (add new strategies without modifying context)
  • Each strategy is independently testable

Drawbacks:

  • Increases number of classes
  • Clients must understand different strategies to choose appropriately
  • May be overkill for simple conditional logic

Comprehensive Practical Example: Notification System

Let’s build a real-world notification system that integrates multiple design patterns. This example demonstrates how patterns work together synergistically to create maintainable, extensible systems.

Requirements:

  • Support multiple notification channels (email, SMS, push)
  • Add logging and retry capabilities to any channel
  • Allow runtime channel selection
  • Notify analytics and billing systems when notifications are sent
  • Coordinate everything through a centralized service

Implementation combining Factory, Decorator, Strategy, Observer, and Singleton:

from abc import ABC, abstractmethod
from typing import List
import time

# 1. STRATEGY PATTERN - Define notification channel interface
class NotificationChannel(ABC):
    """Abstract notification channel."""

    @abstractmethod
    def send(self, recipient, message):
        """Send notification through this channel."""
        pass

# Concrete channels
class EmailChannel(NotificationChannel):
    def send(self, recipient, message):
        print(f"📧 Email sent to {recipient}: {message}")
        return True

class SMSChannel(NotificationChannel):
    def send(self, recipient, message):
        print(f"📱 SMS sent to {recipient}: {message}")
        return True

class PushChannel(NotificationChannel):
    def send(self, recipient, message):
        print(f"🔔 Push notification sent to {recipient}: {message}")
        return True

# 2. FACTORY PATTERN - Create channels
class ChannelFactory:
    """Factory for creating notification channels."""

    @staticmethod
    def create_channel(channel_type):
        channels = {
            'email': EmailChannel,
            'sms': SMSChannel,
            'push': PushChannel
        }
        channel_class = channels.get(channel_type.lower())
        if not channel_class:
            raise ValueError(f"Unknown channel: {channel_type}")
        return channel_class()

# 3. DECORATOR PATTERN - Add logging and retry capabilities
class ChannelDecorator(NotificationChannel):
    """Base decorator for channels."""

    def __init__(self, channel):
        self._channel = channel

    def send(self, recipient, message):
        return self._channel.send(recipient, message)

class LoggingDecorator(ChannelDecorator):
    """Adds logging to any channel."""

    def send(self, recipient, message):
        print(f"  [LOG] Attempting to send via {self._channel.__class__.__name__}")
        result = self._channel.send(recipient, message)
        print(f"  [LOG] Send result: {'Success' if result else 'Failed'}")
        return result

class RetryDecorator(ChannelDecorator):
    """Adds retry logic to any channel."""

    def __init__(self, channel, max_retries=3):
        super().__init__(channel)
        self.max_retries = max_retries

    def send(self, recipient, message):
        for attempt in range(1, self.max_retries + 1):
            print(f"  [RETRY] Attempt {attempt}/{self.max_retries}")
            if self._channel.send(recipient, message):
                return True
            if attempt < self.max_retries:
                print(f"  [RETRY] Failed, retrying in {attempt} second(s)...")
                time.sleep(attempt)
        print(f"  [RETRY] All attempts failed")
        return False

# 4. OBSERVER PATTERN - Notify other systems
class NotificationObserver(ABC):
    """Observer for notification events."""

    @abstractmethod
    def on_notification_sent(self, recipient, channel, message):
        """Called when notification is sent."""
        pass

class AnalyticsObserver(NotificationObserver):
    """Tracks notification metrics."""

    def on_notification_sent(self, recipient, channel, message):
        print(f"  📊 [Analytics] Tracked: {channel} notification to {recipient}")

class BillingObserver(NotificationObserver):
    """Tracks notification costs."""

    def __init__(self):
        self.costs = {'email': 0.01, 'sms': 0.05, 'push': 0.02}

    def on_notification_sent(self, recipient, channel, message):
        cost = self.costs.get(channel.lower(), 0)
        print(f"  💰 [Billing] Charged ${cost:.2f} for {channel} notification")

# 5. SINGLETON PATTERN - Centralized notification service
class NotificationService:
    """Singleton notification service coordinating everything."""

    _instance = None
    _observers: List[NotificationObserver] = []

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._observers = []
            print("🎯 NotificationService initialized (Singleton)")
        return cls._instance

    def attach_observer(self, observer):
        """Attach observer for notification events."""
        self._observers.append(observer)

    def _notify_observers(self, recipient, channel_type, message):
        """Notify all observers."""
        for observer in self._observers:
            observer.on_notification_sent(recipient, channel_type, message)

    def send_notification(self, recipient, message, channel_type='email',
                         enable_logging=True, enable_retry=False):
        """
        Send notification with configurable features.
        Demonstrates all patterns working together.
        """
        print(f"\n{'='*60}")
        print(f"Sending notification via {channel_type}")
        print(f"{'='*60}")

        # Use Factory to create channel
        channel = ChannelFactory.create_channel(channel_type)

        # Use Decorator to add features
        if enable_logging:
            channel = LoggingDecorator(channel)
        if enable_retry:
            channel = RetryDecorator(channel, max_retries=2)

        # Send notification
        success = channel.send(recipient, message)

        # Use Observer to notify other systems
        if success:
            self._notify_observers(recipient, channel_type, message)

        print(f"{'='*60}\n")
        return success

# USAGE - All patterns working together
service = NotificationService()

# Attach observers (Observer Pattern)
service.attach_observer(AnalyticsObserver())
service.attach_observer(BillingObserver())

# Send notifications with different combinations of features
service.send_notification(
    "[email protected]",
    "Welcome to our platform!",
    channel_type='email',
    enable_logging=True
)

service.send_notification(
    "+1234567890",
    "Your verification code is 123456",
    channel_type='sms',
    enable_logging=True,
    enable_retry=True
)

service.send_notification(
    "device_token_xyz",
    "New message received",
    channel_type='push',
    enable_logging=True
)

# Verify Singleton - both references point to same instance
service2 = NotificationService()
print(f"Same instance? {service is service2}")  # True

This example demonstrates:

  • Factory Pattern: Creates appropriate notification channels
  • Decorator Pattern: Adds logging and retry capabilities flexibly
  • Strategy Pattern: Enables runtime channel selection
  • Observer Pattern: Notifies analytics and billing systems
  • Singleton Pattern: Coordinates everything through centralized service

The power of design patterns emerges when they work together. Each pattern solves a specific problem, and their combination creates a flexible, maintainable system.


Common Pitfalls and How to Avoid Them

Pitfall 1: Over-Engineering with Patterns

The Problem: Forcing patterns into problems that don’t need them creates unnecessary complexity. A common example is using the Factory pattern when if/else suffices, or implementing Singleton for stateless utilities.

Why It Happens: Developers excited about learning patterns try to apply them everywhere. This “pattern enthusiasm” leads to over-abstraction.

How to Avoid It:

  • Apply the “Rule of Three”: Wait until you’ve written similar code three times before introducing a pattern
  • Ask: “Does this pattern simplify or complicate the code?”
  • Remember YAGNI (You Aren’t Gonna Need It) - don’t build for hypothetical future needs
  • Start simple, refactor to patterns when complexity justifies it

Example:

# Over-engineered - Factory for two options
class AnimalFactory:
    @staticmethod
    def create_animal(animal_type):
        if animal_type == 'dog':
            return Dog()
        else:
            return Cat()

# Simpler - direct instantiation
def create_animal(animal_type):
    return Dog() if animal_type == 'dog' else Cat()

Pitfall 2: Singleton Abuse

The Problem: Using Singleton for everything creates hidden dependencies and global state, making code difficult to test and understand.

Why It Happens: Global access is convenient, and Singleton seems like an elegant way to ensure single instances.

How to Avoid It:

  • Reserve Singleton for truly global, stateful resources (logging, configuration, connection pools)
  • Use dependency injection for most scenarios
  • Ask: “Does this need to be globally accessible, or just single instance?”
  • Consider making the single instance explicit rather than hidden in Singleton

Better Alternative:

# Instead of Singleton
class ConfigManager:
    _instance = None
    def __new__(cls):
        if not cls._instance:
            cls._instance = super().__new__(cls)
        return cls._instance

# Use dependency injection
class ConfigManager:
    def __init__(self, config_file):
        self.config = load_config(config_file)

# Create one instance explicitly
config = ConfigManager('app.conf')
service = MyService(config)  # Inject it where needed

Pitfall 3: Ignoring Context - Applying Patterns Inappropriately

The Problem: Patterns have specific contexts where they excel. Applying them outside those contexts creates confusion.

Why It Happens: Focusing on pattern mechanics without understanding their purpose and appropriate use cases.

How to Avoid It:

  • Study the “Intent” and “Applicability” sections in pattern documentation
  • Understand the problem before selecting a solution
  • Consider alternatives - sometimes simple inheritance or composition suffices
  • Look at real-world examples in established codebases

Example: Using Observer pattern for simple callback situations where a function parameter would suffice.

Pitfall 4: Mixing Pattern Responsibilities

The Problem: Creating classes that combine multiple pattern responsibilities, violating Single Responsibility Principle.

Why It Happens: Trying to minimize class count or not fully understanding pattern boundaries.

How to Avoid It:

  • Keep patterns focused - each class should implement one pattern role
  • Use composition to combine patterns rather than inheritance
  • If a class is hard to name, it probably has too many responsibilities
  • Maintain clear separation between pattern roles

Example:

# Mixed responsibilities - BAD
class NotificationFactoryObserver:
    """Trying to be both Factory and Observer - confusing!"""
    def create_and_notify(self, type, observers):
        notification = self.create(type)
        for observer in observers:
            observer.update(notification)
        return notification

# Separated responsibilities - GOOD
factory = NotificationFactory()
subject = NotificationSubject()
notification = factory.create('email')
subject.notify_observers(notification)

Pitfall 5: Not Documenting Pattern Usage

The Problem: Future developers (including yourself) don’t realize patterns are being used, leading to incorrect modifications.

Why It Happens: Assuming patterns are self-documenting or forgetting documentation in production pressure.

How to Avoid It:

  • Add comments identifying which pattern you’re using and why
  • Document pattern participants and their roles
  • Include examples of correct usage in docstrings
  • Reference pattern documentation for complex implementations

Example:

class NotificationService:
    """
    Centralized notification service (SINGLETON PATTERN).

    This class ensures only one notification service exists throughout
    the application lifetime, coordinating all notification operations.

    Thread-safe implementation uses double-checked locking.

    Example:
        service = NotificationService()  # Creates or returns existing instance
        service.send_notification("[email protected]", "Hello")
    """
    _instance = None
    _lock = threading.Lock()
    # ... implementation

Comparison with Alternatives

When to Use Patterns vs. Simple Functions

Use patterns when:

  • You anticipate changes or extensions
  • Multiple implementations of the same concept exist
  • You need runtime flexibility
  • The problem matches a well-known pattern’s intent

Use simple functions when:

  • The problem is straightforward and unlikely to change
  • You’re building a prototype or proof-of-concept
  • The overhead of pattern infrastructure outweighs benefits
  • YAGNI applies - you don’t need future extensibility

Example: For a one-time data conversion script, a simple function beats a Strategy pattern. For a payment processing system supporting multiple payment methods, Strategy pattern provides essential flexibility.

Patterns vs. Language Features

Modern languages often provide built-in features that replace certain patterns:

Python Examples:

  • Decorators (language feature) vs. Decorator pattern
  • Generators and iterators vs. Iterator pattern
  • Context managers vs. Resource Acquisition patterns
  • First-class functions vs. Strategy pattern (for simple cases)

When to use language features: When they provide the same benefits with less code and better integration with the language ecosystem.

When to use explicit patterns: When you need:

  • More control over behavior
  • Additional capabilities beyond language features
  • Clearer communication of design intent
  • Cross-language consistency

Patterns vs. Frameworks

Many frameworks implement patterns internally:

Django/Flask: Factory pattern for view creation, Observer for signals React: Observer pattern for state management, Composite for component trees Spring: Factory pattern (ApplicationContext), Proxy (AOP), Singleton (beans)

When to use framework mechanisms: When building applications in that framework - leverage framework’s pattern implementations.

When to use custom patterns: When:

  • Working outside framework scope
  • Framework patterns don’t fit your use case
  • Building framework-agnostic libraries
  • Learning pattern fundamentals

Patterns vs. Simple Composition

Sometimes simple composition (combining objects) provides the flexibility you need without pattern overhead.

Use patterns when:

  • You need formal structure and documentation
  • Multiple developers need clear architectural guidance
  • The problem maps cleanly to a pattern’s intent
  • You’re building infrastructure or libraries

Use simple composition when:

  • Straightforward object combination suffices
  • The team is small and communicates frequently
  • The system is simple and unlikely to scale
  • You’re in exploratory phase

Example: Building a logging system with file and console output might use simple composition initially. As requirements grow (filtering, formatting, rotation), Observer pattern might become beneficial.


Key Takeaways

  1. Design patterns are proven solutions to recurring problems, providing vocabulary and architectural templates. They’re not code to copy-paste but blueprints to adapt to your context [1][2].

  2. Three main categories serve distinct purposes: Creational patterns address object creation, structural patterns handle object composition, and behavioral patterns manage object interaction and communication [1].

  3. Patterns combine effectively in real applications. The notification system example demonstrated Factory, Decorator, Strategy, Observer, and Singleton patterns working together synergistically.

  4. Apply patterns pragmatically, not dogmatically. Use the “Rule of Three” (wait for three instances before abstracting) and ask whether patterns simplify or complicate your specific situation [2].

  5. Context determines appropriateness more than implementation. Singleton works well for connection pools but creates testing nightmares for business logic. Understanding when to apply patterns is more valuable than knowing implementation details [1].

  6. Modern languages and frameworks often provide built-in alternatives to patterns. Use language features when they provide the same benefits with less code, and explicit patterns when you need more control or clearer design communication.

  7. Document pattern usage clearly. Future developers (including yourself) need to understand which patterns you’re using and why, enabling correct modifications rather than inadvertent violations of pattern structure.


Practice Quiz

Question 1: Identifying Pattern Categories

You’re building a logging system that needs to send logs to multiple destinations (file, console, remote server) simultaneously. When a new log entry is created, all destinations should receive it automatically. Which pattern category best addresses this requirement?

a) Creational pattern - managing log object creation b) Structural pattern - composing log destinations c) Behavioral pattern - managing communication between logger and destinations d) Concurrency pattern - handling multi-threaded logging

Answer: c) Behavioral pattern

This scenario describes the Observer pattern (behavioral). The logger is the subject, and destinations (file, console, remote) are observers that need automatic notification when events occur. Behavioral patterns govern object interaction and communication—exactly what’s needed here.

While you might use structural patterns to compose destinations or concurrency patterns for thread safety, the core requirement (automatic notification of multiple dependents) is a behavioral concern.

Question 2: Singleton Appropriateness

Which of the following scenarios is an appropriate use case for the Singleton pattern?

a) A utility class with only static methods for string manipulation b) A database connection pool managing connections to a database c) A User class representing user accounts in the system d) A CalculationService class performing mathematical operations

Answer: b) A database connection pool

A database connection pool is an excellent Singleton candidate because:

  • It manages a shared resource (database connections)
  • Multiple instances would waste resources and cause conflicts
  • It needs global access throughout the application
  • It maintains state (available connections, connection limits)

Option a (utility class) doesn’t need Singleton—static methods suffice without instantiation. Option c (User class) should have multiple instances (one per user). Option d (CalculationService) should likely use dependency injection rather than Singleton, as it doesn’t manage shared resources and Singleton would complicate testing.

Question 3: Pattern Combination Understanding

In the comprehensive notification system example, why were both Factory pattern and Decorator pattern used together rather than choosing just one?

a) They’re always used together as a standard combination b) Factory creates notification channels; Decorator adds features (logging, retry) to those channels—they solve different problems c) Using more patterns makes the code more professional d) One could replace the other, but both were shown for educational purposes

Answer: b) Factory creates notification channels; Decorator adds features to those channels—they solve different problems

Factory and Decorator address different concerns:

  • Factory pattern handles object creation (which notification channel to instantiate)
  • Decorator pattern handles feature enhancement (adding logging or retry to any channel)

These are complementary, not redundant. You use Factory to get the right channel, then Decorator to add capabilities. Each pattern has a distinct responsibility, following Single Responsibility Principle.

This demonstrates that patterns aren’t mutually exclusive—real systems often combine multiple patterns, each solving a specific problem in the overall architecture.

Question 4: Avoiding Over-Application

Your team is building a simple utility to format dates in two formats: “MM/DD/YYYY” and “DD/MM/YYYY”. A junior developer proposes implementing the Strategy pattern with abstract DateFormatter interface, two concrete strategies, and a Context class. What’s the best feedback?

a) Great idea! Strategy pattern is perfect for multiple algorithms b) Add Factory pattern too for creating formatter instances c) This is over-engineering; a simple function parameter or conditional is sufficient d) Use Singleton to ensure only one formatter exists

Answer: c) This is over-engineering; a simple function parameter or conditional is sufficient

While Strategy pattern technically applies (multiple algorithms for the same problem), the overhead isn’t justified here:

  • Only two simple formats (not complex algorithms)
  • Unlikely to add many more formats
  • No runtime algorithm selection based on complex conditions
  • A function with a boolean parameter or simple conditional would be clearer and more maintainable
# Sufficient solution
def format_date(date, use_us_format=True):
    return date.strftime("%m/%d/%Y" if use_us_format else "%d/%m/%Y")

Apply the “Rule of Three”: wait until you’ve written similar code three times before introducing pattern abstractions. Patterns should simplify, not complicate.

Question 5: Pattern Selection

You’re integrating a third-party payment library that uses method processTransaction(amountCents, cardNumber), but your application uses makePayment(amountDollars, paymentDetails). You need to make the third-party library work with your existing code without modifying either. Which pattern best solves this?

a) Factory Method - create different payment types b) Adapter - translate between incompatible interfaces c) Decorator - add payment functionality d) Observer - notify when payments complete

Answer: b) Adapter - translate between incompatible interfaces

This scenario perfectly describes the Adapter pattern’s purpose: bridging incompatible interfaces. You have:

  • Existing code expecting makePayment(amountDollars, paymentDetails)
  • Third-party library providing processTransaction(amountCents, cardNumber)
  • Cannot modify either system

An adapter class would implement your expected interface while internally calling the third-party library’s interface, translating parameters (dollars to cents) as needed.

Factory creates objects but doesn’t handle interface translation. Decorator adds functionality but doesn’t change interfaces. Observer handles notifications, not interface compatibility. Adapter is specifically designed for this exact situation.


References

[1] Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley Professional. URL: https://www.pearson.com/en-us/subject-catalog/p/design-patterns-elements-of-reusable-object-oriented-software/P200000003499

[2] Refactoring.Guru. (2024). Design Patterns. URL: https://refactoring.guru/design-patterns

[3] SourceMaking. (2024). Design Patterns. URL: https://sourcemaking.com/design_patterns

[4] Microsoft Learn. (2024). Cloud Design Patterns. URL: https://learn.microsoft.com/en-us/azure/architecture/patterns/

[5] Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall. URL: https://www.pearson.com/en-us/subject-catalog/p/clean-code-a-handbook-of-agile-software-craftsmanship/P200000009044

[6] Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd Edition). Addison-Wesley Professional. URL: https://martinfowler.com/books/refactoring.html