Patrones de diseño y principios de SOLID | De la práctica a la teoría
Revisemos algunos problemas de la vida real a nivel de código y pensemos en los beneficios que los principios de SOLID y patrones de diseño pueden ofrecer
problema
Imaginemos que estamos trabajando con una aplicación existente de comercio electrónico, y nos piden agregar un nuevo método de pago, ahora veamos el código inicial que nuestros colegas han dejado de las iteraciones anteriores de la solución
public class PaymentService {
public void handlePayment(final String paymentType) {
if ("credit-card".equals(paymentType)) {
creditCard();
} else if ("loyalty-points".equals(paymentType)) {
loyaltyPoints();
} else {
throw new IllegalArgumentException("Payment type [" + paymentType + "] is not supported");
}
}
private void creditCard() {}
private void loyaltyPoints() {}
}
Lo más lógico es seguir el mismo patron y agregar más métodos de pago, por ejemplo: cupones, paypal, etc. Sin embargo, los problemas que esta solución generaría son: una clase con muchisimas lineas = dificil de leer = dificil de mantener; pruebas unitarias se vuelven mas complejas porque el código está en un solo lugar para todos los casos
patrones de diseño
Analicemos nuestro código para encontrar algunas características sobre el porqué tendríamos que modificar nuestra implementación:
- Por cada tipo de pago, hay que agregar un nuevo método -> vuelve difícil de mantener y probar el código
- Si necesitamos cambiar la integracion con alguno de los metodos de pagos, hay que modificar la misma clase que se usa para todos los pagos (
PaymentService
) -> genera un riesgo de romper comportamiento existente
Entonces podemos decir que definitivamente tenemos un problema de acoplamiento, así que pensemos en como resolverlo!
Necesitamos crear un componente base o abstracto que indique que indique la estructura de nuestro comportamiento (ya que todos usan la misma firma de sus métodos), y esos componentes en Java son interfaces
Pero antes pensemos en la acción que tenemos que realizar (un pago), y esta acción puede resolverse de distintas formas (tarjeta de crédito, paypal, cupones, etc.), existe un patrón de diseño que precisamente se especializa para estos casos, se llama estrategia, así que usaremos ese nombre para nuestro componente abstracto:
public interface PaymentStrategy {
void handlePayment();
}
Bien, ahora podemos tomar las mismas implementaciones que tenemos divididos en metodos y las movemos a clases distintas que implementen esta interface:
public class CCPaymentStrategy implements PaymentStrategy {
public void handlePayment() {}
}
public class LoyaltyPointPaymentStrategy implements PaymentStrategy {
public void handlePayment() {}
}
Ahora debemos refactorizar nuestro componente principal, donde comunmente agregaríamos las dependencias directas a cada tipo de pago, por ejemplo:
public class PaymentService {
private PaymentStrategy ccPaymentStrategy;
private PaymentStrategy loyaltyPointPaymentStrategy;
// y las demas
Pero antes de hacerlo, tenemos que preguntarnos si existe alguna forma en la que podamos reducir la cantidad de veces que tocamos PaymentService
y la respuesta es si, podríamos crear un componente intermedio que se encargue de proveer las instancias necesarias que utilizaremos en la aplicacion, y esto es precisamente un patrón de diseño llamado Factory
public class PaymentFactory {
public PaymentStrategy getInstance(final String paymentType) {
// XXX: podria ser debatible si usar inyeccion de dependencias
// en lugar de generar las instancias manualmente
// seria mejor para este caso, pero no es el objetivo principal ahora
if ("credit-card".equals(paymentType)) {
return new CCPaymentStrategy();
} else if ("loyalty-points".equals(paymentType)) {
return new LoyaltyPointPaymentStrategy();
} else {
throw new IllegalArgumentException("Payment type [" + paymentType + "] is not supported");
}
}
}
public class PaymentService {
private final paymentFactory;
// inyeccion de dependencias ;D
public PaymentService(final PaymentFactory paymentFactory) {
this.paymentFactory = paymentFactory;
}
public void handlePayment(final String paymentType) {
final var paymentStrategy = paymentFactory.getInstance(paymentType);
paymentStrategy.handlePayment();
}
}
SOLID
Este no es un tutorial de SOLID en Java asi que brincaremos la explicación teórica, pero la idea principal es desacoplar los componentes lo más posible, y lo que haremos ahora es mirar hacia nuestro código final y tratar de evaluar que tan alineados están nuestros cambios con la filosofía detrás de SOLID
NOTA: no todas las letras de SOLID van a ser evaluadas porque algunas simplemente no tienen una aplicación directa dentro del alcance de los cambios que aplicamos
Single Responsibility
Las clases cumplen una sola función, cada implementación de nuestra estrategia principal maneja su propio método de pago, el factory se encarga únicamente de generar instancias y el servicio ahora puede extender funcionalidad sin tener que introducir riesgos a las implementaciones de pagos existentes
Open/Closed
En este caso no podemos evaluarlo completamente en funcion de los cambios que hemos agregado, porque realmente no estamos en el escenario donde tengamos que modificar funcionalidad a un componente existente, pero si este fuera el caso, la sugerencia de este principio es extender la clase existente para agregar el cambio, contrario a agregar la modificación directamente a CCPaymentStrategy
, por ejemplo:
public class VisaCCPaymentStrategy
extends CCPaymentStrategy {
// tus cambios
}
Con la refactorización que hemos hecho, creamos las bases para realizar este tipo de cambios sin problemas en el futuro
Dependency Inversion
Adecuado, ya que el componente de PaymentService
hace uso de la dependencia de PaymentFactory
quien a su vez retorna la abstraccion de PaymentStrategy
post análisis
Depués de haber refactorizado nuestro código mediante el uso de patrones de diseño, podemos mirar atrás para saber si SOLID fue una buena unidad de medida, y dependiendo de como lo veas podría variar desde no realmente, mas o menos hasta ¡SI, por supuesto! - este comentario se basa en el hecho de que no necesariamente marcamos todas las características dentro de SOLID, sin embargo, el código puede escalar fácilmente a partir de este punto, y esto abre tres discusiones grandes
¿es necesario/posible tener un código perfecto?
Esta pregunta es más de estrategia, y la respuesta la mayoría de las veces es un rotundo depende
- Cantidad de recursos
- Estado actual del sistema
- Experiencia del equipo
- Planes de nuevos requerimientos o cambios en los existentes
- Practicas como CI/CD para salidas a mercado
En fin, esto puede impactar directamente el tiempo que tengamos para construir nuevos módulos completamente perfectos desde el punto de vista del código
Aún así, podemos acordar que los impactos pueden ser reducidos mediante encapsulamiento y desacoplamiento de componentes (que son los principios de SOLID) mediante el uso de buenas practicas y patrones de diseno
¿SOLID o patrones de diseño?
Nuevamente, depende de algunas variables, por ejemplo: para un developer que recién comienza a desarollar, le puede resultar más sencillo aprender los patrones de diseño ya que tienen ejemplos prácticos sobre escenarios que podemos encontrar en nuestro día a día; por otro lado, un developer más experimentado podrá interiorizar los fundamentos básicos detrás de las ideas usadas en cada patrón de diseño, lo cual es ideal para un code review (por ejemplo)
¿me hace mejor developer conocer SOLID a fondo?
Si.
Algunas de las ventajas que trae conocer SOLID a profundidad es:
- Romper tareas resulta más sencillo, así que la planeación de un proyecto se simplifica
- Las revisiones de código pueden encontrar problemas con escalabilidad desde el punto de vista de diseño
- Las pruebas unitarias se vuelven más sencillas de realizar
Aunque sinceramente, nunca he escuchado a alguien comentar algo como Aquí vendría bien utilizar el principio de sustitución de Liskov
conclusión
Los patrones de diseño son herramientas que podemos utilizar como base para implementar nuestras soluciones de forma escalable y mantenible
Conceptos como DRY, KISS, SOLID, entre otros expresan las ideas fundamentales de forma atómica, estas ideas son comunmente utilizadas en los patrones de diseño, por lo que es importante familiarizarse con ellos