A maioria dos cursos de design patterns ensina UML, exemplos em Java dos anos 90, e uma lista de 23 patterns para "conhecer". O resultado: engenheiros que sabem nomear patterns mas que não os reconhecem quando aparecem em código real, e não sabem quando aplicá-los.

Patterns são vocabulário. Quando você reconhece um pattern num sistema, você entende intenções de design que não estão escritas no código. Quando você aplica um pattern, você comunica uma decisão de design para quem vai ler depois.

Estes são os patterns que aparecem em todo codebase de produção que já analisei, com implementação em TypeScript moderno.

Repository Pattern

Abstrai o acesso a dados atrás de uma interface. O código de domínio não sabe se os dados vêm de um banco SQL, de uma API, ou de memória.

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,
    })
  }
  // ...
}

// Em testes: substitua por InMemoryUserRepository
// sem mudar nada no código de domínio

Quando usar: sempre que você acessa dados persistidos. Facilita testes, permite trocar a source de dados, e mantém lógica de domínio limpa.

Strategy Pattern

Encapsula algoritmos intercambiáveis. Em vez de if/else gigante para comportamento variável, você define uma interface e múltiplas implementações.

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)
  }
}

Quando usar: quando você tem variações de um algoritmo que podem crescer independentemente. Evita switch statements que precisam mudar toda vez que uma nova variante é adicionada.

Observer Pattern

Permite que objetos sejam notificados de eventos sem acoplamento direto. O padrão por trás de sistemas de eventos, reactive programming, e 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)))
  }
}

// Uso
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

Adiciona comportamento a um objeto sem modificar sua classe. Em TypeScript, implementado tanto com classes quanto com higher-order functions.

// Com funções — mais idiomático em TypeScript moderno
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

Constrói objetos complexos passo a passo. Especialmente útil quando um objeto tem muitos parâmetros opcionais ou configuração variável.

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
  }
}

// Uso fluente e legível
const query = new QueryBuilder()
  .from('users')
  .where('active = true')
  .where('role = "admin"')
  .orderBy('created_at')
  .limit(10)
  .build()

Quando não usar patterns

O erro mais comum com patterns não é não conhecê-los — é over-engineering: aplicar patterns em código simples que não precisa deles.

Se você tem um único algoritmo que não vai variar, Strategy é complexidade desnecessária. Se você tem um único subscriber num sistema de eventos, Observer é overhead sem benefício. Se você tem um objeto com dois campos, Builder é demais.

Patterns resolvem problemas de variabilidade e extensibilidade. Se você não tem esses problemas, você não precisa das soluções.