PythonMastery
advanced 18 min read · lesson 8 of 9 in Python Advanced

Design Patterns That Actually Fit Python

1 · The lesson

read

The Gang of Four book is a great document about C++ from 1994. Half of its patterns dissolve in Python because the language already gives you the pattern as syntax: first-class functions replace Strategy, modules replace Singleton, @classmethod replaces most Factory hierarchies, decorators are the Decorator pattern, with is RAII, and for is Iterator.

This lesson is the honest list — the patterns worth recognising in Python form, the patterns you can stop reaching for, and the one principle behind all of it: prefer composition and first-class functions over class hierarchies.


1. The Honest Framing

A "design pattern" is a recurring shape that solves a recurring problem in a particular language. In a static, single-dispatch language without first-class functions (C++ 1994), most GoF patterns are how you get around what the language lacks. Move to Python and many of those patterns become a single line.

Norvig's famous slide deck "Design Patterns in Dynamic Languages" makes the case: of the 23 GoF patterns, 16 are either invisible or trivial in Lisp/Python. The remaining 7 are worth learning — as shapes you'll recognise, not as ceremony to apply.

Patterns are descriptions of what good code already looks like. They are not goals.


2. Strategy — Just Pass a Function

Intent: Make an algorithm pluggable. Same input shape, different implementations.

GoF form: an abstract Strategy class with execute(...), plus N concrete subclasses, plus a Context class that holds a strategy instance.

Pythonic form: pass a function.

python
# OVER-ENGINEERED — Java-style
class SortStrategy:
    def sort(self, items): raise NotImplementedError

class QuickSort(SortStrategy):
    def sort(self, items): return sorted(items)

class ReverseSort(SortStrategy):
    def sort(self, items): return sorted(items, reverse=True)

class Sorter:
    def __init__(self, strategy):
        self.strategy = strategy
    def run(self, items):
        return self.strategy.sort(items)

# IDIOMATIC
def run(items, sort_fn=sorted):
    return sort_fn(items)

run([3, 1, 2])                          # default: ascending
run([3, 1, 2], lambda xs: sorted(xs, reverse=True))

The Python form has no class, no inheritance, and one line of dispatch. key= arguments in sorted/min/max are exactly this pattern — the library accepts a function instead of a subclass.

Use Strategy when you have multiple algorithms with the same call signature and you want the caller to pick. Don't reach for a class hierarchy — a callable is the abstraction.


3. Singleton — Use a Module

Intent: Exactly one instance of something in the process.

GoF form: a class with a private constructor and a static getInstance() method.

Pythonic form: a module-level constant. Modules are singletons by definition — imported once, cached in sys.modules, shared across the process.

python
# settings.py
import os

DATABASE_URL = os.environ["DATABASE_URL"]
DEBUG = os.environ.get("DEBUG") == "1"
+ setup added so this can run · defines
import os  # noqa: F401
os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example")
os.environ.setdefault("DEBUG", "false")
python
# any other module
from settings import DATABASE_URL

That's the singleton. One source of truth, imported anywhere. No class needed.

If you genuinely need a class instance (because it has methods, lazy init, etc.), instantiate it once at module scope:

python
# logger.py
class _Logger:
    def __init__(self): self.handlers = []
    def info(self, msg): ...

log = _Logger()                         # the only instance, exported

For the rare case where you want class-based singleton enforcement (e.g. a third-party framework expects a class), the metaclass version from metaclasses does it in 6 lines:

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

class Cache(metaclass=Singleton):
    def __init__(self): self.data = {}

Cache() is Cache()                      # True — same object every time
+ setup added so this can run · defines args, kw
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

args = _AutoMock('args')
kw = _AutoMock('kw')

Why Singleton is usually a smell: it's global mutable state in disguise. Two tests mutate the singleton and now their order of execution matters. Pass the dependency explicitly when you can.


4. Factory — Just a @classmethod

Intent: Encapsulate construction logic — "give me an X from this string / dict / file / row."

GoF form: an abstract Creator class with a createProduct() method, subclassed per concrete product.

Pythonic form: a @classmethod on the class itself. The canonical name is from_X.

