Design patterns that still matter (GoF, modern)¶
At a glance
Priority: P1 · Complexity: ⅖ · Est. time: 3 h · Phase: 1 · Prereqs: SOLID & refactoring, Advanced typing You're done when: you can name the ~10 patterns that actually recur in production code, show idiomatic Python and Java forms (including where the language makes the pattern disappear), and pick the right one for a forces-based problem, including AI-era cases (strategy for model routing, decorator for LLM middleware).
Why it matters¶
The 23 GoF patterns are 30 years old, but the forces behind them (vary this without touching that) are permanent. In 2026 the value is in three skills: recognising patterns in frameworks you didn't write (FastAPI dependencies, Spring AOP, LangGraph nodes), knowing which patterns your language has already absorbed (first-class functions replace Strategy/Command/Observer in Python), and avoiding pattern-fever. Interviews for Staff roles use patterns as a shared language for low-level design and code review.
Patterns also map cleanly onto LLM application code: strategy (model selection), decorator/chain-of-responsibility (middleware for retry, cache, guardrails), adapter (provider SDKs), template method (agent loops), observer (event streaming to UI), state (conversation/workflow phase).
Core concepts¶
Pattern = problem + forces + consequences¶
A pattern is not code to copy; it is a named solution to a recurring tension with known trade-offs. Always state: what varies? what must stay stable? what does this cost? (indirection, more types, harder debugging).
The ones that recur (and their modern shape)¶
| Pattern | Forces | Python form | Java form | Watch-out |
|---|---|---|---|---|
| Strategy | Swap an algorithm at runtime | A callable / Protocol |
Interface + lambdas | Over-abstracting a single implementation |
| Adapter | Make an incompatible interface fit | Small class wrapping SDK behind a Protocol |
Class implementing your interface | Leaky adapters that mirror the vendor API |
| Decorator | Add behaviour without changing the class; stackable | @decorator functions, wrapper classes |
Wrapper classes, Spring AOP | Order of stacking matters (cache before retry?) |
| Factory / Abstract factory | Choose concrete type from config | Function returning instance, registry dict | Static factory, ServiceLoader, Spring @Bean |
Reflection-heavy factories hide dependencies |
| Builder | Many optional parameters, staged construction | dataclass with defaults, Pydantic, keyword args |
Records + builder, Lombok/Immutables | Unneeded in Python; use kwargs |
| Observer / pub-sub | Notify many of a change, decoupled | Callbacks, asyncio.Queue, signals, event bus |
ApplicationEventPublisher, Flow |
Hidden control flow, memory leaks (unregistered listeners) |
| Command | Encapsulate a request (queue, undo, log) | Dataclass + handler function | Record + handler | Prefer plain functions unless serialising |
| State | Behaviour varies by state machine | Enum + match, or state classes |
Sealed interface + pattern matching | Ad hoc if status == sprawl |
| Template method | Fixed skeleton, variable steps | Base class hooks or pass functions | Abstract class | Inheritance coupling; prefer strategy composition |
| Chain of responsibility / middleware | Pipeline of handlers each may act/stop | List of callables (ASGI middleware) | Filter chain, interceptors | Debugging order and short-circuiting |
| Facade | Simple API over a complex subsystem | Module with a few functions | Service class | Becoming a god class |
| Proxy | Control access (lazy, caching, auth, remote) | __getattr__, wrapper classes |
Dynamic proxies (Spring) | Surprising behaviour, hard debugging |
| Composite | Treat tree of objects uniformly | Recursive classes | Interface + composite | Rare; useful for rule trees, UI, ASTs |
| Iterator / generator | Traverse lazily | Generators | Streams | Built into languages |
| Singleton | One instance | Module-level object | DI container scope | Global state, testability nightmare — prefer DI |
Patterns the language has already absorbed¶
- Python: Strategy, Command, Observer, Iterator, Template Method (as passing functions), Singleton (modules) collapse into first-class functions, generators and modules. Writing a
PaymentStrategyABC with one method where a function would do is a Java accent in Python. - Java 21–25: records, sealed interfaces and pattern-matching
switchmake Visitor largely obsolete for closed hierarchies, and give State/algebraic data types a native form; lambdas absorb Strategy/Command.
# Strategy as a function type — model routing
from typing import Callable, Protocol
from dataclasses import dataclass
@dataclass(frozen=True)
class Request:
prompt: str
needs_reasoning: bool
max_cost_usd: float
RouteFn = Callable[[Request], str] # returns a model id
def cheapest(_: Request) -> str: return "small-model"
def by_complexity(r: Request) -> str:
return "reasoning-model" if r.needs_reasoning else "small-model"
class Router:
def __init__(self, strategy: RouteFn): self.strategy = strategy
def route(self, r: Request) -> str: return self.strategy(r)
// Java 21+: sealed types + pattern matching replace Visitor / State classes
sealed interface BookingState permits Draft, Confirmed, Shipped, Cancelled {}
record Draft() implements BookingState {}
record Confirmed(Instant at) implements BookingState {}
record Shipped(String vessel) implements BookingState {}
record Cancelled(String reason) implements BookingState {}
static String describe(BookingState s) {
return switch (s) { // compiler checks exhaustiveness
case Draft d -> "Not yet confirmed";
case Confirmed c -> "Confirmed at " + c.at();
case Shipped sh -> "On " + sh.vessel();
case Cancelled x -> "Cancelled: " + x.reason();
};
}
Decorator/middleware for LLM calls¶
A pipeline of decorators is the cleanest way to add cross-cutting behaviour to LLM calls without polluting business code:
import time, hashlib
from typing import Protocol
class LLM(Protocol):
def complete(self, prompt: str) -> str: ...
class Retrying:
def __init__(self, inner: LLM, attempts=3): self.inner, self.attempts = inner, attempts
def complete(self, prompt):
for i in range(self.attempts):
try: return self.inner.complete(prompt)
except TimeoutError:
if i == self.attempts - 1: raise
time.sleep(2 ** i)
class Cached:
def __init__(self, inner: LLM, store: dict): self.inner, self.store = inner, store
def complete(self, prompt):
k = hashlib.sha256(prompt.encode()).hexdigest()
return self.store.setdefault(k, self.inner.complete(prompt))
class Guarded: # output guardrail
def __init__(self, inner: LLM, check): self.inner, self.check = inner, check
def complete(self, prompt):
out = self.inner.complete(prompt)
if not self.check(out): raise ValueError("guardrail violation")
return out
llm: LLM = Guarded(Cached(Retrying(RealLLM()), {}), check=no_pii) # order is a design decision
Order: cache before retry (don't cache errors, don't retry cache hits); guardrails outermost so they see final output. Same idea as ASGI/Starlette middleware, Spring interceptors and LiteLLM callbacks.
Patterns in the frameworks you use (recognition skill)¶
| Framework | Pattern |
|---|---|
FastAPI Depends |
Dependency injection + factory; scoped lifetimes |
| Starlette/ASGI middleware | Chain of responsibility / decorator |
| SQLAlchemy Session | Unit of Work + Identity Map |
Spring AOP (@Transactional, @Retryable) |
Proxy + decorator |
| Spring AI advisors | Chain of responsibility around chat calls |
| LangGraph | State machine (graph) + reducers; nodes are commands |
| Pydantic AI tools | Command/registry, dependency injection via RunContext |
| Kafka consumers | Observer/competing consumers |
| Pytest fixtures | Dependency injection, template method |
Choosing: a decision guide¶
| Symptom | Consider |
|---|---|
if/elif on a type/flag in many places |
Polymorphism / Strategy / State (or sealed types + match) |
| Constructor with 10 optional args | Builder (Java) / kwargs + dataclass (Python) |
| Cross-cutting behaviour repeated at call sites | Decorator / middleware / AOP |
| Third-party API shape leaking into your code | Adapter behind a port |
| Need to undo/queue/log actions | Command |
| Many things must react to one event | Observer / event bus |
| Complex subsystem, callers need 3 operations | Facade |
Senior-level nuance¶
- Patterns are vocabulary for reviews, not a design method. Start from forces; a pattern name is a shortcut for communicating the result.
- Indirection has a cost — each pattern adds names, files and stack frames. Add it at the second real variation, not the first (rule of three; YAGNI).
- Prefer composition over inheritance; template method via inheritance is fragile.
- Anti-patterns are patterns too: God object, Service Locator, Singleton-as-global, Anemic domain in a core context, Golden hammer.
- Beyond GoF: enterprise patterns (Fowler's PoEAA: Repository, Unit of Work, Identity Map, Data Mapper), concurrency patterns (producer-consumer, actor), resilience patterns (circuit breaker, bulkhead) and cloud patterns are more relevant day to day for architects than Flyweight or Memento.
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| Refactoring.Guru — Design Patterns | docs | Best-illustrated catalogue with Python and Java examples and pros/cons | intermediate | free/freemium |
| The Python Patterns Guide (Brandon Rhodes) |
docs | Which GoF patterns survive in Python and how they change; rare, thoughtful | advanced | free |
| Composition over Inheritance (Rhodes) |
article | Sharp argument with concrete Python examples | intermediate | free |
| Game Programming Patterns (Robert Nystrom) |
book | The clearest patterns writing anywhere; free online; applies well beyond games | intermediate | free |
| Design Patterns (GoF) | book | The source; read for forces and consequences, skip the C++ | advanced | paid |
| Patterns of Enterprise Application Architecture (Fowler catalog) | docs | Repository, Unit of Work, Data Mapper and friends | intermediate | free |
| Cosmic Python | book | Repository/UoW/service layer/message bus patterns in idiomatic Python | intermediate | free |
| Azure Architecture Center — cloud design patterns | docs | Resilience, messaging and data patterns with when-not-to-use | intermediate | free |
| Refactoring catalog (Fowler) | docs | Move from smell to pattern via small refactorings | intermediate | free |
Hands-on lab¶
Goal: refactor a messy LLM call site into patterns. 60–90 min.
- Start with a function that builds a prompt, calls a provider SDK, retries in a loop, caches in a global dict and logs — 120 lines.
- Extract an Adapter (
LLMprotocol + provider class). - Turn retry, cache, logging and guardrail into Decorators; compose them in a factory.
- Replace an
if provider == ...chain with a Strategy registry. - Model conversation phases (
Collecting,Confirming,Done) as State using an enum +match, then as sealed types in a Java snippet. - Add tests: fake LLM, assert decorator order behaviour (cache hit skips retry; guardrail sees final output).
- Write down what you did not abstract and why.
Expected output: a small package with tests, and a table mapping each refactoring to the force it resolves.
Questions¶
L1 — Recall¶
Q1. Which GoF patterns are mostly absorbed by first-class functions in Python?
Answer
Strategy, Command, Template Method (via passed callables), Observer (callbacks), and often Factory (functions returning objects). Iterator is absorbed by generators, Singleton by modules. A class hierarchy for a single-method strategy is usually unnecessary.
Q2. What is the difference between Decorator and Proxy?
Answer
Both wrap an object with the same interface. Decorator adds behaviour/responsibilities (logging, retry, caching) and is typically stackable by the client. Proxy controls access to the object (lazy loading, remote call, access control, caching as access control) and usually manages the subject's lifecycle itself. Intent differs more than structure.
Q3. State two problems with Singleton in modern code.
Answer
Hidden global state makes testing hard (shared mutable state across tests, impossible to substitute) and couples code to a concrete instance; concurrency issues in initialisation and access. Prefer dependency injection with a scoped lifetime (singleton scope in the container) so consumers depend on an interface.
L2 — Apply¶
Q4. Implement a middleware chain for LLM calls with retry, cache and guardrail; where does each go and why?
Answer
Outermost: guardrail (must validate the final output, whether from cache or model, and block unsafe content). Next: cache (avoid model calls and cost; cached responses never trigger retries). Innermost: retry (only for calls hitting the model; transient errors). Add timeout at the innermost layer and observability (tracing) outermost to time the whole. Rationale: caching failures or retrying cached hits are bugs; guardrails after cache protect against poisoned entries.
Q5. A NotificationService has if channel == 'email' ... elif 'sms' ... elif 'slack' repeated in four methods. Refactor.
Answer
Introduce a Channel protocol with send(recipient, message), implementations per channel, and a registry/factory keyed by channel name (config-driven). The service depends on the protocol; adding WhatsApp = one new class + registration (Open/Closed). If the variation is only a function (no state), use a dict of callables. Add a test per channel and a contract test for the protocol. Don't add a Factory class in Python if a dict suffices.
Q6. Model an order lifecycle (Draft → Paid → Shipped → Delivered/Cancelled) in Python so illegal transitions are impossible to code by accident.
Answer
Option 1: Enum + transition table {Draft: {Paid, Cancelled}, ...} and a transition(order, to) method that raises on invalid moves; simple and inspectable. Option 2: separate immutable state objects (Draft, Paid(paid_at), ...) with methods only valid for that state (Paid.ship()), typed with Union so type checkers flag misuse. Option 3: match over the state in the aggregate methods. Emit domain events on transitions. In Java, sealed interface + records + exhaustive switch.
L3 — Design & trade-offs¶
Q7. Inheritance-based Template Method vs Strategy for an agent loop (plan → act → observe → evaluate). Choose.
Answer
Strategy/composition. The skeleton (loop with budget, tracing, error handling, stop conditions) is stable; the varying parts (planner, tool executor, evaluator) become injected collaborators. Template method via inheritance couples subclasses to the base's internal order, makes testing require subclassing, and complicates mixing variations (e.g. reflective evaluator + parallel tools). With composition you can test each piece, swap them per tenant, and wrap the loop with decorators. Use inheritance only if variation is tiny and the base class is stable and owned by you.
Q8. Your team wants an abstract factory + builder + strategy for a component that has exactly one implementation today. Respond.
Answer
Ask what force justifies each abstraction now. With one implementation and no known second, abstractions are speculative generality: they add files, indirection and cognitive load and often guess the wrong seam. Recommend the simplest code, isolate the volatile dependency (if any, e.g. vendor SDK) behind one thin port, and introduce the second abstraction when a second implementation appears (rule of three). Keep the code refactorable through tests. Exception: public library APIs or boundaries expected to be extended by others.
Q9. Observer/event bus vs direct calls between modules in a modular monolith: decide.
Answer
Use direct calls (module APIs) for queries and commands needing immediate results; use in-process domain events for reactions where the publisher shouldn't know consumers (e.g. BookingConfirmed → invoicing, notifications). Costs of events: hidden control flow, ordering, error handling (what if a handler fails?), transaction semantics. Mitigate: handlers after commit, persisted event publication (outbox-like, e.g. Spring Modulith registry), naming in past tense, tests for handlers, and observability. Balanced coupling: events reduce strength between modules that don't need coordination.
L4 — Staff-level ambiguity¶
Q10. A new hire's PRs apply GoF patterns everywhere; reviews are turning into pattern debates. How do you coach and set guidelines?
Answer
Reframe from patterns to forces: in reviews ask "what varies here, and what evidence do we have it will?" Provide a short team guideline: prefer functions and composition; add abstraction at the second real variation; isolate volatile external dependencies with ports; patterns must be named in the PR description with the force resolved. Pair on a refactoring to show removing an unnecessary abstraction is valued. Provide reading (Nystrom, Rhodes) and examples of code in the repo that's good (simple) and bad (over-engineered). Recognise the learner's enthusiasm; steer, don't shame.
Q11. How would you build an internal 'LLM middleware' standard so 20 teams get retries, caching, PII redaction and tracing consistently without a mandated framework?
Answer
Provide the cross-cutting concerns at the gateway (server-side) where possible: an LLM gateway offering auth, quotas, caching, PII policies, tracing and provider failover — language-agnostic, one place to enforce. For client-side patterns, publish a small library per language implementing decorators around a minimal LLM protocol, plus reference templates. Standardise the contract (OpenAI-compatible API or a thin internal one), trace/attribute conventions (OTel GenAI) and the eval harness, not the agent framework. Track adoption and incidents; deprecate duplicated implementations gradually. Guilds maintain the library with clear ownership and semantic versioning.
Real-world use cases¶
- Web frameworks: middleware chains (ASGI, servlet filters) as chain of responsibility/decorator.
- Payments: strategy/adapter per PSP behind a
PaymentGateway; state pattern for payment status. - ORMs: unit of work, identity map, proxies for lazy loading.
- LLM platforms: strategy for routing, decorator for cache/retry/guardrails, adapter for providers, observer for streaming tokens to UI.
- Workflow engines: command + state + saga orchestration.
Pitfalls & anti-patterns¶
- Pattern-first design; abstractions with a single implementation.
- Java-style class hierarchies in Python where functions suffice.
- Singletons and service locators as hidden globals.
- Inheritance for reuse; deep hierarchies and template methods with many hooks.
- Decorator stacks with unclear order semantics.
- Facades that grow into god classes.
- Event buses that hide critical control flow.
Checklist¶
- I can name ~12 recurring patterns with the force each resolves, without notes
- I can show which patterns Python and modern Java absorb natively
- I built an LLM call pipeline with adapter, decorators and strategy
- I can justify not introducing a pattern
- I answered all L3 questions out loud in < 3 min each