The Gang of Four's "Design Patterns" book is more than thirty years old, but you no longer need to read it as a ready-made catalog of recipes — half the patterns have long since dissolved into the language and frameworks. So why learn them? Because Django, SQLAlchemy, and every HTTP framework speak exactly this vocabulary. ast.NodeVisitor, sessionmaker, SimpleLazyObject — these aren't random names, they're GoF patterns embedded in class names. Without knowing the pattern, it's hard to understand why a class is built the way it is and not some other way.
Below are all 23 patterns in their classic groups. For each one: the gist in a single sentence, where it already lives in off-the-shelf tools, and whether you need to write it yourself.
Creational Patterns
This group answers one question: how do you create objects the right way?
Singleton
Gist: one instance for the whole application.
If you create an object everywhere it's needed, you end up with several unrelated copies with different state. Singleton solves this: the object exists as a single instance, and everyone talks to the same one.
In Python, a singleton is implemented by a module: an object created at module level exists as a single instance — the module is imported once and cached in sys.modules. This is better than the classic GoF variant with a get_instance() method: a module-level singleton is imported explicitly and is easily swapped out in tests via monkeypatch.
The key consequence: one instance serves all requests in parallel, so such objects must not carry mutable state — otherwise you get a thread race.
# pricing.py — the module itself is the singleton
pricing_service = PricingService()
def test_calculates_price():
service = PricingService() # in a test we create our own instance — simple
Prototype
Gist: a new instance on every request.
The opposite of Singleton: sometimes you need a fresh object for each operation, not a shared one. In Python this is literally the copy module: copy.copy() and copy.deepcopy() create a new instance from an existing one, and the behavior is customized via the __copy__ and __deepcopy__ methods.
The trap: a function argument's default value is evaluated once, when the function is defined — and the "freshness" is lost. The solution is to create the object inside the call:
def create_report_broken(request: ReportRequest, builder: ReportBuilder = ReportBuilder()) -> ReportDto:
... # trap: one builder for all calls
def create_report(request: ReportRequest) -> ReportDto:
builder = ReportBuilder() # a new instance every time
return builder.with_request(request).build()
Factory Method
Gist: object creation is delegated to a method that hides the concrete class.
Instead of writing ConcreteClass(...) everywhere, the calling code works with an abstraction, and the factory method decides which implementation to create.
In FastAPI, every dependency function in Depends(...) is a factory method: the calling code knows the interface, the function decides which implementation to configure. In SQLAlchemy, sessionmaker plays the same role:
def make_clock(settings: Settings) -> Clock:
if settings.profile == "integration-test":
return FixedClock(datetime(2026, 1, 15, 10, 0, tzinfo=UTC))
return SystemClock()
In application code, classmethod factories are a good way to create objects with validation:
class Order:
@classmethod
def create(cls, customer_id: CustomerId, lines: list[OrderLine]) -> "Order":
if not lines:
raise ValueError("An order must contain at least one line")
return cls(OrderId.generate(), customer_id, lines, Status.CREATED)
Abstract Factory
Gist: a factory that creates a family of related objects.
Where Factory Method creates a single object, Abstract Factory creates a whole group of objects that must "fit together."
In Python, the role of the abstract factory is played by DB-API: a driver module (sqlite3, psycopg) hands out mutually consistent Connection and Cursor objects under a single contract, hiding the concrete implementation. Which driver it will be is decided by configuration, not by the calling code.
In application code, Abstract Factory is rarely written by hand: a plain factory function covers picking the implementation from settings more simply.
Builder
Gist: step-by-step assembly of a complex object with readable code.
A constructor with eight positional parameters is a source of bugs: it's easy to mix up the order, and it's unclear what does what. In Python, most of Builder's job is done by keyword arguments and dataclass: mixing up the order is impossible, and default values are built into the language.
A full-fledged Builder survives in query builders: select(...).where(...).order_by(...) in SQLAlchemy is exactly that step-by-step assembly of a complex object:
@dataclass(frozen=True)
class OrderSearchQuery:
customer_id: CustomerId | None = None
status: OrderStatus | None = None
page: int = 0
size: int = 20
query = OrderSearchQuery(status=OrderStatus.PAID, size=20)
Structural Patterns
This group answers the question: how do you build the connections between objects the right way?
Adapter
Gist: converts one interface into another that the client expects.
Imagine two plugs of different shapes: your code expects one interface, but an external library offers another. Adapter is the connector between them.
In Python, this is WSGI/ASGI: the server (uvicorn, gunicorn) doesn't care what the application is written with — Django, FastAPI, or Flask. It works with a single contract that every framework brings its own style to.
In application code, Adapter is the foundation for working with external dependencies: the adapter translates a domain interface into the language of a specific SDK:
class S3DocumentStorageAdapter:
def __init__(self, s3_client: S3Client) -> None:
self._s3_client = s3_client
def store(self, document: Document) -> DocumentRef:
key = str(document.id)
self._s3_client.put_object(Bucket="documents", Key=key, Body=document.content)
return DocumentRef(key)
Bridge
Gist: the abstraction and the implementation evolve independently.
The classic example: fsspec — one abstraction "filesystem," independent implementations for file://, s3://, https://. The code that reads a file doesn't change when the source changes. The same scheme is in logging: the logger is the abstraction, the handlers are independent output implementations.
In application code, Bridge in its pure form is almost never seen — it's replaced by the "Protocol + dependency injection" combination.
Composite
Gist: a group of objects is used the same way as a single object.
You need to send a notification over email and SMS at the same time, but the calling code shouldn't know the details. Composite lets you "wrap" several objects into one that implements the same interface.
In the standard library this is unittest.TestSuite: a set of tests is run the same way as a single test. The same mechanics are in contextlib.ExitStack — several contexts look like one.
class CompositeNotificationAdapter:
def __init__(self, channels: list[NotificationPort]) -> None:
self._channels = channels
def order_cancelled(self, order: Order) -> None:
for channel in self._channels:
channel.order_cancelled(order)
Decorator
Gist: an object is wrapped in a wrapper with the same interface that adds behavior.
You need to add caching to a repository, but you can't (or don't want to) change its class. Decorator creates a wrapper with the same interface that intercepts calls and adds the desired behavior.
In Python, the pattern is built into the syntax: @functools.lru_cache, @functools.wraps, @retry from tenacity — wrappers with the same interface that add behavior to a function. For objects, the same idea is written by hand:
class CachingProductRepository:
def __init__(self, delegate: ProductRepository, cache: Cache) -> None:
self._delegate = delegate
self._cache = cache
def find_by_id(self, product_id: ProductId) -> Product | None:
return self._cache.get_or_load(
product_id, lambda: self._delegate.find_by_id(product_id)
)
Facade
Gist: a simple interface over a complex subsystem.
Working with HTTP directly through http.client requires: open a connection, assemble the headers, handle redirects and encodings, close everything in the right order. requests.get(...) hides all this complexity behind a single call.
In Python, facades are everywhere: requests (over urllib3), shutil (over os), subprocess.run (over Popen). In application code, a facade over an external SDK is a normal form of adapter: a single method can hide three third-party API calls, retries, and error translation.
class PaymentGatewayAdapter:
def __init__(self, sdk_client: PaymentSdkClient) -> None:
self._sdk_client = sdk_client
def charge(self, order: Order, method: PaymentMethod) -> PaymentResult:
request = self._sdk_client.new_request(
amount=order.total.amount,
currency=order.total.currency.code,
method=method.token,
)
response = self._sdk_client.submit(request)
return PaymentResult(response.transaction_id, response.status)
Flyweight
Gist: shared immutable objects instead of thousands of identical copies.
If you create one object per word in a text, memory runs out fast. Flyweight shares objects with identical content — one instance per value.
In Python, this is the small-int cache (from −5 to 256) and string interning (sys.intern). In frameworks — internal caches of compiled regular expressions (re.compile) and type metadata.
In application code, Flyweight is almost never written by hand. Its idea is carried by immutable value objects and Enum members: Currency.RUB is one for the whole application precisely because it's immutable.
Proxy
Gist: a stand-in object controls access to the real object.
The proxy intercepts calls and does something before or after: opens a transaction, checks permissions, caches the result.
In Python, a proxy is built on __getattr__: Django wraps request.user in a SimpleLazyObject — the database query fires only on first access; unittest.mock.MagicMock intercepts any call; weakref.proxy controls an object's lifetime.
Hence the classic traps: the proxy isn't fully transparent — isinstance against the concrete class and strict type checks break on the stand-in.
class LazyProxy:
def __init__(self, factory: Callable[[], Any]) -> None:
self._factory = factory
self._target: Any = None
def __getattr__(self, name: str) -> Any:
if self._target is None:
self._target = self._factory() # the real object is created on first access
return getattr(self._target, name)
Behavioral Patterns
This group answers the question: how do you organize the interaction between objects?
Chain of Responsibility
Gist: a request travels down a chain of handlers until someone handles it.
An HTTP request needs to be checked first for authentication, then for CSRF, then for authorization, and each step can stop processing. Instead of one huge method — a chain of independent handlers.
In Python, this is the middleware stack — Django middleware and ASGI middleware in FastAPI/Starlette — the reference implementation of the pattern. The same mechanics apply to the logger hierarchy in logging: a record travels from logger to logger until someone handles it.
class TraceIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
token = trace_id_var.set(resolve_trace_id(request))
try:
return await call_next(request) # pass it further down the chain
finally:
trace_id_var.reset(token)
Command
Gist: an operation is packaged into an object — it can be passed around, deferred, queued.
Normally a method call is instant and anonymous. Command turns an operation into an object with data — it can be passed to another thread, deferred, logged, undone.
In Python, this is Celery tasks: task.delay(...) packages the call into an object and sends it to a queue. The same idea lives in functools.partial going into a ThreadPoolExecutor.
In application code, Command is the structural foundation for separating "what to do" from "how to do it": one object carries the operation's data, another executes it.
@dataclass(frozen=True)
class CancelOrderCommand:
order_id: OrderId
reason: CancelReason
class CancelOrderHandler:
def __init__(self, order_repository: OrderRepository, session: Session) -> None:
self._order_repository = order_repository
self._session = session
def handle(self, command: CancelOrderCommand) -> None:
with self._session.begin():
order = self._order_repository.find_by_id(command.order_id)
order.cancel(command.reason)
self._order_repository.save(order)
Interpreter
Gist: a language with a grammar and an interpreter for expressions written in it.
In Python, this is Jinja2 templates, the mini-language of format specs, and Django ORM lookups (created_at__gte=...) — string expressions with their own grammar and interpreter.
In your own code, creating mini-languages isn't worth it: string expressions aren't checked by the type checker, they break during refactoring, and they complicate debugging.
Iterator
Gist: sequential access to elements without exposing the internal structure.
The pattern has long since dissolved into the language: the iterator protocol (__iter__/__next__), the for loop, generators, itertools. Clients of external APIs add paginators on top of the same idea (for example, paginators in boto3).
You never have to implement Iterator by hand. The one close case is returning immutable views from an aggregate's collections.
Mediator
Gist: objects communicate through a mediator, without knowing about each other.
If one service calls another directly, they become tightly coupled: changing one breaks the other. Mediator removes the direct dependency — objects publish events, and whoever wants to subscribes.
In Django, this is signals: the application publishes a signal, handlers subscribe, and nobody is directly coupled to anyone. Outside Django, blinker plays the same role. An ASGI application's router is also a mediator between the transport and the handlers.
order_cancelled = Signal()
class CancelOrderHandler:
def handle(self, command: CancelOrderCommand) -> None:
# ... cancellation logic ...
order_cancelled.send(sender=self.__class__, order_id=command.order_id)
@receiver(order_cancelled)
def schedule_refund(sender: type, order_id: OrderId, **kwargs: Any) -> None:
# a different module, unaware of the cancellation handler
...
Memento
Gist: a snapshot of an object's state for a later rollback.
A savepoint in transactions is a pure Memento: session.begin_nested() in SQLAlchemy records a point, rollback() rolls back to it.
In application code it's almost never needed: rolling back state is the job of a database transaction, and change history is a separate audit table or event sourcing.
Observer
Gist: subscribers get notified when the publisher's state changes.
You need to send an email when an order is cancelled. You could call email_service.send(...) right inside the business logic — but then the business logic knows about the email service. Observer separates them: the business logic publishes an event, the email service subscribes.
In Django, this is signals (post_save and custom signals). An important nuance: if a handler with external effects (an email, an SMS) is attached without being tied to a transaction, the notification may go out for a rolled-back transaction. The right way is transaction.on_commit(...).
@receiver(order_cancelled)
def notify_customer(sender: type, order_id: OrderId, **kwargs: Any) -> None:
transaction.on_commit(lambda: notification_port.order_cancelled(order_id))
State
Gist: an object's behavior changes as its internal state changes.
An order in the CREATED status can be cancelled. An order in the DELIVERED status can't. The transition logic can be split into separate state classes, but for most tasks, checks inside the object's own methods are enough:
class Order:
def cancel(self, reason: CancelReason) -> None:
if self.status not in (Status.PAID, Status.CREATED):
raise IllegalOrderStateError(self.id, self.status, "cancel")
self.status = Status.CANCELLED
self._register_event(OrderCancelled(self.id, reason))
The classic State with a separate class per state is justified for a very large state machine. For an ordinary object with a few statuses, an Enum plus transition checks is enough.
Strategy
Gist: a family of algorithms behind a common interface, chosen depending on the situation.
Discounts for different customer categories: you can write a chain of if conditions, or you can pass one function per category. Adding a new category means a new function, not editing the conditions.
In Python, Strategy is everywhere and often it's just a function: sorted(key=...), json.dumps(default=...) — the algorithm is passed as a first-class value. A family with several operations is shaped as a Protocol.
DiscountPolicy = Callable[[Order], Money]
def vip_discount(order: Order) -> Money:
if not order.customer.is_vip:
return Money.ZERO
return order.total * Decimal("0.10")
class DiscountService:
def __init__(self, policies: list[DiscountPolicy]) -> None:
self._policies = policies
def calculate_discount(self, order: Order) -> Money:
return sum((policy(order) for policy in self._policies), start=Money.ZERO)
Template Method
Gist: the skeleton of an algorithm in a base class, the changeable steps in subclasses.
A test must always follow one scenario: setup → the test itself → teardown, even if the test failed. The unittest.TestCase base class takes care of this, leaving the subclass only the meaningful steps:
class OrderApiTest(unittest.TestCase):
def setUp(self) -> None:
# we write only the changeable step, the superclass guarantees the run skeleton
self.client = make_test_client()
The modern variant is to pass the step as a function rather than creating a subclass. Context managers do exactly this: the skeleton (open connection → execute → close) is constant, and the variable step is passed as the body of the with block.
Visitor
Gist: a new operation over a structure of objects without changing their classes.
There's a hierarchy of PaymentMethod types: Card, Sbp, Cash. You need to compute the fee differently for each type without adding a fee() method to every class. Visitor adds the operation from the outside. In Python, the classic Visitor survives in ast.NodeVisitor — walking the syntax tree.
In modern languages, Visitor has been displaced by pattern matching:
# Python 3.10+: match instead of Visitor
type PaymentMethod = Card | Sbp | Cash
def fee(method: PaymentMethod) -> Decimal:
match method:
case Card(amount=amount):
return amount * Decimal("0.02")
case Sbp():
return Decimal("0")
case Cash():
return Decimal("50")
All 23 Patterns: A Quick Summary
| Pattern | Where it shows up in off-the-shelf tools | Do you need it in your own code |
|---|---|---|
| Singleton | The module and module-level objects | Don't write by hand — a module is already a singleton |
| Prototype | copy.copy / copy.deepcopy | Rarely; a fresh constructor call is usually enough |
| Factory Method | sessionmaker in SQLAlchemy, Depends in FastAPI | Yes — classmethod factories to create objects with validation |
| Abstract Factory | DB-API: a driver hands out consistent Connection and Cursor | Not needed — a factory function picks the implementation |
| Builder | Query builders: select(...).where(...) in SQLAlchemy | Rarely — keyword arguments and dataclass cover it |
| Adapter | WSGI/ASGI between the server and the framework | Yes — adapters to external dependencies |
| Bridge | fsspec, logging handlers | Almost never — "Protocol + DI" covers it |
| Composite | unittest.TestSuite, contextlib.ExitStack | Yes — when you need several recipients behind one interface |
| Decorator | @lru_cache, @wraps, middleware wrappers | Yes — both for functions (@decorator) and for objects |
| Facade | requests, shutil, subprocess.run | Yes — an adapter-facade over someone else's SDK |
| Flyweight | The small-int cache, sys.intern, re.compile | Almost never — the idea is carried by immutable value objects |
| Proxy | SimpleLazyObject in Django, weakref.proxy, mock objects | Don't write — use __getattr__ delegation and ready-made wrappers |
| Chain of Responsibility | ASGI/Django middleware, the logger hierarchy | Rarely — the framework's ready-made chains are enough |
| Command | Celery tasks, functools.partial + thread pool | Yes — separating "what" from "how" in operation handlers |
| Interpreter | Jinja2, Django ORM lookups (field__gte=...) | Don't invent your own expression languages |
| Iterator | The iterator protocol, generators, itertools | Dissolved into the language |
| Mediator | Django signals, blinker | Yes — events instead of direct calls between modules |
| Memento | Savepoint: begin_nested() in SQLAlchemy | Almost never — the database transaction does the rollback |
| Observer | Django signals, transaction.on_commit | Yes — domain events, constantly |
| State | The transitions library for complex cases | Yes, in a lightweight form — Enum + transition checks |
| Strategy | sorted(key=...), json.dumps(default=...) | Yes — first-class functions instead of sprawling if statements |
| Template Method | unittest.TestCase (setUp/tearDown) | The callback variant is preferable to inheritance |
| Visitor | ast.NodeVisitor | Displaced by pattern matching (Python 3.10+) |
Of the 23 patterns, you'll regularly write seven or eight in application code: Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method, and State. Another handful you use ready-made every day without noticing: Proxy, Singleton, Builder, Facade, Template Method, Chain of Responsibility. The rest are vocabulary for reading other people's code.
In Short
- GoF patterns aren't recipes to copy, they're a vocabulary: this is exactly what frameworks speak in their class names.
- Proxy in Python is built on
__getattr__: Django's lazy objects, mock objects in tests,weakref.proxy. - Strategy is the main tool against sprawling
ifstatements: often it's just a function passed as a first-class value. - Observer is the standard way to separate side effects (an email, a metric) from the business logic.
- Adapter is the foundation for working with external dependencies: the domain knows the interface, the adapter knows the concrete SDK.
- Singleton isn't written by hand with
get_instance()— a module is already a singleton. - Decorator adds behavior without inheritance — in Python it's literally the
@syntax. - Of the 23 patterns, you regularly write ~7 by hand; the rest live in off-the-shelf tools.
What to Read Next
- SOLID by Example — the principles these patterns exist for.
- GRASP by Example — which class to give responsibility to before choosing a pattern.
- Spring AOP — how Proxy, Spring's number-one pattern, is built.
- DI/IoC, bean scopes — Singleton and Prototype as container scopes.
- Spring Events — Observer and Mediator in action.