Skip to content

description: "Design Patterns in Python — Design patterns are named, reusable solutions to common design problems. This module covers four of the most useful ones…"---

01 · Design Patterns in Python

Design patterns are named, reusable solutions to common design problems. This module covers four of the most useful ones day-to-day, implemented in a way that leans on Python's own features (first-class functions, modules as singletons) rather than translating them literally from a language like Java.

Factory pattern

A factory centralizes object creation logic, so callers don't need to know which concrete class to instantiate.

from abc import ABC, abstractmethod


class Notifier(ABC):
    @abstractmethod
    def send(self, message: str) -> None: ...


class EmailNotifier(Notifier):
    def send(self, message: str) -> None:
        print(f"Emailing: {message}")


class SMSNotifier(Notifier):
    def send(self, message: str) -> None:
        print(f"Texting: {message}")


def notifier_factory(kind: str) -> Notifier:
    notifiers = {"email": EmailNotifier, "sms": SMSNotifier}
    try:
        return notifiers[kind]()
    except KeyError:
        raise ValueError(f"unknown notifier kind: {kind!r}")


notifier = notifier_factory("email")
notifier.send("Your order shipped!")

Adding a new notifier type means adding one entry to the dict, not scattering if/elif chains across the codebase.

Strategy pattern

Strategy lets you swap an algorithm at runtime by passing in different behavior — in Python this is often just a function, no class hierarchy required.

def total_price(items, discount_strategy):
    subtotal = sum(item["price"] for item in items)
    return discount_strategy(subtotal)


def no_discount(subtotal):
    return subtotal

def ten_percent_off(subtotal):
    return subtotal * 0.9

def bulk_discount(subtotal):
    return subtotal * 0.8 if subtotal > 100 else subtotal


items = [{"price": 40}, {"price": 70}]
print(total_price(items, no_discount))       # 110
print(total_price(items, ten_percent_off))    # 99.0
print(total_price(items, bulk_discount))      # 88.0

Each "strategy" is just a plain function with a matching signature — no Strategy base class needed, thanks to Python's first-class functions.

Observer pattern

Observer lets one object ("subject") notify a list of interested listeners whenever something happens, without the subject knowing anything about them.

class EventBus:
    def __init__(self):
        self._subscribers = {}

    def subscribe(self, event_name, callback):
        self._subscribers.setdefault(event_name, []).append(callback)

    def publish(self, event_name, **data):
        for callback in self._subscribers.get(event_name, []):
            callback(**data)


bus = EventBus()

def send_confirmation_email(order_id, total):
    print(f"Emailing confirmation for order {order_id} (${total})")

def update_inventory(order_id, total):
    print(f"Updating inventory for order {order_id}")

bus.subscribe("order_placed", send_confirmation_email)
bus.subscribe("order_placed", update_inventory)

bus.publish("order_placed", order_id=42, total=79.99)
# Emailing confirmation for order 42 ($79.99)
# Updating inventory for order 42

This is the same core idea behind GUI event handlers, webhooks, and pub/sub message queues — a subject publishes events, and any number of decoupled listeners react.

Singleton pattern

Singleton ensures a class has exactly one instance. In Python, a module is already a singleton (it's only ever imported once and cached in sys.modules), which is usually the simplest and most Pythonic way to share one piece of global state — no class needed at all.

# config.py — a module used as a singleton
_settings = {"debug": False, "api_url": "https://api.example.com"}

def get(key):
    return _settings[key]

def set(key, value):
    _settings[key] = value
import config
config.set("debug", True)

# anywhere else that imports config, it's the SAME dict — modules are cached
import config as config_again
print(config_again.get("debug"))   # True

If you do need a class-based singleton (e.g. because it needs __init__ arguments the first time it's created), override __new__:

class Logger:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.logs = []
        return cls._instance

    def log(self, message):
        self.logs.append(message)


a = Logger()
b = Logger()
a.log("first entry")
print(a is b)          # True — same object
print(b.logs)           # ['first entry'] — shared state

Choosing a pattern

Pattern Problem it solves Pythonic shortcut
Factory centralize "which class do I create?" logic a dict of constructors
Strategy swap an algorithm at runtime pass a plain function
Observer decouple "something happened" from "who reacts" a dict of event -> [callbacks]
Singleton exactly one shared instance a module (not a class)

How It Actually Works

The factory pattern's dict-of-constructors trick works because a class itself is a callable objectEmailNotifier is not sugar for anything else, it's a real value of type type that supports () to invoke __call__ on its metaclass, which runs __new__ then __init__. Storing {"email": EmailNotifier} is therefore storing an ordinary reference to a first-class object, exactly like storing a function in a dict — notifiers[kind]() is just "look up a callable, then call it," with no special-casing for the fact that the callable happens to be a class rather than a plain function.

The strategy pattern needs no Strategy base class specifically because Python's call syntax discount_strategy(subtotal) is duck-typed: the bytecode compiles to CALL, which just checks that whatever object sits in that variable defines __call__ (which every plain function does, via its type's tp_call slot in C) and invokes it with the given arguments — there's no interface check against a declared type anywhere in this path. This is different from a language with nominal typing, where "any object implementing an interface" needs that interface declared explicitly; here, "has the right shape and is callable with the right arguments" is sufficient at the point of the call.

EventBus.publish uses callback(**data) to invoke every subscriber with the same keyword arguments regardless of what each specific callback's parameter names are — this works because Python resolves keyword arguments by matching them against the receiving function's parameter names at call time (a dict-like binding step done by the interpreter before the function body runs), so as long as each subscribed function declares parameters named order_id and total, the dispatch is uniform even though the functions themselves are otherwise unrelated.

The module-as-singleton pattern relies directly on the import-caching mechanism from Level 1: sys.modules["config"] is created exactly once per process, and every subsequent import config anywhere in the codebase returns that same module object rather than re-running its top-level code — _settings is therefore one dictionary shared by reference across every importer, with no synchronization needed because there's only ever one copy to begin with. The __new__-based class singleton achieves the same one-instance guarantee through a different, more explicit mechanism: __new__ is the method actually responsible for allocating a new object (called before __init__, which only initializes an already-allocated one), so caching and returning the same instance from __new__ means Logger() a second time never allocates a new object at all — __init__ still runs again on that cached instance afterward (Python calls it unconditionally after __new__ returns an instance of the class), which is worth knowing since it can silently reset state if you're not careful.

Exercise

Build a small "report exporter" using the strategy pattern: a function export_report(data, format_strategy) that accepts strategies as_csv_text, as_json_text, and as_markdown_table. Then add an EventBus-style observer so that every time export_report runs, it publishes an "export_completed" event with the format used and row count, and register two independent listeners (one that logs to a list, one that prints) to prove they're both notified independently.