Python Lesson 33 of 71

Software Design Patterns in Python: Factory, Strategy, Observer & Friends

The Design Patterns book — the 1994 “Gang of Four” catalogue — is the most influential software book most people have never actually needed. It gave a generation a shared vocabulary: say “Observer” and a room of engineers pictures the same thing. That vocabulary is genuinely valuable. But the book was written in and for C++, a language with no first-class functions, no closures, no duck typing, and a type system that fights you. A large fraction of the twenty-three patterns are elaborate ceremonies for doing something those languages could not do directly — and Python can.

Here is the uncomfortable truth this lesson is built around, stated once, plainly:

# The "Strategy pattern": an interface, two concrete classes, a context that holds one.
class Order:
    def __init__(self, shipping): self.shipping = shipping
    def cost(self, w): return self.shipping.cost(w)     # delegate to the strategy

# The same thing in Python:
order.shipping = by_weight(20)      # a strategy is just a function you pass in

Both are “the Strategy pattern.” One is four classes and an abstract base; the other is a function passed into a slot. When a pattern is mostly boilerplate, the boilerplate is not the pattern — the intent is. Python keeps the intent and throws the boilerplate away, because a function is an object, a dict is dispatch, a module is a singleton, and any object with the right methods is the interface. Peter Norvig made this point in 1996: sixteen of the twenty-three GoF patterns are “invisible or simpler” in a language with first-class functions and dynamic typing. Python is exactly that language.

This lesson teaches each pattern twice: the real, classic structure (so you recognise it in Java, in a codebase, in an interview) and the pythonic form it collapses to (so you write the smaller, better version). For every one you get the problem it solves, a minimal example that actually runs, the pythonic alternative, and — the part that matters most — when the full ceremony genuinely earns its keep, because sometimes it does.


Why this matters

You will meet patterns from two directions, and both go wrong without this lesson.

From the interview and the textbook, you will be asked to “implement the Observer pattern” or “use a Factory here,” and if you have only ever seen the Java form you will write four classes where Python wanted a function — and a good reviewer will quietly mark you down for it. The pattern vocabulary is real and worth knowing; reciting the implementation from a language that isn’t yours is not.

From the codebase, you will inherit — or worse, write — a NotificationManager that is a 2,000-line God object, a SingletonConfig that makes every test flaky, a five-level inheritance tree that could have been a list of functions, an AbstractWidgetFactoryProvider that constructs one kind of widget. This is cargo-cult engineering: applying the ritual of a pattern without the problem that justifies it. The GoF book itself warns against this on page 1, and it is the single most common way patterns hurt a Python codebase. Patterns are a vocabulary for a problem you already have, not a checklist to apply pre-emptively.

The mental model to carry through the whole lesson is this. A design pattern is an answer to a question of the form “how do I vary X without rewriting Y?” — vary the algorithm without rewriting the caller (Strategy), vary the created type without rewriting the constructor (Factory), vary who-reacts without rewriting the source (Observer). In a language with first-class functions, the answer to almost all of those is the same: pass the varying part in as an argument. A function is the smallest possible unit of “behaviour you can pass around,” and most patterns are scaffolding built to move behaviour around in languages that can’t pass a function. Keep asking “what is varying, and can I just pass it in?” and you will reinvent the pythonic form of half the catalogue on your own.

The GoF’s twenty-three patterns fall into three families, and it helps to hold the whole map in view before zooming in — because the family tells you, in advance, roughly what the pattern will collapse to in Python:

GoF family What it varies Example patterns Typical Python status
Creational How objects are made Factory Method, Abstract Factory, Singleton, Builder, Prototype A function, a dict, a registry, or a module
Structural How objects are composed Adapter, Decorator, Proxy, Facade, Composite, Bridge, Flyweight Some survive (Adapter); many become a wrapping function or __getattr__
Behavioural How responsibility and behaviour flow Strategy, Observer, Command, Template Method, Iterator, State, Visitor, Chain, Mediator, Memento Mostly “pass a function”; Iterator and resource-management became syntax

The behavioural row is the one that all but vanishes, because behaviour is precisely what a first-class function is. Keep that map open as we walk the patterns one by one.

One more framing, because it prevents the biggest mistake. The goal is never “use a pattern.” The goal is code that is easy to change, easy to test, and easy to read. A pattern is worth its cost only when it buys one of those three against a change you can actually name. If you cannot name the change you are protecting against, you are not designing — you are decorating, and the decoration will cost the next reader real time.


Strategy: interchangeable algorithms

The problem: you have one operation whose algorithm needs to vary — how shipping is priced, how a list is sorted, how a payment is retried — and you do not want a growing if/elif ladder in the caller, one branch per algorithm, edited by everyone.

The classic OO Strategy defines an interface, one class per algorithm, and a context that holds a strategy and delegates to it:

from abc import ABC, abstractmethod
from decimal import Decimal

class ShippingStrategy(ABC):
    @abstractmethod
    def cost(self, weight_kg: float) -> Decimal: ...

class FlatRate(ShippingStrategy):
    def cost(self, weight_kg):
        return Decimal("50")

class ByWeight(ShippingStrategy):
    def __init__(self, per_kg):
        self.per_kg = Decimal(str(per_kg))
    def cost(self, weight_kg):
        return self.per_kg * Decimal(str(weight_kg))

class Order:
    def __init__(self, weight_kg, shipping: ShippingStrategy):
        self.weight_kg = weight_kg
        self.shipping = shipping                 # the context HOLDS a strategy
    def shipping_cost(self):
        return self.shipping.cost(self.weight_kg)   # ... and DELEGATES to it

print(Order(3, FlatRate()).shipping_cost())      # => 50
print(Order(3, ByWeight(20)).shipping_cost())    # => 60

o = Order(3, FlatRate())
o.shipping = ByWeight(20)                          # swap the algorithm at runtime
print(o.shipping_cost())                           # => 60

That works, and the runtime swap is the whole point: Order never contains a single if about shipping, and adding a FreeShipping strategy touches zero existing code. This is genuinely good design. But look at FlatRate — a class, an ABC, a method — to hold one function’s worth of behaviour. In Python, an object with one method is a function wearing a costume.

The pythonic Strategy is a function you pass in:

from decimal import Decimal

def flat_rate(weight_kg):
    return Decimal("50")

def by_weight(per_kg):                    # returns a configured strategy (a closure)
    def cost(weight_kg):
        return Decimal(str(per_kg)) * Decimal(str(weight_kg))
    return cost

class Order:
    def __init__(self, weight_kg, shipping):   # `shipping` is just a callable
        self.weight_kg = weight_kg
        self.shipping = shipping
    def shipping_cost(self):
        return self.shipping(self.weight_kg)   # call it directly — no .cost()

print(Order(3, flat_rate).shipping_cost())            # => 50
print(Order(3, by_weight(20)).shipping_cost())        # => 60
print(Order(3, lambda w: Decimal("0")).shipping_cost())  # => 0   free shipping, inline

Same behaviour, same runtime swappability, a third of the code, and you can pass a lambda for a one-off without declaring a class at all. by_weight(20) is a closure — a function carrying its per_kg — which is the functional twin of “a strategy object with configuration.” (Closures are covered in depth in Functional Python; here they are the engine that makes the pattern vanish.)

The diagram below is the whole pattern in one picture. Read it left to right, following a single call: the Caller invokes the Context, which holds a strategy reference and delegates through one call site; behind that seam sit interchangeable algorithms — as OO objects, or, pythonically, as a plain function or closure — and both produce the identical result without a single if kind == ... branch in the caller.

Strategy pattern in Python drawn as a left-to-right call: a Caller invokes a Context (an Order) that holds a strategy reference and delegates through exactly one call site, self.shipping(w); behind that seam are interchangeable algorithms shown first as OO classes FlatRate and ByWeight sharing a cost() method and then collapsing to the pythonic form where the strategy is just a function or closure passed in, both returning the same Decimal result and replacing an if/elif anti-pattern ladder

The six badges mark the load-bearing ideas: the Context delegates through one call site (1) to interchangeable algorithms (2) that are injected at runtime (3); in Python those collapse to just a function (4); both paths yield the same result (5); and the whole thing exists to replace the if/elif anti-pattern (6) that would otherwise grow in the caller.

