Design Patterns & Architecture
Design patterns are solutions to recurring problems, not rules carved in stone. Most GoF patterns were written for statically-typed, class-heavy languages. Python has first-class functions, duck typing, and protocols — so the Pythonic version of many patterns is often just “use a function.” Know the intent, not just the implementation.
SOLID Principles — Python Edition
SOLID is a set of five principles for writing code that doesn’t rot when requirements change. In small scripts they’re overkill. In a 50,000-line codebase with five engineers, they’re survival tools.
S — Single Responsibility
One class or module should have one reason to change. The wrong read: split everything into micro-classes. The right read: don’t let UserService handle authentication, email sending, and database access at the same time.
# Bad: one class, three reasons to change
class UserService:
def authenticate(self, user, password): ...
def send_welcome_email(self, user): ...
def save_to_db(self, user): ...
# Better: each class changes for one reason
class UserAuthenticator:
def authenticate(self, user, password): ...
class UserEmailer:
def send_welcome(self, user): ...
class UserRepository:
def save(self, user): ...
ELI5: If your class has a section on authentication AND a section on sending emails, you’ll edit it for two completely different reasons (auth bug vs email template change). That’s two responsibilities.
O — Open/Closed
Open for extension, closed for modification. In Python this means: don’t add if isinstance(x, NewType) blocks — extend via composition, plugins, or protocols instead.
# Bad: every new shape modifies this function
def area(shape):
if isinstance(shape, Circle): return math.pi * shape.r**2
if isinstance(shape, Rectangle): return shape.w * shape.h
# ...add Square? modify here. Fragile.
# Good: shapes implement the protocol, this never changes
class Shape(Protocol):
def area(self) -> float: ...
def total_area(shapes: list[Shape]) -> float:
return sum(s.area() for s in shapes)
L — Liskov Substitution
Subtypes must be usable wherever the base type is expected — without surprising the caller. The classic violation: a Square subclass of Rectangle that breaks when you set width/height independently.
class Rectangle:
def set_width(self, w): self.width = w
def set_height(self, h): self.height = h
class Square(Rectangle):
def set_width(self, w): # violates LSP
self.width = self.height = w # surprise!
def set_height(self, h):
self.width = self.height = h
ELI5: If you have a function that works with
Animal, it should work withDogwithout any surprises. IfDog.speak()raisesNotImplementedError, that’s a surprise.
I — Interface Segregation
Prefer small, focused protocols over fat base classes. In Python this is natural with typing.Protocol — clients depend only on the methods they actually use.
# Bad: everything must implement everything
class DataProcessor(ABC):
def read(self): ...
def write(self): ...
def validate(self): ...
def transform(self): ...
# Good: split into what callers actually need
class Readable(Protocol):
def read(self) -> bytes: ...
class Writable(Protocol):
def write(self, data: bytes) -> None: ...
D — Dependency Inversion
High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions. In Python: accept callables or Protocols as parameters instead of hardcoding concrete classes.
# Bad: high-level code depends on low-level EmailSender
class OrderService:
def __init__(self):
self.notifier = EmailSender() # hardcoded
# Good: pass in anything that matches the protocol
class Notifier(Protocol):
def send(self, message: str) -> None: ...
class OrderService:
def __init__(self, notifier: Notifier):
self.notifier = notifier # test with FakeNotifier, prod with EmailSender
ELI5: Don’t hard-wire a lamp into the wall. Use a plug. Anyone who provides a compatible socket works.
SOLID in context:
| Scale | SOLID value |
|---|---|
| 1-file script | Ignore it, not worth it |
| Small service (< 5k lines) | Apply S and D selectively |
| Medium library | Apply all five, use Protocol heavily |
| Large application (50k+ lines) | SOLID is required for survival |
Creational Patterns
Factory Function
The Pythonic factory. Just a function that constructs and returns an object. No AbstractCreatorFactory needed.
def create_connection(db_type: str, **kwargs):
match db_type:
case "postgres": return PostgresConnection(**kwargs)
case "sqlite": return SQLiteConnection(**kwargs)
case _: raise ValueError(f"Unknown db: {db_type}")
ELI5: A factory is just a function that decides what to build and builds it. You tell it “I want a postgres connection” and you get one back without knowing the gory details.
Abstract Factory
When you need families of related objects that must be consistent with each other — e.g., “create a UI kit where everything is either dark-themed or light-themed.”
class UIFactory(Protocol):
def make_button(self) -> Button: ...
def make_dialog(self) -> Dialog: ...
class DarkUIFactory:
def make_button(self) -> DarkButton: ...
def make_dialog(self) -> DarkDialog: ...
Use this when you switch entire families together. If you’re just switching one thing, a factory function is enough.
Builder
For constructing complex objects step by step. Python’s dataclass with defaults, keyword arguments, and attrs cover 80% of builder use cases without a formal Builder class.
# Python builder via method chaining
class QueryBuilder:
def __init__(self):
self._table = ""
self._filters = []
def from_table(self, table: str) -> "QueryBuilder":
self._table = table
return self
def where(self, condition: str) -> "QueryBuilder":
self._filters.append(condition)
return self
def build(self) -> str:
where = " AND ".join(self._filters)
return f"SELECT * FROM {self._table} WHERE {where}"
query = QueryBuilder().from_table("users").where("age > 18").where("active = 1").build()
Singleton
The Pythonic singleton is a module-level instance. Module imports are cached — the second import config returns the same object.
# config.py — already a singleton by virtue of being a module
_db_pool = None
def get_pool():
global _db_pool
if _db_pool is None:
_db_pool = create_pool()
return _db_pool
Need a real singleton class? Override __new__:
class Registry:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
Anti-pattern: Singletons make testing hard because they carry global state. Prefer dependency injection for testable code — only use singletons for truly shared resources like DB connection pools.
Structural Patterns
Decorator
Python has built-in decorator syntax. This is the most powerful structural pattern in the language.
import functools, time
def retry(max_attempts: int = 3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)
return wrapper
return decorator
@retry(max_attempts=3)
def fetch_data(url: str): ...
Adapter
Wraps an incompatible interface to match what the caller expects. Use when you can’t change the third-party code.
class OldPaymentGateway:
def process_credit_card(self, card_num, amount): ...
class PaymentAdapter:
"""Makes OldGateway look like our new PaymentProcessor protocol."""
def __init__(self, gateway: OldPaymentGateway):
self._gateway = gateway
def charge(self, payment_method, amount: float) -> bool:
return self._gateway.process_credit_card(payment_method.number, amount)
Facade
Hides a complex subsystem behind a simple interface. The most underused pattern — aggressively apply this when you have a complex library (e.g., boto3) that you call from many places.
class S3Facade:
"""Hides boto3 complexity. Callers don't need to know about it."""
def __init__(self, bucket: str):
self._s3 = boto3.client("s3")
self._bucket = bucket
def upload(self, key: str, data: bytes) -> None:
self._s3.put_object(Bucket=self._bucket, Key=key, Body=data)
def download(self, key: str) -> bytes:
return self._s3.get_object(Bucket=self._bucket, Key=key)["Body"].read()
ELI5: A facade is the reception desk of a hotel. You don’t call housekeeping, maintenance, and room service directly. You call reception and they handle it.
Proxy
Controls access to another object. Useful for lazy loading, caching, and access control. Python’s __getattr__ makes transparent proxying easy.
class LazyLoader:
def __init__(self, factory):
self._factory = factory
self._obj = None
def __getattr__(self, name):
if self._obj is None:
self._obj = self._factory()
return getattr(self._obj, name)
Behavioral Patterns
Strategy
Pass a function instead of a strategy class. This is Python’s biggest advantage over Java-style GoF.
# Java way: class hierarchy for each strategy
# Python way: just pass a callable
def process_payments(payments, sorter=sorted):
return sorter(payments, key=lambda p: p.amount)
# Swap strategies at call site
process_payments(payments, sorter=lambda ps, key: sorted(ps, key=key, reverse=True))
ELI5: Instead of creating a
SortStrategyclass withAscendingSortStrategyandDescendingSortStrategysubclasses, just passsortedorreversedas an argument.
Observer
Notify multiple listeners when something changes. Use weakref to avoid memory leaks when observers are short-lived.
import weakref
from typing import Callable
class EventEmitter:
def __init__(self):
self._listeners: dict[str, list] = {}
def on(self, event: str, callback: Callable) -> None:
self._listeners.setdefault(event, [])
self._listeners[event].append(weakref.ref(callback))
def emit(self, event: str, *args, **kwargs) -> None:
dead = []
for ref in self._listeners.get(event, []):
cb = ref()
if cb is None:
dead.append(ref)
else:
cb(*args, **kwargs)
for ref in dead:
self._listeners[event].remove(ref)
Anti-pattern: Storing strong references to callbacks causes memory leaks. If a listener object is garbage collected, the emitter still holds it alive.
Iterator
Python’s iteration protocol is built-in. Generators are the idiomatic way to write lazy iterators.
def chunked(iterable, size: int):
"""Yield successive chunks. Memory-efficient for large sequences."""
chunk = []
for item in iterable:
chunk.append(item)
if len(chunk) == size:
yield chunk
chunk = []
if chunk:
yield chunk
Command
Callables with functools.partial cover most command use cases. Use command objects only when you need undo/redo.
import functools
# Simple commands as partials
def set_volume(mixer, level): mixer.volume = level
undo_stack = []
command = functools.partial(set_volume, mixer, 80)
command()
undo_stack.append(functools.partial(set_volume, mixer, mixer.volume)) # capture current state
State Machine
Dict-based dispatch tables are cleaner than a class hierarchy for state machines.
from enum import Enum, auto
class State(Enum):
IDLE = auto()
RUNNING = auto()
PAUSED = auto()
TRANSITIONS = {
(State.IDLE, "start"): State.RUNNING,
(State.RUNNING, "pause"): State.PAUSED,
(State.RUNNING, "stop"): State.IDLE,
(State.PAUSED, "resume"): State.RUNNING,
(State.PAUSED, "stop"): State.IDLE,
}
class StateMachine:
def __init__(self):
self.state = State.IDLE
def trigger(self, event: str) -> None:
key = (self.state, event)
if key not in TRANSITIONS:
raise ValueError(f"No transition for {self.state} + {event!r}")
self.state = TRANSITIONS[key]
ELI5: A state machine is like a traffic light. It can only be in one state at a time (red, yellow, green), and only specific transitions are allowed (red -> green is valid, red -> yellow is not).
Chain of Responsibility — Middleware
Middleware is Python’s most common chain of responsibility. Django, Flask, Starlette all use it.
from typing import Callable
Middleware = Callable[[dict, Callable], dict]
def logging_middleware(request: dict, next_handler: Callable) -> dict:
print(f"Request: {request}")
response = next_handler(request)
print(f"Response: {response}")
return response
def auth_middleware(request: dict, next_handler: Callable) -> dict:
if not request.get("token"):
return {"error": "unauthorized"}
return next_handler(request)
Dependency Injection in Python
DI is about making dependencies explicit and swappable. Python doesn’t need a DI framework for most use cases.
| Approach | When to use |
|---|---|
| Constructor injection | Long-lived objects, complex dependencies |
| Function argument injection | Simple functions, one-off calls |
| Module-level configuration | Singletons, global settings |
| DI container (framework) | 10+ interchangeable components |
# Constructor injection — explicit, testable
class ReportGenerator:
def __init__(self, fetcher: DataFetcher, renderer: Renderer):
self._fetcher = fetcher
self._renderer = renderer
# In tests: inject mocks
gen = ReportGenerator(fetcher=FakeFetcher(), renderer=FakeRenderer())
# In production: inject real implementations
gen = ReportGenerator(fetcher=HttpFetcher(api_key), renderer=PDFRenderer())
ELI5: DI is just “don’t create your own tools inside the class, ask for them in the constructor.” This way your tests can hand the class a fake tool.
When a DI framework (dependency-injector, punq) is worth it: when you have 10+ services with nested dependencies that change based on environment. For 3-5 services, manual constructor injection is cleaner.
Plugin & Extension Systems
Entry Points (the Standard Way)
pyproject.toml entry points let third-party packages register plugins that your application discovers at runtime.
# In the plugin's pyproject.toml
[project.entry-points."myapp.plugins"]
my_plugin = "my_package.plugin:MyPlugin"
# In the host application
from importlib.metadata import entry_points
def load_plugins():
plugins = {}
for ep in entry_points(group="myapp.plugins"):
plugins[ep.name] = ep.load()
return plugins
This is how pytest discovers plugins (pytest11 group), how Flask extensions register themselves, how Django apps register.
__init_subclass__ Auto-Registration
Auto-register subclasses without decorators — they register themselves just by being defined.
class Plugin:
_registry: dict[str, type] = {}
def __init_subclass__(cls, name: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if name:
Plugin._registry[name] = cls
class CSVExporter(Plugin, name="csv"):
def export(self, data): ...
class JSONExporter(Plugin, name="json"):
def export(self, data): ...
# Plugin._registry == {"csv": CSVExporter, "json": JSONExporter}
ELI5: As soon as Python reads the
class CSVExporter(Plugin, name="csv")line, it callsPlugin.__init_subclass__automatically. The plugin registers itself. You don’t need to maintain a list anywhere.
Clean Architecture in Python
Clean architecture separates your system into layers where inner layers don’t know about outer layers. The dependency arrow always points inward.
Infrastructure (DB, HTTP, Files)
↓ depends on
Adapters (Repositories, Controllers)
↓ depends on
Use Cases (Business Logic)
↓ depends on
Domain (Entities, Value Objects)
The rule: Domain imports nothing from your project. Use Cases import only Domain. Adapters import Use Cases and Domain. Infrastructure imports everything.
# domain/order.py — no external imports
@dataclass
class Order:
id: str
items: list[OrderItem]
def total(self) -> Decimal:
return sum(item.price for item in self.items)
# use_cases/place_order.py — depends only on domain + abstractions
class OrderRepository(Protocol):
def save(self, order: Order) -> None: ...
def place_order(items: list[OrderItem], repo: OrderRepository) -> Order:
order = Order(id=str(uuid4()), items=items)
repo.save(order)
return order
# infrastructure/postgres_order_repo.py — the outer layer, knows about DB
class PostgresOrderRepository:
def save(self, order: Order) -> None:
# actual SQL here
...
ELI5: Your business rules (
Order.total()) shouldn’t care if data is stored in Postgres or a CSV file. Swapping the database should only require changing one class in the infrastructure layer.
When clean architecture is overkill:
| Situation | Right call |
|---|---|
| One-off script | Flat module, no layers |
| Simple CRUD microservice | Flask/FastAPI directly, no layers |
| Service with real business rules | Use case layer makes sense |
| Large system with team of 5+ | Full clean architecture justified |
Anti-patterns to Avoid
God class: One class with 20 methods covering authentication, business logic, and data access. Grows without bound. Split by responsibility.
Premature abstraction: Creating a DataProvider protocol when you have exactly one implementation. Wait until you have two different implementations before abstracting.
Java-in-Python: Writing getUsername()/setUsername() methods instead of @property. Python has properties, __slots__, __getattr__ for a reason.
Factory of factories:
# You have created a problem factory
class AbstractParserFactoryFactory:
def create_factory(self) -> AbstractParserFactory: ...
ELI5: If you’re three levels deep in factory classes and none of them do real work, you’re doing accidental complexity. Accidental complexity is complexity you created, not complexity that was in the problem.
Not using Python’s strengths:
| Java pattern | Pythonic equivalent |
|---|---|
| Strategy class hierarchy | Pass a callable |
| Command class hierarchy | functools.partial |
| Iterator class | Generator function |
| Singleton pattern | Module-level instance |
| Builder class | dataclass + keyword args |
Decision Table
| Problem | Pythonic solution | When to escalate |
|---|---|---|
| Create objects conditionally | Factory function | Abstract Factory if families required |
| Complex object construction | dataclass + keyword args | Builder class if ordering matters |
| Shared global state | Module-level instance | __new__ singleton if subclassing needed |
| Swappable behavior | Pass a callable | Strategy class if state is needed |
| Extend without modification | Protocol + composition | ABC if shared base behavior required |
| Cross-cutting concerns | Decorator | Middleware chain if ordering matters |
| Loose coupling | Constructor injection | DI container if 10+ dependencies |
| Discover plugins at runtime | Entry points | __init_subclass__ for internal plugins |
| Separate business from infra | Use case layer + Protocols | Full clean architecture if team of 3+ |