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: