Design patterns in Python: which ones the language already does, which are still worth learning

The Gang of Four's 23 patterns were written for C++ and Smalltalk. In Python, Strategy is a function, Singleton is a module, Iterator is a generator. A tour of the patterns still used every day, found in real code, with how many nanoseconds each kind of indirection costs.

The previous post was about the shape of the joints in code. In 1994, four authors (Gamma, Helm, Johnson and Vlissides, known as the "Gang of Four") published Design Patterns, gathering 23 recurring shapes into a shared vocabulary: Strategy, Factory, Singleton, Observer, Adapter... The book's greatest value is the names: saying "this uses Strategy" instead of three paragraphs of explanation.

But the book's examples are in C++ and Smalltalk, languages where functions couldn't be passed around like ordinary values (C++ at the time) or where everything has to be a class. In 1996, Peter Norvig pointed out that in dynamic languages, 16 of the 23 patterns become much simpler or almost vanish into the language. Python is one of those languages.

The route: the three groups of patterns, the patterns Python already does for you (Strategy, Singleton, Iterator, Decorator, Command), the ones still worth learning (Factory as a registry, Adapter, Observer, Template Method versus composition, Dependency Injection), finding them in this website's own code, measuring the price of each kind of indirection, then when patterns backfire.

Every number in this post was measured on a real machine, with Python 3.12, single-threaded, each measurement the best of 5 runs of a million calls.

Three groups

The Gang of Four split the 23 patterns into three groups by the problem they solve:

Group Question Examples
Creational Who creates objects, and which kind? Factory, Builder, Singleton
Structural How are objects put together? Adapter, Decorator, Facade, Proxy
Behavioral How do objects divide work and tell each other things? Strategy, Observer, Command, Iterator, Template Method

You don't need to memorize all 23. Most everyday code meets about a dozen, and in Python, half of those are one line long.

Patterns Python already does

Strategy: package an algorithm so it can be swapped at run time. In the book, that's a SortStrategy interface and a few implementing classes. In Python, functions are values, so Strategy is just passing a function in:

sorted(users, key=lambda u: u.last_login)  # the comparison strategy is a parameter

This site's OAuthProvider class (from the previous post) is the same: what differs between providers is the field parse_user: Callable[[dict], ProviderUser], a function, not a tree of subclasses.

Singleton: make sure there's only one object. In Python, a module is already a singleton: the first import runs the module and stores it in sys.modules, and later imports just get that same object back (the post Inside CPython walks through how). This site's configuration is objects created once at module level, auth_settings, mail_settings, imported by whoever needs them. No __new__ tricks or locks required.

Iterator: walk through a collection without exposing its insides. Python has the __iter__/__next__ protocol built in, and a generator writes one with nothing but yield:

def pages(items, size):
    for start in range(0, len(items), size):
        yield items[start:start + size]

(The post Streaming with async generators goes deep into these.)

Decorator: wrap an object to add behaviour without changing it. Python's @ syntax isn't quite the Gang of Four's Decorator (it wraps functions, not objects), but the idea is the same: add a shell, keep the core. This site uses @lru_cache to remember the content hash of static files:

@lru_cache(maxsize=256)
def _content_hash(path: str, mtime_ns: int) -> str:
    return hashlib.sha256((STATIC_DIR / path).read_bytes()).hexdigest()[:12]

The function inside knows nothing about caching. And there's a neat detail: the file's modification time (mtime_ns) is part of the key, so when the file changes, the key changes, and the cache "invalidates" itself without anyone clearing it.

Command: package an action as an object so it can be stored, queued or undone. In Python, a function, a lambda or a functools.partial is already a "command" you can pass around:

from functools import partial

undo = []
def set_title(doc, title):
    undo.append(partial(setattr, doc, "title", doc.title))  # a command to restore the old title
    doc.title = title

FastAPI's BackgroundTasks is Command too: the registration route queues mailer.send with the confirmation email using background.add_task(mailer.send, email), and that command runs after the response has gone out, so a slow SMTP server never holds up the page.

Patterns still worth learning

Factory, as a registry. When you have to create "some kind of thing" based on data (a name in a URL, a type field in JSON), replace an if/elif chain with a dict from name to what to create. This site's get_providers() returns exactly such a dict:

return {p.name: p for p in providers}  # "google" -> OAuthProvider(...)

The route /auth/oauth/{provider}/login gets its provider through a small dependency, valid_provider, which just does get_providers().get(provider) and raises "not found" if there's none. Adding a provider doesn't touch the route.

