Day 26: Design Patterns in Python

🐍 Day 26: Design Patterns in Python


1. Learning Objectives

By the end of Day 26, you will be able to:

  • Understand what design patterns are and why they matter
  • Implement common patterns: Singleton, Factory, Observer, Strategy
  • Apply patterns in a Pythonic way — using modules, decorators, and first‑class functions
  • Recognize when a pattern solves a real problem vs. when it’s over‑engineering
  • Combine patterns into a clean, maintainable architecture

2. Concept Explanation

2.1 What Are Design Patterns?

Design patterns are reusable solutions to common software problems. They’re not code you copy‑paste, but templates that you adapt to your specific need.

Analogy:
A design pattern is like a recipe for a cake. It tells you the steps and ingredients, but you still need to bake it.

Patterns usually fall into three categories:

CategoryPurposeExamples
CreationalHow objects are createdSingleton, Factory
StructuralHow objects are composedAdapter, Decorator (already covered!)
BehavioralHow objects communicateObserver, Strategy

⚠️ A word of caution: Not every problem needs a formal design pattern. Many patterns emerge naturally from good code. Use them where they simplify, not where they add unnecessary complexity.

Python’s dynamic nature means some classic patterns from languages like Java are trivial or unnecessary in Python. We’ll focus on the ones that deliver the most value.


2.2 Singleton — One and Only One Instance

The Singleton pattern ensures a class has exactly one instance and provides a global point of access to it.

When to use:

  • A shared configuration object
  • A logger
  • A database connection pool

NaΓ―ve (Java‑style) implementation:

class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

# Test
a = Singleton()
b = Singleton()
print(a is b)  # True

Pythonic way #1: Module as Singleton

In Python, a module is already a singleton — it’s imported once and shared everywhere. Simply define the instance at module level:

# config.py
class Config:
    def __init__(self):
        self.debug = False

config = Config()  # The one and only instance

# other_module.py
from config import config
print(config.debug)

Pythonic way #2: Decorator

def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Logger:
    def log(self, msg):
        print(f"LOG: {msg}")

logger1 = Logger()
logger2 = Logger()
print(logger1 is logger2)  # True

2.3 Factory — Creating Objects Without Specifying the Exact Class

The Factory pattern centralises object creation, making it easy to change what objects are built without modifying client code.

Simple Factory:

class Dog:
    def speak(self): return "Woof!"

class Cat:
    def speak(self): return "Meow!"

class AnimalFactory:
    @staticmethod
    def create_animal(animal_type):
        if animal_type == "dog":
            return Dog()
        elif animal_type == "cat":
            return Cat()
        raise ValueError("Unknown animal")

# Client code
animal = AnimalFactory.create_animal("dog")
print(animal.speak())  # Woof!

Pythonic way: Using a function or dictionary

Python’s first‑class functions make factories trivial:

def create_animal(animal_type):
    animals = {
        "dog": Dog,
        "cat": Cat,
    }
    klass = animals.get(animal_type)
    if not klass:
        raise ValueError("Unknown animal")
    return klass()

This is cleaner and more extensible than a separate factory class.


2.4 Observer — One-to-Many Dependency

The Observer pattern defines a subscription mechanism so that when one object (the subject) changes state, all its dependents (the observers) are notified automatically.

Classic implementation:

class Subject:
    def __init__(self):
        self._observers = []

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

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

    def notify(self, message):
        for observer in self._observers:
            observer.update(message)

class Observer:
    def update(self, message):
        pass

class Logger(Observer):
    def update(self, message):
        print(f"Logger: {message}")

class Emailer(Observer):
    def update(self, message):
        print(f"Emailer: {message}")

# Usage
subject = Subject()
subject.attach(Logger())
subject.attach(Emailer())
subject.notify("Event happened")

Pythonic way: Use decorators or callbacks

Often, you just need a list of callbacks, which Python handles naturally:

class EventManager:
    def __init__(self):
        self._listeners = []

    def subscribe(self, callback):
        self._listeners.append(callback)

    def notify(self, event, data):
        for callback in self._listeners:
            callback(event, data)

# Usage
def log_event(event, data):
    print(f"[LOG] {event}: {data}")

def email_event(event, data):
    print(f"[EMAIL] {event}: {data}")

em = EventManager()
em.subscribe(log_event)
em.subscribe(email_event)
em.notify("user_login", {"username": "alice"})

This leverages Python’s function‑as‑object nature and is much lighter than full‑class hierarchies.


2.5 Strategy — Selecting an Algorithm at Runtime

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.

Classic implementation:

class Sorter:
    def __init__(self, strategy):
        self._strategy = strategy

    def sort(self, data):
        return self._strategy.sort(data)

class BubbleSort:
    def sort(self, data):
        # ... bubble sort
        pass

class QuickSort:
    def sort(self, data):
        # ... quick sort
        pass

Pythonic way: Pass a function directly

Because Python functions are objects, you can just pass the sorting function:

def bubble_sort(data):
    # implementation
    pass

def quick_sort(data):
    # implementation
    pass

def sort_data(data, algorithm):
    return algorithm(data)

sorted_data = sort_data(my_list, quick_sort)

Again, the pattern collapses into a simple higher‑order function.


2.6 Mixins — Python’s Secret Weapon for Code Reuse

Not a Gang‑of‑Four pattern, but a Python‑specific technique. A mixin is a class that provides methods to other classes via multiple inheritance, without being a base class on its own.

class LogMixin:
    def log(self, message):
        print(f"[{self.__class__.__name__}] {message}")

class PaymentProcessor(LogMixin):
    def process(self, amount):
        self.log(f"Processing {amount}")
        # ...

