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

Design Patterns That Fit Python

1 · The lesson

read

The Gang of Four (GoF) book is from 1994, and its examples are written in C++ and Smalltalk. Many of its patterns are not needed 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 lists the patterns worth recognising in their Python form, the patterns you can stop using, and the one principle behind all of it: prefer composition and first-class functions over class hierarchies.


1. What a Pattern Is

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++ in 1994), most GoF patterns are ways to work around what the language lacks. Move to Python and many of those patterns become a single line.

Peter Norvig's well-known talk "Design Patterns in Dynamic Languages" argues this: of the 23 GoF patterns, 16 become invisible or much simpler in dynamic languages (his examples use Lisp and Dylan). The patterns in this lesson are still worth learning, as shapes you will recognise in code, not as rules you must follow.

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


2. Strategy: 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: the 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 create a class hierarchy for it. A callable is enough.


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 already singletons: Python imports each one once, caches it in sys.modules, and shares it across the whole 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 is the singleton. There is one source of truth that you can import anywhere, and you don't need a class.

If you really 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 a few 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 (the 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 warning sign: it is global mutable state with a different name. If two tests change the singleton, the order in which they run starts to matter. Pass the dependency explicitly when you can.


4. Factory: A @classmethod

Intent: Keep construction logic in one place: "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 usual 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()

The important detail is cls(...) (not Date(...)): it gives subclasses their own type back. Inherit Date and SubDate.from_iso(...) returns a SubDate.

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

Use 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 Small 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 is the entire Observer pattern. There is no abstract Observer interface, no Subject.attach(observer), and no notify() method.

Larger systems use the same idea: Qt's signal/slot system, Django signals, blinker, and the event systems in many web frameworks. The structure 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 is all. There is no AbstractAdapter base and 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 only 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. Here the GoF pattern and the Python feature have the same name, because the language made the pattern into 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')

The retry behaviour is added around fetch without inheritance and without changing fetch. You can combine several decorators by stacking them.


8. Context Manager: Scoped Lifetimes Built Into Python

Intent: Acquire a resource, use it, and always release it, even when there is an exception. In C++, this is called RAII.

Pythonic form: the with statement. See contextmanagers.

python
with open("data.txt") as f:
    for line in f:
        process(line)
# the 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 a few lines. Context managers are used for transactions, locks, temp directories, mocks, timing and request scope: anywhere you have "do something / undo something" around a block of code.

Python made this pattern a normal part of everyday code.


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, and everything else in the language that iterates.

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 a simpler version: a generator function. yield turns a function into an iterator without the extra methods.

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

It has the same behaviour in much less code. The GoF Iterator pattern is built into the language.


10. Patterns Not Worth Porting

A short list of patterns that are not needed in Python, because a function, a @classmethod, or a literal dict does the job.

GoF patternWhat Python uses instead
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 steps that change as functions, the same as Strategy.

The pattern names a problem. The Python solution is usually very short.


11. End-to-End: Extracting a Strategy

A hard-to-test class that mixes 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 is buried here and can't be tested 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 add a new pricing rule, you edit this class, and you touch DB code that has nothing to do with the rule change. Two unrelated concerns are joined 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 and no service needed
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 in a different pricing rule 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. You can change the rule for one customer (tenant), an A/B test or a holiday sale, without subclassing or changing 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. Recognising a pattern is useful, but applying a pattern should make the code shorter, not longer.

2. Over-applying Singleton. Global mutable state is a warning sign. If two tests change the singleton, the test order matters. Pass dependencies, and 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. Use a separate factory function only when the choice of class depends on the data (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 and pass it in.

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. The GoF book describes Iterator as a pattern you build yourself. Python has for. Don't rebuild what the language already gives you.


🎯 Your Turn: A Pluggable PaymentProcessor

Build a PaymentProcessor that accepts a payment-strategy function and uses it to charge. Provide three strategies: a realistic 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, with 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 call Stripe and return a dict shaped like Stripe's
    ...

def paypal_charge(amount, currency, customer_id):
    # TODO: return the same shape of dict, for a different provider
    ...

def fake_charge(amount, currency, customer_id):
    # TODO: always return the same success dict, with no I/O
    ...

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

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

# 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 class ----

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, which 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 and no patching of real code ----

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 gives 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. Use fake_charge (or a lambda *a: {...}) and you can test the whole processor in microseconds, with no network.
  • The boundary handles errors. The strategy raises whatever it wants, and 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 one idea, pass the behaviour that changes in as a callable, covers Strategy, Template Method, Command, and most uses of Chain of Responsibility. Learn to recognise it, use it, and don't add class hierarchies it doesn't need.


What You Learned

  • Many GoF patterns are not needed 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 small 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 one becomes a function or a small dict-based dispatch.
  • Composition > inheritance. Inject behaviour as a callable, and subclass only when you really have is-a polymorphism.
  • Patterns describe shapes you should recognise. They are not goals. Python code that solves your problem in 10 lines is better than the same logic spread across 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 did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.