Most design pattern courses teach UML, Java examples from the 1990s, and a list of 23 patterns to "know." The result is engineers who can name patterns but don't recognize them when they appear in real code, and don't know when to apply them.
Patterns are vocabulary. When you recognize a pattern in a system, you understand design intentions that aren't written in the code. When you apply a pattern, you communicate a design decision to whoever reads it later.
These are the patterns that appear in every production codebase I've analyzed, with implementation in modern TypeScript.
Repository Pattern
Abstracts data access behind an interface. Domain code doesn't know whether data comes from a SQL database, an API, or memory.
interface UserRepository {
findById(id: string): Promise<User | null>
findByEmail(email: string): Promise<User | null>
save(user: User): Promise<User>
delete(id: string): Promise<void>
}
class PrismaUserRepository implements UserRepository {
constructor(private readonly db: PrismaClient) {}
async findById(id: string): Promise<User | null> {
return this.db.user.findUnique({ where: { id } })
}
async save(user: User): Promise<User> {
return this.db.user.upsert({
where: { id: user.id },
create: user,
update: user,
})
}
// ...
}
// In tests: replace with InMemoryUserRepository
// without changing anything in domain code
When to use: whenever you access persisted data. Makes testing easy, allows swapping data sources, and keeps domain logic clean.
Strategy Pattern
Encapsulates interchangeable algorithms. Instead of a giant if/else for variable behavior, you define an interface and multiple implementations.
interface NotificationStrategy {
send(to: string, message: string): Promise<void>
}
class EmailNotification implements NotificationStrategy {
async send(to: string, message: string): Promise<void> {
await sendEmail({ to, body: message })
}
}
class SlackNotification implements NotificationStrategy {
async send(to: string, message: string): Promise<void> {
await postToSlack({ channel: to, text: message })
}
}
class NotificationService {
constructor(private readonly strategy: NotificationStrategy) {}
async notify(user: User, message: string): Promise<void> {
await this.strategy.send(user.contact, message)
}
}
When to use: when you have variations of an algorithm that can grow independently. Avoids switch statements that need to change every time a new variant is added.
Observer Pattern
Allows objects to be notified of events without direct coupling. The pattern behind event systems, reactive programming, and webhooks.
type EventHandler<T> = (payload: T) => void | Promise<void>
class EventBus {
private readonly handlers: Map<string, EventHandler<unknown>[]> = new Map()
on<T>(event: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(event) ?? []
this.handlers.set(event, [...existing, handler as EventHandler<unknown>])
}
async emit<T>(event: string, payload: T): Promise<void> {
const handlers = this.handlers.get(event) ?? []
await Promise.all(handlers.map(h => h(payload)))
}
}
// Usage
const bus = new EventBus()
bus.on<{ userId: string }>('user.created', async ({ userId }) => {
await sendWelcomeEmail(userId)
})
bus.on<{ userId: string }>('user.created', async ({ userId }) => {
await createDefaultSettings(userId)
})
Decorator Pattern
Adds behavior to an object without modifying its class. In TypeScript, implemented with both classes and higher-order functions.
// With functions: more idiomatic in modern TypeScript
function withRetry<T extends (...args: unknown[]) => Promise<unknown>>(
fn: T,
maxAttempts = 3
): T {
return (async (...args: Parameters<T>) => {
let lastError: Error
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn(...args)
} catch (error) {
lastError = error as Error
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, attempt * 1000))
}
}
}
throw lastError!
}) as T
}
const fetchWithRetry = withRetry(fetchUserFromAPI, 3)
Builder Pattern
Builds complex objects step by step. Especially useful when an object has many optional parameters or variable configuration.
class QueryBuilder {
private table = ''
private conditions: string[] = []
private limitValue: number | null = null
private orderByField: string | null = null
from(table: string): this {
this.table = table
return this
}
where(condition: string): this {
this.conditions.push(condition)
return this
}
limit(n: number): this {
this.limitValue = n
return this
}
orderBy(field: string): this {
this.orderByField = field
return this
}
build(): string {
let query = `SELECT * FROM ${this.table}`
if (this.conditions.length) {
query += ` WHERE ${this.conditions.join(' AND ')}`
}
if (this.orderByField) query += ` ORDER BY ${this.orderByField}`
if (this.limitValue) query += ` LIMIT ${this.limitValue}`
return query
}
}
// Fluent and readable usage
const query = new QueryBuilder()
.from('users')
.where('active = true')
.where('role = "admin"')
.orderBy('created_at')
.limit(10)
.build()
When not to use patterns
The most common mistake with patterns is not failing to know them. It's over-engineering: applying patterns to simple code that doesn't need them.
If you have a single algorithm that won't vary, Strategy is unnecessary complexity. If you have a single subscriber in an event system, Observer is overhead without benefit. If you have an object with two fields, Builder is overkill.
Patterns solve problems of variability and extensibility. If you don't have those problems, you don't need the solutions.
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk