Design Patterns That Fit Python
1 · The lesson
readThe 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.
# 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.
# 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 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:
# 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:
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.
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"):
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.
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.
# 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:
# 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.
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.
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.
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.
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 pattern | What Python uses instead |
|---|---|
| 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 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:
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.
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.
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 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 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_chargeare sibling functions, not subclasses ofPaymentBackend. Adding a fourth provider is one new function, not a new file. - Test in isolation. Use
fake_charge(or alambda *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-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 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
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 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.inShort 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.