When does the class form earn its keep over a bare function? When the strategy carries state or configuration you want to inspect, when it needs a good repr / equality / serialisation (a frozen dataclass strategy can be compared and stored in a config table — see domain models), or when it groups several related methods rather than one. A retry policy with should_retry(), backoff() and max_attempts is a real object; a single pricing rule is a function.

Aspect Classic OO Strategy Pythonic (pass a function)
Interface An ABC with @abstractmethod The function’s call signature — nothing to declare
One algorithm A class + a method A def (or a lambda)
Configuration __init__ stores it on the instance A closure captures it (by_weight(20))
Swap at runtime ctx.strategy = OtherStrategy() ctx.strategy = other_fn
A one-off Still a whole class An inline lambda
Comparable / serialisable Yes, if you write __eq__ Functions aren’t; use a frozen-dataclass strategy if needed
Lines of code ~4×
Reach for it when The strategy has state + several methods The strategy is one operation (the common case)
Strategy in the wild The “strategy” you pass
sorted(data, key=...) A key function — the canonical pythonic Strategy
sorted(data, key=..., reverse=True) Ordering strategy as a flag + function
defaultdict(list) A factory strategy for missing keys
max(items, key=len) The comparison strategy
re.sub(pat, repl, s) where repl is a function A per-match replacement strategy
list.sort(key=attrgetter("age")) operator.attrgetter as a reusable strategy

That first row is the point: Python’s own standard library implements Strategy hundreds of times a day and never once defines a SortStrategy class. It passes a function.


Factory, Factory Method and Abstract Factory

The problem: you need to create an object, but which concrete type depends on data known only at runtime — a config string, a file extension, a message type field — and you do not want if/elif construction ladders scattered across the code.

The GoF splits this into three patterns of increasing ceremony. It helps to see them named:

GoF factory pattern What it is Ceremony
Simple Factory (not officially GoF) One function/method that returns one of several types by argument Low
Factory Method A method on a base class that subclasses override to choose the type Medium — a class hierarchy
Abstract Factory An object whose several methods each create a matching family of products High — a hierarchy of factories

Here is Factory Method in full classic form — a factory hierarchy that mirrors the product hierarchy:

from abc import ABC, abstractmethod

class Notifier(ABC):
    @abstractmethod
    def send(self, msg): ...

class EmailNotifier(Notifier):
    def send(self, msg): return f"email: {msg}"

class SmsNotifier(Notifier):
    def send(self, msg): return f"sms: {msg}"

class NotifierFactory(ABC):
    @abstractmethod
    def create(self) -> Notifier: ...

class EmailFactory(NotifierFactory):
    def create(self): return EmailNotifier()

class SmsFactory(NotifierFactory):
    def create(self): return SmsNotifier()

def notify(factory: NotifierFactory, msg):
    return factory.create().send(msg)

print(notify(EmailFactory(), "hi"))     # => email: hi
print(notify(SmsFactory(), "hi"))       # => sms: hi

Count the classes: two products, two factories, two abstract bases — six types to send a string two ways. In Java, where a function cannot be passed and a class cannot be created from a name at runtime, this is necessary. In Python it is theatre, because a class is already a first-class object you can pass, store and call.

Pythonic factory, level 1 — a plain function:

def make_notifier(kind):
    if kind == "email":
        return EmailNotifier()
    if kind == "sms":
        return SmsNotifier()
    raise ValueError(f"unknown notifier: {kind!r}")

print(make_notifier("email").send("hi"))       # => email: hi
make_notifier("carrier-pigeon")
# ValueError: unknown notifier: 'carrier-pigeon'

A single function replaces the entire factory hierarchy. For a handful of fixed types this is the correct answer — do not out-engineer it.

Pythonic factory, level 2 — dict dispatch. When the if/elif ladder grows, replace it with a mapping from name to class. The class is the value.

NOTIFIERS = {"email": EmailNotifier, "sms": SmsNotifier}

def make_notifier(kind):
    try:
        return NOTIFIERS[kind]()               # look up the class, then call it
    except KeyError:
        raise ValueError(f"unknown notifier: {kind!r}") from None

print(make_notifier("email").send("hi"))       # => email: hi

The from None suppresses the KeyError context so the traceback shows only your clean ValueError rather than a confusing “during handling of the above exception” chain. Adding a type is now one dict entry, and the mapping is data you can iterate, log, or load from config.

Pythonic factory, level 3 — a real plugin registry with a decorator. This is the pattern worth mastering, because it is how Flask routes, pytest plugins, Click commands and Django admin registrations all work. A decorator registers each class at definition time, so adding a plugin never edits a central list:

_REGISTRY = {}

def register(name):
    def deco(cls):
        if name in _REGISTRY:
            raise ValueError(f"notifier {name!r} already registered")
        _REGISTRY[name] = cls
        return cls                              # return cls unchanged — just record it
    return deco

@register("email")
class Email:
    def send(self, msg): return f"email: {msg}"

@register("sms")
class Sms:
    def send(self, msg): return f"sms: {msg}"

@register("slack")
class Slack:
    def send(self, msg): return f"slack: {msg}"

def create(name, *args, **kwargs):
    try:
        return _REGISTRY[name](*args, **kwargs)
    except KeyError:
        raise ValueError(f"unknown notifier {name!r}; "
                         f"have {sorted(_REGISTRY)}") from None

print(sorted(_REGISTRY))                        # => ['email', 'slack', 'sms']
print(create("slack").send("deploy done"))      # => slack: deploy done
create("fax")
# ValueError: unknown notifier 'fax'; have ['email', 'slack', 'sms']

The decorator is the Abstract Factory’s registration, done in one line per plugin with zero factory classes. A new notification channel is a new file with @register("...") on top — the core never changes, which is exactly the open/closed goal the GoF hierarchy chases with six classes.

This is not a toy: the decorator registry is one of the most widely used patterns in the Python ecosystem, and recognising it in a library’s source is a genuine skill. You have almost certainly used all of these:

Library Registration syntax What the registry maps
Flask / FastAPI @app.route("/path") / @app.get(...) URL rule → view function
pytest @pytest.fixture / entry-point plugins Name → fixture / hook
Click @cli.command() Command name → callback
Django @admin.register(Model) Model → admin class
functools @fn.register (singledispatch) Argument type → implementation
atexit @atexit.register — → cleanup callback to run at exit
Celery @app.task Task name → callable for the worker

Every one of these is “a decorator writing into a dict” — the factory registry you just built, at production scale.

Two more pythonic factories deserve a mention because they cover the remaining cases:

from functools import singledispatch

@singledispatch                                  # dispatch on the TYPE of the argument
def to_json(x):
    raise TypeError(f"no rule for {type(x).__name__}")

@to_json.register
def _(x: int):
    return str(x)

@to_json.register
def _(x: list):
    return "[" + ",".join(to_json(i) for i in x) + "]"

print(to_json(5))            # => 5
print(to_json([1, 2, 3]))    # => [1,2,3]

functools.singledispatch is a type-based factory: it picks the implementation from the argument’s class — the pythonic answer to “Abstract Factory keyed on product type.” And for a fixed, closed set of choices, an Enum is the right registry:

from enum import Enum

class Channel(Enum):
    EMAIL = "email"
    SMS = "sms"

print(Channel("email"), Channel.EMAIL.value)     # => Channel.EMAIL email
Channel("fax")
# ValueError: 'fax' is not a valid Channel
Factory need Pythonic form Why
A few fixed types A plain function with if/elif Smallest thing that works
Growing set, known at import dict mapping name → class Data-driven; add a row
Open/extensible plugins @register(...) decorator registry New plugin = new file, core untouched
Choose by argument type functools.singledispatch Type-keyed dispatch, built in
Fixed, closed vocabulary Enum Validates membership for free
Family of related products A module or a frozen dataclass of callables A module groups related factories naturally

When is the classic factory hierarchy actually right in Python? Rarely — but when a factory itself needs to carry configuration and state (a ConnectionFactory holding a pool, credentials and retry policy, with several create_* methods), a small factory class is reasonable. The tell is state: if the factory is stateless, it wants to be a function or a dict, not a class.


Observer and pub/sub

The problem: something happens in one place (an order is placed, a sensor reads a value, a file changes) and an open-ended set of other parts of the system need to react — send an email, update a metric, invalidate a cache — without the source knowing who they are or how many there will be.

