Design Patterns That Actually Fit Python
1 · The lesson
readThe 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.
# 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.
# 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")
# 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:
# 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:
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.
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"):
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.
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.
# 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:
# 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.
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.
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.
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.
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 pattern | Why it dissolves in Python |
|---|---|
| Abstract Factory | Stack of factories of factories. In Python: one function that picks the concrete class based on input, or a dict[str, type] registry. |
| Builder | Step-by-step construction. In Python: keyword arguments with defaults, optionally a fluent @dataclass with methods returning self. |
| Chain of Responsibility | A 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 .... |
| Visitor | Double-dispatch over a class hierarchy. In Python: match/case on types (see modernsyntax) or functools.singledispatch. |
| Command | Encapsulate an action as an object. In Python: a function or a partial. |
| Template Method | Algorithm 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:
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.
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.
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 raiseConnectionError, 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
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_chargeare sibling functions, not subclasses ofPaymentBackend. Adding a fourth provider is one new function, not a new file. - Test in isolation. Swap in
fake_charge(or alambda *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-specificexceptclauses. - Strategy without Strategy classes. Compare to the Java version:
interface PaymentStrategy { Result charge(...); }, three implementations, anOrderServicethat holds aPaymentStrategyreference. 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
Eventclass. 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.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.