python
from datetime import date

class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    @classmethod
    def from_iso(cls, s):
        y, m, d = s.split("-")
        return cls(int(y), int(m), int(d))

    @classmethod
    def from_dict(cls, d):
        return cls(d["year"], d["month"], d["day"])

    @classmethod
    def today(cls):
        t = date.today()
        return cls(t.year, t.month, t.day)

d1 = Date.from_iso("2026-05-14")
d2 = Date.from_dict({"year": 2026, "month": 5, "day": 14})
d3 = Date.today()

cls(...) (not Date(...)) is the trick — it gives subclasses their own type back. Inherit Date and SubDate.from_iso(...) returns a SubDate.

The stdlib uses this pattern everywhere: dict.fromkeys, int.from_bytes, datetime.fromisoformat, Path.cwd. When you see Type.from_<source> or Type.<verb>, that's a factory @classmethod.

Reach for a separate Factory function only when construction depends on data the class shouldn't know about (e.g. "pick a parser subclass based on file extension"):

python
def parser_for(path):
    if path.endswith(".json"):  return JSONParser()
    if path.endswith(".xml"):   return XMLParser()
    if path.endswith(".csv"):   return CSVParser()
    raise ValueError(f"no parser for {path}")
+ setup added so this can run · defines JSONParser, XMLParser, CSVParser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def JSONParser(*_a, **_kw):
    print('-> JSONParser() called')
    return _AutoMock('JSONParser()')
def XMLParser(*_a, **_kw):
    print('-> XMLParser() called')
    return _AutoMock('XMLParser()')
def CSVParser(*_a, **_kw):
    print('-> CSVParser() called')
    return _AutoMock('CSVParser()')

One function. No abstract base, no registry hierarchy.


5. Observer — A 15-Line Event Class

Intent: Publish/subscribe. Multiple listeners react to something happening, without the publisher knowing about them.

Pythonic form: a tiny Event class with subscribe and fire. Listeners are plain functions.

python
class Event:
    def __init__(self):
        self._listeners = []

    def subscribe(self, callback):
        self._listeners.append(callback)
        return callback                 # allows use as a decorator

    def fire(self, *args, **kwargs):
        for cb in self._listeners:
            cb(*args, **kwargs)


on_user_created = Event()

@on_user_created.subscribe
def send_welcome_email(user):
    print(f"emailing {user['email']}")

@on_user_created.subscribe
def add_to_crm(user):
    print(f"CRM: {user['name']}")

on_user_created.fire({"name": "Linus", "email": "s@example.com"})
# emailing s@example.com
# CRM: Linus
+ setup added so this can run · defines args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

args = _AutoMock('args')
kwargs = _AutoMock('kwargs')

That's the entire Observer pattern. No abstract Observer interface, no Subject.attach(observer), no notify() ceremony.

Larger systems build on this primitive: Qt's signal/slot system, Django signals, blinker, every event bus in every web framework. The shape is the same.

For async workloads, the listener can be an async def and fire becomes async def fire(...) that awaits each callback or runs them concurrently with asyncio.gather.


6. Adapter — A Thin Wrapper

Intent: Make a thing with interface A satisfy interface B.

Pythonic form: a wrapper class or, often, a function.

python
# We have a legacy API that returns dicts
def legacy_user(id):
    return {"first": "Linus", "last": "T", "yrs": 28}

# Our code wants objects with .full_name and .age
class UserAdapter:
    def __init__(self, raw):
        self._raw = raw

    @property
    def full_name(self):
        return f"{self._raw['first']} {self._raw['last']}"

    @property
    def age(self):
        return self._raw["yrs"]

u = UserAdapter(legacy_user(1))
print(u.full_name, u.age)               # Linus T 28

That's it. No AbstractAdapter base. No registry. If the adapter is read-only and the source data fits, a dataclass + a constructor function is even simpler.

When the target "interface" is just a function signature, the adapter is itself a function:

python
# legacy returns (status, body); modern wants a Response object
def adapt(legacy_call):
    status, body = legacy_call()
    return Response(status=status, json=body)