Adapter (and its sibling Facade): wrap an outside API behind an interface of your own. This site's Mailer class wraps the aiosmtplib library behind a single method, send(email): the rest of the code knows nothing about port 587 versus 465, STARTTLS versus TLS, or the 20-second timeout. Switching to an email service with an HTTP API means rewriting one class. The adapter is also the natural place to put a fake in tests, as the previous post showed.

Observer: one object emits events, and others subscribe to them without the emitter knowing who they are:

listeners = {}

def on(event, fn):
    listeners.setdefault(event, []).append(fn)

def emit(event, **data):
    for fn in listeners.get(event, []):
        fn(**data)

on("user_registered", lambda email: print("welcome mail to", email))
on("user_registered", lambda email: print("count +1"))
emit("user_registered", email="[email protected]")

The registration code doesn't need to know there's a welcome email or a statistic. The price: the flow is harder to follow (who is listening to this event?), and a slow or failing listener can slow down the emitter. At the scale of a system, Observer grows into message queues and pub/sub, the subject of the next post.

Template Method versus composition. Template Method: a parent class defines the steps of an algorithm, and subclasses override some of them. It works, but it couples tightly through inheritance, and the previous post showed how easily inheritance breaks promises. The Gang of Four's own long-standing advice is to "favor composition over inheritance": pass in the steps that vary as functions or objects, the way OAuthProvider takes parse_user, instead of making each provider inherit from a parent class.

Dependency Injection. Not among the original 23, but the most used pattern in a modern web application: an object doesn't create its own dependencies; they're passed in. FastAPI has this built in with Depends, and this site uses it even for small things like setting cache headers:

async def cache_page(response: Response) -> Response:
    response.headers["Cache-Control"] = PAGE
    return response

CachedPage = Annotated[Response, Depends(cache_page)]

Any route declaring a parameter of type CachedPage gets cached under the shared policy. Changing the cache policy for every HTML page means changing one constant.

What each kind of indirection costs

Every pattern above adds a step of indirection: calling through a function passed in, looking up a dict, calling a method instead of writing the code directly. Ten kinds of "behaviour chosen by type", each a small addition, measured five ways:

How the behaviour is chosen Per call
Calling a function directly 13.6 ns
if/elif, matching the first branch 17.5 ns
if/elif, matching the 10th branch 62.3 ns
match, matching the 10th case 66.6 ns
Dict lookup to a function (registry) 29.4 ns
Method on an object (polymorphism) 15.9 ns

A few things stand out:

  • A polymorphic method call is almost free: 15.9 ns against 13.6 ns for a direct call. Python looks methods up through the object's type, and that's heavily optimized.
  • An if chain gets slower with each branch: each extra branch adds about 5 ns for one string comparison. match with constant strings isn't faster: in CPython, it still compares case by case.
  • A registry has a fixed price: 29.4 ns whether there are 10 kinds or 1,000, because a dict lookup doesn't depend on its size.

But look at the table again: the biggest difference is about 50 ns. An indexed database query costs microseconds, a request over the network tens of milliseconds. For code that runs a few times per request, choose patterns for clarity, not nanoseconds. Only in a hot loop running tens of millions of times is it worth measuring again.

When patterns backfire

  • Patterns for their own sake. An AbstractNotificationStrategyFactory for exactly one way of sending notifications isn't good design; it's three layers to read through. A pattern is a solution to a problem; no problem, no solution needed yet.
  • Translating Java word for word. Getters and setters for every attribute (Python has @property for when you need it), classes holding a single execute method (a function is enough), interfaces for everything (duck typing and Protocol are enough).
  • Singleton as a disguised global. Shared mutable state hidden behind get_instance() makes tests depend on the order they run in. A read-only configuration object is fine; an object holding changing state should be passed in through dependency injection so tests can replace it.

Summary

Pattern In Python In this site's code
Strategy Pass a function OAuthProvider.parse_user, sorted(key=...)
Singleton A module, a module-level object auth_settings, mail_settings
Iterator Generators, yield The database session in get_session
Decorator @decorator @lru_cache with mtime_ns in the key
Command Functions, partial, BackgroundTasks Sending email in the background
Factory A dict registry get_providers()
Adapter / Facade A class wrapping an outside API Mailer wrapping aiosmtplib
Observer Callbacks, events, later pub/sub
Dependency Injection Parameters, Depends CachedPage, get_mailer

From here on, the unit of design is no longer a function or a class but a whole server: a database, a cache, a queue, a load balancer. Next post: the building blocks of system design, with measured numbers for estimating, and why a server running at 90% capacity is 5 times slower than at 50%.