La mayoría de los cursos de design patterns enseña UML, ejemplos en Java de los años 90 y una lista de 23 patterns para "conocer". El resultado es que los ingenieros saben nombrar patterns pero no los reconocen cuando aparecen en código real, y no saben cuándo aplicarlos.
Patterns son vocabulario. Cuando reconoces un pattern en un sistema, entiendes intenciones de diseño que no están escritas en el código. Cuando aplicas un pattern, comunicas una decisión de diseño a quien lo va a leer después.
Estos son los patterns que aparecen en todo codebase de producción que he analizado, con implementación en TypeScript moderno.
Repository Pattern
Abstrae el acceso a datos detrás de una interfaz. El código de dominio no sabe si los datos vienen de una base SQL, de una API o de 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,
})
}
// ...
}
// En tests: reemplázalo por InMemoryUserRepository
// sin cambiar nada en el código de dominio
Cuándo usarlo: siempre que accedes a datos persistidos. Facilita los tests, permite cambiar la fuente de datos y mantiene la lógica de dominio limpia.
Strategy Pattern
Encapsula algoritmos intercambiables. En lugar de un if/else gigante para comportamiento variable, defines una interfaz y múltiples implementaciones.
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)
}
}
Cuándo usarlo: cuando tienes variaciones de un algoritmo que pueden crecer independientemente. Evita switch statements que necesitan cambiar cada vez que se agrega una nueva variante.
Observer Pattern
Permite que objetos sean notificados de eventos sin acoplamiento directo. El patrón detrás de sistemas de eventos, reactive programming y 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
Agrega comportamiento a un objeto sin modificar su clase. En TypeScript, implementado tanto con clases como con higher-order functions.
// Con funciones: más idiomático en 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
Construye objetos complejos paso a paso. Especialmente útil cuando un objeto tiene muchos parámetros opcionales o configuración variable.
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 fluido y legible
const query = new QueryBuilder()
.from('users')
.where('active = true')
.where('role = "admin"')
.orderBy('created_at')
.limit(10)
.build()
Cuándo no usar patterns
El error más común con patterns no es no conocerlos. Es over-engineering: aplicar patterns en código simple que no los necesita.
Si tienes un único algoritmo que no va a variar, Strategy es complejidad innecesaria. Si tienes un único subscriber en un sistema de eventos, Observer es overhead sin beneficio. Si tienes un objeto con dos campos, Builder es demasiado.
Los patterns resuelven problemas de variabilidad y extensibilidad. Si no tienes esos problemas, no necesitas las soluciones.
¿Te gustó el contenido?
Construyo productos web y soluciones con IA de la manera correcta — arquitectura sólida, código sostenible y entrega real.
Hablemos