Un design pattern es un nombre compartido. Escribir "Repository" en la descripción del PR ya comunica que existe una capa entre el dominio y la base de datos, y que cambiar la base no debería tocar las reglas de negocio. Los cinco de abajo aparecen seguido en los codebases de producción que reviso, y cada uno tiene una versión TypeScript que cabe en un archivo.

Repository: contrato entre dominio y base de datos

El código de negocio llama a una interfaz; la implementación habla con Postgres, una API o memoria.

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

El test inyecta InMemoryUserRepository y el dominio no cambia una línea. Fowler cataloga el pattern en Patterns of Enterprise Application Architecture con ese trabajo: aislar la persistencia detrás de una interfaz de colección.

Strategy: variante cambiada por inyección

Un algoritmo que varía por contexto se vuelve una interfaz con una implementación por variante. El switch deja de crecer cuando entra el canal nuevo.

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

Quien arma la clase elige la variante. Refactoring.Guru describe la misma forma y nombra su costo: un algoritmo de una sola variante no gana nada con la interfaz.

Observer: quién escucha el evento

El emisor no conoce a quién reacciona. Es la estructura detrás de buses de eventos, webhooks y del addEventListener del navegador.

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

Un handler que falla dentro de Promise.all tumba toda la respuesta del emit. En producción cada handler recibe su propio try/catch o una cola con retries.

Decorator: una función que envuelve otra

TypeScript implementa Decorator con higher-order functions, sin depender de decoradores de clase.

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)

La misma estructura sirve para log de duración, cache y métricas. El nombre withRetry ya le dice al lector qué comportamiento se suma a la llamada.

Builder: objetos con demasiada configuración

Un constructor con ocho parámetros opcionales se vuelve un ejercicio de lectura. El builder devuelve llamadas fluidas y valida en el 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()

El ejemplo de arriba mete valores directo en la query y no sirve en producción sin parametrizar. Lo que importa del pattern es cómo se arma el objeto, no la concatenación.

Cuándo ninguno de ellos vale la entrada

El error común con los patterns es el over-engineering. Un algoritmo que no varía no necesita Strategy. Un único subscriber no justifica Observer. Un objeto con dos campos no pide Builder. Una interfaz con una implementación y un consumidor es un archivo más para buscar en la hora del debug.

Fowler trata ese límite en Is Design Dead?, y el catálogo del GoF describe un pattern como la solución a un problema recurrente en contexto. Sin el problema, la solución no entra.

Referencias

  • Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994)
  • Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) y la entrada Repository
  • Design Patterns (Refactoring.Guru)
  • Is Design Dead? (Martin Fowler, 2001)