The Gang of Four subset that actually appears
What it is
The 23 Gang of Four patterns are a shared vocabulary from 1994. About eight of them appear in modern interviews and modern code; several became language features; two or three are worth being able to criticise. Knowing which is which is the signal, not being able to recite all 23.
STILL LOAD-BEARING WHAT IT BECAME
-----------------------------------------------------------
Strategy a function parameter
Adapter the anti-corruption layer
Decorator middleware / interceptors
Observer events, reactive streams
Command CQRS commands, job queues, undo
Factory the DI container
Builder still a builder, and still useful
Proxy lazy loading, and the mesh sidecar
WORTH CRITICISING
Singleton global mutable state with a nicer
name
Template Method inheritance where composition is
better
Visitor correct and fragile; fine for a
stable hierarchy, painful otherwise
RARELY SEEN
Flyweight, Bridge, Mediator, Memento, Prototype, Interpreter,
Chain of Responsibility (as a class hierarchy; the pipeline
form is everywhere), Composite (except in trees), State,
Iterator (a language feature since ~1998), Abstract Factory,
Facade (real and too obvious to discuss)
What this is confused with: patterns as a catalogue to apply. They are a vocabulary for describing designs that already exist. A design produced by choosing patterns first is the thing the pattern literature's own critics complained about, and it produces the FactoryFactory jokes.
Also confused: a pattern and its 1994 implementation. Strategy in the book is an interface with implementations because C++ and early Java had no first-class functions. In any language with closures, Strategy is passing a function, and writing the class hierarchy is reproducing a workaround for a limitation you no longer have.
The problem it solves
Two problems, and only the first is about design.
Naming compresses a design discussion.
Without: "we should have a thing that wraps the client and
adds the retry behaviour without the caller knowing,
and you can stack several of them"
With: "decorate the client"
Without: "the caller should get an object that looks like our
interface but talks to their API and translates the
fields, so their model never leaks into ours"
With: "put an adapter at that boundary"
And the second: interviews ask. Not because the interviewer wants a recitation, but because how you talk about patterns reveals whether you have written a lot of code or read a lot of books. The tell is whether you can say which ones you avoid and why.
The failure the vocabulary prevents, at staff level, is a real one:
An engineer proposes an abstraction. Nobody has a name for it,
so the review discusses the code rather than the shape.
Three months later a second, near-identical abstraction is
built elsewhere, because nobody recognised it as the same
thing.
Naming is what makes a shape recognisable across a codebase.
Mechanics
The eight that are load-bearing
Strategy: swap an algorithm.
# 1994: an interface and three implementations.
# Now: a function.
def price(items, discount: Callable[[Decimal], Decimal]) -> Decimal:
return discount(sum(i.price for i in items))
price(cart, discount=lambda t: t * Decimal("0.9"))
price(cart, discount=loyalty_discount_for(customer))
Reach for the class form only when the strategy needs state, configuration, or its own lifecycle, which is a real minority of cases. The interview signal is saying that Strategy is a function in any language with closures, rather than drawing the class diagram.
Adapter: make an incompatible interface fit, and keep their model out of yours.
# The adapter IS the anti-corruption layer from DDD, at a
# smaller scale. Their vocabulary stops here.
class StripeGateway(PaymentGateway): # our interface
def charge(self, amount: Money, token: str) -> ChargeResult:
resp = self._stripe.PaymentIntent.create(
amount=amount.minor_units, currency=amount.currency.lower(),
payment_method=token, confirm=True,
)
# Their status vocabulary never escapes this method.
return ChargeResult(
ok=resp.status == "succeeded",
reference=resp.id,
failure=FAILURE_MAP.get(resp.last_payment_error and resp.last_payment_error.code),
)
Adapter is the most consistently useful pattern on the list, because every integration with an external system needs one and the cost of not having one is that a vendor's model spreads through your domain.
Decorator: add behaviour by wrapping, and stack it.
# Every middleware stack you have used is this pattern.
def with_retry(inner, attempts=3): ...
def with_timeout(inner, seconds=2): ...
def with_metrics(inner, name): ...
client = with_metrics(with_retry(with_timeout(raw_client, 2), 3), "pricing")
The property that matters is composability: each decorator is independently testable and the order is explicit and meaningful (timeout inside retry means per-attempt timeout; retry inside timeout means a total budget). Being able to say which order you want and why is a good depth answer.
Observer: react to something happening.
The in-process version is an event bus or a listener list.
The modern versions:
reactive streams, with backpressure via request(n)
domain events inside an aggregate boundary
a message broker, across process boundaries
The failure the pattern has always had: the observer list is
invisible at the call site, so "who runs when this happens" is
not answerable by reading the code. Modern versions mitigate
this with explicit registration and tracing rather than by
solving it.
Command: turn an intent into an object.
Once an action is a value, you can queue it, log it, retry it,
undo it, authorise it, and route it.
This is why the pattern outlived its 1994 framing: CQRS
commands, job queue payloads, HTTP request objects, editor
undo stacks and event-sourced aggregates all rely on the
action being data.
Factory: construction that is not new.
Modern reality: a DI container is a configurable abstract
factory, so the explicit pattern appears mostly where
construction depends on runtime data (a parser choosing a
handler by content type) or where an object has invariants
that a constructor cannot express, in which case a named
static factory method beats a factory class.
Builder: many optional parameters, and immutability.
// The genuinely useful case: an immutable object with many
// optional fields, where a telescoping constructor is
// unreadable and setters would break immutability.
HttpRequest.newBuilder()
.uri(uri).timeout(Duration.ofSeconds(2))
.header("Accept", "application/json")
.GET().build();
Builder survives because named parameters do not exist everywhere and because it can validate
invariants at build(), which a bag of setters cannot. In a language with named and default
arguments (Python, Kotlin, C#), it is usually unnecessary.
Proxy: same interface, different behaviour before or instead.
Lazy loading in an ORM: a proxy that materialises on first
access. Also the source of most N+1 problems, which is worth
saying: the pattern hides a network call behind a field
access.
Access control: a proxy that checks authorisation.
Remote: a client stub.
And a service mesh sidecar is a proxy, which is the same
pattern moved out of the process and into the pod.
The ones worth criticising
Singleton.
The stated problem: exactly one instance.
The actual effect: global mutable state, an invisible
dependency, and an initialisation order you cannot control.
The costs, concretely:
- tests cannot substitute it or reset it, so they share
state and become order-dependent
- the dependency does not appear in any signature, so a
caller's requirements are not readable from its interface
- lazy initialisation in a multithreaded program needs care
that people get wrong (double-checked locking was
broken in Java before the memory model was fixed)
What to do instead: one instance, created once, INJECTED. A DI
container with a singleton lifetime gives you the uniqueness
without the global access point, and the distinction between
those two is the whole critique.
Legitimate remaining uses: a stateless immutable value, a
process-wide logger facade, a metrics registry the platform
owns. All of them share the property that there is nothing to
reset between tests.
Template Method.
A base class defines the skeleton and subclasses fill in
steps. It works, and it couples the subclass to the base
class's shape permanently.
The specific failure: the base class evolves, and every
subclass has to change or silently break. Fragile base class.
And you get one axis of variation, because you can only
inherit once.
Prefer: pass the varying steps in (Strategy), or compose the
skeleton from functions. You get multiple axes of variation
and no inheritance coupling.
It is defensible when the skeleton is genuinely fixed and
shared by many implementations, which is why it survives in
frameworks and rarely in application code.
Visitor.
Double dispatch over a type hierarchy: correct, and it makes
the trade explicit.
EASY to add a new operation over the existing types.
HARD to add a new type: every visitor must change.
So it is right for a STABLE hierarchy with many operations,
an AST or an expression tree, and wrong when the hierarchy
grows, which is where teams discover it after adopting it.
In languages with pattern matching over sealed types (Rust,
Scala, Kotlin, modern Java), the pattern is largely replaced,
and the compiler enforces exhaustiveness, which the visitor
was emulating.
What became language features
Iterator for-each, generators, IEnumerable. Nobody
writes this.
Prototype object literals, structural copy,
dataclasses.replace
Strategy / Command first-class functions and closures
Chain of
Responsibility middleware pipelines, which are a list of
decorators rather than a linked chain
Interpreter you almost certainly want a real parser,
and if you are writing an interpreter,
the GoF version is not the design you want
Saying "several of these are workarounds for languages without first-class functions" is a stronger answer than describing any individual pattern, because it explains the subset rather than listing it.
A worked example: a pattern review that deleted more than it added
A payments integration layer, five years old, 24,000 lines, and a stated problem that adding a new payment provider took roughly six weeks.
What the code contained:
AbstractPaymentProcessorFactory
-> StripePaymentProcessorFactory
-> AdyenPaymentProcessorFactory
(2 factories, 1 abstract factory interface)
AbstractPaymentProcessor (Template Method)
validate() -> authorise() -> capture() -> reconcile()
with 4 abstract hooks and 3 protected helpers
-> StripePaymentProcessor
-> AdyenPaymentProcessor
PaymentConfigSingleton
PaymentMetricsSingleton
PaymentRetryPolicySingleton
PaymentVisitor, over a 9-type PaymentMethod hierarchy
-> 6 visitor implementations
IPaymentRepository (one implementation)
IPaymentValidator (one implementation)
IPaymentMapper (one implementation)
Why adding a provider took six weeks, traced concretely:
1. TEMPLATE METHOD. The base class's skeleton assumed
authorise-then-capture. A new provider that only supported
a combined sale required a fifth hook, which meant
changing the base class, which meant re-testing both
existing providers.
Measured: 11 of the 24 files changed for the new provider
were in the shared base class or its tests.
2. VISITOR. PaymentMethod had 9 types and 6 visitors. Adding
a tenth payment method (a new wallet) required editing all
6 visitors, 5 of which were unrelated to the change.
This is the documented trade of the pattern, encountered
in the direction that hurts.
3. SINGLETONS. PaymentRetryPolicySingleton was initialised
from config at first use. Integration tests for the new
provider needed a different retry policy, and could not get
one, so the tests either ran with production retry
behaviour (slow, flaky) or reflected the singleton away,
which two of them did, with a comment.
4. THREE SINGLE-IMPLEMENTATION INTERFACES, which added three
files per change and prevented nothing.
Eleven of twenty-four changed files being in the shared base class is the measurement that named the problem, and it is the fragile-base-class failure exactly.
The restructure, over five weeks:
TEMPLATE METHOD -> STRATEGY (composition)
A PaymentFlow value describes the steps a provider supports:
@dataclass(frozen=True)
class PaymentFlow:
authorise: Callable[[AuthRequest], AuthResult] | None
capture: Callable[[CaptureRequest], CaptureResult] | None
sale: Callable[[SaleRequest], SaleResult] | None
refund: Callable[[RefundRequest], RefundResult]
A provider supplies the operations it has. The orchestrator
picks a path from what is present. A provider with only
`sale` needs no base-class change, because there is no base
class.
VISITOR -> PATTERN MATCHING over a sealed hierarchy
The language had exhaustive matching over sealed types, so
the compiler enforces what the visitor was emulating, and
adding a tenth type produces compile errors ONLY in the
matches that genuinely need to handle it, because the others
have a meaningful default.
6 visitor classes (1,900 lines) -> 6 functions (400 lines).
SINGLETONS -> INJECTED, singleton-lifetime
Same single instance in production, supplied by the
container. Tests supply their own. The two reflection hacks
were deleted.
SINGLE-IMPLEMENTATION INTERFACES -> deleted (3)
FACTORIES -> a dict from provider id to a construction
function, which is what the abstract factory was
implementing with four classes.
RETAINED AND ADDED:
ADAPTER per provider, unchanged and untouched, because it
was correct: each provider's vocabulary stopped at its
adapter and never entered the domain.
DECORATOR for retry, timeout, metrics and idempotency, which
replaced the retry logic previously baked into the base
class. Order is now explicit:
metrics(idempotency(retry(timeout(adapter))))
timeout inside retry, deliberately, so each attempt is
bounded rather than the total.
COMMAND, added: an AuthoriseCommand/CaptureCommand value,
which made the operations queueable and replayable and
gave the idempotency decorator something to key on.
The adapters were the only thing in the original design that needed no change, which is the strongest evidence on this page for which patterns are load-bearing.
Measured after:
before after
lines 24,000 15,200
time to add a provider 6 weeks 4 days
files changed for a new provider 24 6 (all in
the new
provider's
directory)
files changed in shared code 11 0
single-implementation interfaces 3 0
singletons 3 0
test files needing reflection 2 0
provider integration tests
running with production retry
policy yes no
Zero files changed in shared code when adding a provider is the outcome that mattered, and it came from removing an inheritance skeleton rather than from adding any abstraction.
What was NOT changed, deliberately:
The Adapter per provider stayed exactly as it was.
The Facade over the whole payments module stayed.
Both were proposed for "modernisation" during the work and
both were left alone, because they were doing their job and
the failure mode of a pattern review is treating every pattern
as suspect. The review's finding was not "patterns are bad",
it was "inheritance-based patterns encoded a shape that stopped
being true, and function-based ones did not."
Production evidence
Peter Norvig's "Design Patterns in Dynamic Languages" (1996) showed that 16 of the 23 GoF patterns have qualitatively simpler implementations, or disappear entirely, in languages with first-class functions and dynamic dispatch, which is the systematic version of the "several of these are workarounds" claim.
The GoF book itself states "favour object composition over class inheritance" as one of its two guiding principles, which is the basis for preferring Strategy over Template Method; the pattern literature's own advice is more moderate than its reputation.
Sealed types with exhaustive pattern matching in Rust, Scala, Kotlin, Swift and modern Java (sealed interfaces plus switch patterns) give the compiler-checked equivalent of Visitor's double dispatch, and their introduction is documented as addressing exactly the expression-problem trade the pattern encodes.
Dependency injection containers (Spring, .NET's built-in container, Guice) provide singleton lifetime as a configuration rather than as a global access point, which is the standard resolution of the Singleton critique: uniqueness without global access.
Java's double-checked locking bug is the canonical evidence for lazy Singleton being harder than it looks: the idiom was widely published and was broken until the Java memory model was revised in JSR-133, and the safe replacements (holder idiom, enum singleton) are documented in Effective Java.
Middleware pipelines in every modern web framework (Express, ASP.NET Core, Go's http.Handler
wrapping, Rack, WSGI) are the decorator pattern as the framework's primary extension mechanism, which
is the clearest evidence for which patterns survived.
The debate
Are design patterns still relevant? As a vocabulary, yes, and as a catalogue to apply, no. The value is that "decorate the client" replaces three sentences, and the failure mode the pattern literature's critics identified, designs assembled from patterns rather than described by them, is real and produces the FactoryFactory codebases people mock.
Is Singleton always wrong? The uniqueness is fine; the global access point is the problem. Injected singleton lifetime gives you one instance without the invisible dependency, the untestability and the initialisation-order surprise, and the remaining legitimate cases (an immutable value, a process-wide logger facade) all share the property that there is nothing to reset between tests.
Template Method or Strategy? Strategy, in application code, because inheritance couples the subclass to a base-class shape that will change and gives you one axis of variation. Template Method is defensible in a framework, where the skeleton is genuinely fixed and shared by many implementations, which is why it survives there and rarely elsewhere.
Is Visitor worth learning? Yes, mainly to be able to name the trade it makes: easy to add operations, hard to add types. It is right for a stable hierarchy with many operations and wrong when the hierarchy grows, and in a language with exhaustive matching over sealed types the compiler does the job better.
Do patterns matter less in functional languages? The named class-based implementations do; the underlying shapes do not. Strategy is a higher-order function, Decorator is function composition, Command is a data type, Observer is a stream, and the vocabulary still compresses the discussion even when the implementation is three lines.
Should you name patterns in code? Sparingly. RetryDecorator is informative; AbstractPaymentProcessorFactoryImpl is a
confession. Name the thing by what it does in the domain, and use the pattern name in conversation and
in the ADR rather than in every class name.
Follow-up Q&A
"Which design patterns do you actually use?"
Adapter, at every external boundary, because it stops a vendor's model entering the domain and it is the same idea as an anti-corruption layer. Decorator, for retry, timeout, metrics and idempotency, which is what every middleware stack is. Strategy, almost always as a function rather than a class hierarchy. Command, because turning an intent into a value is what makes it queueable, retryable, authorisable and undoable. Builder, where a language lacks named arguments and an object has invariants to validate at construction. And Proxy, which shows up as ORM lazy loading and, at a larger scale, as a mesh sidecar.
"Which do you avoid, and why?"
Singleton, because the uniqueness is fine and the global access point is not: it hides a dependency from every signature, makes tests order-dependent because they cannot reset it, and adds an initialisation order you do not control. Injected singleton lifetime gives the same one instance without any of that. Template Method in application code, because it couples every subclass to a base-class shape that will change, and gives one axis of variation. And Visitor unless the type hierarchy is genuinely stable, since its trade is easy-to-add-operations against hard-to-add-types, and modern exhaustive pattern matching over sealed types does the same job with compiler enforcement.
"Why did several patterns disappear?"
They were workarounds for language limitations. Strategy and Command are classes in the book because C++ and early Java had no first-class functions; with closures both are a function parameter. Iterator became a language feature. Prototype became object literals and structural copy. Norvig showed in 1996 that 16 of the 23 are simpler or invisible in a language with first-class functions and dynamic dispatch, which is the systematic version of the observation.
"When is Template Method actually right?"
In a framework, where the skeleton is genuinely fixed and shared by many implementations and the base class changes rarely because it is versioned and released. In application code it is usually wrong, because the base class evolves and every subclass changes with it. In one payments codebase, adding a provider changed 24 files, of which 11 were in the shared base class and its tests, purely because the skeleton assumed authorise-then-capture and the new provider only supported a combined sale. Replacing it with composed functions took shared-code changes to zero.
"What is the trade Visitor makes?"
It makes adding an operation easy and adding a type hard, because every visitor must handle every type. That is correct for an expression tree or an AST, where the node types are fixed and you keep adding traversals. It is wrong wherever the hierarchy grows: in one case adding a tenth payment method required editing all six visitors, five of which were unrelated to the change. Sealed types with exhaustive matching give the same guarantee with compiler enforcement and without the ceremony.
"How should patterns show up in a code review?"
As vocabulary, not as a checklist. Naming a shape is what makes it recognisable across a codebase, and
without a name two near-identical abstractions get built in different places because nobody realised they
were the same thing. But the name belongs in the conversation and the ADR rather than in every class
name: RetryDecorator is informative, and a class called
AbstractPaymentProcessorFactoryImpl is a confession.
Common misconceptions
"You should know all 23." About eight appear in modern code, several became language features, and the useful signal is being able to say which you avoid and why.
"Patterns are a design method." They are a vocabulary for describing designs. Assembling a design by selecting patterns is what produced the codebases the pattern literature's critics mock.
"Singleton guarantees one instance, so it is fine." The uniqueness is fine; the global access point hides the dependency, breaks test isolation and creates initialisation-order problems. Inject it.
"Strategy needs an interface and implementations." In any language with closures, it is a function parameter. The class form is for strategies with state or their own lifecycle.
"Visitor is the way to traverse a hierarchy." It is the way when the hierarchy is stable and the operations keep growing. Exhaustive pattern matching over sealed types is better where it exists.
"Patterns do not apply in functional languages." The class-based implementations do not; the shapes do, and the vocabulary still compresses the discussion.
Interview delivery note
Say this verbatim: "About eight of the twenty-three are still load-bearing, several were workarounds for languages without first-class functions, and the ones I avoid are Singleton, because the uniqueness is fine and the global access point is the problem, and Template Method in application code, because it couples every subclass to a base-class shape that will change." It shows the subset, the reason for the subset, and a committed position on two of them.
The senior-versus-staff separator is measuring an inheritance-based pattern's cost rather than asserting it. A senior engineer prefers composition to inheritance. A staff engineer says that adding a payment provider changed 24 files, 11 of them in the shared base class and its tests, because the skeleton assumed a flow the new provider did not have, and that replacing Template Method with composed functions took shared-code changes per provider to zero and the lead time from six weeks to four days.
The second signal is naming what you did not change. Saying "the per-provider adapters were the only part of the original design that needed no change, so they stayed exactly as they were, and the facade stayed too, because the failure mode of a pattern review is treating every pattern as suspect" shows the review had a criterion rather than an aesthetic.
Further reading
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), particularly its own guiding principle to favour composition over inheritance.
- Peter Norvig, "Design Patterns in Dynamic Languages" (1996), for which patterns dissolve given first-class functions.
- Joshua Bloch, Effective Java, on the enum and holder idioms for singletons and why lazy double-checked locking was broken.
- The SOLID with mature caveats page, which is the principles half of the same conversation.
- The repository, unit of work and specification page, for a pattern this book argues against in its common form.