Design Patterns
When I first read about design patterns, they felt like solutions looking for problems. Factory? Strategy? Observer? The names were opaque, the Java examples were verbose, and I couldn't see where I'd use them in a React/Node.js codebase. Then I realized I was already using most of them—I just didn't know their names. React's useState is the observer pattern. Express middleware is a chain of responsibility. Dependency injection is how every modern framework manages services.
Design patterns aren't blueprints to copy. They're a shared vocabulary for recurring design problems. Once you recognize the pattern, you can communicate solutions faster, evaluate tradeoffs more clearly, and avoid reinventing solutions that others have already refined.
Strategy Pattern
The strategy pattern lets you swap algorithms or behaviors at runtime by defining a family of interchangeable functions behind a common interface.
The problem: You have multiple ways to do something (sort, validate, format, price) and want to switch between them without changing the code that uses them.
type PricingStrategy = (basePrice: number) => number
const standardPricing: PricingStrategy = (price) => price
const premiumDiscount: PricingStrategy = (price) => price * 0.85
const bulkDiscount: PricingStrategy = (price) => price * 0.7
function calculateTotal(items: number[], strategy: PricingStrategy): number {
return items.reduce((sum, price) => sum + strategy(price), 0)
}
calculateTotal([100, 200, 50], standardPricing) // 350
calculateTotal([100, 200, 50], premiumDiscount) // 297.5
calculateTotal([100, 200, 50], bulkDiscount) // 245In TypeScript/JavaScript, the strategy pattern is just passing functions as arguments. You don't need classes or interfaces—functions are first-class citizens.
Where you already use it:
Array.sort((a, b) => a - b)— the comparator is a strategy.- React's
childrenor render props — the rendering strategy is passed from outside. - Express middleware — each middleware is a strategy for handling a request.
Factory Pattern
A factory encapsulates object creation. Instead of scattering new calls with configuration logic throughout your code, a factory centralizes it.
The problem: Creating objects requires logic (environment-specific config, conditional types, complex initialization) that shouldn't be repeated everywhere.
interface Logger {
log(message: string): void
error(message: string): void
}
function createLogger(env: string): Logger {
if (env === "production") {
return {
log: (msg) => sendToLoggingService(msg),
error: (msg) => sendToErrorTracker(msg),
}
}
return {
log: (msg) => console.log(`[LOG] ${msg}`),
error: (msg) => console.error(`[ERROR] ${msg}`),
}
}
const logger = createLogger(process.env.NODE_ENV ?? "development")The rest of the application uses logger without knowing or caring whether it's logging to the console or to a remote service.
Where you already use it:
React.createElement()— a factory for React elements.createContext(),createRoot()— factories in the React API.- Database client creation:
new PrismaClient(),createClient(). fetch()creates Request/Response objects internally.
Observer Pattern
The observer pattern defines a one-to-many dependency: when one object (the subject) changes state, all its dependents (observers) are notified.
The problem: Multiple parts of a system need to react to state changes without being tightly coupled to each other.
type Listener<T> = (value: T) => void
class EventEmitter<T> {
private listeners = new Set<Listener<T>>()
subscribe(listener: Listener<T>) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
emit(value: T) {
for (const listener of this.listeners) {
listener(value)
}
}
}
const priceUpdates = new EventEmitter<number>()
const unsubscribeUI = priceUpdates.subscribe((price) => {
renderPrice(price)
})
const unsubscribeAnalytics = priceUpdates.subscribe((price) => {
trackPriceChange(price)
})
priceUpdates.emit(42.99) // both subscribers are notifiedWhere you already use it:
- DOM events:
addEventListener/removeEventListeneris the observer pattern. - React's
useState: When state changes, React re-renders all components that depend on that state. Components are observers of the state. - Node.js
EventEmitter:server.on('request', handler)is subscribing an observer. - RxJS, Zustand, Redux: All are variations of the observer pattern.
- WebSockets: Clients subscribe to server-sent events.
Adapter Pattern
An adapter wraps one interface to make it compatible with another. It's the design pattern equivalent of a power plug converter.
The problem: You have code that expects one interface, but your dependency provides a different one.
// Your application expects this interface
interface Analytics {
track(event: string, data: Record<string, unknown>): void
identify(userId: string, traits: Record<string, unknown>): void
}
// Segment's API has a different shape
import segment from "@segment/analytics-node"
// Adapter: wraps Segment's API to match your interface
function createSegmentAdapter(writeKey: string): Analytics {
const client = new segment.Analytics({ writeKey })
return {
track(event, data) {
client.track({ event, properties: data, anonymousId: "anon" })
},
identify(userId, traits) {
client.identify({ userId, traits })
},
}
}
// Your code uses the Analytics interface, not Segment directly
const analytics: Analytics = createSegmentAdapter(process.env.SEGMENT_KEY!)
analytics.track("page_view", { path: "/home" })Switching from Segment to Mixpanel means writing a new adapter—the rest of your code doesn't change.
Where you already use it:
- ORM adapters: Prisma, Drizzle, and TypeORM adapt SQL databases to a JavaScript-friendly API.
- API clients: GraphQL clients adapt a schema to typed function calls.
- Polyfills: They adapt modern browser APIs for older browsers.
Decorator Pattern
A decorator wraps an object to add behavior without modifying the original. It's like layering functionality on top.
The problem: You want to add logging, caching, retry logic, or validation to an existing function without changing it.
type Fetcher = (url: string) => Promise<Response>
// The base fetcher
const baseFetch: Fetcher = (url) => fetch(url)
// Decorator: adds logging
function withLogging(fetcher: Fetcher): Fetcher {
return async (url) => {
console.log(`Fetching: ${url}`)
const start = performance.now()
const response = await fetcher(url)
console.log(`Fetched ${url} in ${performance.now() - start}ms`)
return response
}
}
// Decorator: adds retry logic
function withRetry(fetcher: Fetcher, retries = 3): Fetcher {
return async (url) => {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fetcher(url)
} catch (err) {
if (attempt === retries - 1) throw err
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt))
}
}
throw new Error("unreachable")
}
}
// Compose decorators
const enhancedFetch = withLogging(withRetry(baseFetch))
await enhancedFetch("/api/data")Each decorator adds one concern. They compose naturally—you can stack them in any order.
Where you already use it:
- Express/Hono middleware: Each middleware decorates the request handler with additional behavior (auth, logging, CORS).
- Higher-Order Components (HOCs) in React:
withAuth(Component)wraps a component with authentication logic. - TypeScript decorators:
@Controller,@Injectablein NestJS. - Python decorators:
@app.route('/'),@login_required.
Dependency Injection
Dependency injection (DI) means providing a module's dependencies from the outside rather than having the module create them internally.
The problem: Hard-coded dependencies make code untestable, inflexible, and tightly coupled.
// Without DI: UserService creates its own database connection
class UserService {
private db = new PostgresDatabase(process.env.DATABASE_URL!)
async getUser(id: string) {
return this.db.query("SELECT * FROM users WHERE id = $1", [id])
}
}
// With DI: database is injected
class UserService {
constructor(private db: Database) {}
async getUser(id: string) {
return this.db.query("SELECT * FROM users WHERE id = $1", [id])
}
}
// Production
const service = new UserService(new PostgresDatabase(process.env.DATABASE_URL!))
// Test
const service = new UserService(new InMemoryDatabase())DI in functional style (no classes):
function createUserService(db: Database) {
return {
getUser: (id: string) =>
db.query("SELECT * FROM users WHERE id = $1", [id]),
}
}Where you already use it:
- React Context:
<ThemeProvider value={theme}>injects a theme dependency into the component tree. - Next.js API routes: The request and response objects are injected by the framework.
- Testing: Every mock, stub, or fake you pass to a function is DI.
- NestJS, Angular: These frameworks have built-in DI containers.
Repository Pattern
The repository pattern abstracts data access behind a collection-like interface. Your business logic asks the repository for data without knowing how it's stored.
interface UserRepository {
findById(id: string): Promise<User | null>
findByEmail(email: string): Promise<User | null>
save(user: User): Promise<void>
delete(id: string): Promise<void>
}
// PostgreSQL implementation
class PostgresUserRepository implements UserRepository {
async findById(id: string) {
const row = await this.db.query("SELECT * FROM users WHERE id = $1", [id])
return row ? toUser(row) : null
}
// ...
}
// In-memory implementation for tests
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>()
async findById(id: string) {
return this.users.get(id) ?? null
}
// ...
}The repository is an adapter (it wraps the database) combined with DIP (business logic depends on the interface, not the implementation). It's one of the most useful patterns for keeping business logic clean.
Builder Pattern
The builder pattern constructs complex objects step by step, allowing different configurations without telescoping constructor parameters.
class QueryBuilder {
private table = ""
private conditions: string[] = []
private orderBy = ""
private limitVal = 0
from(table: string) {
this.table = table
return this
}
where(condition: string) {
this.conditions.push(condition)
return this
}
order(column: string) {
this.orderBy = column
return this
}
limit(n: number) {
this.limitVal = n
return this
}
build(): string {
let sql = `SELECT * FROM ${this.table}`
if (this.conditions.length) sql += ` WHERE ${this.conditions.join(" AND ")}`
if (this.orderBy) sql += ` ORDER BY ${this.orderBy}`
if (this.limitVal) sql += ` LIMIT ${this.limitVal}`
return sql
}
}
const query = new QueryBuilder()
.from("users")
.where("active = true")
.where("role = 'admin'")
.order("created_at DESC")
.limit(10)
.build()Where you already use it:
- Query builders: Prisma, Drizzle, Knex all use the builder pattern.
- Request builders:
fetchoptions, Axios config. - Fluent APIs:
zod.string().min(3).max(100).email()is a builder. - Test fixtures: Building test data with sensible defaults and overrides.
When Patterns Become Anti-Patterns
Every pattern has a cost. The wrong pattern—or the right pattern applied too eagerly—makes code worse.
| Anti-pattern | What went wrong |
|---|---|
AbstractSingletonProxyFactoryBean | Too many layers of indirection for a simple problem |
| Observer spaghetti | Everything observes everything; changes cascade unpredictably |
| Premature DI container | A 50-line script doesn't need a dependency injection framework |
| Strategy for one strategy | An interface with a single implementation is just ceremony |
| Decorator chain from hell | Seven wrappers deep, impossible to debug |
The test: Can you explain why the pattern is here in one sentence? If the answer is "because the book said so," remove it. If the answer is "because we need to swap implementations" or "because this needs to be testable in isolation," keep it.
The Pragmatic Takeaway
You don't need to memorize the Gang of Four book. You need to recognize five or six patterns that appear constantly in modern codebases: strategy (pass behavior as functions), factory (centralize creation), observer (react to changes), adapter (bridge interfaces), decorator (layer behavior), and dependency injection (externalize dependencies).
The real skill isn't applying patterns—it's recognizing when a design problem matches a known pattern. When you find yourself writing a growing switch statement, you're looking at a strategy pattern. When you're wrapping a third-party API to match your interface, you're writing an adapter. When you're adding logging/caching/retrying to an existing function, you're decorating.
Patterns are most useful as a communication tool. Saying "this is a repository over the CMS client" tells another engineer exactly what to expect—an interface abstracting data access. That shared vocabulary is worth more than any UML diagram.