The classic OO Observer defines an Observer interface with update(), and a Subject that keeps a list of observers and notifies each:

from abc import ABC, abstractmethod

class Observer(ABC):
    @abstractmethod
    def update(self, temp): ...

class Display(Observer):
    def __init__(self, name): self.name = name
    def update(self, temp): print(f"  {self.name}: {temp}C")

class Subject:
    def __init__(self):
        self._observers = []
    def attach(self, obs): self._observers.append(obs)
    def detach(self, obs): self._observers.remove(obs)
    def notify(self, temp):
        for obs in self._observers:
            obs.update(temp)

s = Subject()
s.attach(Display("phone"))
s.attach(Display("watch"))
s.notify(21)
# =>   phone: 21C
# =>   watch: 21C

Again, Display is a class with one method — update — so again it wants to be a function. The pythonic Observer is a list of callbacks:

class Sensor:
    def __init__(self):
        self._callbacks = []
    def subscribe(self, fn):
        self._callbacks.append(fn)
        return fn                         # return fn so it also works as a decorator
    def emit(self, temp):
        for fn in self._callbacks:
            fn(temp)

sensor = Sensor()
sensor.subscribe(lambda t: print(f"  log: {t}C"))

@sensor.subscribe                          # decorator registration reads beautifully
def alarm(temp):
    if temp > 30:
        print(f"  ALARM: {temp}C")

sensor.emit(21)     # =>   log: 21C
sensor.emit(35)     # =>   log: 35C
                    # =>   ALARM: 35C

An observer is any callable of the right shape. return fn from subscribe is a small, idiomatic touch: it lets the method double as a decorator, so @sensor.subscribe above the function is the subscription — the same trick Flask’s @app.route uses.

When many event types and many subscribers meet, the pattern grows into an event bus (pub/sub): a central broker keyed by event name, so publishers and subscribers never reference each other at all.

from collections import defaultdict

class EventBus:
    def __init__(self):
        self._subs = defaultdict(list)
    def on(self, event, fn):
        self._subs[event].append(fn)
        return fn
    def off(self, event, fn):
        self._subs[event].remove(fn)
    def emit(self, event, **data):
        for fn in list(self._subs[event]):    # iterate a COPY: a handler may unsubscribe
            fn(**data)

bus = EventBus()

def on_order(order_id, total, **_):
    print(f"  invoice for {order_id}: {total}")

def on_order_metrics(order_id, **_):
    print(f"  metric: order_placed {order_id}")

bus.on("order.placed", on_order)
bus.on("order.placed", on_order_metrics)
bus.emit("order.placed", order_id="ORD-1", total="INR 484.00")
bus.off("order.placed", on_order_metrics)
print("  -- after off --")
bus.emit("order.placed", order_id="ORD-2", total="INR 99.00")
  invoice for ORD-1: INR 484.00
  metric: order_placed ORD-1
  -- after off --
  invoice for ORD-2: INR 99.00

Three details in that EventBus are not decoration — they are the difference between a toy and a tool:

Detail Why it is there The bug it prevents
defaultdict(list) on() and emit() never check whether the event key exists KeyError on the first subscribe/emit of a new event
**data payload Publisher and subscriber signatures stay decoupled; each handler takes **_ A new payload field breaking every existing handler
for fn in list(self._subs[event]) Iterates a copy of the handler list RuntimeError: ... changed size during iteration when a handler unsubscribes mid-emit
off() provided at all Lets a subscriber be released A memory leak — the bus pins subscribers forever (see troubleshooting)
return handler from on() Lets on double as a decorator Boilerplate — you’d write the handler then subscribe it on a second line

That third row is the classic Observer bug, and it is invisible until a handler removes itself during a notification.

Observer scale Pythonic form Use when
One event, a few reactors A list of callbacks + emit() A single subject, in-process
One event, decorator-registered subscribe returns fn; @subject.subscribe You want registration to read declaratively
Many events, many subscribers An EventBus keyed by event name Publishers and subscribers must not know each other
Cross-process / durable A real broker (Redis pub/sub, Kafka, RabbitMQ) Events cross machines or must survive a crash
Attribute changed → react property setter fires callbacks, or __set_name__ descriptors You are observing state, not events

When is the class-based Observer interface worth it? When observers are stateful objects that also do other things — a CacheInvalidator that reacts to twelve event types and holds a connection — grouping those reactions as methods on a class is cleaner than twelve loose functions. But the subject almost never needs a base class; a list of callables is enough. And note the memory trap that the off() method exists to prevent, covered in troubleshooting below: a subject holds strong references to its subscribers, so a subscriber that never unsubscribes never dies.


Singleton: usually a module, occasionally a mistake

The problem the pattern claims to solve: exactly one instance of something (a config, a connection pool, a logger) shared everywhere.

The pythonic answer, first, because it is almost always the right one: use a module. A Python module is imported once and cached in sys.modules; every subsequent import returns the same object. Module-level state is a singleton, enforced by the interpreter, with zero boilerplate:

import sys
print(sys.modules["sys"] is sys)     # => True   the second import returns the SAME object

So the idiomatic “singleton config” is just a module:

# settings.py
DEBUG = False
_cache = {}

def get(key): return _cache.get(key)
def set(key, value): _cache[key] = value
# anywhere.py
import settings
settings.DEBUG = True        # every importer sees this — it is one shared object

No class, no __new__, no metaclass, no thread-safety dance. The module is the single shared namespace. For the overwhelming majority of “I need one shared thing,” this is the answer, and reaching for a Singleton class instead is the first sign of a Java accent.

Module-as-singleton gives you Watch out for
One shared object, enforced by sys.modules caching It is still global mutable state — the same test-isolation risk applies
Zero boilerplate — no __new__, metaclass, or lock Import-time side effects (opening a DB on import) make imports slow and tests heavy
Lazy, thread-safe first import (the import lock handles it) Circular imports if the module reaches back into its importers
Functions and module-level state, testable in isolation Reassigning module.x from many places is as tangled as any global

The caveats are real, which is why the best answer for a shared collaborator is usually not even a module singleton but injection — construct the object once and pass it down. But when you genuinely want process-wide constants or a small shared helper, a module beats every class-based singleton on this list.

But you will meet the class-based forms, so here they are. Via __new__:

class Config:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

a, b = Config(), Config()
print(a is b)                # => True

Via a metaclass (the “cleanest” OO form, reusable across classes):

class SingletonMeta(type):
    _instances = {}
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Registry(metaclass=SingletonMeta):
    def __init__(self):
        self.items = []

r1, r2 = Registry(), Registry()
print(r1 is r2)              # => True
r1.items.append("x")
print(r2.items)             # => ['x']   they are the same object — shared state

Via Borg / Monostate (many instances, one shared __dict__ — a cult favourite that is rarely a good idea):

class Borg:
    _shared = {}
    def __init__(self):
        self.__dict__ = self._shared        # all instances share one attribute dict

x, y = Borg(), Borg()
x.value = 42
print(y.value, x is y)      # => 42 False   different objects, identical state

Every one of these has a testing problem, and it is not academic. A singleton is global mutable state, and global mutable state leaks between tests. Watch two tests that each pass alone but not together:

class Counter(metaclass=SingletonMeta):
    def __init__(self):
        self.n = 0

def test_a():
    c = Counter(); c.n += 1
    assert c.n == 1

def test_b():
    c = Counter(); c.n += 1               # SAME instance as test_a — n is already 1
    assert c.n == 1

test_a()     # passes
test_b()     # AssertionError: expected 1, got 2

test_b fails with got 2 because Counter() returned the instance test_a already mutated. The tests are correct; the singleton is poisoning them. Now every test needs teardown that reaches into SingletonMeta._instances and clears it — global reset code, run in a specific order, exactly the fragility good tests avoid. Dependency injection (next section but one) is the cure: pass the shared thing in, and each test passes a fresh one.

The __new__ form has a second, subtler trap worth seeing once, because it silently corrupts state:

class Config:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance
    def __init__(self):
        print("  __init__ ran")           # runs on EVERY Config() call, not once
        self.data = {}

a = Config(); a.data["x"] = 1
b = Config()                              # __init__ runs AGAIN and wipes data
print(a is b, a.data)     # => True {}    your data is gone

__new__ returns the cached instance, but Python still calls __init__ on it every time — so self.data = {} resets the object on the second construction. a.data is empty despite a is b. This is a real bug people ship, and it is one more reason the module form — which has no __init__ to re-run — is the sane default.

