The Strategy & Dispatch Patterns
Replace growing if/elif chains with strategy registries (dicts of callables) or functools.singledispatch for type-based dispatch. Both keep open/closed design: add a new variant without editing the dispatcher core.
Recipe
Quick-reference recipe card - copy-paste ready.
from functools import singledispatch
STRATEGIES = {
"email": lambda user: f"email:{user['email']}",
"sms": lambda user: f"sms:{user['phone']}",
}
def notify(user: dict, channel: str) -> str:
return STRATEGIES[channel](user)
@singledispatch
def serialize(obj) -> str:
raise TypeError(type(obj))
@serialize.register(int)
def _(obj: int) -> str:
return str(obj)When to reach for this:
- Strategy dict when behavior varies by string/enum key (payment method, export format)
- singledispatch when behavior varies by argument type
- Registry pattern when plugins register handlers at import time
- Replacing 5+ elif branches that only differ by which function runs
- Testability when each strategy is a small, pure function
Working Example
Notification strategies plus singledispatch for API response shaping.
from dataclasses import dataclass
from functools import singledispatch
from typing import Callable
@dataclass(frozen=True)
class User:
name: str
email: str
phone: str
Notifier = Callable[[User, str], None]
def log_notify(user: User, message: str) -> None:
print(f"[log] {user.name}: {message}")
def email_notify(user: User, message: str) -> None:
print(f"[email] to {user.email}: {message}")
CHANNELS: dict[str, Notifier] = {
"log": log_notify,
"email": email_notify,
}
def dispatch_notify(channel: str, user: User, message: str) -> None:
try:
handler = CHANNELS[channel]
except KeyError as exc:
raise ValueError(f"unknown channel: {channel}") from exc
handler(user, message)
@singledispatch
def to_json(obj) -> dict:
raise TypeError(f"unsupported: {type(obj)!r}")
@to_json.register(User)
def _(user: User) -> dict:
return {"name": user.name, "email": user.email}
@to_json.register(int)
def _(value: int) -> dict:
return {"type": "int", "value": value}
ada = User("Ada", "ada@example.com", "+15551234")
dispatch_notify("email", ada, "hello")
print(to_json(ada))
print(to_json(42))What this demonstrates:
- Dict lookup replaces elif on channel strings
- Unknown keys raise clear
ValueErrorwith chaining singledispatchadds types without touching the base functionCallabletyping documents the strategy signature
Deep Dive
How It Works
- Strategy pattern: select an algorithm at runtime via a map or object attribute
singledispatchstores registered implementations keyed by type- MRO resolution picks the most specific registered type
- Registries can be populated at module import or via decorator plugins
- Both patterns localize change: new variant = new entry, not edited dispatcher
Strategy vs singledispatch
| Mechanism | Key | Best for |
|---|---|---|
| Dict of callables | String/enum | Config-driven, plugin names |
| singledispatch | Type | Serialization, formatting |
| match/case | Structure | Complex pattern shapes (3.10+) |
Python Notes
from enum import StrEnum
class Format(StrEnum):
JSON = "json"
CSV = "csv"
EXPORTERS: dict[Format, Callable] = {
Format.JSON: export_json,
Format.CSV: export_csv,
}Gotchas
- Mutable default registries - shared dict mutated at runtime leaks state across tests. Fix: copy registries or use immutable mappings (
MappingProxyType). - KeyError vs explicit error - raw dict access gives vague errors. Fix: wrap with helpful
ValueErrormessages. - singledispatch on methods - registering on methods is awkward; use standalone functions or
singledispatchmethod. - elif still growing elsewhere - dispatch only helps at one boundary; duplicate branches in callers mean wrong abstraction level. Fix: dispatch once at the facade.
- Untested default branch -
singledispatchbase that always raises hides missing registrations until runtime. Fix: test each registered type.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
match/case | Fixed, structural variants | Plugins added dynamically at runtime |
| OOP polymorphism | Behavior tied to class hierarchy | Functions are sufficient |
if/elif chain | 2-3 simple branches | Branches keep growing |
| Visitor pattern | Double dispatch over AST-like trees | Flat enum dispatch |
FAQs
When is a dict better than singledispatch?
When the key is a string, config value, or plugin name - not a Python type. Payment providers, export formats, and CLI subcommands fit dicts.
Can I register strategies at runtime?
Yes. Dicts and singledispatch.register both support runtime registration for plugin systems.
How do I test dispatchers?
Test each strategy function in isolation, then one integration test per key/type through the dispatcher.
Does singledispatch work with ABCs?
Yes. Registration on base classes applies to subclasses unless a more specific registration exists.
What about async handlers?
Keep async strategies as async functions; the dispatcher must await them. Consider separate async registries.
Is match/case a replacement?
For static, known variants, match is readable. For open plugin sets loaded from entry points, dict registries remain better.
How do I document valid keys?
Use Literal types, enums, or expose CHANNELS.keys() in API docs and validation.
Can strategies be lambdas?
Yes for one-liners. Name functions when logic grows - stack traces and tests need readable names.
What about default fallbacks?
.get(key, default_fn) for dicts. For singledispatch, keep a sensible base implementation or explicit TypeError.
How does this relate to the Strategy OOP pattern?
Dict-of-callables is the functional Strategy pattern - same intent, less class boilerplate in Python.
Related
- Factory & Builder Patterns - constructing strategy objects
- Observer & Pub/Sub - event dispatch
- Dependency Injection in Python - inject strategies
- Common Anti-Patterns - elif sprawl
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.