A design pattern is a shared name. Writing "Repository" in the PR description already tells the reader that a layer sits between the domain and the database, and that swapping the database should not touch business rules. The five below appear in the production codebases I review, and each has a TypeScript version that fits in one file.
Repository: the contract between domain and database
Business code calls an interface; the implementation talks to Postgres, 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,
})
}
// ...
}
The test injects InMemoryUserRepository and the domain does not change a line. Fowler catalogs the pattern in Patterns of Enterprise Application Architecture with that job: isolate persistence behind a collection interface.
Strategy: a variant swapped through injection
An algorithm that varies by context becomes one interface with one implementation per variant. The switch stops growing when the next channel arrives.
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)
}
}
Whoever assembles the class picks the variant. Refactoring.Guru describes the same shape and names its cost: an algorithm with a single variant gains nothing from the interface.
Observer: who listens to the event
The emitter does not know who reacts. This is the structure behind event buses, webhooks, and the browser's addEventListener.
type EventHandler<T> = (payload: T) => void | Promise<void>
class EventBus {
private readonly handlers = new Map<string, EventHandler<unknown>[]>()
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)))
}
}
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)
})
A handler that fails inside Promise.all takes down the whole emit response. In production each handler gets its own try/catch or a queue with retries.
Decorator: a function that wraps a function
TypeScript implements Decorator with higher-order functions; class decorators are optional.
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)
The same shape carries duration logging, caching, and metrics. The name withRetry tells the reader what behavior joins the call.
Builder: objects with too much configuration
A constructor with eight optional parameters turns into a reading exercise. A builder returns fluent calls and validates at build().
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
}
}
const query = new QueryBuilder()
.from('users')
.where('active = true')
.where('role = "admin"')
.orderBy('created_at')
.limit(10)
.build()
The example above concatenates values straight into the query and has no production value without parameters. The point of the pattern is how the object is assembled, not the string building.
When none of them earns its entry
Over-engineering is the common failure with patterns. An algorithm that does not vary needs no Strategy. A single subscriber does not justify Observer. An object with two fields does not ask for Builder. An interface with one implementation and one caller is another file to hunt during a debug session.
Fowler treats that boundary in Is Design Dead?, and the GoF catalog itself describes a pattern as a solution to a recurring problem in context. Without the problem, the solution stays out.
References
- Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994)
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) and the Repository entry
- Design Patterns (Refactoring.Guru)
- Is Design Dead? (Martin Fowler, 2001)
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk