Resumen teórico
Imagina que construyes cosas con LEGO. Después de un tiempo notas que resuelves siempre los mismos problemitas: cómo hacer una pared fuerte, cómo armar una puerta que abre, cómo unir dos sets que no encajan. Los constructores inteligentes les pusieron nombre a las buenas respuestas, y así dicen "usa el truco de la bisagra" sin explicar pieza por pieza. Un patrón de diseño (design pattern) es exactamente eso: un nombre para una buena respuesta a un problema que vuelve una y otra vez cuando escribimos programas.
Antes de los patrones existen cinco reglas prácticas llamadas SOLID. No son leyes. Son consejos sobre cómo escribir clases (una claseun plano que dice qué datos guarda un objeto y qué sabe hacer es como un molde de galletas, y cada galleta es un objeto) para que el programa sea fácil de cambiar después. Los programas siempre cambian: una regla nueva del jefe, una nueva empresa de pagos, un nuevo tipo de cliente. El código fácil de cambiar ahorra días de trabajo y evita errores nuevos.
Vas a encontrar todo esto en la vida real: en las aplicaciones que usas, en los frameworks que descargan los programadores y en cada entrevista de trabajo sobre software. Al final de esta página conocerás los cinco principios SOLID, diez patrones famosos (Factory, Builder, Singleton, Adapter, Decorator, Facade, Strategy, Observer, Command y State), el Repository y la inyección de dependencias que se usan en sistemas back-end, y las señales de alerta de un mal diseño. También sabrás cuándo no usar un patrón, que es igual de importante.
El panorama general y el vocabulario
El software nunca está terminado. Siempre alguien pide una cosa más. Por eso un buen diseño no trata de dejar el código bonito. Trata de que el próximo cambio cueste poco: el cambio debe tocar pocos lugares y no puede romper lo que ya funciona.
Para hablar de diseño necesitamos algunas palabras. Apréndelas ahora, porque todas las secciones las usan.
| Palabra | Qué significa | Imagen cotidiana |
|---|---|---|
| Cohesión | Cuánto combinan entre sí las partes dentro de una clase. Cohesión alta es bueno. | Un estuche que guarda solo lápices y bolígrafos |
| Acoplamiento | Cuánto depende una clase de otras clases. Acoplamiento bajo es bueno. | Juguetes unidos por un hilo delgado, no soldados |
| Interfaz | La lista de lo que una clase promete hacer, sin decir cómo. | Los botones de un control remoto |
| Contrato | La promesa completa de un método: qué debe dar quien lo llama, qué devuelve el método y qué errores puede lanzar. | Un pedido de pizza: das una dirección, recibes la pizza |
| Abstracción | Una vista simple que esconde los detalles. | Manejas un auto sin conocer el motor |
| Patrón | Una respuesta con nombre y reutilizable para un problema que aparece siempre. | Una receta con nombre |
Hay tres familias de patrones. Los creacionales tratan de la creación de objetos. Los estructurales tratan de cómo encajan los objetos. Los de comportamiento tratan de cómo los objetos conversan y se reparten el trabajo. El famoso libro que listó 23 de ellos lo escribieron cuatro autores, apodados la "Gang of Four" (la banda de los cuatro).
Mira el video: Design Patterns in Object Oriented Programming (lista de Christopher Okhravi, en inglés)↑ Volver al índicePrincipio de responsabilidad única (SRP)
Piensa en una mochila que además es lonchera, almohada y casco de bicicleta. Cuando una parte se rompe, no puedes arreglarla sin tocar las demás. El principio de responsabilidad única dice que una clase debe tener un solo motivo para cambiar.
Un "motivo para cambiar" es una persona o un grupo que puede pedir una modificación. Mira la clase de abajo. Finanzas puede cambiar la regla del impuesto, diseño puede cambiar el texto del recibo y el equipo de base de datos puede cambiar cómo se guardan los datos. Son tres motivos para cambiar en un solo lugar.
class Invoice {
calculateTax(): number { /* reglas de impuestos */ }
saveToDatabase(): void { /* SQL */ }
sendByEmail(): void { /* SMTP */ }
}La cura es separar: una clase para el cálculo del impuesto, una para guardar, una para el correo. Ahora cada cambio toca una clase pequeña, y las pruebas de la regla del impuesto no necesitan una base de datos.
Cohesión y dos olores que se reflejan
Una clase cohesivatodas sus partes pertenecen a una sola idea tiene métodos y campos que tratan de un único concepto. Una pista para encontrar una división que falta: si algunos métodos usan solo algunos campos y otros métodos usan solo los demás campos, probablemente hay dos clases escondidas en una. Una clase que gana un bloque nuevo por cada caso nuevo (un cargo nuevo, un tipo de cliente nuevo) también es una señal.
| Olor (smell) | Qué ves | Qué dice |
|---|---|---|
| Divergent Change | Una clase se edita por muchos motivos distintos (regla de impuestos y diseño de un informe) | Falta de cohesión: la clase tiene varias responsabilidades |
| Shotgun Surgery | Un cambio obliga a hacer pequeñas ediciones en muchas clases | Una responsabilidad se rompió en demasiados pedazos |
Principio abierto-cerrado (OCP)
Una consola de videojuegos tiene entradas para nuevos controles. No abres el aparato y sueldas cables para conectar uno más. La consola está cerrada a la modificación (nadie toca su interior) y abierta a la extensión (los controles nuevos solo se enchufan). El principio abierto-cerrado pide lo mismo al código: un comportamiento nuevo debe entrar como código nuevo, sin editar el código que ya existe y ya fue probado.
La forma más común es una abstracciónuna interfaz que esconde los detalles. La clase conversa con una interfaz, y cada comportamiento nuevo es una clase nueva que la implementa. Esto se llama polimorfismouna llamada, varios comportamientos posibles, según el objeto real. El lugar donde la clase llama algo que no conoce en detalle es su punto de extensión.
interface DiscountRule {
apply(total: number): number;
}
class PriceCalculator {
constructor(private readonly rules: DiscountRule[]) {}
total(base: number): number {
return this.rules.reduce((value, rule) => rule.apply(value), base);
}
}ANTES: area() tiene un if por figura
area(figura) --> if circulo ... if cuadrado ... (¡edítame!)
DESPUÉS: cada figura conoce su propia área
area(figura) --> figura.area()
|-- Circulo.area()
|-- Cuadrado.area()
'-- (nuevo) Triangulo.area()Dónde ocurre el cambio
Ningún programa está cerrado contra todo cambio. En algún lugar hay que crear la clase nueva. Una factory (una función que decide qué clase crear) puede tener un switch para eso. Es aceptable: la variación queda confinada a un punto, y el resto del código solo conoce la abstracción. El principio no elimina el cambio. Elige dónde ocurre.
La misma cadena if (x instanceof ...) copiada en un auxiliar, en un informe y en una pantalla es la verdadera señal de alerta. Cuando aparece un tipo nuevo, hay que encontrar y editar cada copia, y una copia olvidada falla en silencio.
Principio de sustitución de Liskov (LSP)
Supón que pides "una mascota" y te dan un pez dorado. Luego intentas sacarlo a pasear. No funciona. Un pez dorado es una mascota, pero no una mascota que camina. El principio de sustitución de Liskov dice que un subtipo (una clase hija, o cualquier clase que implementa una interfaz) debe funcionar en cualquier lugar donde se espera su tipo base. Si el código funciona con el padre, debe funcionar con el hijo, sin saber cuál hijo recibió.
class Bird {
fly(): string { return 'flying'; }
}
class Penguin extends Bird {
fly(): string { throw new Error('penguins cannot fly'); }
}
function release(birds: Bird[]): string[] {
return birds.map((bird) => bird.fly());
}En biología el pingüino es un ave. En el código, lo que decide la herencia es el contrato de los métodos, no la frase "es un". La solución es cambiar la jerarquía, por ejemplo dejando fly solo en un tipo para aves que vuelan.
El contrato tiene dos lados
Un método tiene una precondiciónlo que quien llama debe garantizar antes de la llamada y una poscondiciónlo que el método garantiza después de la llamada. También tiene una forma de avisar un fallo, como devolver null o lanzar un error. El compilador solo revisa los tipos de la firma. No revisa estas promesas, así que las pruebas de contrato son las que deben hacerlo.
| Parte del contrato | Qué puede hacer un subtipo |
|---|---|
| Precondición (lo que exige) | Mantenerla o debilitarla (exigir menos). Nunca hacerla más estricta |
| Poscondición (lo que entrega) | Mantenerla o fortalecerla (entregar más). Nunca debilitarla |
| Cómo se avisa el fallo | Usar la misma forma. Si la base devuelve null para un usuario que no existe, el subtipo no debe lanzar un error |
El ejemplo clásico es Square extends Rectangle. Un Rectangle promete que cambiar el ancho no cambia el alto. Un Square no puede cumplir esa promesa, porque cambiar un lado cambia el otro. Otro caso: una Account base promete que después de un retiro el saldo nunca es negativo, y una OverdraftAccount permite saldo negativo. Debilitó una poscondición.
El síntoma más fuerte de una sustitución rota es el instanceof en el código cliente (if (account instanceof StudentAccount) ...). El cliente tuvo que aprender los subtipos, así que cada subtipo nuevo necesita otra rama. Entonces una violación de Liskov se vuelve también una violación del abierto-cerrado.
Principio de segregación de interfaces (ISP)
Te prestan una navaja suiza solo para abrir una carta. Recibes 30 herramientas que no necesitas, y si una está rota igual te preocupas por ella. El principio de segregación de interfaces dice que los clientes no deben verse obligados a depender de métodos que no usan. Una interfaz es un contrato visto desde el lado de quien la usa. Si mezcla métodos para usuarios distintos, todos quedan atados a todo.
interface Machine {
print(doc: string): void;
scan(): string;
fax(number: string): void;
}
class SimplePrinter implements Machine {
print(doc: string): void { /* imprime */ }
scan(): string { throw new Error('not supported'); }
fax(number: string): void { throw new Error('not supported'); }
}Un método escrito como "no soportado" es una señal clara: la interfaz pide más de lo que la clase tiene. La solución es dividir por capacidad: Printer, Scanner, Fax. Ahora el código que necesita escanear pide un Scanner, y una impresora simple nunca llega ahí. El sistema de tipos trabaja a tu favor.
Lo que cuesta una interfaz gorda
- Quien implementa debe escribir métodos que no puede cumplir, y lanzan errores.
- Los clientes dependen de métodos que nunca llaman, así que un cambio en un método que no usan aún puede obligarlos a cambiar.
- Cuando un decimotercer método, usado por un solo cliente, entra en una interfaz con doce métodos y ocho implementaciones, las ocho clases deben cambiar, incluso aquellas cuyos clientes nunca lo llaman.
- Los test doubles (objetos falsos usados en pruebas) deben tener todos los métodos. Un informe que solo lee necesita un falso con un método, no cuatro.
Un buen ejemplo es un repositorio de usuarios. Un informe solo necesita leer. Dale un UserReader con findAll y deja save y delete en otra interfaz. La clase concreta implementa ambas, así que sigue igual. El informe no puede escribir ni borrar por error, y su falso necesita un solo método.
ISP y SRP son dos vistas de la cohesión. El SRP pregunta por qué cambia el propio tipo. El ISP pregunta qué necesita cada cliente de un tipo. Un repositorio puede tener una responsabilidad (guardar usuarios) y aun así necesitar interfaces separadas de lectura y de escritura.
↑ Volver al índicePrincipio de inversión de dependencias (DIP)
Una lámpara tiene un enchufe. No tiene un cable fijo a la pared de una sola casa. La lámpara (lo importante) define la forma del enchufe, y sirve cualquier toma que encaje. El principio de inversión de dependencias dice: los módulos de alto nivel y los de bajo nivel deben depender de abstracciones. El código de alto nivellas reglas de negocio: para qué sirve el programa no debe importar los detalles de bajo nivellos detalles: base de datos, correo, empresa de pagos. En cambio, la regla dice lo que necesita mediante una interfaz, y el detalle la implementa.
class OrderService {
private readonly database = new MySqlDatabase();
place(order: Order): void {
if (order.total() <= 0) throw new Error('empty order');
this.database.insert('orders', order);
}
}El new dentro de la clase es una decisión doble: la clase elige lo que necesita y qué implementación recibe. La solución es declarar una interfaz en el lenguaje del negocio (OrderStore) y recibirla en el constructor. Así la regla se puede probar con un falso en memoria.
NORMAL INVERTIDO
OrderService OrderService --> OrderStore
| ^
v |
MySqlDatabase MySqlOrderStore -----'
flecha: regla -> detalle detalle -> interfazNota la diferencia entre el flujo de llamadas y la dependencia en el código fuente. En tiempo de ejecución OrderService todavía llama a la base de datos. En el código, ambos conocen solo la interfaz. Nota también quién es dueño de la interfaz: vive con el módulo de alto nivel, y el adaptador de la base de datos la implementa. Esa propiedad es lo que significa "inversión". Poner la interfaz junto a la base de datos no invertiría nada.
DIP no es lo mismo que DI
| Tema | DIP (inversión de dependencias) | DI (inyección de dependencias) |
|---|---|---|
| Qué es | Una regla de diseño: depender de abstracciones | Una técnica: alguien de afuera entrega la dependencia |
| Pregunta que responde | ¿De qué debo depender? | ¿Quién la crea y me la entrega? |
| ¿Puede existir sola? | Sí | Sí |
Las interfaces son menos riesgosas que las clases concretas porque cambian menos: no cargan detalles y muchas clases dependen de ellas. Pero la regla no es "pon una interfaz en todo". El criterio es la estabilidad. La clase de texto de la biblioteca estándar (String) casi nunca cambia, no hace entrada ni salida y se comporta igual cada vez, así que se puede usar directamente. Lo que cambia, habla con el mundo exterior o da una respuesta distinta en cada llamada (base de datos, reloj, red, números aleatorios) va detrás de una interfaz.
Creacionales: Factory, Builder, Singleton
Crear un objeto implica decisiones: qué clase, con qué partes, en qué estado inicial. Si cada usuario del objeto repite esas decisiones, un cambio debe repetirse en todas partes. Los patrones creacionales sacan las decisiones de creación del código que usa el objeto, para que quien lo usa dependa solo del tipo abstracto.
| Patrón | El problema | La idea |
|---|---|---|
| Factory | Hay varias clases para el mismo papel, y quien decide cuál se usa está copiado por todos lados | Una función o clase decide qué clase crear |
| Builder | Una clase con muchos datos, algunos opcionales, en una lista larga de parámetros | Pasos con nombre y un build final que lo revisa todo |
| Singleton | Queremos exactamente una instancia de una clase | Una instancia más una forma global de alcanzarla |
Factory
function createNotifier(channel: string): Notifier {
if (channel === 'email') return new EmailNotifier();
if (channel === 'sms') return new SmsNotifier();
throw new Error(`unknown channel: ${channel}`);
}El valor de una factory está en lo que esconde. El cliente debe depender de la interfaz del producto, no de su clase concreta. Si el cliente hace un cast del resultado a la clase concreta, el beneficio se pierde.
Builder
// difícil de leer: ¿qué significan null, 30, true y false?
new HttpRequest('POST', '/orders', null, null, 30, true, false, body);
// con un builder, cada valor tiene nombre
new RequestBuilder().method('POST').path('/orders').timeout(30).body(body).build();El método build es el lugar natural para reglas que cruzan campos, como "un POST debe tener cuerpo". El objeto solo nace si todo el conjunto tiene sentido. Por eso también un constructor rico es mejor que un constructor vacío seguido de setters: con setters, entre el new y el último set el objeto existe en un estado inválido. Otro cuidado: build debe entregar al producto una copia de los datos mutables, como una lista de encabezados. Si entrega el arreglo del propio builder, una llamada posterior al builder cambia un producto que ya fue entregado.
Singleton
El Singleton une dos decisiones: hay una sola instancia y es alcanzable desde cualquier lugar. La primera puede ser una necesidad legítima. La segunda es una variable global disfrazada, y por eso hoy se mira el patrón con desconfianza.
class InvoiceService {
issue(order: Order): Invoice {
const now = Clock.getInstance().now();
const rate = TaxTable.getInstance().rateFor(order.state);
return new Invoice(order, rate, now);
}
}La firma de un constructor es la documentación más honesta de lo que una clase necesita. Un acceso global escondido hace que esa documentación mienta. Peor aún: un Singleton con estado mutable une todo el código que lo usa. En las pruebas esto aparece como dependencia del orden: la primera prueba incrementa un contador, la segunda empieza con el valor sobrante y falla, aunque cada una pasa sola. Una constante exportada de un módulo (export const config = loadConfig()) es lo mismo en JavaScript: quienes importan quedan atados a esa instancia.
Estructurales: Adapter, Decorator, Facade
Los patrones estructurales tratan de cómo juntar clases. Tres de ellos se parecen, porque cada uno pone una clase delante de otro código. Lo que cambia es la intención.
| Patrón | Interfaz que muestra | Intención |
|---|---|---|
| Adapter | Una distinta: convierte una clase a la interfaz que el cliente ya espera | Hacer que un desajuste encaje |
| Decorator | La misma interfaz del objeto que envuelve | Agregar comportamiento (log, caché, retry) |
| Facade | Una nueva y más simple, para varias clases | Una entrada fácil a un subsistema complejo |
Adapter
interface PaymentGateway {
charge(cents: number): Receipt;
}
class AcmePayGateway implements PaymentGateway {
constructor(private readonly sdk: AcmePaySdk) {}
charge(cents: number): Receipt {
const result = this.sdk.createTransaction({ amount: cents / 100, currency: 'BRL' });
return { id: result.transactionId, approved: result.status === 'OK' };
}
}El sistema es dueño del contrato estable (PaymentGateway). Los tipos del proveedor nunca salen del adaptador. Cuando se cambia de proveedor, solo cambia el adaptador y las reglas de negocio quedan como están. El costo de cambiar de proveedor pasa a ser el costo de una clase.
Decorator
Un decorator implementa una interfaz y recibe, en su constructor, otro objeto de la misma interfaz. Como la forma es la misma, los decorators se pueden apilar en cualquier combinación, como capas de ropa en una muñeca.
// Logged escribe cada llamada, Cached recuerda las respuestas
const a: Users = new Logged(new Cached(new SqlUsers()));
const b: Users = new Cached(new Logged(new SqlUsers()));El orden es parte del comportamiento. En a, Logged está por fuera, así que cada llamada se registra, incluso las que responde la caché. En b, Cached está por fuera, así que el segundo find('42') lo responde la caché y nunca llega a Logged: no se escribe en el log. Ambas son válidas y responden preguntas distintas.
¿Por qué no herencia? Con herencia, cada combinación de funciones es una subclase. Con n funciones independientes son 2ⁿ − 1 clases (contando cada combinación con al menos una función, ignorando el orden). Con decorators escribes n clases y las combinas cuando se arma el programa.
- Herencia, 3 funciones
- 7 clases
- Decorators, 3 funciones
- 3 clases
- Herencia, 6 funciones
- 63 clases
- Decorators, 6 funciones
- 6 clases
Un decorator es un subtipo de lo que envuelve, así que se aplica el principio de Liskov. Un decorator de caché cuyo save no limpia el producto guardado hace que find devuelva datos viejos después de una escritura. Rompió el contrato de la interfaz. Una prueba de contrato, ejecutada contra el repositorio simple y contra el decorado, lo detecta.
Facade
Un subsistema suele tener muchas clases que hay que llamar en cierto orden. La facade guarda ese conocimiento en un solo lugar, y los clientes dependen de una clase y no de diez. El subsistema puede cambiar por dentro sin dañarlos. El límite es claro: la facade conoce el orden de las llamadas, no las reglas.
↑ Volver al índiceDe comportamiento: Strategy, Observer, Command, State
Los patrones de comportamiento tratan de cómo los objetos se reparten el trabajo y conversan. En cada uno, una clase deja de decidir algo por sí sola y pasa la decisión a otro objeto que se puede cambiar.
| Patrón | Intención | Señal en el código |
|---|---|---|
| Strategy | Poner algoritmos intercambiables detrás de una interfaz | Un if por tipo, cada rama un cálculo independiente |
| Observer | Un objeto avisa a muchos otros cuando algo ocurre | Un par de llamadas: subscribe y publish |
| Command | Guardar una petición como objeto, con sus datos | Cola, historial, deshacer y rehacer |
| State | Cambiar el comportamiento cuando cambia el estado interno | El mismo campo status probado en cada método |
Strategy
Imagina ir a la escuela: en autobús, en bicicleta o a pie. La meta es la misma y el camino cambia. El Strategy separa lo que varía (cómo calcular) de lo que se queda (quién necesita el resultado). Cada tipo de envío tiene una clase que se puede probar sola, y un tipo nuevo no edita a los otros.
type Pricing = (base: number) => number;
const regular: Pricing = (base) => base;
const blackFriday: Pricing = (base) => base * 0.7;
function checkout(base: number, pricing: Pricing): number {
return pricing(base);
}Los patrones describen papeles y relaciones, no sintaxis. Cuando la estrategia tiene un método y ningún estado, un tipo función hace el papel de la interfaz. Usa una clase cuando necesite datos o más de una operación.
Observer
Un canal de YouTube no sabe quiénes son sus suscriptores. Solo publica, y todos los que se suscribieron reciben el aviso. En el Observer, quien publica no sabe quién escucha, así que una reacción nueva (sumar puntos de fidelidad cuando se paga un pedido) es un subscribe más al arrancar, y la clase del pedido no se edita. Es el principio abierto-cerrado aplicado a eventos.
publish(event: string): void {
for (const listener of [...this.listeners]) {
try { listener(event); } catch (error) { this.errors.push(error); }
}
}- Recorre una copia. Si el oyente A se cancela durante la entrega, un ciclo común mueve a B a su lugar y se lo salta. Publicar sobre una instantánea (snapshot) significa: quien estaba suscrito cuando empezó el evento lo recibe.
- Aísla cada oyente. Si B lanza un error y nadie lo captura, C nunca es llamado y el error llega a quien publicó, justo lo que el patrón quería desacoplar. Captura y registra el error.
- Cancela al salir. El sujeto (subject) guarda una referencia a cada observador. Una pantalla que se cierra sin cancelar sigue en memoria y sigue siendo llamada. Esa fuga es el "lapsed listener" (oyente olvidado). Un buen
subscribedevuelve la función que cancela.
Command
Una llamada a un método común ocurre y desaparece. Un comando es la misma intención guardada como dato: qué acción, sobre quién, con qué argumentos. Eso permite ponerlo en una cola, repetirlo y deshacerlo. Si cada comando sabe revertirse, un historial con dos pilas da deshacer y rehacer.
run(a) run(b) run(c) historial = [a, b, c]
undo() saca c -> historial = [a, b]
undo() saca b -> historial = [a]
Efectos aún aplicados: solo a
Ejecutar un comando nuevo limpia la pila de rehacerState
Un pedido pasa de pendiente a pagado y a enviado. Cada fase responde de forma distinta a la misma operación: no se envía un pedido pendiente. Con condicionales, cada método empieza con una prueba sobre status. Con el patrón State, cada fase es una clase, y las transiciones permitidas quedan explícitas. La regla que rechaza ship para un pedido pendiente vive en la clase del estado pendiente.
Cuenta lo que crece. Con 4 estados y 5 operaciones escritas como métodos con condicionales, un quinto estado obliga a revisar los 5 métodos. Con State, es una clase nueva más las transiciones que llevan a ella. Lo contrario también vale: si las operaciones crecen más que los estados, los condicionales pueden ser la mejor elección.
| Tema | Strategy | State |
|---|---|---|
| Qué son las alternativas | Formas independientes de hacer la misma tarea | Fases de un ciclo de vida |
| Quién las cambia | Se eligen desde afuera | Las cambian las propias operaciones, como consecuencia |
Repository e inyección de dependencias
Cuando le pides un libro a la bibliotecaria, no entras al depósito a buscar en los estantes. Dices el título y ella lo trae. Un Repository es esa bibliotecaria para tus datos. Da a las reglas de negocio una interfaz parecida a una colección (add, find, save) y esconde cómo se guardan los datos: SQL, archivos o memoria.
interface OrderRepository {
find(id: string): Order | null;
save(order: Order): void;
}
class InMemoryOrders implements OrderRepository {
private readonly items = new Map<string, Order>();
find(id: string) { return this.items.get(id) ?? null; }
save(order: Order) { this.items.set(order.id, order); }
}Como la regla solo conoce la interfaz, se puede probar con un repositorio en memoria: rápido e igual cada vez. Un segundo grupo de pruebas, más lento, revisa el repositorio real de la base de datos. Una prueba de contrato ejecutada contra ambas implementaciones asegura que se comportan igual.
Inyección de dependencias
Inyección de dependencias significa que la clase deja de crear o buscar lo que necesita y lo recibe desde afuera. La forma más común es la inyección por constructor: las dependencias son visibles en la firma, obligatorias y pueden ser de solo lectura. Mirar el constructor muestra todo lo que la clase necesita.
class RegisterUser {
constructor(
private readonly users: UserRepository,
private readonly mailer: Mailer,
) {}
execute(email: string): void {
this.users.add(new User(email));
this.mailer.send(email, 'Welcome');
}
}main() <-- raíz de composición: el único lugar con 'new'
| crea la conexión
| crea SqlOrderRepository(conexión)
| crea RegisterUser(repo, mailer)
v
el resto del programa solo conoce interfacesEl lugar donde todo se crea y se conecta se llama raíz de composición (composition root), cerca del punto de entrada del programa. Escribir new SqlOrderRepository(connection) allí mantiene al resto del sistema dependiendo solo de abstracciones.
Dos trampas
- Límite de la transacción. Para guardar un pedido y bajar el inventario de forma atómica (todo o nada), la transacción pertenece al caso de uso, que abre una unidad de trabajo (unit of work) que cubre ambos repositorios. Un repositorio cuida un tipo de objeto y no sabe de qué operación mayor forma parte.
- Dependencia cautiva (captive dependency). Un servicio creado una vez al arrancar que recibe en el constructor un objeto que debería crearse por cada petición se queda con el objeto de la primera petición para siempre. Un objeto solo puede depender de objetos que viven tanto como él o más. Los datos por petición llegan como argumento del método, o mediante un proveedor consultado en cada uso.
Antipatrones y cuándo no usar un patrón
Un code smell (olor del código) es una pista de que el diseño puede tener un problema, como un olor raro en el refrigerador. Un antipatrón es una mala respuesta que se volvió popular. Los patrones también tienen un precio: más tipos, más archivos y un salto más para entender el flujo. Solo valen la pena si el problema que resuelven realmente existe.
| Olor | Cómo reconocerlo | Por qué duele |
|---|---|---|
| God Class | Una clase enorme que concentra gran parte de la lógica y conoce casi todas las demás | Baja cohesión, difícil de probar, todos la editan y chocan |
| Shotgun Surgery | Un cambio obliga a hacer pequeñas ediciones en muchas clases | Lo que cambia junto no está junto |
| Feature Envy | Un método lee muchos getters de otro objeto para calcular algo sobre ese objeto | El cálculo pertenece al otro objeto |
| Refused Bequest | Una subclase sobrescribe métodos heredados para que no hagan nada o lancen error | Rompe Liskov: quería el código, no el contrato |
| Modelo anémico | Clases con solo campos, getters y setters, y todas las reglas en servicios | Código procedural disfrazado: cualquier servicio puede dejar un objeto inválido |
| Generalidad especulativa | Interfaz, factory y builder, cada uno con una implementación, "por si acaso" | Todos pagan por una flexibilidad que nadie usó |
Tell, Don't Ask y la Ley de Deméter
// pide todo y luego decide
const city = order.getCustomer().getAddress().getCity().getName();
// le dice al pedido lo que se quiere
const city = order.shippingCity();La Ley de Deméter dice que un método debe hablar solo con sus vecinos inmediatos. Una cadena de getters cruza varios objetos, así que si la dirección cambia de forma, cada cadena repartida por el sistema se rompe. Pedir el resultado al primer objeto mantiene el conocimiento de la estructura en un solo lugar. La misma idea, Tell, Don't Ask (di, no preguntes), cura la Feature Envy: lleva el comportamiento junto a los datos que usa.
Sobre el modelo anémico: un diseño orientado a objetos mantiene cada regla junto al dato que protege (encapsulamiento). Si las clases de dominio son solo bolsas de campos, cualquier servicio puede dejar un pedido en un estado inválido, y la misma regla se reescribe en muchos lugares.
Cuándo no aplicar un patrón
- Con un solo algoritmo y ninguna señal de un segundo, el Strategy no se justifica.
- Una pantalla de registro con dos campos hecha con una interfaz, una abstract factory y un Builder, cada uno con una implementación, es generalidad especulativa. Espera el segundo caso real: refactorizar a un patrón cuando surge la necesidad cuesta menos que cargar la estructura durante años.
- Aplicar un patrón por precaución suele producir la abstracción equivocada, y la flexibilidad equivocada estorba al cambio que de verdad llega.
Uniéndolo todo
Los principios y los patrones están conectados. Los patrones suelen ser el camino para seguir un principio. Cuando veas un problema, pregunta qué dolor sientes y elige el principio o el patrón que lo quita.
| Dolor que sientes | Principio que lo explica | Patrón o técnica que ayuda |
|---|---|---|
| Una clase cambia por muchos motivos | SRP | Extraer cada tema a su propia clase |
Cada caso nuevo edita una cadena de if vieja | OCP | Strategy, Factory, polimorfismo |
| Un subtipo lanza un error o sorprende a sus clientes | LSP | Cambiar la jerarquía o usar composición |
| Quienes implementan escriben métodos "no soportado" | ISP | Dividir la interfaz por papel |
| Las reglas de negocio importan la base de datos o el correo | DIP | Interfaz que pertenece a la regla, DI, Adapter, Repository |
| Un constructor con muchos parámetros | (creación) | Builder, constructor rico |
| Un global escondido que rompe las pruebas | DIP | Inyectar la instancia única en lugar de un Singleton |
| Comportamientos que se apilan en cualquier orden | OCP, composición | Decorator |
| Muchas clases que llamar en orden | (simplicidad) | Facade |
| Reacciones a un evento | OCP | Observer |
| Deshacer, cola, repetir | (petición como dato) | Command |
| Estado probado en cada método | OCP | State |
Una nota justa sobre el costo: el código refactorizado tiene más tipos y más líneas que la primera versión. Ese es el precio. Vale la pena donde cambios así realmente llegan, y es un desperdicio donde nunca llegan. El cambio no desaparece. Los diseños solo eligen dónde cae.
- Escribe un código simple que funcione, con pruebas.
- Observa el dolor: una clase que cambia por muchos motivos, una cadena de
ifque crece, una prueba que necesita una base de datos. - Ponle nombre al dolor con un principio o un olor.
- Aplica el patrón más pequeño que lo quite, y nada más.
- Mantén las pruebas en verde durante la refactorización.