Case Study: Design Patterns in the Wild

The design patterns from this chapter aren't academic exercises — they're used extensively in real Python libraries you'll encounter throughout your career. This case study traces Strategy, Observer, and Factory patterns through popular open-source projects to show how the concepts you've learned appear in production code.

The Question

Students often ask: "Do real programmers actually use design patterns, or is this just something textbooks talk about?" The answer is emphatic: yes, real code uses these patterns constantly. But here's the nuance — experienced Python developers don't always use the formal, class-heavy versions from the Gang of Four book. Python's dynamic nature often lets you implement the spirit of a pattern more simply.

Let's look at three real-world examples.

Strategy Pattern: Python's sorted() and key Functions

You've already used the Strategy pattern without knowing it. Every time you pass a key function to sorted(), you're using Strategy:

students = [
    {"name": "Alice", "gpa": 3.8, "age": 20},
    {"name": "Bob", "gpa": 3.5, "age": 22},
    {"name": "Carol", "gpa": 3.9, "age": 19},
]

# Strategy 1: Sort by GPA
by_gpa = sorted(students, key=lambda s: s["gpa"])

# Strategy 2: Sort by name
by_name = sorted(students, key=lambda s: s["name"])

# Strategy 3: Sort by age, descending
by_age_desc = sorted(students, key=lambda s: s["age"], reverse=True)

The sorted() function is the context. The key function is the strategy. You're swapping the comparison algorithm at runtime without modifying sorted() itself. This is exactly the Strategy pattern — just expressed with functions instead of classes.

How Python Simplifies It

In classic Java or C++, you'd write a Comparator interface, concrete comparator classes, and explicit type declarations. (Modern versions of both languages have lambdas that shrink this considerably — a sign of how useful the idea is.) In Python, a function (or lambda) serves as the strategy directly. This is what Peter Norvig meant in a 1996 talk when he argued that 16 of the 23 Gang of Four patterns become simpler — or invisible — in dynamic languages like Lisp and Dylan. Strategy was one of them, because functions you can pass around do its job.

But when the strategy is more complex than a one-liner, Python developers do use the class-based version:

# Real-world example: Django REST Framework renderers
# Each renderer is a Strategy for turning response data into text

# Heavily simplified version of the idea:
class JSONRenderer:
    def render(self, data):
        import json
        return json.dumps(data)

class XMLRenderer:
    def render(self, data):
        # Convert data to XML string
        return f"<data>{data}</data>"

class APIView:
    renderer = JSONRenderer()  # Default strategy

    def respond(self, data):
        return self.renderer.render(data)

Django REST Framework uses this idea to let API views support multiple output formats. A view lists the renderer classes it accepts in a renderer_classes attribute — JSON and HTML renderers come built in, and XML and YAML renderers are available as add-on packages — and the framework picks the right one for each request. The view's own code never changes, exactly like our ReportFormatter example.

Observer Pattern: GUI Frameworks and Event Systems

The Observer pattern is the backbone of every GUI framework. When you click a button in a graphical application, here's what happens behind the scenes:

# Simplified version of how tkinter and similar frameworks work

class Button:
    def __init__(self, text):
        self.text = text
        self._click_handlers = []  # List of observers

    def on_click(self, handler):
        """Register an observer (callback function)."""
        self._click_handlers.append(handler)

    def click(self):
        """Simulate a click — notify all observers."""
        for handler in self._click_handlers:
            handler()

# Register multiple observers for the same event
save_button = Button("Save")
save_button.on_click(lambda: print("Saving file..."))
save_button.on_click(lambda: print("Updating status bar..."))
save_button.on_click(lambda: print("Logging action..."))

# When clicked, all observers fire
save_button.click()

Expected output:

Saving file...
Updating status bar...
Logging action...

This is Observer in its purest form. The button doesn't know what happens when it's clicked — it just maintains a list of handlers and calls them all. Sound familiar? It's exactly the pattern from Section 16.5.

Real Examples in Python Libraries

Flask (web framework): Flask's signal system (built on a small library called Blinker) uses Observer to notify parts of the application when events occur. Flask's documentation points out that signal receivers can't directly affect the application. They're listeners, which makes them a good fit for logging, metrics, and auditing:

# How Flask uses signals (simplified)
# When a request starts, multiple components get notified

# This is conceptually what happens inside Flask:
class RequestSignal:
    def __init__(self):
        self._receivers = []

    def connect(self, func):
        self._receivers.append(func)

    def send(self, **kwargs):
        for receiver in self._receivers:
            receiver(**kwargs)

request_started = RequestSignal()

# Different parts of the app register to observe request events
def log_request(**kwargs):
    print(f"[LOG] {kwargs['method']} {kwargs['path']} received")

def start_timer(**kwargs):
    print("[METRICS] Starting response timer")

def track_analytics(**kwargs):
    print(f"[ANALYTICS] Recording page view of {kwargs['path']}")

request_started.connect(log_request)
request_started.connect(start_timer)
request_started.connect(track_analytics)

# When a request comes in, all observers are notified
request_started.send(path="/home", method="GET")

(A quick note on the **kwargs syntax: in a function definition, **kwargs gathers any keyword arguments into a dictionary, so send(path="/home", method="GET") makes kwargs equal to {"path": "/home", "method": "GET"}. In a call, receiver(**kwargs) does the reverse and unpacks the dictionary back into keyword arguments. That lets every receiver accept whatever details the signal sends.)

Expected output:

