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.