Chapter 54: Design Patterns in Python
Design patterns are battle-tested solutions to problems that come up again and again in software development.
They're not code you copy. They're blueprints — proven ways of structuring relationships between objects. The Gang of Four (GoF) book catalogued 23 patterns in 1994. Some are essential. Some are obsolete. Some are already built into Python.
This chapter teaches you the ones that actually matter in modern Python code, how they look in practice, and which patterns Python makes unnecessary.
Why Design Patterns Matter
Without patterns, codebases grow in ad-hoc ways:
- Logic duplicated in ten places
- Classes that know too much about each other
- Changes that break things across the codebase
- Code impossible to test in isolation
Patterns give you a vocabulary and a toolkit to avoid these problems.
Category 1: Creational Patterns
Creational patterns deal with object creation.
Singleton — One Instance Only
The Singleton pattern ensures only one instance of a class ever exists. Common uses: database connections, configuration objects, caches.
import threading
class Singleton:
_instance = None
_lock = threading.Lock()
def __new__(cls):
with cls._lock: # thread-safe
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# Both variables point to the SAME object
a = Singleton()
b = Singleton()
print(a is b) # True
The Pythonic way: Use a module. A Python module is a singleton by definition — it's loaded once and cached. Your config.py with a @lru_cache singleton is idiomatic Python:
# config.py
from functools import lru_cache
@lru_cache(maxsize=None)
def get_settings():
return Settings()
# Anywhere in your app:
settings = get_settings() # always the same object
Factory — Create Objects Without Specifying the Exact Class
The Factory pattern provides a function or method that creates objects. The caller doesn't need to know which class to instantiate.
from abc import ABC, abstractmethod
# Abstract product
class Notification(ABC):
@abstractmethod
def send(self, message: str, recipient: str) -> None:
...
# Concrete products
class EmailNotification(Notification):
def send(self, message: str, recipient: str) -> None:
print(f"Email to {recipient}: {message}")
class SMSNotification(Notification):
def send(self, message: str, recipient: str) -> None:
print(f"SMS to {recipient}: {message}")
class PushNotification(Notification):
def send(self, message: str, recipient: str) -> None:
print(f"Push to {recipient}: {message}")
# Factory function
def create_notification(channel: str) -> Notification:
channels = {
"email": EmailNotification,
"sms": SMSNotification,
"push": PushNotification,
}
cls = channels.get(channel.lower())
if cls is None:
raise ValueError(f"Unknown channel: {channel!r}. "
f"Valid: {list(channels)}")
return cls()
# Usage — caller doesn't know which class is created
notifier = create_notification("email")
notifier.send("Your order shipped!", "alice@example.com")
notifier = create_notification("sms")
notifier.send("Your code is 1234", "+44 7700 900000")
To add a new channel: add one line to channels. Nothing else changes.
Builder — Construct Complex Objects Step by Step
The Builder pattern constructs complex objects with many optional parameters through a fluent interface:
from dataclasses import dataclass, field
@dataclass
class Query:
table: str
columns: list[str] = field(default_factory=lambda: ["*"])
conditions: list[str] = field(default_factory=list)
order_by: str | None = None
limit_: int | None = None
offset_: int | None = None
def to_sql(self) -> str:
cols = ", ".join(self.columns)
sql = f"SELECT {cols} FROM {self.table}"
if self.conditions:
sql += " WHERE " + " AND ".join(self.conditions)
if self.order_by:
sql += f" ORDER BY {self.order_by}"
if self.limit_ is not None:
sql += f" LIMIT {self.limit_}"
if self.offset_ is not None:
sql += f" OFFSET {self.offset_}"
return sql
class QueryBuilder:
def __init__(self, table: str):
self._query = Query(table=table)
def select(self, *columns: str) -> "QueryBuilder":
self._query.columns = list(columns)
return self
def where(self, condition: str) -> "QueryBuilder":
self._query.conditions.append(condition)
return self
def order_by(self, column: str) -> "QueryBuilder":
self._query.order_by = column
return self
def limit(self, n: int) -> "QueryBuilder":
self._query.limit_ = n
return self
def offset(self, n: int) -> "QueryBuilder":
self._query.offset_ = n
return self
def build(self) -> Query:
return self._query
# Fluent interface — each method returns self
query = (
QueryBuilder("products")
.select("id", "name", "price")
.where("price > 10")
.where("in_stock = 1")
.order_by("price")
.limit(20)
.offset(40)
.build()
)
print(query.to_sql())
# SELECT id, name, price FROM products
# WHERE price > 10 AND in_stock = 1
# ORDER BY price LIMIT 20 OFFSET 40
Category 2: Structural Patterns
Structural patterns deal with composing classes and objects.
Decorator Pattern (vs Python's @decorator)
The design-pattern Decorator wraps an object to add behaviour. Python's @decorator syntax is related but different — it wraps a function.
The object-based Decorator pattern:
from abc import ABC, abstractmethod
class DataSource(ABC):
@abstractmethod
def write(self, data: str) -> None: ...
@abstractmethod
def read(self) -> str: ...
class FileDataSource(DataSource):
def __init__(self, path: str):
self._path = path
def write(self, data: str) -> None:
with open(self._path, "w") as f:
f.write(data)
def read(self) -> str:
with open(self._path) as f:
return f.read()
class DataSourceDecorator(DataSource):
"""Base decorator — wraps another DataSource."""
def __init__(self, source: DataSource):
self._source = source
def write(self, data: str) -> None:
self._source.write(data)
def read(self) -> str:
return self._source.read()
class EncryptionDecorator(DataSourceDecorator):
"""Adds encryption on top of any DataSource."""
def write(self, data: str) -> None:
encrypted = data[::-1] # toy encryption
self._source.write(encrypted)
def read(self) -> str:
return self._source.read()[::-1]
class CompressionDecorator(DataSourceDecorator):
"""Adds compression on top of any DataSource."""
def write(self, data: str) -> None:
import zlib, base64
compressed = base64.b64encode(zlib.compress(data.encode())).decode()
self._source.write(compressed)
def read(self) -> str:
import zlib, base64
return zlib.decompress(base64.b64decode(self._source.read())).decode()
# Stack decorators — each adds a layer
source = FileDataSource("data.txt")
source = CompressionDecorator(source)
source = EncryptionDecorator(source)
source.write("Hello, World!") # encrypt(compress("Hello, World!"))
print(source.read()) # -> "Hello, World!" (unwrapped transparently)
Adapter — Make Incompatible Interfaces Work Together
You have a class with one interface. You need another. The Adapter wraps the original:
class EuropeanSocket:
"""Provides 230V."""
def voltage(self) -> int: return 230
def live(self) -> int: return 1
def neutral(self) -> int: return -1
class USASocket:
"""Provides 110V."""
def voltage(self) -> int: return 110
def live(self) -> int: return 1
def neutral(self) -> int: return -1
class USADevice:
"""Requires a USASocket interface."""
def run(self, socket: USASocket) -> None:
if socket.voltage() > 150:
raise RuntimeError("Too much voltage!")
print(f"Running on {socket.voltage()}V")
class EuropeanToUSAAdapter(USASocket):
"""Makes a EuropeanSocket look like a USASocket."""
def __init__(self, socket: EuropeanSocket):
self._socket = socket
def voltage(self) -> int:
return 110 # step down from 230V
def live(self) -> int:
return self._socket.live()
def neutral(self) -> int:
return self._socket.neutral()
# Plug a US device into a European socket via the adapter
euro_socket = EuropeanSocket()
adapter = EuropeanToUSAAdapter(euro_socket)
device = USADevice()
device.run(adapter) # "Running on 110V"
Real-world example: wrapping a third-party API client to match your own interface.
Facade — Simplify a Complex System
Facade provides a simple interface to a complex subsystem:
class VideoConverter:
"""
Facade that hides the complexity of video conversion.
Internally uses Codec, AudioMixer, BitrateReader, etc.
"""
def convert(self, filename: str, target_format: str) -> str:
print(f"VideoConverter: opening file {filename}")
print(f"VideoConverter: detecting codec")
print(f"VideoConverter: extracting audio")
print(f"VideoConverter: re-encoding to {target_format}")
print(f"VideoConverter: writing output file")
output = filename.rsplit(".", 1)[0] + "." + target_format
print(f"VideoConverter: done -> {output}")
return output
# Client uses one simple method
converter = VideoConverter()
output = converter.convert("movie.avi", "mp4")
Flask itself is a Facade — it hides the complexity of WSGI, HTTP parsing, and routing behind a simple @app.route decorator.
Category 3: Behavioural Patterns
Behavioural patterns deal with communication between objects.
Observer — Notify Multiple Objects of State Changes
When one object changes state, dependent objects are notified automatically:
from abc import ABC, abstractmethod
from typing import Any
class Observer(ABC):
@abstractmethod
def update(self, event: str, data: Any) -> None: ...
class EventBus:
"""Simple publish/subscribe event bus."""
def __init__(self):
self._subscribers: dict[str, list[Observer]] = {}
def subscribe(self, event: str, observer: Observer) -> None:
self._subscribers.setdefault(event, []).append(observer)
def unsubscribe(self, event: str, observer: Observer) -> None:
self._subscribers.get(event, []).remove(observer)
def publish(self, event: str, data: Any = None) -> None:
for observer in self._subscribers.get(event, []):
observer.update(event, data)
# Concrete observers
class EmailService(Observer):
def update(self, event: str, data: Any) -> None:
if event == "user.registered":
print(f"EmailService: sending welcome email to {data['email']}")
class AnalyticsService(Observer):
def update(self, event: str, data: Any) -> None:
print(f"Analytics: tracking {event} — {data}")
class AuditLog(Observer):
def update(self, event: str, data: Any) -> None:
print(f"AuditLog: [{event}] {data}")
# Wire it up
bus = EventBus()
bus.subscribe("user.registered", EmailService())
bus.subscribe("user.registered", AnalyticsService())
bus.subscribe("user.registered", AuditLog())
# Publish an event — all subscribers notified
bus.publish("user.registered", {"email": "alice@example.com", "id": 42})
Output:
EmailService: sending welcome email to alice@example.com
Analytics: tracking user.registered — {'email': 'alice@example.com', 'id': 42}
AuditLog: [user.registered] {'email': 'alice@example.com', 'id': 42}
Strategy — Swap Algorithms at Runtime
Define a family of algorithms, encapsulate each one, make them interchangeable:
from abc import ABC, abstractmethod
from typing import Callable
class SortStrategy(ABC):
@abstractmethod
def sort(self, data: list) -> list: ...
class BubbleSort(SortStrategy):
def sort(self, data: list) -> list:
arr = data.copy()
n = len(arr)
for i in range(n):
for j in range(n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
class QuickSort(SortStrategy):
def sort(self, data: list) -> list:
if len(data) <= 1:
return data
pivot = data[len(data) // 2]
left = [x for x in data if x < pivot]
mid = [x for x in data if x == pivot]
right = [x for x in data if x > pivot]
return self.sort(left) + mid + self.sort(right)
class Sorter:
def __init__(self, strategy: SortStrategy):
self._strategy = strategy
def set_strategy(self, strategy: SortStrategy) -> None:
self._strategy = strategy
def sort(self, data: list) -> list:
return self._strategy.sort(data)
data = [5, 2, 8, 1, 9, 3]
sorter = Sorter(BubbleSort())
print(sorter.sort(data)) # [1, 2, 3, 5, 8, 9]
sorter.set_strategy(QuickSort())
print(sorter.sort(data)) # [1, 2, 3, 5, 8, 9]
Pythonic version — just pass a function:
def sort_with(data: list, key_fn: Callable = None, reverse: bool = False) -> list:
return sorted(data, key=key_fn, reverse=reverse)
products = [{"name": "B", "price": 20}, {"name": "A", "price": 5}]
by_price = sort_with(products, key_fn=lambda p: p["price"])
by_name = sort_with(products, key_fn=lambda p: p["name"])
Python's first-class functions often replace the Strategy pattern entirely.
Command — Encapsulate Requests as Objects
The Command pattern turns a request into a standalone object. This enables undo/redo, queuing, and logging of operations:
from abc import ABC, abstractmethod
from dataclasses import dataclass
class Command(ABC):
@abstractmethod
def execute(self) -> None: ...
@abstractmethod
def undo(self) -> None: ...
@dataclass
class TextEditor:
text: str = ""
def insert(self, pos: int, chars: str) -> None:
self.text = self.text[:pos] + chars + self.text[pos:]
def delete(self, pos: int, length: int) -> None:
self.text = self.text[:pos] + self.text[pos + length:]
class InsertCommand(Command):
def __init__(self, editor: TextEditor, pos: int, chars: str):
self._editor = editor
self._pos = pos
self._chars = chars
def execute(self) -> None:
self._editor.insert(self._pos, self._chars)
def undo(self) -> None:
self._editor.delete(self._pos, len(self._chars))
class CommandHistory:
def __init__(self):
self._history: list[Command] = []
def execute(self, cmd: Command) -> None:
cmd.execute()
self._history.append(cmd)
def undo(self) -> None:
if self._history:
self._history.pop().undo()
editor = TextEditor()
history = CommandHistory()
history.execute(InsertCommand(editor, 0, "Hello"))
history.execute(InsertCommand(editor, 5, ", World"))
print(editor.text) # Hello, World
history.undo()
print(editor.text) # Hello
history.undo()
print(editor.text) # (empty)
Iterator — Traverse Without Exposing Internals
You've used Python iterators throughout this book — for x in collection is the Iterator pattern. Python makes it trivially easy with __iter__ and __next__ or yield.
class NumberRange:
"""Iterate over a range of numbers, step by step."""
def __init__(self, start: int, stop: int, step: int = 1):
self._start = start
self._stop = stop
self._step = step
def __iter__(self):
current = self._start
while current < self._stop:
yield current
current += self._step
for n in NumberRange(0, 10, 2):
print(n, end=" ") # 0 2 4 6 8
Python's built-in iter() / next() / StopIteration handle the Iterator pattern natively.
Patterns Python Makes Unnecessary
Some classic GoF patterns exist to work around limitations in languages like Java or C++. Python doesn't have those limitations:
| Pattern | Why Python doesn't need it |
|---|---|
| Abstract Factory | Just use a dict of classes or a factory function |
| Template Method | Use a base class with a method that calls self.step() — same thing |
| Prototype | copy.deepcopy(obj) |
| Memento | dataclasses with copy.copy() or pickling |
| Chain of Responsibility | A list of callables: for handler in handlers: if handler(request): break |
| Mediator | An event bus (shown above) |
Pythonic Patterns — The Ones Python Adds
Python has its own idiomatic patterns that don't appear in GoF:
Context Manager (already in Chapter 24)
with open("file.txt") as f:
data = f.read()
# file always closed — even on exception
Descriptor (already in Chapter 36)
class Validated:
def __set_name__(self, owner, name):
self._name = name
def __get__(self, obj, type=None):
return obj.__dict__.get(self._name)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self._name} must be int")
obj.__dict__[self._name] = value
Mixin
Mixins add behaviour to a class without requiring a specific base class:
class TimestampMixin:
"""Add created_at / updated_at to any model."""
created_at: datetime = None
updated_at: datetime = None
def touch(self) -> None:
from datetime import datetime
now = datetime.utcnow()
if self.created_at is None:
self.created_at = now
self.updated_at = now
class JSONMixin:
"""Add .to_json() to any class."""
def to_json(self) -> str:
import json
return json.dumps(self.__dict__, default=str)
class User(TimestampMixin, JSONMixin):
def __init__(self, name: str, email: str):
self.name = name
self.email = email
user = User("Alice", "alice@example.com")
user.touch()
print(user.to_json())
When to Use Design Patterns
Use a pattern when:
- You recognise the problem it solves
- The pattern makes the code clearer to the next developer
- You need the specific benefit (undo/redo -> Command, swap algorithms -> Strategy)
Don't use a pattern when:
- It adds complexity without a clear benefit
- A simpler Python feature already handles it (first-class functions,
with, generators) - You're trying to pattern-match to a GoF name rather than solving a real problem
The best code is readable code. Patterns are tools, not goals.
What You Learned in This Chapter
- Creational: Singleton ensures one instance (
@lru_cacheis the Pythonic way). Factory delegates object creation to a function. Builder constructs complex objects with a fluent interface. - Structural: Decorator wraps objects to add behaviour. Adapter makes incompatible interfaces compatible. Facade simplifies a complex subsystem behind a clean interface.
- Behavioural: Observer notifies subscribers of events (publish/subscribe). Strategy swaps algorithms at runtime (or just pass a function). Command encapsulates operations as objects, enabling undo/redo. Iterator traverses collections (Python's
yieldandforloop handle this natively). - Many GoF patterns are unnecessary in Python because the language already has first-class functions,
withstatements, generators, and powerful standard library tools. - Pythonic patterns include Context Managers, Descriptors, and Mixins.
- Use patterns when they solve a real problem. Avoid them when a simpler Python feature already does the job.
What's Next?
Chapter 55 covers Security Best Practices — the common mistakes that lead to data breaches, how to validate and sanitise input, how to store passwords correctly, prevent SQL injection, manage secrets, and audit your dependencies for known vulnerabilities.