+ setup added so this can run · defines Response
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def Response(*_a, **_kw):
    print('-> Response() called')
    return _AutoMock('Response()')

7. Decorator (the GoF pattern) — Python's @ Decorators

Intent: Add behaviour to a function/object without subclassing.

Pythonic form: Python's @decorator syntax. The GoF pattern and the Python feature collide here — the language adopted the pattern as syntax. See decorators for the full treatment.

python
import functools

def with_retry(times=3):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kw):
            for attempt in range(times):
                try:
                    return fn(*args, **kw)
                except Exception:
                    if attempt == times - 1:
                        raise
        return wrapper
    return deco

@with_retry(times=5)
def fetch(url): ...
+ setup added so this can run · defines args, kw
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

args = _AutoMock('args')
kw = _AutoMock('kw')

Behaviour (retry) added around fetch without inheritance, without modifying fetch. Compose multiple decorators by stacking them. The GoF dreamed of this; Python ships it.


8. Context Manager — Python-Native Scoped Lifetimes

Intent: Acquire a resource, use it, always release it — even on exception. Classic C++ RAII.

Pythonic form: the with statement. See contextmanagers.

python
with open("data.txt") as f:
    for line in f:
        process(line)
# file is closed here — even if process(line) raised
+ setup added so this can run · defines process
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def process(*_a, **_kw):
    print('-> process() called')
    return _AutoMock('process()')

@contextmanager from contextlib lets you turn any try/finally into a with-block in 6 lines. Context managers cover transactions, locks, temp directories, mocks, timing, request scope — anywhere you have "do something / undo something" around a block of code.

This is the pattern Python normalised. C++ programmers go quiet when they see how clean it looks.


9. Iterator — The __iter__/__next__ Protocol

Intent: Walk through a collection without exposing its internal structure.

Pythonic form: the iterator protocol itself. Implement __iter__ (returns the iterator) and __next__ (returns next value or raises StopIteration), and your object works with for, list(), sum(), comprehensions — every consumer in the language.

python
class CountDown:
    def __init__(self, start):
        self.n = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        self.n -= 1
        return self.n + 1

for x in CountDown(3):
    print(x)                            # 3, 2, 1

But Python has an even cleaner version: a generator function. yield makes a function an iterator with no boilerplate.

python
def countdown(start):
    while start > 0:
        yield start
        start -= 1

Same behaviour. Three lines. The GoF Iterator pattern is built into the language.


10. Patterns Not Worth Porting

A short list of patterns that disappear in Python — usually into a function, a @classmethod, or a literal dict.

GoF patternWhy it dissolves in Python
Abstract FactoryStack of factories of factories. In Python: one function that picks the concrete class based on input, or a dict[str, type] registry.
BuilderStep-by-step construction. In Python: keyword arguments with defaults, optionally a fluent @dataclass with methods returning self.
Chain of ResponsibilityA linked list of handlers each saying "not me, try the next." In Python: a list of functions, for handler in handlers: if handler(req) is not None: return ....
VisitorDouble-dispatch over a class hierarchy. In Python: match/case on types (see modernsyntax) or functools.singledispatch.
CommandEncapsulate an action as an object. In Python: a function or a partial.
Template MethodAlgorithm skeleton with overridable steps. In Python: pass the variable steps as functions — same as Strategy.

The pattern recognises a problem. The Python answer is usually shorter than the pattern's name.


11. End-to-End: Extracting a Strategy

A hard-to-test class that knots together pricing logic with database access:

python
class OrderService:
    def __init__(self, db):
        self.db = db

    def total(self, order_id):
        rows = self.db.fetch("SELECT * FROM line_items WHERE order_id = ?", order_id)
        # Pricing logic, deeply nested, untestable without a DB
        total = 0
        for r in rows:
            line = r["qty"] * r["price"]
            if r["qty"] >= 10:
                line *= 0.9                     # bulk discount
            if r["category"] == "books":
                line *= 0.95                    # category discount
            total += line
        return total

To test the pricing logic, you need a database. To test against a new pricing rule, you edit this class — touching DB code that has nothing to do with the rule change. Two unrelated concerns are stitched together.

Refactor: extract the pricing rule as a function. Inject it.

