Design Patterns
Prerequisite:
Design patterns are named solutions to problems that come up repeatedly in software design. They’re not code you copy-paste - they’re vocabulary for describing intent and structure. Knowing them helps you recognise familiar shapes in unfamiliar code, and communicate design decisions without writing an essay.
The original catalogue comes from the “Gang of Four” book (Gamma, Helm, Johnson, Vlissides, 1994). The patterns are grouped into three categories: creational, structural, and behavioural.
Creational Patterns
These deal with how objects are created.
Singleton - ensure only one instance of a class exists:
class Logger:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.logs = []
return cls._instance
def log(self, message):
self.logs.append(message)
log1 = Logger()
log2 = Logger()
print(log1 is log2) # True - same object
Singleton is useful when exactly one shared resource is needed (a logger, a config object). Use it sparingly - global state makes code harder to test and reason about.
Factory - return objects without specifying their exact class:
def create_animal(kind):
animals = {"dog": Dog, "cat": Cat, "bird": Bird}
cls = animals.get(kind)
if cls is None:
raise ValueError(f"Unknown animal: {kind}")
return cls()
The caller asks for a "dog" and gets back an object. The exact class is an implementation detail.
Builder - construct complex objects step by step:
class QueryBuilder:
def __init__(self):
self._table = None
self._conditions = []
self._limit = None
def from_table(self, table):
self._table = table
return self # returning self enables chaining
def where(self, condition):
self._conditions.append(condition)
return self
def limit(self, n):
self._limit = n
return self
def build(self):
query = f"SELECT * FROM {self._table}"
if self._conditions:
query += " WHERE " + " AND ".join(self._conditions)
if self._limit:
query += f" LIMIT {self._limit}"
return query
query = (QueryBuilder()
.from_table("users")
.where("age > 18")
.where("active = true")
.limit(10)
.build())
print(query)
# SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10
Structural Patterns
These deal with how objects and classes are composed.
Adapter - wrap an incompatible interface so it fits what you need:
class OldPaymentSystem:
def make_payment(self, dollars):
print(f"Paying ${dollars} via old system")
class PaymentAdapter:
def __init__(self, old_system):
self._old = old_system
def pay(self, amount): # new interface
self._old.make_payment(amount)
old = OldPaymentSystem()
adapter = PaymentAdapter(old)
adapter.pay(50) # works with the new interface
Use this when you need to integrate a library or legacy system whose interface you can’t change.
Decorator - add behaviour to an object without subclassing:
Python’s @decorator syntax is literally this pattern. A function wrapping another function adds behaviour (logging, timing, caching) without modifying the original:
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
print(f"Attempt {attempt + 1} failed: {e}")
return wrapper
return decorator
@retry(max_attempts=3)
def fetch_data(url):
# might raise on network errors
...
Facade - provide a simple interface over a complex subsystem:
class EmailFacade:
"""Simple interface hiding SMTP, auth, and template complexity."""
def send_welcome(self, user_email, username):
# internally: connect to SMTP, load template, render, send, log
self._send(user_email, subject="Welcome!", body=f"Hi {username}...")
def _send(self, to, subject, body):
# complex SMTP logic here
pass
Callers use EmailFacade().send_welcome(...) without knowing anything about SMTP.
Behavioural Patterns
These deal with how objects communicate and distribute responsibility.
Observer - objects subscribe to events and get notified when they happen:
class EventSystem:
def __init__(self):
self._listeners = {}
def subscribe(self, event, callback):
self._listeners.setdefault(event, []).append(callback)
def emit(self, event, data=None):
for callback in self._listeners.get(event, []):
callback(data)
events = EventSystem()
events.subscribe("user_login", lambda data: print(f"User logged in: {data}"))
events.subscribe("user_login", lambda data: print(f"Sending welcome email to {data}"))
events.emit("user_login", "alice@example.com")
# User logged in: alice@example.com
# Sending welcome email to alice@example.com
This is pub/sub. The emitter doesn’t know or care who’s listening. Listeners can be added and removed independently.
Strategy - swap algorithms at runtime:
class Sorter:
def __init__(self, strategy):
self._strategy = strategy
def sort(self, data):
return self._strategy(data)
import random
data = [random.randint(0, 100) for _ in range(10)]
sorter = Sorter(sorted) # use Python's built-in
print(sorter.sort(data))
sorter = Sorter(lambda x: sorted(x, reverse=True)) # swap strategy
print(sorter.sort(data))
Strategy is useful when you have multiple ways to do something and want to choose at runtime - payment processors, sorting algorithms, export formats.
Command - encapsulate an action as an object, so it can be queued, undone, or logged:
class Command:
def execute(self): raise NotImplementedError
def undo(self): raise NotImplementedError
class DepositCommand(Command):
def __init__(self, account, amount):
self.account = account
self.amount = amount
def execute(self):
self.account.deposit(self.amount)
def undo(self):
self.account.withdraw(self.amount)
This lets you build undo/redo systems or task queues where each action is a self-contained object.
Python-Specific: Context Managers
Python’s with statement is the RAII pattern - resources are acquired and released automatically:
with open("data.txt") as f:
content = f.read()
# file is closed automatically, even if an exception occurs
You can implement this for your own classes with __enter__ and __exit__:
class Timer:
def __enter__(self):
import time
self._start = time.time()
return self
def __exit__(self, *args):
import time
self.elapsed = time.time() - self._start
print(f"Elapsed: {self.elapsed:.4f}s")
with Timer():
result = sum(range(10_000_000))
# Elapsed: 0.2847s
Context managers guarantee cleanup regardless of whether an exception is raised.
When Patterns Become Anti-Patterns
Patterns are solutions to real problems. Applied to problems they don’t fit, they’re just complexity:
- A Singleton for something that doesn’t need global uniqueness is a global variable with extra steps.
- Wrapping a two-line function in a Strategy class is over-engineering.
- A six-level class hierarchy when composition would do is the wrong tool.
The best codebases use the simplest structure that works. Patterns are named so you can reach for them when a problem genuinely matches - not to prove you know them.
Examples
Logger as Singleton - one shared logger for the whole application (shown above). In practice, Python’s logging module already does this; use that instead of rolling your own.
Plugin system as Strategy:
class ReportExporter:
def __init__(self, format_strategy):
self._strategy = format_strategy
def export(self, data):
return self._strategy(data)
def to_csv(data): return ",".join(str(v) for v in data)
def to_json(data): import json; return json.dumps(data)
exporter = ReportExporter(to_csv)
print(exporter.export([1, 2, 3])) # 1,2,3
exporter = ReportExporter(to_json)
print(exporter.export([1, 2, 3])) # [1, 2, 3]
Event system as Observer - shown in full above. The same pattern powers UI frameworks, game engines, and message brokers at every scale.
Read Next: