The Patterns Seniors Actually Use
Of the 23 patterns in the Gang of Four book, a working senior reaches for maybe six — and usually in a lighter form than the book version.
Patterns Are Vocabulary, Not Recipes
The Gang of Four didn't invent design patterns. They walked through large C++ and Smalltalk systems in the early 90s, noticed the same structural solutions appearing independently in codebase after codebase, and catalogued them. Patterns were discovered, the way naturalists discover species. That distinction matters, because it tells you what patterns are for: they name solutions that competent engineers were already converging on. If you find yourself forcing one into code that wasn't heading there on its own, you've inverted the whole idea.
Thirty years later, two things have changed the economics of the catalog. First, mainstream languages got first-class functions, which collapsed several patterns (Strategy, Command, some of Template Method) into "pass a function." Second, dependency injection became the default way to wire applications, which absorbed Singleton and most Factory machinery into the composition root. The patterns didn't die — the ideas are as alive as ever — but the ceremony around them mostly did.
What survives is enormously valuable, and it's two things:
- Shared vocabulary. "Wrap the SDK in an adapter" or "make retries a decorator" transmits a whole design in one sentence. In a design review, that compression is the difference between a five-minute conversation and a whiteboard session.
- A map of failure modes. Each pattern is an answer to a specific kind of pain: Strategy answers "this conditional keeps growing," Adapter answers "vendor types are leaking everywhere," Observer answers "the producer knows too much about its consumers." Knowing the pain each pattern addresses is what lets you reach for the right one at the right moment — and skip it when the pain isn't there.
Patternitis is a junior tell
Applying patterns to feel senior is the most junior thing you can do. An AbstractWidgetFactoryBuilder in a 2,000-line app announces that you learned the catalog but not the judgment. Seniority shows up as the opposite: writing the boring, direct version first, and introducing a pattern only when the code itself starts asking for it — a conditional that keeps sprouting branches, a vendor type that shows up in your domain layer, a class you can't test without the network.
This module covers the six patterns that come up constantly in real production work — Strategy, Factory, Decorator, Adapter, Observer, and Repository — plus Singleton, which you need to know mostly so you can explain why you wouldn't use it. For each one: the failure mode it addresses, the modern lightweight form, and where the classic heavyweight form still earns its keep.
Strategy: Swap the Algorithm, Keep the Caller
The failure mode: a growing conditional that selects between variants of the same computation. Fee calculation per payment method. Retry policy per endpoint. Pricing per customer tier. The first two branches are fine. By the fifth, the switch statement is duplicated in three files and every new variant means hunting them all down.
Strategy's answer: extract the varying computation behind a common interface, and let callers hold "a fee policy" without knowing which one. The caller's code stops changing when you add variants — which is the Open/Closed Principle from the previous module, made concrete.
Classic Form vs. Modern Form
The book version is an interface plus N implementing classes. In Java that's still roughly what you write — though since Java 8 a lambda can stand in for any single-method interface. In TypeScript and Python, the honest modern form is lighter still: a strategy with one method is a function, and a registry of strategies is a map from key to function. Compare all three — this is exactly where the languages diverge, and the divergence is the lesson:
type FeePolicy = (amountCents: number) => number;
const feePolicies: Record<string, FeePolicy> = {
card: (amount) => Math.round(amount * 0.029) + 30,
ach: (amount) => Math.min(Math.round(amount * 0.008), 500),
invoice: () => 0,
};
function computeFee(method: string, amountCents: number): number {
const policy = feePolicies[method];
if (!policy) {
throw new Error("No fee policy for method: " + method);
}
return policy(amountCents);
}
// Adding a payment method is one entry in the map --
// no class hierarchy, no switch statements to hunt down.
feePolicies["wallet"] = (amount) => Math.round(amount * 0.015);A one-method strategy is a function
If your Strategy interface has exactly one method and no state, the interface is ceremony — pass the function. Reach for a class only when the strategy carries configuration or dependencies of its own (a retry policy that owns a clock and jitter settings, say), or when your language makes classes the natural unit, as Java did before lambdas.
Factory: Contain the Construction Logic
The failure mode: constructing an object stops being trivial, and the non-trivial part starts leaking into callers. Choosing the concrete type from config. Wiring three dependencies in the right order. Validating that the API key matches the environment. When every place that needs a PaymentGateway repeats that dance, construction knowledge is smeared across the codebase, and changing it means touching every call site.
The fix is almost embarrassingly small: put construction in one function. That's it. That's the load-bearing 90% of the Factory pattern in modern code — a plain function (or a static create method) that owns the decision of which concrete type to build and how to wire it:
interface PaymentGateway {
charge(amountCents: number, token: string): Promise<ChargeResult>;
}
function createGateway(config: AppConfig): PaymentGateway {
switch (config.gatewayProvider) {
case "stripe":
return new StripeGateway(config.stripeApiKey, config.stripeAccount);
case "adyen":
return new AdyenGateway(config.adyenApiKey, config.adyenMerchant);
case "sandbox":
return new SandboxGateway({ alwaysSucceed: true });
default:
throw new Error("Unknown gateway: " + config.gatewayProvider);
}
}
// Composition root: the decision is made once, injected everywhere.
const gateway = createGateway(loadConfig());
const checkout = new CheckoutService(gateway, orderRepository);Notice what this buys you beyond tidiness: the rest of the codebase depends on the PaymentGateway interface, never on Stripe or Adyen types. Swapping providers, adding a sandbox mode for CI, or running one provider per region becomes a change to one function.
A note on taxonomy, since interviewers sometimes probe it: what you just saw is often called a simple factory. The book's Factory Method is a variation where subclasses override a creation method — occasionally useful in frameworks, rarely in application code. The full Abstract Factory — an interface for creating whole families of related objects — is rarer still; you mostly meet it inside UI toolkits and database drivers, not in code you write. If you find yourself designing a factory hierarchy for your own application, stop and check whether a plain function and a DI container would do. They almost always will.
Decorator: Layer Orthogonal Behavior
The failure mode: cross-cutting concerns tangled into core logic. Your HTTP client needs retries. And request logging. And response caching. Written inline, the actual "make the request" code drowns in bookkeeping, and every combination of concerns (retries in prod, no caching in tests) needs its own code path.
Decorator's answer: implement each concern as a wrapper that implements the same interface as the thing it wraps, does its one job, and delegates inward. Because wrapper and wrapped are interchangeable, you compose the stack you want at wiring time — and each layer stays independently testable and oblivious to the others:
interface HttpClient {
get(url: string): Promise<HttpResponse>;
}
class RetryingClient implements HttpClient {
constructor(private inner: HttpClient, private maxAttempts = 3) {}
async get(url: string): Promise<HttpResponse> {
let lastError: unknown;
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
return await this.inner.get(url);
} catch (err) {
lastError = err;
}
}
throw lastError;
}
}
class LoggingClient implements HttpClient {
constructor(private inner: HttpClient) {}
async get(url: string): Promise<HttpResponse> {
const start = Date.now();
const response = await this.inner.get(url);
console.log("GET " + url + " took " + (Date.now() - start) + "ms");
return response;
}
}
// Wiring: logging sees the total time across all retries.
const client = new LoggingClient(new RetryingClient(new FetchClient()));Two details worth internalizing. First, order is part of the design: logging outside retries measures total elapsed time; logging inside retries logs every attempt. Neither is wrong — but the wiring line is where that decision lives, so it deserves a comment. Second, you've been using this pattern for years: Express and Koa middleware, ASP.NET Core's pipeline, gRPC interceptors — each middleware wraps "the rest of the pipeline" behind the same call signature and decides what to do before and after delegating inward. Middleware is Decorator (with a dash of Chain of Responsibility, since a layer may decline to delegate at all).
Adapter: The Anti-Corruption Layer
The failure mode: a third-party SDK's types and idioms metastasize through your codebase. Stripe's Charge object in your domain logic, LaunchDarkly's context format in your controllers, SendGrid's error codes in your tests. The day the vendor ships a breaking v2 — or the day finance renegotiates and you switch vendors — the migration touches hundreds of files.
Adapter's answer: define the interface you wish the dependency had — in your domain's vocabulary, exposing only the operations you actually use — and write one class that translates between your interface and the vendor's SDK. Vendor types stop at that file. This is the Dependency Inversion Principle from the SOLID module applied at a system boundary: your core depends on an abstraction you own, and the vendor-specific detail depends on (implements) it, not the other way around. Domain-driven design people call this an anti-corruption layer, which is a good name for what it does.
// The interface WE define: our vocabulary, only what we use.
interface FlagClient {
isEnabled(flag: string, userId: string): boolean;
}
// Adapter over the vendor SDK. Vendor types stop at this file.
class LaunchDarklyFlags implements FlagClient {
constructor(private ld: LDClient) {}
isEnabled(flag: string, userId: string): boolean {
return this.ld.variation(flag, { key: userId }, false) === true;
}
}
// Test double: no SDK, no network, no mocking library.
class StaticFlags implements FlagClient {
constructor(private enabled: Set<string>) {}
isEnabled(flag: string, _userId: string): boolean {
return this.enabled.has(flag);
}
}When is this overkill? For dependencies that are effectively part of the platform. Nobody wraps the standard library's JSON parser, the language's date type, or a stable utility like lodash behind a home-grown interface — the churn risk is near zero and the adapter would just be indirection tax. The heuristic: adapt dependencies that are likely to change out from under you (paid vendors, immature SDKs, anything with an account manager) or that you'd need to fake in tests (anything doing network I/O). Leave the rest alone.
What this question is really testing
When I ask "how would you integrate this third-party payment provider," I'm not testing whether you know the vendor's API. I'm listening for whether you instinctively put an interface you own between your domain and their SDK, whether you can say what lives on each side of that line, and whether you can articulate the cost — one more layer, one more set of types — and when you'd skip it. Candidates who name the trade-off in both directions read as senior. Candidates who wrap everything on principle, or nothing on principle, don't.
Observer: Decouple Producers from Consumers
The failure mode: the code that does a thing accumulates knowledge of everyone who cares about the thing. Your checkout service captures a payment, then sends the receipt, then updates analytics, then notifies the fraud team, then invalidates a cache. Every new consumer means editing checkout — the one class that should be boring and stable.
Observer's answer: the subject emits an event; interested listeners subscribe. The producer knows only "something happened worth announcing"; consumers decide for themselves whether to care. Today you rarely build this from the book diagram — you meet it as Node's EventEmitter, domain events on a message bus, React's state subscriptions, or a plain listener list like this one:
type Listener<E> = (event: E) => void;
class EventBus<E> {
private listeners = new Set<Listener<E>>();
subscribe(listener: Listener<E>): () => void {
this.listeners.add(listener);
// Hand back the unsubscribe -- callers must be able to leave.
return () => this.listeners.delete(listener);
}
emit(event: E): void {
for (const listener of [...this.listeners]) {
listener(event);
}
}
}
interface PaymentCaptured {
orderId: string;
amountCents: number;
}
const paymentEvents = new EventBus<PaymentCaptured>();
const stop = paymentEvents.subscribe((e) => {
receiptService.send(e.orderId);
});
// When the subscriber goes away, so must the subscription:
stop();Two bugs account for most Observer pain in production, and both are worth naming in an interview:
- Forgetting to unsubscribe. A long-lived emitter holds a strong reference to every listener. Subscribe from a short-lived object (a request handler, a React component, a session) without unsubscribing and the emitter keeps it — and everything it references — alive forever. That's the classic slow memory leak, plus zombie listeners still reacting to events for a screen the user left. This is exactly why React's useEffect makes you return a cleanup function.
- Hidden ordering dependencies. Listener A updates the cache; listener B reads the cache. It works — because A happened to subscribe first. Nothing in the type system or the code records that dependency, so a harmless refactor of registration order breaks production. If consumers depend on each other's effects, they aren't independent observers; make the sequence an explicit ordered pipeline instead.
Events hide the control flow -- on purpose, at a price
The whole point of Observer is that the emitter doesn't know who's listening. That's also its cost: to answer "what actually happens when a payment is captured?" a reader now has to find every subscription in the codebase. Use events for genuinely independent reactions, and keep first-class business sequences (charge, then fulfill, then receipt) as plain calls where the reader can see them.
Repository: Persistence Behind a Collection
The failure mode: domain logic that can't run without a database. Query-building tangled into business rules, tests that need a seeded Postgres to check a discount calculation, and SQL changes that ripple into service code.
Repository's answer: give domain code a collection-like interface — find, add, save — and keep everything about tables, queries, and ORMs behind it. The domain asks for "overdue invoices for this customer"; the repository worries about the join. Tests hand the service an in-memory implementation and run in milliseconds. It's the same move as Adapter — an interface you own at a boundary — pointed at your own database instead of a vendor.
The senior caution is about shape. A generic Repository<T> with twenty methods — findAll, findWhere(criteria), findPaged(spec, sort, limit) — feels reusable but becomes a leaky lowest common denominator: too generic to express any real use case, too broad to implement efficiently, and before long callers are passing raw query fragments through it, which means the abstraction has failed and you've paid for it anyway. Prefer narrow, intent-revealing repositories whose method names are use cases:
- invoiceRepository.findOverdueForCustomer(customerId) — not findWhere("status = ?", ...)
- exportRepository.nextPendingBatch(limit) — the queue-polling semantics live in one place, with the locking done right once
- Each method can be implemented as one deliberate, indexed query — instead of a generic criteria engine you have to performance tune blind
A narrow repository also tells reviewers what the system does: the interface reads like a list of use cases. Twenty generic methods tell them nothing.
Singleton: The Pattern That Became an Anti-Pattern
Singleton is the one GoF pattern you should know mostly so you can explain why you won't use it. The book version: a class that enforces its own single instance via a private constructor and a static getInstance(), reachable from anywhere. It solved a real problem — "there must be exactly one of this, shared everywhere" — but the mechanism aged terribly, because it bundles two decisions that should be separate: how many instances exist (a lifetime question) and how code gets access to it (a global variable).
The global-access half is what hurts:
- Hidden dependencies. Any function anywhere can call Config.getInstance(), so a class's constructor signature no longer tells you what it needs. Dependencies become archaeology.
- Untestable by construction. You can't swap the instance for a fake, and state leaks between tests through the static. Every test framework's "reset singletons between tests" utility is a confession that the design fights testing.
- Initialization-order and concurrency bugs. Lazy getInstance() means the singleton initializes whenever the first caller happens to touch it — an ordering you didn't choose, that changes when unrelated code changes, and that historically spawned a whole literature of double-checked locking bugs.
What replaced it separates those two decisions cleanly: create the object once, at the composition root (your main, or your DI container configured with a singleton lifetime), and inject it into everything that needs it. One instance still exists — you get the sharing — but access flows through constructors, so dependencies are visible, fakes are trivial, and construction order is explicit in one file. The connection pool, the config object, the metrics client: all "singletons" in lifetime, none a Singleton in pattern.
Don't reach for getInstance()
If you catch yourself writing a static getInstance() to make something globally reachable, stop and pass it in instead. The interview phrasing worth memorizing: "I want singleton lifetime, not the Singleton pattern — one instance created at the composition root and injected, not a global static." That sentence, and being able to defend it, is the whole point of this section.
Patterns in the Wild
The most useful pattern skill in 2026 is not implementation — it's recognition. The tools you use daily are built from these patterns, and seeing that has two payoffs: the tools stop being magic, and you absorb calibration for when each pattern is worth its weight, from codebases that made the call under real constraints.
| Tool you already use | Pattern hiding inside | What to notice |
|---|---|---|
| Express / Koa middleware | Decorator + Chain of Responsibility | Each middleware wraps the rest of the pipeline behind one call signature and chooses whether to delegate inward |
| ORM sessions (Hibernate, SQLAlchemy, Prisma) | Unit of Work + Identity Map | Change tracking with one flush per transaction; one in-memory object per row per session |
| React context and state subscriptions | Observer | Components subscribe to state; useEffect cleanup functions exist precisely to prevent the unsubscribe leak |
| Webpack / Vite plugin systems | Strategy + Observer | Loaders and transforms are swappable strategies; plugin hooks are lifecycle events you subscribe to |
| Fetch / axios interceptors, gRPC interceptors | Decorator | Cross-cutting request behavior layered without touching call sites |
| Database drivers behind a common API (JDBC, DB-API) | Abstract Factory + Adapter | One interface, one concrete family per vendor — the rare place the heavyweight factory genuinely earns its keep |
A good exercise before the practice round: open a library you use every week and find one pattern in it that nobody labeled. The naming convention won't say "Decorator" — production code almost never does — and learning to see the shape without the label is exactly the skill interviews for senior roles probe.
Name the failure mode, then the pattern
In design discussions, lead with the pain, not the pattern: "this switch on provider keeps growing and it's in three files — I want to collapse it into a map of handlers" lands better than "we should use Strategy here." The pattern name is a compression format for people who already agree about the problem. Establish the problem first and the vocabulary does its job; skip that step and it reads as jargon.
Knowledge Check
Test your understanding before the practice round.