pp = PaymentProcessor()
pp.process(100)   # [PaymentProcessor] Processing 100

Mixins keep your code DRY without deep inheritance chains.


2.7 When to Use Patterns — Decision Guide

PatternUse When
SingletonExactly one instance needed; shared resource
FactoryObject creation logic is complex or needs to be centralised
ObserverOne object’s state change must update many others
StrategyMultiple algorithms for a task; choose at runtime
Decorator (Day 17)Adding behaviour to individual functions without modifying them

If you find yourself forcing a pattern where a simple function or dictionary would do, reconsider. Python rewards clarity.


3. Code Examples — Full Working Snippets

Example 1: Singleton Config Manager (Module style)

# config_manager.py
class ConfigManager:
    """Manage application settings."""
    def __init__(self):
        self.settings = {}

    def set(self, key, value):
        self.settings[key] = value

    def get(self, key, default=None):
        return self.settings.get(key, default)

# The singleton instance
config = ConfigManager()

# main.py
from config_manager import config
config.set("theme", "dark")
print(config.get("theme"))   # dark

Example 2: Simple Observer with Callbacks

class DataStore:
    def __init__(self):
        self._data = None
        self._subscribers = []

    def subscribe(self, callback):
        self._subscribers.append(callback)

    def set_data(self, value):
        self._data = value
        for cb in self._subscribers:
            cb(value)

# Subscriber functions
def on_data_change(value):
    print(f"Data changed to {value}")

store = DataStore()
store.subscribe(on_data_change)
store.set_data(42)   # Output: Data changed to 42

Example 3: Strategy with lambda

def apply_discount(price, discount_func):
    return discount_func(price)

# Different discount strategies as lambdas
ten_percent = lambda p: p * 0.9
flat_five = lambda p: max(0, p - 5)

print(apply_discount(100, ten_percent))  # 90.0
print(apply_discount(100, flat_five))    # 95

4. Hands-On Exercises

Exercise 1: Thread‑Safe Singleton Logger

Implement a Logger class using the @singleton decorator (from the lesson). Add a log() method that appends messages to a list. Verify that two variables referencing Logger() are the same object.

Exercise 2: Simple Shape Factory

Write a factory function create_shape(shape_type, **kwargs) that returns an instance of Circle or Rectangle based on the string. Use a dictionary to map name to class. Test by creating a circle with radius=5 and rectangle with width=4, height=6.

Exercise 3: Notification System (Observer)

Build a NotificationCenter class with subscribe(event_type, callback) and post(event_type, message). Create two callbacks: one that prints to console, and another that writes to a log file. Post a few events.

Exercise 4: Strategy for Text Filtering

Define functions remove_spaces(text), to_upper(text), and replace_bad_words(text). Write a function process_text(text, pipeline) that applies a list of filter functions in order. Process a sample text.

Exercise 5: Mixin for Serialisation

Create a JsonSerializableMixin that adds a to_json() method to any class that inherits it. The method should return a JSON string of the object’s __dict__. Test with a class Product(name, price).


5. Applied Challenge Task πŸ—️

Event‑Driven Trading Simulator

Design a mini trading system using Observer and Strategy patterns.

Requirements:

  1. StockMarket (Subject)

    • Holds a dictionary of stock symbols and their prices.
    • update_price(symbol, new_price) updates the price and notifies all observers.
    • Observers can be any callable that receives (symbol, new_price).
  2. Observers:

    • PriceLogger: prints every price change to the console.
    • AlertSystem: if price drops below a threshold, prints an alert. The threshold should be configurable when subscribing.
  3. TradingStrategy:

    • Use the Strategy pattern to decide when to buy/sell.

    • Implement at least two strategies:

      • MomentumStrategy: buy if price increases by more than 5% in a single update.
      • MeanReversionStrategy: buy if price drops more than 10% from its last high.
    • The strategy should be a callable that takes the price history (list) and returns "BUY", "SELL", or "HOLD".

  4. Simulation:

    • Initialise a StockMarket with a few symbols.
    • Attach a PriceLogger, an AlertSystem with threshold=90, and a trader that uses MomentumStrategy.
    • Simulate several price updates and observe the output.

Stretch goals:

  • Make the observers and strategies configurable via a JSON file.
  • Use a Factory to create strategies based on a name string.
  • Add a graphical display (preview of Day 28) using simple ASCII charts.

6. Brief Review Summary

PatternPythonic ImplementationKey Benefit
SingletonModule‑level instance, decoratorControlled single access point
FactoryDictionary mapping, functionCentralised, flexible object creation
ObserverList of callbacks, decoratorLoose coupling between components
StrategyPassing functions, lambdasSwappable algorithms at runtime
MixinMultiple inheritance with simple classesReusable behaviour without deep hierarchies

7. Preview of Next Topic — Day 27

Tomorrow we’ll make our applications web‑connected:

  • Building REST APIs with Flask or FastAPI
  • Routing, request/response handling
  • JSON serialisation
  • Testing APIs with pytest and requests
  • Choosing between Flask (simple) and FastAPI (modern, async)

🎯 Your Action Items for Day 26:

  1. ✅ Complete all 5 exercises
  2. ✅ Build the Event‑Driven Trading Simulator challenge
  3. ✅ Refactor one of your previous projects to use a Singleton or Observer (if appropriate)
  4. ✅ Identify one pattern you’ve already used unknowingly (hint: Decorator from Day 17!)

Comments

Popular posts from this blog

Day 1: Welcome to Python — Your Journey Begins

Python: Your Gateway to Coding Adventures

Day 11: List Comprehensions & Lambda Functions

Earn From the Comfort of Your Home

Build the skills to work comfortably from home, on your own terms.

Get Started Today