Te piden añadir una campaña a la tienda online. El cálculo vive en una clase que también prepara facturas y llama al proveedor de pagos. Antes de tocar el descuento, tienes que averiguar si vas a cambiar de paso cómo se cobra una compra. La promoción venía con más trabajo del anunciado.
SOLID reúne cinco principios de diseño para revisar situaciones como esta. Podemos llevarlos a preguntas cercanas: ¿por qué cambia esta clase?, ¿qué promete esta operación?, ¿qué detalles necesita conocer quien la utiliza? Vamos a recorrerlos con una precaución práctica: cada separación debe aportar una mejora que podamos explicar.
S: responsabilidad única
El principio de responsabilidad única, o SRP, propone separar responsabilidades que cambian por motivos diferentes.
Imagina una clase que calcula promociones y genera el formato de la factura. El equipo comercial puede cambiar los descuentos mientras las necesidades de facturación evolucionan por otra razón. Reunir ambos asuntos hace que sus modificaciones interfieran.
Podemos localizar las reglas de promociones en una pieza y la presentación de la factura en otra. Ambas colaborarán con el proceso que corresponda.
Una responsabilidad puede necesitar varios métodos. Para evaluarla conviene mirar qué conocimiento agrupa, quién necesita cambiarlo y qué elementos deberían evolucionar juntos. Contar métodos nos dice poco sobre esas relaciones.
En el código podríamos pasar de un OrderManager que calcula descuentos y dibuja facturas a dos colaboradores: DiscountPolicy y InvoicePresenter. El proceso que los utiliza coordina sus resultados. Así, cambiar una cabecera de la factura nos lleva a su presentación y deja el cálculo al margen.
Señal para revisarlo: una clase recibe cambios frecuentes por asuntos que apenas tienen relación entre sí.
O: abierto a extensión, cerrado a modificación
El principio abierto/cerrado, u OCP, busca incorporar variaciones sin tener que modificar repetidamente una pieza estable.
Nuestra tienda tiene varias políticas de descuento. Un calculador podría contener una lista creciente de condiciones. Este fragmento es un contraejemplo esquemático:
if (promotionType.equals("summer")) { /* apply campaign */ }
else if (promotionType.equals("employee")) { /* apply employee discount */ }
else if (promotionType.equals("welcome")) { /* apply welcome discount */ }
Cuando esas políticas tienen un contrato común, podemos hacer que el cálculo utilice la política seleccionada. Añadir otra permite extender el comportamiento con una nueva implementación, conservando el recorrido que ya funciona.
Habrá que registrar o seleccionar la nueva política en algún lugar. El principio orienta qué parte queda estable frente a una variación concreta; el sistema completo seguirá recibiendo cambios.
Elegir bien ese punto de extensión requiere observar las diferencias reales. Si todavía tenemos una única promoción sencilla, quizá sea pronto para construir toda esa estructura.
Un contrato y dos políticas
Este ejemplo utiliza la clase PercentageDiscounts del artículo sobre KISS, DRY y YAGNI.
DiscountPolicy.applyTo() recibe un importe no negativo en céntimos y devuelve el importe final, también en céntimos y entre cero y el original. Las implementaciones rechazan importes negativos con IllegalArgumentException. Las dos políticas porcentuales redondean el descuento con HALF_UP antes de restarlo, igual que PercentageDiscounts.
import java.util.Objects;
interface DiscountPolicy {
long applyTo(long amountInCents);
}
final class EmployeeDiscount implements DiscountPolicy {
private static final int DISCOUNT_PERCENTAGE = 10;
@Override
public long applyTo(long amountInCents) {
return PercentageDiscounts.applyTo(amountInCents, DISCOUNT_PERCENTAGE);
}
}
final class CampaignDiscount implements DiscountPolicy {
private static final int DISCOUNT_PERCENTAGE = 20;
@Override
public long applyTo(long amountInCents) {
return PercentageDiscounts.applyTo(amountInCents, DISCOUNT_PERCENTAGE);
}
}
final class OrderTotalCalculator {
private final DiscountPolicy discountPolicy;
OrderTotalCalculator(DiscountPolicy discountPolicy) {
this.discountPolicy = Objects.requireNonNull(
discountPolicy, "Discount policy is required.");
}
long calculateTotal(long amountInCents) {
return discountPolicy.applyTo(amountInCents);
}
}
La llamada ilustrativa new OrderTotalCalculator(new CampaignDiscount()).calculateTotal(10000) devuelve 8000 céntimos: 80 euros sobre una compra de 100. Con EmployeeDiscount devuelve 9000. Sobre 105 céntimos, el descuento de empleados redondea 10,5 a 11 céntimos y deja 94.
Aquí la política ya está seleccionada: comprobar quién tiene derecho al descuento y elegirla corresponde a la coordinación de la compra. El ejemplo calcula una sola promoción sobre un importe en euros expresado en céntimos; impuestos, envíos y combinaciones de descuentos quedan fuera de este cálculo.
En el diagrama, la flecha discontinua con triángulo vacío significa «implementa». La flecha hacia DiscountPolicy indica que el calculador utiliza el contrato. Una nueva política podrá incorporarse sin añadir otra condición a calculateTotal().
Señal para revisarlo: cada variante obliga a editar los mismos bloques de decisiones, aunque el resto del proceso apenas cambie.
L: sustitución de Liskov
El principio de sustitución de Liskov, o LSP, pide que podamos utilizar una implementación donde se espera su abstracción conservando las garantías del contrato.
Supongamos que el contrato de cobro promete que un resultado exitoso confirma el pago. Una implementación nueva devuelve éxito cuando únicamente ha dejado la solicitud en una cola.
Los parámetros y el tipo de retorno pueden coincidir, pero el proceso de compra recibe una garantía diferente. Si marca el pedido como pagado, toma una decisión apoyándose en una promesa que esa implementación ha incumplido.
Podríamos ofrecer otro contrato para solicitar pagos pendientes de confirmación o revisar el modelo de resultados y sus consumidores. Lo necesario es expresar la diferencia.
También importa qué entradas acepta cada implementación. Si DiscountPolicy admite cualquier importe no negativo, una nueva política que rechaza compras inferiores a 100 euros introduce una exigencia que el consumidor desconocía. Podría conservar el importe cuando la promoción no corresponda, si esa es la regla acordada, o necesitar otro contrato.
El enum PaymentOutcome del artículo anterior da una pista: una implementación que desconoce si se ha cobrado debe devolver UNKNOWN, de acuerdo con su contrato. Para comprobar la sustitución conviene ejecutar los mismos casos de contrato contra cada implementación, incluyendo rechazos y respuestas inciertas.
Señal para revisarlo: el consumidor necesita detectar qué implementación ha recibido para esquivar comportamientos incompatibles con lo prometido.
I: segregación de interfaces
El principio de segregación de interfaces, o ISP, propone que los consumidores dependan de las operaciones que necesitan.
Imagina un contrato de catálogo que permita consultar productos, eliminarlos, importar archivos y actualizar existencias. La página que muestra un producto solo utiliza la consulta, pero depende del conjunto entero.
Podemos ofrecer un contrato de consulta y separar las capacidades de administración. El lector del catálogo conocerá las operaciones que tienen sentido para su función.
Eso evita obligar a implementaciones sencillas a proporcionar métodos ajenos a su propósito. Por ejemplo, una fuente de catálogo de solo lectura no tendría que fingir que sabe eliminar productos.
El tamaño adecuado depende de los consumidores y de la coherencia de las operaciones. Una interfaz con varios métodos relacionados puede servir perfectamente.
En Java podríamos expresar la separación con estas dos capacidades:
import java.util.Optional;
interface CatalogReader {
Optional<String> findProductNameById(String productId);
}
interface CatalogAdmin {
void remove(String productId);
}
La página recibe un CatalogReader. Optional expresa que el producto podría no existir. Una implementación de solo lectura puede cumplir ese contrato sin declarar una operación remove() que nunca va a soportar. Una clase que ofrezca ambas capacidades puede implementar las dos interfaces.
Señal para revisarlo: aparecen métodos que lanzan «operación no soportada» porque la implementación solo necesita una parte del contrato.
D: inversión de dependencias
El principio de inversión de dependencias, o DIP, propone que las políticas de alto nivel y los detalles que las soportan dependan de abstracciones; los detalles se adaptan a esos contratos. El contrato debe expresar lo que necesita la aplicación sin incorporar tipos particulares de la biblioteca externa.
El proceso de compra expresa que necesita cobrar. La integración con el proveedor implementa esa capacidad y traduce los datos y errores particulares de su biblioteca.
Las flechas representan dependencias de código. La línea discontinua con triángulo vacío indica que el adaptador implementa PaymentGateway. El proceso de compra puede utilizar la capacidad que necesita sin importar los tipos concretos del proveedor. Durante la ejecución, la integración seguirá realizando el cobro.
La inversión está en esa relación de código: el adaptador depende del contrato de la aplicación, mientras el proceso de compra puede invocarlo a través de ese contrato. ProviderClient representa la biblioteca externa; este diagrama muestra el reparto de responsabilidades, sin desarrollar su implementación.
La inyección de dependencias ayuda a proporcionar ese colaborador desde fuera. Por sí sola no determina la dirección de las dependencias: también podemos inyectar una clase concreta y quedar ligados a sus detalles.
La traducción de respuestas pertenece a ProviderPaymentAdapter. CheckoutService conoce el contrato PaymentGateway y sus resultados. Si aparece un nuevo proveedor, revisaremos si puede respetar esas garantías antes de tratarlo como una implementación intercambiable.
Señal para revisarlo: una regla de negocio necesita conocer formatos o decisiones de infraestructura que podrían quedar dentro de una integración.
Aplicarlos con un problema concreto delante
Podemos empezar por una dificultad observable: cambios que se mezclan, condiciones que se repiten o contratos que prometen más de lo que cumplen. Después elegimos la separación que ayude a resolverla.
La composición suele ser una herramienta útil: el proceso de compra combina colaboradores con papeles claros. La herencia también puede servir cuando la relación entre tipos respeta sus contratos; utilizarla únicamente para compartir unas líneas puede arrastrar obligaciones innecesarias.
Tras un cambio, conviene recorrer el código y comprobar qué hemos ganado. ¿Se localizan mejor las reglas? ¿Es más fácil añadir la variación que necesitábamos? ¿Se entiende lo que promete cada operación?
SOLID resulta útil cuando mejora esas respuestas. El número de interfaces puede esperar fuera de la evaluación.