Advanced · Lesson 70 of 94

Design patterns, the Python way

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Design patterns are named solutions to problems that recur in object-oriented code. The names come from a 1994 book written for languages without first-class functions or dynamic typing, and much of the ceremony in those solutions exists to work around those limits. Python has functions as values, duck typing, and dictionaries, so most patterns shrink to a few lines, and several disappear. The names are still worth knowing: they let you say "this is a strategy" and be understood in a code review, and they tell you the shape of the fix when you recognize the problem.

Design patterns, the Python way: a recurring problem → a named pattern → Java-era shape: interfaces, abstract classes, boilerplate → Python shape: a function, a dict, a small class
Design patterns, the Python way · Figure 1 · Open for full size

Strategy: an algorithm that varies is passed in rather than hard-coded. In Python a strategy is a function; sorted(key=...) is the strategy pattern. When behavior needs configuration or state, a small class with one method works, but start with a function. Factory: the code that creates an object is separated from the code that uses it, so the caller asks for "a notifier for this config" without knowing the class. A dictionary mapping names to classes or functions is a factory; so is a classmethod alternative constructor. Observer: an object announces events, and interested parties register callbacks to be told. A list of callables and a subscribe method is the whole pattern. Adapter: an object with the wrong interface is wrapped in one with the right interface, so existing code can use it; a small class that delegates, or even a function, does it.

Pattern Smell it cures Python shape
Strategy an if/elif choosing between algorithms pass a function; a dictionary of functions
Factory if kind == "email": Email(...) elif ... at every creation site a dictionary mapping names to classes; a classmethod
Observer one object calling many others by name when something happens a list of callbacks and a subscribe method
Adapter code that cannot use a library because the method names differ a wrapper class that delegates and renames
Template subclasses that repeat a skeleton with small differences an ABC with the skeleton calling abstract steps
Decorator adding behavior around many functions @decorator (the language has it built in)

Some patterns are unnecessary in Python. Singleton becomes a module, which is already a single shared object; a module-level instance is the idiom. Iterator is the iteration protocol and generators. Command is a function or a functools.partial. Decorator is a language feature. Builder is keyword arguments and dataclasses with defaults. Recognizing these saves you from importing Java shapes into Python code, which is one of the most common things reviewers flag in code written by developers arriving from other languages.

The habit to build is to name the smell first. An if chain over a kind field means a factory or polymorphism. Growing chains of if algorithm == ... mean a strategy. A class that has to know about everyone it notifies means an observer. A library whose interface does not match yours means an adapter. Reach for the pattern when the smell appears, in its smallest Python form, and let it grow only if it needs to.

Design patterns, the Python way: if kind == 'a': ... elif kind == 'b': ... → factory dict / polymorphism → if method == 'fast': ... else: ... → strategy: pass the function → obj calls a.notify(), b.notify(), c.notify() → observer: subscribe callbacks → their .fetch() vs our .get() → adapter: wrap and rename
Design patterns, the Python way · Figure 2 · Open for full size

Code

Strategy as functions. The shipping calculator takes the pricing rule as a parameter; a dictionary maps names to rules so a config string can choose one.

# strategy.py
def flat_rate(weight_kg):
    return 5.0


def by_weight(weight_kg):
    return round(2.0 + 1.5 * weight_kg, 2)


def free_over_10kg(weight_kg):
    return 0.0 if weight_kg > 10 else by_weight(weight_kg)


STRATEGIES = {"flat": flat_rate, "weight": by_weight, "promo": free_over_10kg}


def shipping_cost(weight_kg, strategy=by_weight):
    return strategy(weight_kg)


print(shipping_cost(4))
print(shipping_cost(4, flat_rate), shipping_cost(12, free_over_10kg))
config = "promo"
print(shipping_cost(3, STRATEGIES[config]))
Output
8.0
5.0 0.0
6.5

Factory as a registry. New notifier types register themselves with a decorator; the creation site never changes. A classmethod factory handles the "build from config" variant.

# factory.py
REGISTRY = {}


def register(kind):
    def decorator(cls):
        REGISTRY[kind] = cls
        return cls
    return decorator


@register("email")
class EmailNotifier:
    def __init__(self, address):
        self.address = address

    def send(self, msg):
        return f"email to {self.address}: {msg}"


@register("sms")
class SmsNotifier:
    def __init__(self, number):
        self.number = number

    def send(self, msg):
        return f"sms to {self.number}: {msg}"

    @classmethod
    def from_config(cls, config):
        return cls(config["phone"].replace(" ", ""))


def make_notifier(kind, target):
    try:
        return REGISTRY[kind](target)
    except KeyError:
        raise ValueError(f"unknown notifier {kind!r}; known: {sorted(REGISTRY)}") from None


print(make_notifier("email", "ada@example.com").send("hi"))
print(SmsNotifier.from_config({"phone": "555 0100"}).send("hi"))
try:
    make_notifier("fax", "x")
except ValueError as err:
    print(err)
Output
email to ada@example.com: hi
sms to 5550100: hi
unknown notifier 'fax'; known: ['email', 'sms']

Observer as a list of callbacks. The order knows nothing about logging, stock, or email; each subscribes to the events it cares about.

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

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

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


bus = EventBus()
stock = {"pen": 5}
log = []

bus.subscribe("order.placed", lambda item, qty: log.append(f"logged {qty} x {item}"))
bus.subscribe("order.placed", lambda item, qty: stock.__setitem__(item, stock[item] - qty))
bus.subscribe("order.placed", lambda item, qty: log.append("email sent") if qty >= 3 else None)

bus.publish("order.placed", item="pen", qty=3)
bus.publish("order.placed", item="pen", qty=1)
print(log)
print(stock)
Output
['logged 3 x pen', 'email sent', 'logged 1 x pen']
{'pen': 1}

Adapter around a third-party-shaped class, and the patterns Python makes unnecessary: a module-level singleton and a function standing in for a command object.

# adapter_and_absent.py
import functools


class LegacyPaymentGateway:               # the library we cannot change
    def make_payment(self, cents, currency_code):
        return f"charged {cents / 100:.2f} {currency_code}"


class PaymentAdapter:                     # the interface our code expects
    def __init__(self, gateway):
        self._gateway = gateway

    def charge(self, amount, currency="EUR"):
        return self._gateway.make_payment(round(amount * 100), currency)


def checkout(payment, amount):
    return payment.charge(amount)


print(checkout(PaymentAdapter(LegacyPaymentGateway()), 12.5))

# "singleton": a module-level object is already shared by every importer
settings = {"debug": False}
print(id(settings) == id(settings), "one shared object per module")

# "command": a function, or a partial, is the command object
commands = [functools.partial(print, "undo"), functools.partial(print, "redo")]
for command in commands:
    command()
Output
charged 12.50 EUR
True one shared object per module
undo
redo

Try it yourself

Create report_engine.py with a strategy dictionary of three formatters (plain, CSV, Markdown), a registry-based factory for data sources (list, json_file, sqlite), an EventBus that logs when a report starts and finishes, and an adapter that wraps a class with a write_out(text) method so it can be used where write(text) is expected. Wire them into a run_report(source_kind, format_name) function and run two combinations.

Common mistakes

What's next

You can shape larger programs with intent. Next you put the Advanced level together: a typed, tested library with dataclasses, logging, and a test suite, ready to become a real package in the Expert level.

Course overview