Singleton form One instance? Testing pain Verdict
A module Yes (interpreter-enforced) Low — but still global state Default. Use this.
__new__ guard Yes High + __init__ re-runs every call Avoid; the re-run bug is easy to hit
Metaclass Yes, reusable High — must clear _instances per test Only if you truly need many singleton classes
Borg / Monostate No (many objects, shared state) High — shared __dict__ leaks Almost never; surprises readers
Injected dependency As many as you choose Low — pass a fresh one per test The design that scales.

The honest summary: “I need one of these” is usually “I need to not construct twelve of these,” and the answer to that is to construct one at the top of your program and pass it down — not to make the class refuse to be built twice. A Singleton that enforces oneness is a global in a costume, and globals are the thing your tests hate most.


Adapter, Decorator, Command, Template Method

Four “wrap or defer behaviour” patterns, each of which collapses in Python — but along different lines, so it is worth seeing them side by side.

Adapter — make an incompatible interface fit

The problem: you have code that expects one interface, and an object (a library, a legacy class) that offers a different one. An Adapter wraps the incompatible object and exposes the interface your code wants.

class LegacyThermometer:
    def fetch_celsius_times_ten(self):     # an awkward legacy API
        return 215                         # 21.5C, scaled by 10

class ThermometerAdapter:
    def __init__(self, legacy):
        self._legacy = legacy              # HAS-A the adaptee (composition)
    def read(self):                        # the interface OUR code wants
        return self._legacy.fetch_celsius_times_ten() / 10

def show(source):                          # depends only on .read()
    print(f"  {source.read()}C")

show(ThermometerAdapter(LegacyThermometer()))     # =>   21.5C

The Adapter is one of the patterns that stays useful in Python, because the mismatch is real: your code calls .read(), the library offers .fetch_celsius_times_ten(), and something must bridge them. But when the target interface is a single method, the adapter is often just a function:

def read_celsius(legacy):
    return legacy.fetch_celsius_times_ten() / 10

print(read_celsius(LegacyThermometer()))    # => 21.5

The class form earns its keep when the target interface has several methods, or when you need the adapter to be a drop-in object passed where the real type is expected. The trap — see troubleshooting — is an adapter that leaks: it forwards the clean method but also exposes the adaptee’s ugly one, so callers start depending on fetch_celsius_times_ten through the adapter and the abstraction rots.

Decorator pattern vs Python’s @decorator — related, not the same

This is the most confused pair of names in Python, so be precise. The GoF Decorator pattern wraps an object to add responsibilities while keeping the same interface, and the wrappers stack at runtime:

class Coffee:
    def cost(self): return 100
    def desc(self): return "coffee"

class MilkDecorator:                       # wraps a Coffee, exposes the same interface
    def __init__(self, wrapped): self._wrapped = wrapped
    def cost(self): return self._wrapped.cost() + 20
    def desc(self): return self._wrapped.desc() + " + milk"

class SugarDecorator:
    def __init__(self, wrapped): self._wrapped = wrapped
    def cost(self): return self._wrapped.cost() + 5
    def desc(self): return self._wrapped.desc() + " + sugar"

drink = SugarDecorator(MilkDecorator(Coffee()))    # stack wrappers at runtime
print(f"  {drink.desc()} = {drink.cost()}")        # =>   coffee + milk + sugar = 125

Python’s @decorator syntax wraps a function or class at definition time:

import functools