python
def standard_pricing(row):
    line = row["qty"] * row["price"]
    if row["qty"] >= 10:        line *= 0.9
    if row["category"] == "books": line *= 0.95
    return line

class OrderService:
    def __init__(self, db, price_fn=standard_pricing):
        self.db = db
        self.price_fn = price_fn

    def total(self, order_id):
        rows = self.db.fetch("SELECT * FROM line_items WHERE order_id = ?", order_id)
        return sum(self.price_fn(r) for r in rows)


# Tests — no DB, no service
def test_bulk_discount():
    assert standard_pricing({"qty": 10, "price": 5, "category": "misc"}) == 45.0

def test_books_discount():
    assert standard_pricing({"qty": 1, "price": 10, "category": "books"}) == 9.5

# Swap the rule per-tenant without subclassing
discount_pricing = lambda r: standard_pricing(r) * 0.95
black_friday_service = OrderService(db, price_fn=discount_pricing)

Strategy without Strategy classes. The price function is testable in isolation. The service depends on a callable, not a concrete pricing class. Swap the rule for a tenant, an A/B test, a holiday sale — without subclassing or touching OrderService.


Common Mistakes

1. Implementing GoF patterns by-the-book when a 5-line function would do. Strategy with an AbstractStrategy ABC and three subclasses, when key=fn was the answer. Pattern recognition is a tool; pattern application should make the code shorter, not longer.

2. Over-applying Singleton. Global mutable state is a smell. Two tests mutate the singleton; now the test order matters. Pass dependencies; let pytest fixtures construct fresh instances per test.

3. Building elaborate Factory hierarchies. If Date.from_iso(s) and Date.from_dict(d) solve your construction needs, you do not need an AbstractDateFactory. Reach for an external factory function only when the choice of class is data-driven (a registry).

4. Using inheritance to model what's really a behaviour. If your subclasses differ only in one method's implementation, that method is a strategy — extract it as a callable, inject it.

5. Pattern-matching for its own sake. Patterns are recognition, not prescription. If you find yourself starting from "I want to use the Observer pattern" rather than "I need pub/sub here," reverse the order. Solve the problem first; notice the pattern after.

6. Forgetting that with, for, @, and first-class functions ALREADY ARE patterns. Half the GoF book is "implement Iterator." Python has for. Don't reimplement what the language gave you.


🎯 Your Turn — A Pluggable PaymentProcessor

Build a PaymentProcessor that accepts a payment-strategy function and uses it to charge. Provide three strategies: a real-shaped stripe_charge, a paypal_charge, and a fake_charge for tests. Swap strategies without subclasses.

Requirements:

1. PaymentProcessor.__init__(self, charge_fn) takes the strategy callable.
2. processor.pay(amount, currency, customer_id) calls charge_fn(amount, currency, customer_id) and returns a result dict like {"ok": True, "id": "ch_..."} or {"ok": False, "error": "..."}.
3. The three strategies are plain functions with the same signature — no shared base class.
4. Show how a test uses fake_charge to verify behaviour without hitting a network.

python
def stripe_charge(amount, currency, customer_id):
    # TODO: pretend to hit Stripe — return a Stripe-shaped dict
    ...

def paypal_charge(amount, currency, customer_id):
    # TODO: same shape, different provider
    ...

def fake_charge(amount, currency, customer_id):
    # TODO: deterministic, no I/O — returns a success dict
    ...

class PaymentProcessor:
    def __init__(self, charge_fn):
        # TODO: store the strategy
        ...

    def pay(self, amount, currency, customer_id):
        # TODO: call the strategy; return its result; handle exceptions defensively
        ...

# Usage
real = PaymentProcessor(stripe_charge)
test = PaymentProcessor(fake_charge)

print(test.pay(100, "GBP", "cust_1"))   # {'ok': True, 'id': 'fake_...'}
Hint 1 — Strategy is a callable, not a class self.charge_fn = charge_fn in __init__. In pay, call it: self.charge_fn(amount, currency, customer_id). No isinstance checks, no ABCs.
Hint 2 — Catch broadly at the boundary, narrowly inside The strategy could raise ConnectionError, TimeoutError, or a provider-specific exception. The processor is the boundary — wrap the call in try/except Exception, log, return {"ok": False, "error": ...} rather than propagating.
Show full solution
python
import logging
import uuid

