Chapter 17: Pattern Selection Heuristics
Choosing a pattern is less like picking a tool from a tidy box and more like picking the right lens for a camera; the same scene can look crisp or muddy depending on the lens. The following heuristics help you decide when a pattern fits, when to reshape the problem, and when to step away from patterns entirely. Use these as guides; prefer clarity over ceremony.
When to Choose Strategy
Strategy is a fit when behavior varies by policy, rule, or environment and you want to swap that behavior without changing the host object. If you can write “do the same thing, but with a different algorithm” and keep the same inputs and outputs, you are in Strategy country. It is also useful when testing needs to stub out complex logic with a simpler stand-in; plug a fake strategy in and keep the host untouched.
Heuristics for Strategy
Choose Strategy when you see conditional clusters that only differ in computation, not in data shape. If clients should not know which algorithm runs, supply them with a stable interface such as calculate(…) or render(…). If the number of variants is open-ended, prefer a registry or factory for strategies instead of sprawling if trees.
Anti-signals
Avoid Strategy if variants require incompatible state models or wildly different method signatures; you will end up with adapter glue around each strategy which suggests the problem is not a single axis of variation. If only two strategies exist and will never grow, a simple function parameter or a callable may be clearer.
run(…) so each variant remains independent.
Tiny example
This shows swapping a pricing algorithm without touching the order pipeline.
class PricingStrategy:
def price(self, order): return 0.0
class StandardPricing(PricingStrategy):
def price(self, order): return sum(i.qty * i.unit for i in order.items)
class PromoPricing(PricingStrategy):
def price(self, order): return 0.9 * sum(i.qty * i.unit for i in order.items)
class Checkout:
def __init__(self, pricing): self.pricing = pricing
def total(self, order): return self.pricing.price(order)
Adapter Versus Rewriting
Adapter helps when you must integrate an incompatible API without changing it. Rewriting helps when the foreign API is small, unstable, or low quality. The choice pivots on expected lifetime, ownership, and coupling. If the vendor code will outlive your app, adapt it. If the code is thin and you own it, rewriting can simplify your domain and remove long-term glue.
Cost–benefit quick table
This table frames common trade-offs. Treat it as a compass, not a contract.
| Context | Lean Adapter | Favor Rewrite |
| Third-party API is stable and supported | ✔ Interface shield for your domain | ✖ Costly and risky |
| Third-party API is volatile or poorly designed | ✖ Adapters multiply and fossilize oddities | ✔ Clean, domain-first interface |
| Performance bottleneck in marshaling | ✖ Extra layers add overhead | ✔ Rewrite to native types |
| Time to deliver is very limited | ✔ Thin shim buys time | ✖ Rewrite may slip schedule |
Adapter sketch
Here a legacy payment client is wrapped to match your gateway interface. The adapter isolates odd field names like {"cc_no": …}.
class LegacyPayClient:
def send(self, payload): return {"ok": True, "id": "abc123"}
class PaymentGateway:
def charge(self, card, amount): raise NotImplementedError
class LegacyAdapter(PaymentGateway):
def __init__(self, client): self.client = client
def charge(self, card, amount):
data = {"cc_no": card.number, "amt": amount.cents, "cur": amount.currency}
resp = self.client.send(data)
return resp["ok"], resp.get("id")
FooAdapter for BarAdapter for Baz, you are accumulating structural debt. Consider a targeted rewrite or a façade that presents a single clean surface.
Decorator Versus Inheritance
Use Decorator to add behavior to specific instances without touching their class. Prefer it when features are optional, orthogonal, and can stack in different orders such as logging, caching, or throttling. Use inheritance when the variation is intrinsic to the type and all instances of the subtype must share it.
Heuristics for Decorator
If you can read “base behavior stays the same and extra behavior wraps before or after it,” choose Decorator. When combinations matter, such as Cache then Retry versus Retry then Cache, instance-level stacking beats subclass explosions like LoggedCachingRetryingService.
Inheritance still wins here
Inheritance is simpler when the variation changes the core contract or representation. Examples include Square as a special case of Rectangle that enforces equal sides, or UnsignedInteger that forbids negatives at the type level. If a feature must exist for all instances, make it part of the class rather than a wrapper.
class Repo:
def get(self, key): return load_from_db(key)
class LoggingRepo(Repo):
def __init__(self, inner): self.inner = inner
def get(self, key):
print(f"GET {key}")
return self.inner.get(key)
When Composite Fits
Composite unifies treatment of parts and wholes. Use it when you have recursive hierarchies such as UI trees, file systems, or nested expressions and you want clients to call the same methods on both leaves and groups. The key signal is a tree with behavior that aggregates naturally, for example render(), evaluate(), or size().
Heuristics for Composite
If you find yourself asking “is this a leaf?” before every operation, reach for Composite. If your API can be uniform, define it on a shared interface and let groups forward calls to children. If ordering or constraints matter, let the composite enforce them so clients do not have to juggle sequence rules.
class Node:
def render(self): raise NotImplementedError
class Text(Node):
def __init__(self, s): self.s = s
def render(self): return self.s
class Group(Node):
def __init__(self): self.children = []
def add(self, n): self.children.append(n)
def render(self): return "".join(c.render() for c in self.children)
add(…) so a node cannot be added to one of its descendants.
When Observer Causes Trouble
Observer looks simple yet often misbehaves in production. The most common problems are update storms, unpredictable ordering, memory leaks from forgotten subscriptions, and hidden coupling. If state changes ripple through many observers, small tweaks can trigger large and surprising effects.
Risk signals
Be cautious if observers mutate the subject or each other, if ordering matters to correctness, or if subscriptions lack a lifetime policy. Synchronous notifications can cause deep call chains and stack churn. Asynchronous notifications can reorder events and surface race conditions.
Mitigations
Prefer immutable payloads for notifications so observers cannot fight over shared state. Use unsubscribe() or context managers to tie lifetime to scope. If ordering matters, define it explicitly in the subject and keep it stable. For high-volume signals, batch updates or debounce.
class EventBus:
def __init__(self): self._subs = {}
def on(self, topic, fn): self._subs.setdefault(topic, []).append(fn); return lambda: self._subs[topic].remove(fn)
def emit(self, topic, data):
for fn in list(self._subs.get(topic, [])): fn(data) # copy to avoid mutation issues