[LOG] GET /home received
[METRICS] Starting response timer
[ANALYTICS] Recording page view of /home

Python's logging module: The standard library's logging system is Observer pattern throughout. A logger (subject) sends log records to multiple handlers (observers) — a file handler, a console handler, an email handler — each formatting and delivering the message differently.

import logging

# The logger is the Subject
logger = logging.getLogger("my_app")
logger.setLevel(logging.DEBUG)

# Each handler is an Observer
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))

# You could add a FileHandler, SMTPHandler, etc. — each observes the same logger
logger.addHandler(console_handler)

# When a log event occurs, all handlers are notified
logger.info("Application started")
logger.warning("Disk space low")

Expected output:

INFO: Application started
WARNING: Disk space low

Factory Pattern: Python's Built-in open() and Beyond

Python's built-in open() function is a factory in disguise. When you call open("data.csv", "r"), Python doesn't always return the same type of object. Depending on the mode and arguments, it might return a TextIOWrapper (text modes), a BufferedReader or BufferedWriter (binary modes), or a raw FileIO object (binary with buffering turned off). The calling code doesn't need to know — it just gets a file object with the familiar .read(), .write(), and .close() methods.

# Python's open() acts as a factory
with open("test.txt", "w") as f:  # Create a small file to open
    f.write("hello")

f1 = open("test.txt", "r")     # Returns TextIOWrapper
f2 = open("test.txt", "rb")    # Returns BufferedReader
print(type(f1))  # <class '_io.TextIOWrapper'>
print(type(f2))  # <class '_io.BufferedReader'>
f1.close()
f2.close()

The json Module's Approach

Python's json module uses a form of the Factory pattern through its object_hook parameter:

import json
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

def point_factory(d):
    """Factory function: creates the right object from JSON data."""
    if "x" in d and "y" in d:
        return Point(d["x"], d["y"])
    return d  # Return dict unchanged if not a Point

# The factory decides what type to create based on the data
data = '{"x": 3.5, "y": 7.2}'
result = json.loads(data, object_hook=point_factory)
print(result)       # Point(x=3.5, y=7.2)
print(type(result))  # <class '__main__.Point'>

Expected output:

Point(x=3.5, y=7.2)
<class '__main__.Point'>

(The __main__. prefix tells you Point was defined in the script you ran directly. Note also that object_hook is called for every JSON object in the data, nested ones included — which is why the factory returns ordinary dictionaries unchanged.)

Django's ORM: Factory on a Grand Scale

Django's Object-Relational Mapper is one of the most sophisticated uses of the Factory pattern in the Python ecosystem. When you query the database, Django creates Python objects from database rows — the calling code never constructs the objects directly:

# Conceptually, this is what Django's ORM does (massively simplified):
from dataclasses import dataclass

class ModelFactory:
    """Simplified version of how Django creates model instances from DB rows."""
    _registry = {}

    @classmethod
    def register(cls, table_name, model_class):
        cls._registry[table_name] = model_class

    @classmethod
    def create_from_row(cls, table_name, row_data):
        model_class = cls._registry.get(table_name)
        if model_class is None:
            raise ValueError(f"No model registered for table: {table_name}")
        return model_class(**row_data)

# Two model classes (Django finds and registers models automatically;
# here we register them by hand)
@dataclass
class User:
    username: str
    email: str

@dataclass
class Product:
    name: str
    price: float

ModelFactory.register("users", User)
ModelFactory.register("products", Product)

# Factory creates the right type based on the table
user = ModelFactory.create_from_row("users", {"username": "alice", "email": "[email protected]"})
product = ModelFactory.create_from_row("products", {"name": "Widget", "price": 9.99})

print(user)     # User(username='alice', email='[email protected]')
print(product)  # Product(name='Widget', price=9.99)

Expected output:

User(username='alice', email='[email protected]')
Product(name='Widget', price=9.99)

The Pythonic Twist

Here's the key insight that separates Python's approach to patterns from Java's or C++'s: Python often uses functions where other languages use classes.

Pattern Java/C++ Approach Pythonic Approach
Strategy Interface + concrete classes Function/lambda passed as argument
Observer Abstract observer class + subject with list Callbacks, signals, or __call__ on objects
Factory Factory class with creation methods Factory function, class method, or dict lookup

This doesn't mean the patterns don't apply — it means Python gives you lighter-weight tools to implement them. A key function is still a strategy. A callback list is still Observer. A create() class method is still a Factory. The concepts are the same; the syntax adapts to the language.

Discussion Questions

  1. The chapter's Strategy pattern example uses abstract base classes and concrete strategy classes. Python's sorted() uses a simple key function. When would you use the class-based approach vs. the function-based approach? What factors would influence your decision?

  2. The Observer pattern appears in GUIs, web frameworks, and logging. What do these domains have in common that makes Observer useful? Can you think of a domain where Observer would be a poor fit?

  3. Python's open() returns different types based on the mode argument, but the calling code uses them the same way (through .read() and .write()). How does this relate to the concept of polymorphism from Chapter 15?

  4. Peter Norvig argued that 16 of the 23 GoF patterns are simpler or invisible in dynamic languages such as Lisp and Dylan, and many of the same simplifications apply to Python. Does this mean patterns are less important in Python? Or does it mean something else? Defend your position.

  5. Find a Python library you use (or have heard of) and identify at least one design pattern it employs. Describe how the pattern is implemented and what benefit it provides. (Hint: look at how the library handles configuration, output formats, or event handling.)