Design patterns in idiomatic Python
The four or five patterns that survive in a language with first-class functions, closures, and duck typing.
Why this matters
Most "Gang of Four" patterns from Java/C++ collapse in Python because first-class functions, dynamic typing, and the standard library already give you the moving parts. This lesson shows the Pythonic version of the patterns you'll actually use, plus a few that are idiomatically Pythonic.
Learning objectives
- Use Strategy, Factory, Observer, Adapter, Decorator the Pythonic way.
- Implement Singleton (rarely needed) correctly.
- Use Dependency Injection without a framework.
- Use Builder for complex object construction.
- Recognise patterns and not over-engineer them.
1. The "patterns you don't write" list
These are common patterns that don't need a class in Python:
| GoF pattern | Python equivalent |
|---|---|
| Strategy | A function passed as argument |
| Command | A function or partial |
| Iterator | A generator |
| Template Method | A function with hooks (callbacks) |
| Visitor | singledispatch or match |
| Prototype | copy.deepcopy |
| Chain of Responsibility | A list of callables in a loop |
Python doesn't need classes to support callable polymorphism โ functions are already first-class.
2. Strategy โ pass a function
def total(items, pricing): # pricing IS the strategy
return sum(pricing(item) for item in items)
def regular(item): return item["price"]
def discount_10(item): return item["price"] * 0.9
def gold_member(item): return item["price"] * 0.8 if item["taxable"] else item["price"]
total(items, gold_member)That's it. No Strategy class, no factory, no registry.
If you want a "registry":
PRICINGS = {"regular": regular, "10off": discount_10, "gold": gold_member}
total(items, PRICINGS["gold"])3. Factory โ usually just a class method
class User:
def __init__(self, name, age): ...
@classmethod
def from_dict(cls, d):
return cls(d["name"], d["age"])
@classmethod
def from_json(cls, raw):
return cls.from_dict(json.loads(raw))For multi-type factories ("give me a Storage of type X"):
STORAGES = {"s3": S3Storage, "fs": FileStorage, "memory": MemoryStorage}
def make_storage(kind: str, **kwargs):
return STORAGES[kind](**kwargs)Or use __init_subclass__ to auto-register (Lesson 2.1):
class Storage:
registry = {}
def __init_subclass__(cls, *, name): cls.registry[name] = cls
class S3Storage(Storage, name="s3"): ...
class FileStorage(Storage, name="fs"): ...
make_storage = lambda kind, **k: Storage.registry[kind](**k)4. Singleton โ rarely needed, here's how anyway
In Python, modules are singletons. Just put state at module level:
# config.py
settings = load_settings()Everyone who does from config import settings gets the same object.
When you genuinely need a singleton class (e.g., for a connection pool), use a class method:
class DB:
_instance: "DB | None" = None
@classmethod
def get(cls) -> "DB":
if cls._instance is None:
cls._instance = cls()
return cls._instanceOr the __new__ trick:
class DB:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instanceBeware: singletons are sneaky globals. They make testing hard. Prefer dependency injection.
5. Observer / Pub-Sub โ a list of callables
class EventBus:
def __init__(self):
self._subs: dict[str, list[Callable]] = defaultdict(list)
def subscribe(self, event: str, callback: Callable) -> None:
self._subs[event].append(callback)
def publish(self, event: str, payload) -> None:
for cb in self._subs[event]:
cb(payload)
bus = EventBus()
bus.subscribe("order.created", send_email)
bus.subscribe("order.created", record_metric)
bus.publish("order.created", {"id": 42})For larger systems, look at blinker (sync), asyncio.Queue (async), or full message brokers (Kafka, RabbitMQ, Redis streams).
6. Adapter โ wrap to fit a Protocol
class LegacyAPI:
def fetch_user(self, uid): ...
class UserRepository(Protocol):
def get(self, id: int) -> User: ...
class LegacyAdapter:
def __init__(self, legacy: LegacyAPI): self._legacy = legacy
def get(self, id: int) -> User:
raw = self._legacy.fetch_user(id)
return User(id=raw["uid"], name=raw["nm"])Pythonic and minimal โ the adapter is just one class with one method.
7. Decorator pattern (different from @decorator)
The GoF "decorator" wraps an object to add behaviour. Often implemented as a function decorator in Python:
def with_logging(fn):
def wrapped(*args, **kwargs):
print(f"-> {fn.__name__}")
result = fn(*args, **kwargs)
print(f"<- {fn.__name__}")
return result
return wrapped
@with_logging
def buy(item): ...Or as a class wrapper:
class CachingRepo:
def __init__(self, inner: Repository):
self._inner = inner
self._cache: dict = {}
def get(self, id):
if id not in self._cache:
self._cache[id] = self._inner.get(id)
return self._cache[id]Composes cleanly: CachingRepo(LoggingRepo(S3Repo())).
8. Builder โ when constructor args explode
For 10-argument constructors, builder hides the complexity:
class QueryBuilder:
def __init__(self):
self._where = []; self._order = None; self._limit = None
def where(self, condition): self._where.append(condition); return self
def order_by(self, col, desc=False): self._order = (col, desc); return self
def limit(self, n): self._limit = n; return self
def build(self) -> str:
... # construct SQL
return sql
q = QueryBuilder().where("age > 30").order_by("name").limit(10).build()Each method return self so you can chain (fluent API).
Pythonic alternative: keyword arguments + dataclass config + factory function. The builder shines when chaining reads better than 10 kwargs.
9. Dependency Injection โ without a framework
Bad:
class UserService:
def __init__(self): self.db = Postgres() # hardcodedGood:
class UserService:
def __init__(self, db: Database): self.db = db # injected
# Wire-up at the entry point:
db = Postgres(url=settings.db_url)
service = UserService(db)Testing becomes trivial โ pass a fake:
service = UserService(InMemoryDatabase())For very large apps, frameworks like dependency-injector or wireup exist. For most code, manual wiring at the entry point is enough.
FastAPI's Depends is the most-used DI pattern in modern Python (Phase 8.1).
10. Visitor โ replaced by singledispatch or match
The GoF visitor is verbose. Python alternatives:
functools.singledispatch
from functools import singledispatch
@singledispatch
def serialise(obj):
raise TypeError(type(obj))
@serialise.register
def _(obj: int) -> str: return str(obj)
@serialise.register
def _(obj: list) -> str: return f"[{', '.join(map(serialise, obj))}]"
@serialise.register
def _(obj: dict) -> str:
return "{" + ", ".join(f"{serialise(k)}: {serialise(v)}" for k, v in obj.items()) + "}"match
def visit(node):
match node:
case {"type": "literal", "value": v}: return v
case {"type": "add", "lhs": lhs, "rhs": rhs}: return visit(lhs) + visit(rhs)
case {"type": "mul", "lhs": lhs, "rhs": rhs}: return visit(lhs) * visit(rhs)Pick singledispatch for type-based dispatch, match for structural dispatch.
11. Repository / Service / Use case โ clean architecture in Python
Common layering in 2026 Python services:
HTTP / CLI / Worker (FastAPI route, Typer command)
โ
โผ
Use case / Service (orchestration; pure logic)
โ
โผ
Repository (persistence boundary)
โ
โผ
Database / API client (infrastructure)# domain/user.py
@dataclass(frozen=True)
class User:
id: int; name: str; email: str
# repository.py
class UserRepository(Protocol):
def get(self, id: int) -> User | None: ...
def save(self, user: User) -> None: ...
# infrastructure/postgres.py
class PostgresUserRepository:
def __init__(self, conn): self.conn = conn
def get(self, id): ...
def save(self, user): ...
# service.py
class UserService:
def __init__(self, repo: UserRepository): self.repo = repo
def register(self, name: str, email: str) -> User:
if "@" not in email: raise ValueError("bad email")
user = User(id=next_id(), name=name, email=email)
self.repo.save(user)
return user
# api.py (FastAPI)
@app.post("/users")
def create_user(req: UserCreateRequest, service: UserService = Depends()):
return UserResponse.from_user(service.register(req.name, req.email))The Protocols and DI make each layer testable in isolation.
12. The "don't" list
- Don't write a
Managerclass to wrap a single function. Just have the function. - Don't write a
Factoryclass when a function or@classmethodworks. - Don't write
SingletonMetaClassunless you really need it (you don't). - Don't write
AbstractBase+ConcreteImplif there's only one concrete impl. YAGNI. - Don't use inheritance for code reuse. Use composition.
A useful litmus test: would a Java developer write this? If yes, consider whether there's a more Pythonic shape.
Hands-on lab (1.5 hours)
- Refactor a small "strategy class hierarchy" you've written into a function-as-strategy.
- Build an
EventBus(sync). Wire two subscribers to one event. - Build a
LegacyAdapterthat wraps a class to fit a Protocol you define. - Use
singledispatchto writepretty_print(obj)that handlesint,list,dict,Path. - Implement a small fluent query builder and use it to construct a SQL string.
- Refactor a class that creates its own database connection so the connection is injected; write a unit test using a fake.
- Bonus: implement the same Observer in async (
asyncio.Queue).
Common pitfalls
- Translating Java patterns 1:1 โ produces verbose, alien code.
- Singletons for cross-cutting state โ turn into hidden globals.
- Premature abstraction (Protocol + 3 layers for code that has one impl).
- Builders that don't validate their result.
- Dependency injection without a real wiring composition root โ spaghetti.
Self-check
- State three GoF patterns Python doesn't need.
- How is Strategy implemented Pythonically?
- Why are singletons problematic?
- When prefer composition over inheritance?
- What does
singledispatchdo?
References
- Fluent Python, Ramalho โ Chapter 10.
- Brandon Rhodes, "Python Design Patterns" (talk + site).
- Gamma et al., Design Patterns (the original GoF book โ read with skepticism for dynamic languages).
- "Software Architecture with Python", Wagh.
- "Hexagonal architecture in Python", Mark Brookes.
Sign in to save your progress and earn badges.