def timed(fn):
    @functools.wraps(fn)                   # preserve the wrapped function's name/doc
    def wrapper(*args, **kwargs):
        print(f"  calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

@timed
def add(a, b): return a + b

print(add(2, 3))              # =>   calling add
                             # => 5
print(add.__name__)          # => add   (thanks to functools.wraps)

They share a family resemblance — both “wrap X and preserve X’s interface” — but they operate on different things (objects vs callables) at different times (runtime composition vs definition-time transformation). The GoF version is for adding behaviour to an object you can stack; the @ version is for transforming a function or class where it is defined. (Decorators as a language feature go far deeper.) Say “the Decorator pattern” and you mean coffee-and-milk; say “a decorator” and you mean @timed. Knowing the difference is itself an interview question.

Command — encapsulate an action as an object

The problem: you want to treat “an action to perform later” as a value — to queue it, log it, undo it, or hand it to a button. The Command pattern wraps the action (and its reverse) in an object:

class Command:
    def __init__(self, do, undo):
        self.do, self.undo = do, undo

class Editor:
    def __init__(self):
        self.text = ""
        self.history = []
    def run(self, cmd):
        cmd.do()
        self.history.append(cmd)
    def undo(self):
        self.history.pop().undo()

ed = Editor()
def append(s):
    n = len(s)
    return Command(
        do=lambda: setattr(ed, "text", ed.text + s),
        undo=lambda: setattr(ed, "text", ed.text[:-n]),
    )

ed.run(append("hello "))
ed.run(append("world"))
print(repr(ed.text))     # => 'hello world'
ed.undo()
print(repr(ed.text))     # => 'hello '

Command needs undo here, so a small object holding do/undo pays for itself. But the simplest Command — “run this later, with these arguments baked in” — is a closure or functools.partial, no class at all:

from functools import partial

def greet(name, greeting="hi"): return f"{greeting}, {name}"

cmd = partial(greet, "vinod", greeting="hello")   # a deferred call with args captured
print(cmd())     # => hello, vinod    invoke it whenever you like

A partial (or a lambda) is a command: a callable with its arguments already bound, ready to be stored in a list, put on a queue, or scheduled. Reach for the Command class only when you need more than “call it”:

Once an action is a value you can… Pythonic mechanism
Call it later partial(fn, *args) / a closure
Store a queue of them A list/collections.deque of callables
Run it on a worker functools.partial (picklable if args are) → a job queue
Undo it A small object holding do and undo callables
Name it for logging Give the callable a __name__, or wrap it in a dataclass
Schedule it sched, threading.Timer, or an async task taking the callable

The first three need no class at all; only undo, naming and serialisation push you toward a Command object.

Template Method — a fixed skeleton with pluggable steps

The problem: an algorithm has a fixed shape but a few steps that vary. Template Method puts the skeleton in a base class and leaves the varying steps as methods subclasses override:

from abc import ABC, abstractmethod

class Report(ABC):
    def render(self):                          # the template: the fixed skeleton
        return self.header() + " | " + self.body() + " | " + self.footer()
    def header(self): return "REPORT"          # a hook with a default
    @abstractmethod
    def body(self): ...                        # a hook the subclass MUST fill
    def footer(self): return "end"

class Sales(Report):
    def body(self): return "sales=42"

print(Sales().render())     # => REPORT | sales=42 | end

The pythonic form keeps the skeleton in a function and takes the varying steps as callable arguments — Strategy and Template Method are the same idea once functions are first-class:

def render(body, header=lambda: "REPORT", footer=lambda: "end"):
    return f"{header()} | {body()} | {footer()}"

print(render(body=lambda: "sales=42"))                       # => REPORT | sales=42 | end
print(render(body=lambda: "q4", footer=lambda: "confidential"))  # => REPORT | q4 | confidential

No subclass, no ABC — you pass the steps that vary and default the ones that don’t. The base-class form is worth it only when the varying steps are numerous, stateful, and genuinely belong together as a type.

Pattern Intent Pythonic collapse Keep the class when
Adapter Make interface B look like interface A A function, if A is one method A is several methods / must be a drop-in object
Decorator (GoF) Add responsibilities, stackable, same interface Often a @decorator or a wrapping function You stack object-level responsibilities at runtime
Command Encapsulate an action as a value A closure / functools.partial You need undo, naming, or serialisation
Template Method Fixed skeleton, pluggable steps A function taking callables (= Strategy) Many stateful steps that form a real type

Dependency Injection: the pattern that makes tests easy

Dependency Injection (DI) is not a GoF pattern, but it is the most important idea in this lesson, because it is what makes everything above testable. The idea is a single sentence: an object should receive its collaborators, not construct or import them.

Here is the anti-pattern — a function that reaches out to a global — and why it is untestable:

import time

def make_id_bad():
    return f"ID-{int(time.time())}"    # reaches for the real clock — value is non-deterministic

You cannot assert what make_id_bad() returns, because it depends on the wall clock. Your only recourse is to monkeypatch the global time.time — reaching across module boundaries to replace it, which is fragile and leaks if you forget to undo it. Inject the dependency instead:

def make_id(now):                       # `now` is injected
    return f"ID-{now()}"

print(make_id(lambda: 1000))    # => ID-1000    fully deterministic in a test
print(make_id(lambda: 2000))    # => ID-2000

The test passes a fake now and asserts an exact value — no patching, no globals, no teardown. The same idea at the object level is constructor injection: an object takes its collaborators as __init__ arguments.

class Service:
    def __init__(self, clock, store):   # collaborators passed in, not imported
        self.clock = clock
        self.store = store
    def record(self, event):
        self.store.append((self.clock(), event))

log = []
svc = Service(clock=lambda: 42, store=log)     # inject a fake clock and a list "store"
svc.record("login")
print(log)      # => [(42, 'login')]

In tests you pass a fake clock and a plain list; in production you pass the real clock and a database. The Service code is identical in both — that is the whole payoff. Contrast the two mechanically:

Reach for a global / singleton Inject the dependency
How the collaborator arrives import db / Config() inside the method Passed to __init__ or the function
Test setup Monkeypatch the global; remember to undo it Pass a fake; nothing to undo
Test isolation State leaks between tests Each test builds its own fresh fakes
Swap implementations Edit the code Pass a different object
Reading the code Hidden dependencies, discovered at runtime Dependencies are in the signature
Coupling To a concrete module/class To an interface (a shape)

This ties directly back to Singleton. The reason Counter() poisoned the tests earlier is that it was a hidden global; the reason Service(clock=..., store=...) does not is that its collaborators are visible and replaceable. You almost never need a singleton — you need to construct one object at your program’s entry point and inject it downward. This “construct at the top, inject down” arrangement is the entire content of the heavyweight “DI containers” from the Java world; in Python it is usually just function arguments and a bit of wiring in main(). (The testing lesson builds on exactly this — injected dependencies are what make pytest fixtures and fakes so cheap.)

The one caution: do not inject everything. Injecting a pure helper, a stdlib function, or a value that never varies just adds noise. The rule of thumb:

Inject it (it is a seam) Don’t inject it (it is noise)
The clock (time.time, datetime.now) A pure function like math.sqrt
Randomness (random.choice, uuid4) A constant that never varies
Network / HTTP clients A private helper only this class uses
The database / a repository A dataclass that is plain data
The filesystem Standard-library data structures
The message bus / a notifier Simple arithmetic or string formatting
Anything you want to fake in a test Anything already trivial to construct in a test

The pattern: inject the things that (a) touch the outside world — clocks, network, disk, randomness — or (b) you genuinely want to vary. Those are the seams where tests and changes happen; everything else is just a dependency you construct inline.


Iterator and Context Manager: patterns Python baked into the language

Two GoF patterns did not collapse into functions — they were promoted into syntax. You have used both for lessons without calling them patterns.

Iterator — “traverse a collection without exposing its internals” — is the iterator protocol (__iter__/__next__), and a generator is the one-line way to write one:

def countdown(n):              # a generator function IS an iterator
    while n > 0:
        yield n
        n -= 1

print(list(countdown(3)))      # => [3, 2, 1]

it = iter([10, 20])            # every built-in collection hands you an iterator
print(next(it), next(it))      # => 10 20
next(it)                       # StopIteration   the protocol's "I'm done" signal

Where Java writes a hasNext()/next() iterator class, Python writes yield and the language builds the iterator for you. for, list(), unpacking, comprehensions and in all speak this protocol — a whole GoF pattern reduced to one keyword.

Context Manager — “guaranteed setup and teardown around a block, even on error” — is the with statement (__enter__/__exit__), and contextlib.contextmanager turns a generator into one:

from contextlib import contextmanager

@contextmanager
def tag(name):
    print(f"  <{name}>")       # setup, before the block
    try:
        yield                  # the `with` body runs here
    finally:
        print(f"  </{name}>")  # teardown, guaranteed — even if the body raises

with tag("b"):
    print("  bold text")
# =>   <b>
# =>   bold text
# =>   </b>

This is the pattern behind open(), threading.Lock, sqlite3 transactions and countless resource handles. It is worth naming them as patterns precisely so you stop reinventing them: if you catch yourself writing paired setup/teardown methods that callers must remember to call in order, you want a context manager; if you are exposing has_next/get_next, you want a generator. The language already has the pattern — use the keyword.

Native pattern GoF name Python syntax You almost never write
Iterate without exposing internals Iterator __iter__/__next__, yield A hasNext()/next() class
Guaranteed setup/teardown (resource) with, __enter__/__exit__ Manual try/finally at every call site
React to state change Observer property setter + callbacks A full Subject hierarchy
Pick behaviour by argument Strategy A function argument (key=) A Strategy class per algorithm

The master table: pattern → intent → pythonic form → when to keep the class

This is the reference to bookmark. Read a row as: the pattern, the one-line intent, what it collapses to in Python, and the specific condition under which the full OO version still earns its keep.

Pattern Intent (the question it answers) Pythonic form Keep the classic OO version when
Strategy Vary the algorithm without editing the caller A function passed in (key=fn) The strategy carries state + several methods
Factory Method Vary the created type without editing the constructor A function returning the type The factory holds state (a pool, credentials)
Abstract Factory Create a family of related products A registry (@register) or a module of callables Families are large and swapped as a unit
Observer React to an event without the source knowing who A list of callbacks / an EventBus Observers are stateful objects doing many things
Singleton Exactly one shared instance A module (or inject one object) Almost never — prefer injection
Adapter Make interface B look like interface A A wrapping function (if A is one method) A is several methods / must be a drop-in
Decorator (GoF) Add stackable responsibilities, same interface A @decorator or a wrapping function You stack object-level responsibilities at runtime
Command Treat an action as a value (queue/undo/log) A closure / functools.partial You need undo, naming, or serialisation
Template Method Fixed skeleton, pluggable steps A function taking callables Many stateful steps that form a type
Iterator Traverse without exposing internals yield / the iterator protocol Basically never — the protocol is enough
State Behaviour changes with an internal mode A dict of handler functions keyed by state States are rich objects with much behaviour
Chain of Responsibility Pass a request along handlers until one takes it A list of functions you loop over Handlers are stateful and dynamically reordered
Visitor Add operations to a type hierarchy from outside functools.singledispatch You genuinely have the double-dispatch problem
Builder Construct a complex object step by step Keyword arguments / a dataclass with defaults Construction is genuinely multi-step and validated
Proxy Stand in for another object __getattr__ forwarding / a wrapping function You need lazy loading, access control, or caching
Mediator Centralise how objects interact An EventBus or a coordinating function The interaction rules are complex and stateful

Notice the two dominant right-hand columns: “a function” and “a module/registry/dict.” That is not a coincidence — it is Norvig’s observation in tabular form. First-class functions dissolve the behavioural patterns; the module system and dicts dissolve the creational ones; the language keyword dissolves Iterator and Context Manager. What survives as real objects are the patterns with genuine state — and the tell, every time, is state.

Because “state is the tell” does so much work, it is worth making concrete. Here is the same “carry some behaviour plus a little configuration” need, expressed at four levels of ceremony — pick the lowest one that meets your actual requirements:

Form Carries state? Comparable / has repr? Reach for it when
lambda / bare def No (or via closure) No A one-off; the behaviour is all there is
Closure (by_weight(20)) Yes — captured variables No You need configuration but nothing inspectable
Callable object (__call__) Yes — on the instance Yes, if you write the dunders You want a good repr, or several related methods
@dataclass(frozen=True) with __call__ Yes — inspectable fields Yes, for free The strategy must be compared, hashed, or loaded from config

Every step down that table adds ceremony and buys inspectability. Most strategies live on the first two rows; the day one needs to appear in a config table or a test assertion is the day it earns the fourth. Do not start at the bottom.


When a pattern earns its keep (and when it is just YAGNI)

Patterns have a cost — indirection, more files, more names, a reader who must hold more in their head — and that cost is paid whether or not the flexibility is ever used. YAGNI (“You Aren’t Gonna Need It”) is the discipline of not paying until you must. The question is never “which pattern fits?” but “is the flexibility this pattern buys worth its cost against a change I can actually name?

Signal that a pattern earns its keep Signal it is over-engineering (YAGNI)
There are already three-plus variants of the varying thing There is exactly one, and “there might be more someday”
A new variant is added often, by different people The set has been stable for a year
The variants are chosen at runtime (config, plugin, user input) The choice is fixed at code-writing time
Removing the pattern would force edits across many files The pattern spans one file you fully control
The seam is at a boundary (I/O, plugins, the network) The seam is deep in pure internal logic
You can name the change you are protecting against You are protecting against “flexibility” in the abstract
Tests get easier (a fake drops in) Tests get harder (more setup, more mocks)

The single most useful heuristic is the Rule of Three: do not abstract on the first case, or even the second — write it directly, duplicated if need be. Abstract on the third, when the shape of what varies is finally clear. Premature abstraction is worse than duplication, because a wrong abstraction is expensive to unpick and a duplication is cheap to delete. The patterns above are what you reach for at the third case, not before it.

And know the anti-patterns that cargo-culting produces, because they have names and you will see them in review:

Anti-pattern What it looks like The fix
God object Manager/Handler/Utils class that does everything Split by responsibility; most methods want to be functions or small classes
Poltergeist A class whose only job is to call another class Delete it; call the other class directly
Cargo-cult pattern AbstractSingletonProxyFactoryBean for one concrete type Use a function; add the pattern when a second type appears
Speculative generality Hooks, plugins and config for cases that don’t exist Delete the unused flexibility; add it when a case is real
Class that is one method A class with __init__ + do() and nothing else It is a function; make it a function
Singleton-as-global A Config class that refuses to be built twice A module, or inject one instance

If you remember one sentence from this lesson, make it this: the best pattern is usually a function, and the second-best is a plain object you inject; reach past those two only when state or a named, recurring change forces you to.


Hands-on lab

You will build a small but real checkout system that uses four patterns idiomatically — a registry factory for notification channels, Strategy as pluggable pricing functions, an Observer event bus, and dependency injection so the whole thing is trivially testable — and then write a pytest suite that is easy precisely because the dependencies are injected. Everything is pure standard library except pytest.

Setup. This lab targets Python 3.12+. Create a folder and a virtual environment (on Windows use .venv\Scripts\activate and python instead of python3):

mkdir shoplab && cd shoplab
python3 -m venv .venv && source .venv/bin/activate
python3 --version           # Python 3.12.3
pip install pytest          # the only third-party dependency

Step 1 — shop.py: Strategy as pluggable functions. Create shop.py and start with the pricing rules. A rule is any callable Decimal -> Decimal; no base class, no interface.

from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Callable

def inr(x) -> Decimal:
    return Decimal(str(x))

def fmt(m: Decimal) -> str:
    return f"INR {m:,.2f}"

# STRATEGY: pricing rules are just callables Decimal -> Decimal
PriceRule = Callable[[Decimal], Decimal]

def percent_off(pct: int) -> PriceRule:
    def rule(subtotal: Decimal) -> Decimal:
        return subtotal - subtotal * Decimal(pct) / Decimal(100)
    return rule

def flat_fee(fee) -> PriceRule:
    fee = inr(fee)
    def rule(subtotal: Decimal) -> Decimal:
        return subtotal + fee
    return rule

def apply_rules(subtotal: Decimal, rules) -> Decimal:
    for rule in rules:              # order is EXPLICIT — you wrote it in a tuple
        subtotal = rule(subtotal)
    return subtotal

What just happened: percent_off(10) and flat_fee(50) are closures — configured strategies — and apply_rules composes them in the order you pass, with no if discount_type == ... anywhere. (This mirrors the composition-over-inheritance result from the domain models lesson: the tuple makes the order of operations explicit instead of leaving it to an MRO.)

Step 2 — a decorator-built registry factory for channels. Append this. A channel is registered by a decorator, so adding one never edits a central list.

# FACTORY: a decorator-built registry of notification channels
_CHANNELS: dict[str, type] = {}

def channel(name: str):
    def deco(cls):
        if name in _CHANNELS:
            raise ValueError(f"channel {name!r} already registered")
        _CHANNELS[name] = cls
        return cls
    return deco

def make_channel(name: str, **kwargs):
    try:
        return _CHANNELS[name](**kwargs)
    except KeyError:
        raise ValueError(f"unknown channel {name!r}; "
                         f"have {sorted(_CHANNELS)}") from None

@channel("email")
@dataclass
class Email:
    outbox: list = field(default_factory=list)     # NOT `outbox: list = []`
    def send(self, msg: str) -> None:
        self.outbox.append(("email", msg))

@channel("sms")
@dataclass
class Sms:
    outbox: list = field(default_factory=list)
    def send(self, msg: str) -> None:
        self.outbox.append(("sms", msg))

What just happened: @channel("email") records the class in _CHANNELS at import time and returns it unchanged. make_channel("email") builds one by name. Note field(default_factory=list) — a mutable default in a registered class would share one outbox across every instance, the exact bug the troubleshooting table below catches.

Step 3 — an Observer event bus. Append the bus. Publishers and subscribers never reference each other.

# OBSERVER: a tiny event bus (pub/sub)
class EventBus:
    def __init__(self) -> None:
        self._subs: dict[str, list[Callable]] = defaultdict(list)
    def on(self, event: str, handler: Callable):
        self._subs[event].append(handler)
        return handler                       # returnable -> also usable as a decorator
    def off(self, event: str, handler: Callable) -> None:
        self._subs[event].remove(handler)    # unsubscribe -> no memory leak
    def emit(self, event: str, **data) -> None:
        for handler in list(self._subs[event]):   # copy: a handler may unsubscribe
            handler(**data)

What just happened: defaultdict(list) means on() never checks whether an event exists; off() exists so subscribers can be released (the leak fix); and emit iterates list(...), a copy, so a handler unsubscribing mid-emit cannot corrupt the loop.

Step 4 — the Checkout service with dependency injection. Append it. The service receives its collaborators; it constructs none of them.

# DEPENDENCY INJECTION: the service is handed its collaborators
@dataclass
class Checkout:
    bus: EventBus                            # injected -> a fake bus in tests
    clock: Callable[[], int]                 # injected -> deterministic in tests
    rules: tuple[PriceRule, ...] = ()        # injected Strategy list
    def place(self, order_id: str, subtotal) -> Decimal:
        total = apply_rules(inr(subtotal), self.rules)
        self.bus.emit("order.placed", order_id=order_id,
                      total=total, at=self.clock())
        return total

What just happened: Checkout knows nothing about email, SMS, the wall clock, or which discounts apply. It gets a bus, a clock and a rules tuple. That is the seam that makes Step 6’s test a two-liner.

Step 5 — demo.py: wire it together and run it. Create demo.py alongside shop.py:

from shop import (Checkout, EventBus, make_channel, percent_off, flat_fee, fmt)

bus = EventBus()
email = make_channel("email")
sms = make_channel("sms")

# Observer: channels subscribe to the event; Checkout knows none of them
bus.on("order.placed",
       lambda order_id, total, **_: email.send(f"{order_id}: {fmt(total)}"))
bus.on("order.placed",
       lambda order_id, at, **_: sms.send(f"{order_id} placed at t={at}"))

# DI: hand the service a fixed clock and the rule Strategy list
checkout = Checkout(bus=bus, clock=lambda: 1000,
                    rules=(percent_off(10), flat_fee(50)))
total = checkout.place("ORD-1", 484)

print("total       :", fmt(total))          # (484 * 0.9) + 50
print("email outbox:", email.outbox)
print("sms outbox  :", sms.outbox)

Run it:

python3 demo.py
total       : INR 485.60
email outbox: [('email', 'ORD-1: INR 485.60')]
sms outbox  : [('sms', 'ORD-1 placed at t=1000')]

What just happened: one checkout.place(...) call priced the order through the strategy tuple (484 × 0.9 + 50 = 485.60), emitted one event, and two independently-registered channels reacted — none of which Checkout imports or names. Adding a Slack channel is a new @channel("slack") class plus one bus.on(...); nothing in Checkout changes.

Step 6 — test_shop.py: the payoff. Create the test file. Watch how little each test needs, because every dependency is injected:

from decimal import Decimal
from shop import Checkout, EventBus, percent_off, flat_fee, make_channel

def test_place_applies_rules_in_order():
    checkout = Checkout(bus=EventBus(), clock=lambda: 0,
                        rules=(percent_off(10), flat_fee(50)))
    # (484 * 0.9) + 50 == 485.60, NOT (484 + 50) * 0.9
    assert checkout.place("ORD-1", 484) == Decimal("485.60")

def test_place_emits_event_with_injected_clock():
    seen = []
    bus = EventBus()
    bus.on("order.placed", lambda **data: seen.append(data))
    # the injected clock makes the timestamp deterministic -- no monkeypatch
    checkout = Checkout(bus=bus, clock=lambda: 42, rules=(percent_off(10),))
    total = checkout.place("ORD-9", 200)
    assert total == Decimal("180")
    assert seen == [{"order_id": "ORD-9", "total": Decimal("180"), "at": 42}]

def test_channels_are_pluggable_from_the_registry():
    email = make_channel("email")
    email.send("hi")
    assert email.outbox == [("email", "hi")]

def test_unknown_channel_is_a_clear_error():
    import pytest
    with pytest.raises(ValueError, match="unknown channel 'fax'"):
        make_channel("fax")

Run the suite:

python3 -m pytest -v
test_shop.py::test_place_applies_rules_in_order PASSED                   [ 25%]
test_shop.py::test_place_emits_event_with_injected_clock PASSED          [ 50%]
test_shop.py::test_channels_are_pluggable_from_the_registry PASSED       [ 75%]
test_shop.py::test_unknown_channel_is_a_clear_error PASSED               [100%]
============================== 4 passed in 0.00s ===============================

What just happened — and this is the entire point of the lab: test_place_emits_event_with_injected_clock asserts an exact timestamp of 42 with no monkeypatching, no freezegun, no global reset, because the clock is a constructor argument — pass lambda: 42 and time stands still. A fake bus is a real EventBus with a list-appending subscriber. There is no singleton to clear between tests, so the tests do not interfere and can run in any order. Every one of those properties is a direct consequence of the four patterns being implemented pythonically: functions for strategies, a registry for factories, a bus for observers, and injection for collaborators. Had Checkout reached for time.time() and a global Config() singleton, each of these tests would have needed teardown and patching — and one of them would be flaky.

⚠️ One caution for your own registries: _CHANNELS is module-global state. In a long-lived test suite that registers channels dynamically, clear or snapshot it in a fixture so one test’s registrations do not leak into another — the same global-state discipline the Singleton section warned about, now applied to your own registry.


Common mistakes and troubleshooting

Symptom / traceback Cause Fix
A one-method class with __init__ + do() and nothing else A function wearing a class costume (Strategy/Command over-built) Make it a function or a closure; pass it in
AssertionError in a test that passes when run alone A Singleton’s state leaked from a previous test Inject the object (fresh per test) instead of a singleton; if stuck, reset the singleton in teardown
__init__ runs twice and wipes state on a __new__ singleton __new__ returns the cache but Python still calls __init__ every time Guard __init__ with an _initialised flag, or use a module, or inject
ValueError: mutable default <class 'list'> on a registered dataclass outbox: list = [] shares one list across all instances field(default_factory=list)
RuntimeError: dictionary changed size during iteration (or a skipped observer) A handler subscribed/unsubscribed while emit looped the live list Iterate a copy: for h in list(self._subs[event])
Objects never garbage-collected; memory grows A subject holds strong refs to subscribers that never unsubscribe Provide and call off(); or hold weakref.ref / use weakref.WeakSet
TypeError: unknown notifier 'x' deep in a call, no context A factory KeyError re-raised without a clear message raise ValueError(f"unknown {x!r}; have {sorted(reg)}") from None
A Manager/Handler class of 1,000+ lines touched by every PR God-object anti-pattern from cargo-culted “manager” thinking Split by responsibility; extract functions and small injected collaborators
Changing one class forces edits in five unrelated files Tight coupling from import-ing concrete classes instead of injecting Depend on a passed-in interface (a callable/Protocol), not a concrete import
An adapter exposes both .read() and the adaptee’s .fetch_celsius_times_ten() The adapter leaks the adaptee’s interface Forward only the target interface; keep the adaptee private (self._legacy)
A five-level inheritance tree to combine features Inheritance where composition was needed Compose small strategy/rule objects in a list (see domain-models lesson)
TypeError: 'X' object is not callable when using an object as a strategy You passed a class, not an instance/function, or forgot __call__ Pass fn, Cls(), or give the class __call__
A pattern with exactly one implementation and a “TODO: more later” Speculative generality / premature abstraction Delete the abstraction; inline it; re-add at the third real case

Three of these cost the most time and deserve prose.

1. The Singleton that makes tests flaky. This is the deepest one, because the failure appears far from its cause. You write a Config singleton, everything works, and months later a colleague adds a test that mutates config; now an unrelated test that runs after it fails intermittently, depending on test order. The debugging is miserable because nothing in either test mentions the other. The root cause is that a singleton is global mutable state, and tests must be independent. The permanent fix is not “reset the singleton in teardown” (that is a patch on a patch) — it is to inject the dependency so each test constructs its own. If you truly cannot (legacy code), at minimum expose a reset and call it in a fixture, and treat every such reset as a design debt to repay.

2. The observer memory leak. A subject keeps a list of subscribers, and a Python list holds strong references — so a subscriber the subject never releases can never be garbage-collected, and neither can anything its closure captured. In a long-running server (a GUI, a web app, a daemon) this is a slow leak that surveillance tools eventually flag as “memory grows over time.” You can watch it happen and watch the fix work:

import weakref, gc

class Bus:
    def __init__(self): self.handlers = []
    def on(self, h): self.handlers.append(h)

class BigObject:                      # a stand-in for anything expensive
    pass

def subscribe(bus):
    big = BigObject()                 # local -> the lambda closes over a real cell
    bus.on(lambda ev: big)            # ONLY the bus holds the handler (and thus big)
    return weakref.ref(big)

bus = Bus()
ref = subscribe(bus)
gc.collect()
print("leaked (bus still holds it):", ref() is not None)   # => True   still alive
bus.handlers.clear()                  # unsubscribe -> break the last reference
gc.collect()
print("after unsubscribe:", ref() is not None)             # => False  freed

The object stays alive purely because the bus’s closure references it, and dies the moment the bus lets go. The fixes, in order of preference: give the bus an off() and call it when a subscriber’s life ends; or store subscribers in a weakref.WeakSet / hold weakref.refs so the subject does not keep them alive; or, for bound methods, use weakref.WeakMethod. The plain list is fine for short-lived buses and a footgun for long-lived ones.

3. Over-engineering: the class that should have been a function. The most common pattern mistake in Python is not a wrong pattern — it is a present pattern that should have been absent. A class DiscountStrategy with an __init__ storing one number and an apply() doing one calculation is a function with extra steps; a NotificationManagerFactoryProvider for one concrete notifier is four words of ceremony around EmailNotifier(). The cost is real: every layer of indirection is a jump the next reader must make, a file they must open, a name they must learn, for flexibility that is never exercised. The discipline is the Rule of Three and the “can I name the change?” test. When in doubt, write the function; a function is trivially promoted to a class the day it grows state, and a class is painful to demote to a function the day you realise it never needed to be one.


Cheat-sheet

Pattern / tool Pythonic form
Strategy def do(...); pass it: Ctx(strategy=do), call self.strategy(x)
Configured strategy A closure: by_weight(20) returns the configured function
Sort strategy sorted(xs, key=fn) / key=attrgetter("age")
Factory (few types) def make(kind): ... raise ValueError(...)
Factory (data-driven) REGISTRY = {"email": Email}; REGISTRY[k]()
Factory (extensible) @register("name") decorator writing into a dict
Factory (by type) @functools.singledispatch
Factory (closed set) Enum
Observer (simple) self._cbs.append(fn); loop and call in emit
Observer (as decorator) subscribe returns fn; use @subject.subscribe
Observer (many events) EventBus with defaultdict(list), on/off/emit
Observer safety emit: for h in list(self._subs[e]) (iterate a copy)
Observer no-leak provide off(); or weakref.WeakSet / WeakMethod
Singleton a module (import settings) — interpreter-cached
Singleton (avoid) __new__ guard, metaclass, Borg — all leak in tests
Adapter wrap the adaptee (self._x), expose only the target method
Decorator (GoF) wrap an object, same interface, stackable at runtime
Decorator (@) @functools.wraps(fn) inside def wrapper(*a, **k)
Command a closure / functools.partial(fn, *args)
Command (with undo) a small object holding do/undo callables
Template Method def render(step=default_fn) — pass the varying steps
Dependency Injection pass collaborators to __init__ / the function
DI test win Service(clock=lambda: 42, store=[]) — deterministic, no patch
Iterator def gen(): yield ... — a generator is an iterator
Context Manager @contextlib.contextmanager + try/yield/finally
State HANDLERS = {"open": open_fn}; HANDLERS[state](...)
Chain of Responsibility a list of functions you loop until one returns
Visitor @functools.singledispatch
Proxy __getattr__ forwarding to a wrapped object
Rule of Three Don’t abstract at 1 or 2 variants; abstract at 3
The tell for “keep the class” The thing has state, not just one behaviour

Interview and exam questions

Q: Why do so many Gang-of-Four patterns “disappear” in Python? A: Because most of them are workarounds for two things Python has natively: first-class functions and dynamic (duck) typing. Strategy, Command, Template Method and Observer all exist to move behaviour around, and in Python behaviour is a function you can pass, return and store — so they collapse to “pass a function.” Factory patterns exist to create objects by name at runtime, which Python does with a class-as-value in a dict or a decorator registry. Singleton is handled by the module system. What survives as real OO patterns are the ones with genuine state — the tell, every time, is whether the thing has state and several behaviours, or is one behaviour dressed as an object.

Q: Implement Strategy in Python, then show the pythonic version. A: The OO version is an ABC with a method, one class per algorithm, and a context that holds one and delegates: class Order: def cost(self): return self.shipping.cost(self.w). The pythonic version drops the ABC and the classes and passes a function: Order(shipping=by_weight(20)), calling self.shipping(self.w). by_weight(20) is a closure carrying its configuration. Python’s own sorted(data, key=fn) is Strategy — the standard library never defines a SortStrategy class. Keep the class form only when the strategy has state plus several methods (a retry policy), or must be compared/serialised.

Q: You need a factory that lets people add new types without editing a central file. What do you build? A: A decorator-based registry: a module-level dict, and a register(name) decorator that records each class in it and returns the class unchanged. New types live in their own files with @register("name") on top; a create(name) function looks the class up and instantiates it, raising a clear ValueError listing the known names on a miss. This is exactly how Flask routes, Click commands and pytest plugins register — open for extension, closed for modification, with zero factory classes. For a small fixed set, a plain function or a dict literal is enough; reach for the decorator registry when the set is open and extended by others.

Q: What is the pythonic way to make a singleton, and why avoid the class-based versions? A: Use a module — Python imports a module once and caches it in sys.modules, so module-level state is a singleton the interpreter enforces, with no boilerplate. Avoid __new__/metaclass/Borg singletons mainly because they are global mutable state that breaks tests: state leaks between tests, so a test that passes alone fails when run after another, and you end up writing fragile per-test reset code. The __new__ form has an extra trap — Python calls __init__ on the cached instance every time you “construct” it, silently re-running initialisation and wiping state. The real fix for “I need one shared thing” is usually dependency injection: build one at the program’s entry point and pass it down, so tests can pass a fresh one.

Q: What is the difference between the Decorator pattern and a Python @decorator? A: They rhyme but are different. The GoF Decorator pattern wraps an object to add responsibilities while presenting the same interface, and wrappers stack at runtimeSugarDecorator(MilkDecorator(Coffee())). A Python @decorator wraps a function or class at definition time to transform it — @timed def add(...). Both “wrap X, preserve X’s interface,” but they operate on different things at different times. The @ syntax is a language feature for the “wrap a callable” case; the pattern is a runtime object-composition technique. Confusing them is common; naming the distinction (object vs callable, runtime vs definition time) is the answer.

Q: Why does injecting a dependency make code more testable than reaching for a global? A: Because a test can pass a fake in one line, with nothing to undo. A function that calls time.time() internally can only be tested by monkeypatching the global time.time — reaching across module boundaries, fragile, and it leaks if you forget to restore it. The same function written as make_id(now) is tested with make_id(lambda: 1000) and an exact assertion. At the object level, constructor injection (Service(clock=..., store=...)) means production passes a real clock and DB while tests pass a fake clock and a list — identical code, swappable collaborators. Injection also makes dependencies visible in the signature instead of hidden inside the body, and it kills the test-isolation problems that globals and singletons cause.

Q: When should you not use a design pattern? A: When you cannot name the change you are protecting against. Patterns cost indirection, files, and reader effort, paid whether or not the flexibility is used. Apply the Rule of Three: write the first and second case directly (duplicated if needed), and abstract only at the third, when the varying shape is clear — because a wrong abstraction is far more expensive than duplication. Concretely: don’t build a Strategy for one algorithm, a Factory for one type, or an Observer for one hard-wired reaction. A present-but-unneeded pattern (a one-method “strategy” class, a God-object “manager,” a singleton config) is the most common pattern mistake in Python, more common than choosing the wrong pattern.

Q: A subject notifies observers, and in a long-running server memory keeps growing. Why, and how do you fix it? A: The subject stores observers in a list, and lists hold strong references, so any observer the subject never releases can never be garbage-collected — along with everything its closure captured. In a daemon or GUI that subscribes objects over time without unsubscribing, that is a slow leak. Fixes, best first: give the subject an off()/unsubscribe and call it when an observer’s life ends; store observers in a weakref.WeakSet or as weakref.refs so the subject does not keep them alive; for bound methods use weakref.WeakMethod. Also, always iterate a copy in notify/emit, because an observer that unsubscribes itself mid-notification would otherwise mutate the list being looped.

Q (coding): Turn this if/elif construction ladder into an extensible registry.

def make_exporter(fmt):
    if fmt == "csv": return CsvExporter()
    elif fmt == "json": return JsonExporter()
    elif fmt == "xml": return XmlExporter()
    raise ValueError(fmt)

A: Replace the ladder with a decorator registry so new exporters self-register:

_EXPORTERS = {}
def exporter(name):
    def deco(cls):
        _EXPORTERS[name] = cls
        return cls
    return deco

@exporter("csv")
class CsvExporter: ...
@exporter("json")
class JsonExporter: ...

def make_exporter(fmt):
    try:
        return _EXPORTERS[fmt]()
    except KeyError:
        raise ValueError(f"unknown format {fmt!r}; have {sorted(_EXPORTERS)}") from None

Now adding an XmlExporter is a new file with @exporter("xml") on top — make_exporter never changes. If the set were small and closed, a dict literal {"csv": CsvExporter, ...} would be enough; the decorator earns its keep when third parties extend the set.

Q (coding): Make this function testable without monkeypatching.

import random
def pick_winner(names):
    return random.choice(names)

A: Inject the source of randomness:

def pick_winner(names, choose=random.choice):
    return choose(names)

Production calls pick_winner(names) and gets real randomness; a test calls pick_winner(["a", "b"], choose=lambda xs: xs[0]) and asserts "a" deterministically — no random.seed, no monkeypatch, no global reach. Randomness, clocks, network and disk are exactly the dependencies worth injecting, because they are what make code non-deterministic and slow to test.

Q: Which GoF patterns did Python promote into language syntax, and what are the tells that you want them? A: Iterator became the iterator protocol and yield — if you catch yourself writing has_next()/get_next(), you want a generator. Context Manager (a resource-management pattern) became with/__enter__/__exit__ — if you are writing paired setup/teardown methods callers must remember to call in order, you want a context manager (or @contextlib.contextmanager). Observer partially collapsed into property setters plus callbacks for the “react to state change” case. Strategy collapsed into passing a function (key=). The general lesson: when a pattern becomes syntax, stop hand-rolling it — use the keyword.


Key takeaways


This lesson is the judgement half of the professional-Python arc: the OOP series taught you the machinery of classes, domain models taught you to compose small objects instead of inheriting, functional Python taught you that behaviour is a value you can pass around, and testing taught you why injected dependencies are worth the wiring. Design patterns are where those four meet: knowing them is knowing which to not reach for, and trusting that in Python the answer is very often a function, a dict, or a module.

pythondesign-patternsstrategyfactoryobserversingletonadapterdependency-injectionregistryevent-buspytestyagnigang-of-four
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments