Aprende a aplicar el patrón Strategy con Spring Boot
A medida que nuestras aplicaciones crecen en complejidad el uso de patrones de diseño se hace necesario para poder organizar nuestro proyecto. Esto no solo nos garantiza que nuestro código será más fácil de leer, sino que también será más fácil de mantener y extender en el tiempo.
En Pragma nos esforzamos por siempre desarrollar código de calidad que sea legible y mantenible; es por eso que en esta oportunidad y en pro de #SaberMasParaResolverMejor te compartimos este tutorial que tiene como objetivo el enseñarte como aplicar el patrón Strategy a tu proyecto Spring Boot de forma eficiente y práctica.
Si necesitas refrescar un poco la memoria con respecto a qué es el patrón Strategy y cuáles son sus elementos, aquí te dejo un enlace que puede ayudarte:
Sin nada más que agregar, ¡comencemos!
Definición y alcance del problema
Imagina que haces parte del equipo de desarrollo de cierta empresa especializada en software contable que ya tiene una aplicación funcionando.
La aplicación permite llevar los registros contables de todas las transacciones que pasan en caja usando dinero físico, sin embargo el cliente al cual se le ha vendido el software ha empezado a expandirse y ahora le es necesario poder aceptar pagos a través de medios electrónicos, específicamente, PayPal y PSE.
El cliente ha levantado un requerimiento formal a la empresa y te han designado a ti como responsable. Así las cosas, tu tarea consiste en cumplir los siguientes puntos:
- Extender la funcionalidad de la aplicación de tal manera que se comunique correctamente con las APIs de PayPal y PSE para el manejo de pagos
- Mantener la funcionalidad actual de pago por caja. Esto es, los cambios que se realicen no deben influir en la forma como se procesan los pagos a través de este medio
- El sistema debe permitir elegir en todo momento el medio de pago con el cual se quiere llevar a cabo la operación. Esto es, el sistema debe poder invocar de forma dinámica la lógica propia de cada medio de pago según la elección del usuario
Implementando la solución
Veamos juntos como podemos dar solución a esta tarea. Durante toda esta sección estaremos trabajando con este repositorio que ya nos provee de un código inicial que emulará el código pre-existente del proyecto de nuestro caso de uso. Para comenzar, clona el repositorio y ubícate en la rama starting-code
(esto sólo si quieres programar a la par; de cualquier forma encontrarás el código terminado en las ramas que se te indiquen durante el desarrollo de la solución).
Lo primero que debemos hacer es construir nuestro modelo de clases basado en la estructura proporcionada por el patrón Strategy.
Como parte de la tarea es mantener la funcionalidad que ya existe de pagos en efectivo, echemos un vistazo a la clase CashPaymentAdapter
a ver que podemos rescatar de ahí:
Observamos que la clase implementa una interfaz llamada CashPaymentService
que a su vez expone un único método público llamado processPayment
. Podemos estar de acuerdo en que todos los demás medios de pago necesitarán también de un método similar a este así que parece un buen candidato para abstraerlo a nuestra estrategia de pago.
Comencemos entonces por crear un nuevo paquete strategy
dentro de domain.spi
y movamos la interfaz CashPaymentService
ahí. Una vez hecho esto, podemos renombrar el archivo a algo más acorde, como PaymentStrategy
. Deberiamos tener algo similar a esto:
Ahora también podemos renombrar la implementación concreta de pago en efectivo de la siguiente forma:
¡Excelente 👏! Aunque no lo parezca hemos hecho un gran paso en la implementación. Ahora tenemos la habilidad de definir distintas estrategias de pago a través de la interfaz PaymentStrategy
. El objetivo es que tengamos un modelo similar a este:
Continuemos creando un nuevo paquete dentro de infrastructure.adapters
llamado strategy
y coloquemos la clase CashPaymentStrategy
ahí. A continuación creemos dos clases más que se encargarán de la implementación de la lógica de procesamiento de pago para PSE y PayPal, tal que así:
Recuerda que la implementación como tal no es relevante en este caso. Nos interesa solo modelar la solución de forma que garantice legibilidad y mantenibilidad.
A continuación vamos a crear un enum
que nos permita diferenciar el medio de pago seleccionado. Lo ubicaremos en la capa del dominio.
Una instancia de este enum
viajará en el objeto de tipo PaymentInformation
de forma que siempre podamos saber el medio de pago con el que estamos trabajando.
En este punto hemos implementado correctamente el patrón Strategy en nuestra aplicación, sin embargo si vamos a la clase PaymentProcessUseCase
, que es la que se encarga de la validación y aplicación del pago, ahora presenta un error:
Could not autowire. There is more than one bean of ‘PaymentStrategy’ type.
Esto es porque ahora tenemos varias implementaciones de una misma interfaz y Spring no sabe cuál elegir. Veamos a continuación dos formas en las que podemos solucionar este problema.
Utilizando la anotación Qualifier
Las múltiples implementaciones de una misma interfaz o clase abstracta son de hecho un problema común en Spring. La anotación Qualifier es una solución a este inconveniente dado que nos permite decirle al contexto de Spring Boot exactamente de cuál clase queremos que cree e inyecte la instancia en nuestra variable.
Para comenzar, vayámonos a nuestra archivo CashPaymentStrategy
y coloquemos la anotación Qualifier a nivel de clase. Esta anotación recibe como argumento un identificador, que no es más que un nombre que nos permita identificar la instancia en otras partes de nuestro código. Realmente puede ser cualquier cosa pero por convención se suele colocar el mismo nombre de la clase, por lo que nuestro código quedaría así:
Repitamos el proceso para las otras dos clases restantes y ahora en nuestra clase PaymentProcessUseCase
inyectemos una instancia de cada estrategia de pago haciendo uso de la anotación Qualifier, tal que así:
Cuando iniciemos nuestra aplicación, Spring buscará en todo nuestro código aquellas clases que estén marcadas con la anotación Qualifier y cuyo identificador coincida con alguno de los que hemos colocado en la clase PaymentProcessUseCase
, posteriormente creará una instancia de cada una de esas clases y finalmente las inyectará de forma dinámica según corresponda.
Lo único que queda por hacer es modificar la lógica del método validateAndProcessPayment
de la clase PaymentProcessUseCase
de tal manera que podamos invocar la implementación correcta dependiendo del método de pago elegido por el usuario. Esto es posible gracias al enum
que creamos en la sección anterior.
Así las cosas, nuestro código podría quedar de la siguiente forma:
Para validar que todo funciona correctamente creemos una prueba unitaria en la que podamos variar el medio de pago y verificar que el mensaje que aparezca en la consola sea acorde con ello.
¡Excelente 👏! Ahora nuestra aplicación es capaz de procesar pagos a través de distintos medios de forma dinámica. ¡Hemos cumplido con todos los requerimientos!
Sin embargo… Hay algo extraño en nuestra solución, ¿no? 🤔
Si en el futuro el cliente solicitara agregar uno, dos, tres o N medios de pago adicionales, serían N variables que tendríamos que agregar a nuestra clase PaymentProcessUseCase
, una por cada implementación concreta de la interfaz, de modo que pudiéramos invocarla de forma dinámica dependiendo del medio de pago elegido. Aunque nuestro código sería en efecto extensible, no sería mantenible dado que nuestra lógica podría volverse muy confusa muy rápido. En estos casos debemos emplear una solución distinta.
Podrás encontrar el código desarrollado en esta sección en el repositorio en la rama with-qualifier
.
Utilizando inyección dinámica de Beans
Existen varias anotaciones que nos permiten el manejo eficiente de dependencias (instancias/beans) como Autowired o Qualifier. Sin embargo hay casos como el que tenemos ahora en los que esas herramientas se quedan cortas si queremos manejar múltiples implementaciones desde una misma clase.
Por suerte Spring nos provee de una técnica que podemos emplear para reunir todas las implementaciones de una interfaz dada en un solo lugar y es mas bien una consecuencia de una de las características que posee el framework, la inyección de beans a través del constructor.
Cuando tenemos una clase que hace uso de una implementación concreta de una interfaz cualquiera, Spring se encargará de inyectar una instancia de la clase en tiempo de ejecución, y esto sucede porque Spring sabe que esa clase implementa esa interfaz, luego entonces no hay razón por la cual debamos pensar que Spring no conoce todas las implementaciones concretas de una interfaz dada. ¿Y si te dijera que Spring no solo conoce todas estas implementaciones sino que es capaz de reunirlas todas en una lista? ¡Pongámoslo a prueba!
Para esta implementación nos ubicaremos de nuevo en la rama
starting-code
y seguiremos los primeros pasos de la sección anterior hasta justo antes de hacer uso de la anotación Qualifier.
Comencemos realizando un pequeño ajuste a la interfaz de PaymentStrategy
, agreguemos un método que devuelva una instancia del enum PaymentType
:
Ahora implementamos el método en cada una de las clases de tal manera que cada clase devuelva el tipo que le corresponde, tal que así:
A continuación, creemos una nueva interfaz en domain.spi
llamada DynamicPaymentStrategyService
. Esta interfaz expondrá el mismo método que la interfaz de PaymentStrategy
.
Ahora procedamos con la implementación de esta interfaz en infrastructure.adapters
y aquí viene el paso más importante, debemos definir un constructor explícito dentro de la clase en el que indiquemos que vamos a recibir una lista de tipo PaymentStrategy
, esto efectivamente obligará a Spring a recoger todas las implementaciones de la interfaz y las guardará en esa lista.
La idea es la siguiente, que sea el método processPayment
el que, basado en el parámetro paymentType
que viene dentro del objeto paymentInformation
, escoja la implementación correcta para llevar a cabo el procesamiento del pago. Para esto debemos tener mapeado una relación entre el tipo de pago y la implementación, ¡un mapa! Y ya que hicimos que cada clase retornara su tipo de pago, guardar esta información será sencillo. Luego entonces, una vez tengamos ese insumo, solo nos queda implementar la lógica del método processPayment
para que invoque la clase correcta según sea el caso. Al final obtenemos un código como el siguiente:
Ahora solo nos queda hacer uso de esta lógica en nuestra clase PaymentProcessUseCase
, tal que así:
¡Y eso es todo! Si ejecutamos la misma prueba unitaria que en la sección anterior obtendremos exactamente el mismo resultado, pero ahora ya no nos tendremos que preocupar de cuántas implementaciones concretas de PaymentStrategy
existan, porque la lógica de la clase PaymentProcessUseCase
será la misma siempre. Con esto no solo hemos logrado un mayor desacoplamiento entre las clases, también hemos hecho nuestro código más limpio y mantenible.
Podrás encontrar el código desarrollado en el repositorio en la rama with-dynamic-injection
.
Ten en cuenta que ninguna implementación es mejor que la otra y su aplicación dependerá del contexto o del problema que estemos tratando de solucionar
Conclusión
Hagamos un repaso de todo lo que hicimos:
- Aprendimos como el patrón Strategy puede ayudarnos a organizar nuestras implementaciones permitiendo que nuestro código sea legible y mantenible
- Aprendimos como hacer uso de la anotación Qualifier como herramienta para poder inyectar la implementación correcta de nuestra estrategia según corresponda
- Aprendimos como hacer uso de la inyección dinámica de beans como alternativa a la anotación Qualifier en los casos en los que dispongamos de múltiples implementaciones concretas y necesitemos una forma eficiente de invocarlas
¡Muchas gracias por llegar hasta aquí! Espero que este tutorial haya sido de tu agrado. Si tienes alguna pregunta por favor no dudes en dejarlo en los comentarios.
¡Hasta pronto! 👋