log = logging.getLogger(__name__)


# ---- Strategies — same signature, no shared base ----

def stripe_charge(amount, currency, customer_id):
    # In real code: stripe.Charge.create(...)
    return {"ok": True, "id": f"ch_{uuid.uuid4().hex[:12]}", "provider": "stripe"}

def paypal_charge(amount, currency, customer_id):
    # In real code: paypalrestsdk.Payment(...).create()
    return {"ok": True, "id": f"PAY-{uuid.uuid4().hex[:14].upper()}", "provider": "paypal"}

def fake_charge(amount, currency, customer_id):
    return {"ok": True, "id": "fake_001", "provider": "fake"}


# ---- The processor — knows nothing about Stripe or PayPal ----

class PaymentProcessor:
    def __init__(self, charge_fn):
        self.charge_fn = charge_fn

    def pay(self, amount, currency, customer_id):
        try:
            return self.charge_fn(amount, currency, customer_id)
        except Exception as e:
            log.exception("charge failed for customer=%s amount=%s", customer_id, amount)
            return {"ok": False, "error": str(e)}


# ---- Usage ----

real = PaymentProcessor(stripe_charge)
print(real.pay(2500, "GBP", "cust_42"))
# {'ok': True, 'id': 'ch_a1b2c3d4e5f6', 'provider': 'stripe'}


# ---- Tests — no network, no monkeypatching ----

def test_pay_returns_provider_id():
    proc = PaymentProcessor(fake_charge)
    result = proc.pay(100, "GBP", "cust_1")
    assert result["ok"] is True
    assert result["id"] == "fake_001"

def test_pay_swallows_strategy_errors():
    def explode(amount, currency, customer_id):
        raise ConnectionError("network down")
    proc = PaymentProcessor(explode)
    result = proc.pay(100, "GBP", "cust_1")
    assert result == {"ok": False, "error": "network down"}

test_pay_returns_provider_id()
test_pay_swallows_strategy_errors()
print("tests passed")

What this design buys you:

  • No inheritance. stripe_charge, paypal_charge, fake_charge are sibling functions, not subclasses of PaymentBackend. Adding a fourth provider is one new function, not a new file.
  • Test in isolation. Swap in fake_charge (or a lambda *a: {...}) and the entire processor is testable in microseconds with no network.
  • The boundary handles errors. The strategy raises whatever it wants; the processor translates exceptions into a uniform {"ok": False, "error": ...} shape so callers don't need provider-specific except clauses.
  • Strategy without Strategy classes. Compare to the Java version: interface PaymentStrategy { Result charge(...); }, three implementations, an OrderService that holds a PaymentStrategy reference. Python gets the same flexibility with a callable parameter.

This pattern — inject the variable behaviour as a callable — solves Strategy, Template Method, Command, and most of Chain-of-Responsibility in one move. Recognise it; apply it; don't dress it up in class hierarchies it doesn't need.


What You Learned

  • Many GoF patterns dissolve in Python because the language has first-class functions, decorators, modules, and the iterator protocol built in.
  • Strategy = pass a function. Singleton = a module-level constant or a one-time instance. Factory = @classmethod (from_iso, from_dict).
  • Observer = a 15-line Event class. Adapter = a wrapper class or function. Decorator (GoF) = Python's @ syntax. Context Manager = with. Iterator = __iter__ / generator.
  • Skip in Python: Abstract Factory, Builder, Chain of Responsibility, Visitor, Command, Template Method — each collapses into a function or a small dict-based dispatch.
  • Composition > inheritance. Inject behaviour as a callable; subclass only when you genuinely have is-a polymorphism.
  • Patterns describe shapes you should recognise. They are not goals. The Python code that solves your problem in 10 lines is better than the same code dressed up as four classes and a registry.

Next: Modern Python Syntax — match/case, the walrus, exception groups, Self, PEP 695 generics, and the rest of what 3.8 → 3.13 added.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.