Saltar al contenido
Computer Science Fundamentals
Código fuente
← Inicio

Patrones de diseño y SOLID

100 preguntas

Correctas
0
Incorrectas
0
Sin responder
100
Dificultad
Número de preguntas

100 preguntas en este filtro

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

Las siete palabras del diseño de software
PalabraQué significaImagen cotidiana
CohesiónCuá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
AcoplamientoCuánto depende una clase de otras clases. Acoplamiento bajo es bueno.Juguetes unidos por un hilo delgado, no soldados
InterfazLa lista de lo que una clase promete hacer, sin decir cómo.Los botones de un control remoto
ContratoLa 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ónUna vista simple que esconde los detalles.Manejas un auto sin conocer el motor
PatrónUna 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 índice

Principio 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 */ }
}
Tres temas en una sola clase: viola el SRP

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

Los dos olores que son espejo uno del otro
Olor (smell)Qué vesQué dice
Divergent ChangeUna 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 SurgeryUn cambio obliga a hacer pequeñas ediciones en muchas clasesUna responsabilidad se rompió en demasiados pedazos
↑ Volver al índice

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 . La clase conversa con una interfaz, y cada comportamiento nuevo es una clase nueva que la implementa. Esto se llama . 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);
	}
}
Un descuento nuevo es una clase nueva que implementa DiscountRule, agregada a la lista donde se arma el programa
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()
Una cadena de ifs sobre un tipo está cerrada a la extensión

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.

↑ Volver al índice

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());
}
Penguin no puede reemplazar a Bird: release() revienta

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 y una . 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.

Exige menos, entrega más
Parte del contratoQué 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 falloUsar 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.

↑ Volver al índice

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'); }
}
Una interfaz gorda: la impresora debe escribir métodos que no puede cumplir

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 índice

Principio 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 no debe importar los detalles de . 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);
	}
}
La regla de negocio crea su propio detalle con new: cambia cuando cambia la base de datos

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 -> interfaz
Las flechas del código fuente apuntan hacia la regla

Nota 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

Van juntas, pero son cosas distintas
TemaDIP (inversión de dependencias)DI (inyección de dependencias)
Qué esUna regla de diseño: depender de abstraccionesUna 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.

↑ Volver al índice

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.

La Factory elige qué clase. El Builder arma un objeto en pasos.
PatrónEl problemaLa idea
FactoryHay varias clases para el mismo papel, y quien decide cuál se usa está copiado por todos ladosUna función o clase decide qué clase crear
BuilderUna clase con muchos datos, algunos opcionales, en una lista larga de parámetrosPasos con nombre y un build final que lo revisa todo
SingletonQueremos exactamente una instancia de una claseUna 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 código que usa un Notifier nunca escribe new EmailNotifier

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();
Muchos parámetros posicionales contra pasos con nombre

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 new InvoiceService() dice que no necesita nada. Miente.

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.

↑ Volver al índice

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.

Una prueba simple: ¿se podría usar el objeto envuelto en lugar del que lo envuelve? En el Decorator, sí. En el Adapter, no.
PatrónInterfaz que muestraIntención
AdapterUna distinta: convierte una clase a la interfaz que el cliente ya esperaHacer que un desajuste encaje
DecoratorLa misma interfaz del objeto que envuelveAgregar comportamiento (log, caché, retry)
FacadeUna nueva y más simple, para varias clasesUna 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 adaptador traduce nombres, unidades (centavos contra moneda entera) y respuestas

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()));
Las mismas piezas, otro orden, otro comportamiento

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.

Clases necesarias para las funciones de log, caché y retry
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 índice

De 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.

Cuatro formas de sacar una decisión de una clase
PatrónIntenciónSeñal en el código
StrategyPoner algoritmos intercambiables detrás de una interfazUn if por tipo, cada rama un cálculo independiente
ObserverUn objeto avisa a muchos otros cuando algo ocurreUn par de llamadas: subscribe y publish
CommandGuardar una petición como objeto, con sus datosCola, historial, deshacer y rehacer
StateCambiar el comportamiento cuando cambia el estado internoEl 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);
}
Sin clases, y aun así es un Strategy

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); }
	}
}
Entrega segura: una copia de la lista y cada oyente aislado
  • 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 subscribe devuelve 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 rehacer
Deshacer va en orden inverso: el último en entrar es el primero en salir

State

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.

Misma estructura, distinta intención
TemaStrategyState
Qué son las alternativasFormas independientes de hacer la misma tareaFases de un ciclo de vida
Quién las cambiaSe eligen desde afueraLas cambian las propias operaciones, como consecuencia
↑ Volver al índice

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); }
}
La regla habla de pedidos. Solo el repositorio real conoce SQL.

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');
	}
}
Inyección por constructor
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 interfaces
Dónde se arma el grafo de objetos

El 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.
↑ Volver al índice

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.

Seis olores y su costo
OlorCómo reconocerloPor qué duele
God ClassUna clase enorme que concentra gran parte de la lógica y conoce casi todas las demásBaja cohesión, difícil de probar, todos la editan y chocan
Shotgun SurgeryUn cambio obliga a hacer pequeñas ediciones en muchas clasesLo que cambia junto no está junto
Feature EnvyUn método lee muchos getters de otro objeto para calcular algo sobre ese objetoEl cálculo pertenece al otro objeto
Refused BequestUna subclase sobrescribe métodos heredados para que no hagan nada o lancen errorRompe Liskov: quería el código, no el contrato
Modelo anémicoClases con solo campos, getters y setters, y todas las reglas en serviciosCódigo procedural disfrazado: cualquier servicio puede dejar un objeto inválido
Generalidad especulativaInterfaz, 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();
Una cadena de getters acopla a quien llama con cuatro clases

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.
↑ Volver al índice

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.

Del dolor a la cura
Dolor que sientesPrincipio que lo explicaPatrón o técnica que ayuda
Una clase cambia por muchos motivosSRPExtraer cada tema a su propia clase
Cada caso nuevo edita una cadena de if viejaOCPStrategy, Factory, polimorfismo
Un subtipo lanza un error o sorprende a sus clientesLSPCambiar la jerarquía o usar composición
Quienes implementan escriben métodos "no soportado"ISPDividir la interfaz por papel
Las reglas de negocio importan la base de datos o el correoDIPInterfaz 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 pruebasDIPInyectar la instancia única en lugar de un Singleton
Comportamientos que se apilan en cualquier ordenOCP, composiciónDecorator
Muchas clases que llamar en orden(simplicidad)Facade
Reacciones a un eventoOCPObserver
Deshacer, cola, repetir(petición como dato)Command
Estado probado en cada métodoOCPState

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.

  1. Escribe un código simple que funcione, con pruebas.
  2. Observa el dolor: una clase que cambia por muchos motivos, una cadena de if que crece, una prueba que necesita una base de datos.
  3. Ponle nombre al dolor con un principio o un olor.
  4. Aplica el patrón más pequeño que lo quite, y nada más.
  5. Mantén las pruebas en verde durante la refactorización.
↑ Volver al índice