Design patterns in idiomatic Python

The four or five patterns that survive in a language with first-class functions, closures, and duck typing.

๐Ÿงฑ Module 2 8 min read Not started

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

  1. Use Strategy, Factory, Observer, Adapter, Decorator the Pythonic way.
  2. Implement Singleton (rarely needed) correctly.
  3. Use Dependency Injection without a framework.
  4. Use Builder for complex object construction.
  5. 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 patternPython equivalent
StrategyA function passed as argument
CommandA function or partial
IteratorA generator
Template MethodA function with hooks (callbacks)
Visitorsingledispatch or match
Prototypecopy.deepcopy
Chain of ResponsibilityA 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

python
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":

python
PRICINGS = {"regular": regular, "10off": discount_10, "gold": gold_member}
total(items, PRICINGS["gold"])

3. Factory โ€” usually just a class method

python
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"):

python
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):

python
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:

python
# 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:

python
class DB:
    _instance: "DB | None" = None

    @classmethod
    def get(cls) -> "DB":
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance

Or the __new__ trick:

python
class DB:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

Beware: singletons are sneaky globals. They make testing hard. Prefer dependency injection.


5. Observer / Pub-Sub โ€” a list of callables

python
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

python
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:

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:

python
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:

python
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:

python
class UserService:
    def __init__(self): self.db = Postgres()    # hardcoded

Good:

python
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:

python
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

python
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

python
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)
python
# 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

  1. Don't write a Manager class to wrap a single function. Just have the function.
  2. Don't write a Factory class when a function or @classmethod works.
  3. Don't write SingletonMetaClass unless you really need it (you don't).
  4. Don't write AbstractBase + ConcreteImpl if there's only one concrete impl. YAGNI.
  5. 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)

  1. Refactor a small "strategy class hierarchy" you've written into a function-as-strategy.
  2. Build an EventBus (sync). Wire two subscribers to one event.
  3. Build a LegacyAdapter that wraps a class to fit a Protocol you define.
  4. Use singledispatch to write pretty_print(obj) that handles int, list, dict, Path.
  5. Implement a small fluent query builder and use it to construct a SQL string.
  6. Refactor a class that creates its own database connection so the connection is injected; write a unit test using a fake.
  7. Bonus: implement the same Observer in async (asyncio.Queue).

Common pitfalls

  1. Translating Java patterns 1:1 โ€” produces verbose, alien code.
  2. Singletons for cross-cutting state โ€” turn into hidden globals.
  3. Premature abstraction (Protocol + 3 layers for code that has one impl).
  4. Builders that don't validate their result.
  5. Dependency injection without a real wiring composition root โ†’ spaghetti.

Self-check

  1. State three GoF patterns Python doesn't need.
  2. How is Strategy implemented Pythonically?
  3. Why are singletons problematic?
  4. When prefer composition over inheritance?
  5. What does singledispatch do?

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.