Introducción
Arquitectura de software
Reglas autoimpuestas al definir como diseñamos software
¿Que ganamos entonces imponiéndonos este tipo de reglas?
- Buscamos la mantenibilidad: Somos capaces de mantener mejor el código gracias a como formamos la arquitectura
- Buscamos la variabilidad: Somos capaces de reemplazar piezas de nuestra arquitectura sin aparentemente un costo muy grande
- Buscamos el testing: Somos capaces de testear nuestro código de una forma rápida, sencilla y eficaz.
- Buscamos la simplicidad: Somos capaces de tener un código simétrico, que sea fácil de entender. Si entiende un caso de uso, serás capaz de entender cualquier otro, nuestro código se vuelve predecible.
Esto también nos aleja de errores que no queremos cometer:
- Evitar la complejidad accidental: Evitamos la complejidad accidental al no introducir con nuestros desarrollos más complejidad de la que el sistema ya tiene
La estructura de directorios en arquitectura hexagonal
Las capas superiores conocen las capas inferiores y no al revés:
Nuestro dominio no conoce detalles de implementación de la capa de infraestructura, solo define el contrato para que sea la infraestructura quien implemente dicho funcionamiento.
La aplicación conoce el dominio a modo de poder presentar la información para que otros la consuman, en este caso, sera la capa de infraestructura quien use la aplicación para realizar las operaciones pertinentes.
La infraestructura son detalles de implementación, como puede ser una base de datos, y debería poder cambiarse un tipo de infraestructura por otra sin afectar al funcionamiento base de la aplicación y el dominio.
Application service vs Domain service
Servicios de aplicación
Como podemos ver en la imagen, los servicios de aplicación son el punto de entrada de nuestra aplicación. Desde el controlador ya sea de tipo API o línea de comandos, se llama al servición de aplicación para que este se encarge de realizar las operaciones pertinentes, como pueda ser:
- Solicita operaciones al sistema de persistencia. Dichas operaciones comienzan y finalizan en los servicios de aplicación.
- Publicar eventos de dominio
Son el inicio de un caso de uso de nuestro aplicación.
Un Servicio de aplicación instancia un Servicio de dominio para evitar la duplicidad de código.
Servicios de dominio
Son el resultado de agrupar lógica de negocio que podremos reutilizar desde los servicios de aplicación. Imaginemos que tenemos dos casos de uso en nuestra aplicación:
- Obtener una playlist en base a su identificador
- Modificar el nombre de una playlist
¿Que comparten ambos casos?
- Necesitan ir al repositorio de playlists a buscar la playlist dado un identificador
- Lanzar una excepción de dominio tipo PlaylistNotFound en el caso de que no encuentre la playlist
- Retornar la playlist en caso de encontrarla
Entonces extraeremos a un Servicio de dominio dicho compartimiento común que tendrían nuestros dos casos de uso (dos servicios de aplicación).
Sevicios de infraestructura
❌ No acoplar la estructura de un contrato con su implementación
Un error muy común sería modelar el dominio de tal forma que aunque no esté acoplado a la infraestructura, si esté pensando para tener la estructura para alguna implementación específica. Por ejemplo, en nuestro aplicación tenemos un servicio de notificaciones, y desde un principio tenemos claro que en la infraestructura inicial estará slack como ese servicio, el error sería modelar el dominio para que cumpla los requisitos que pide tal implementación.
💉 Inyectar las dependencias de los adaptores/implementaciones por constructor
Para poder solventar esto, debemos modelar el dominio pensando en que sea lo más abstracto posible, sin influirle con nada, y en el caso de necesitar parámetros específicos para la implementación, lo haríamos mediante su constructor. El contructor nunca lo definiremos en la interface, irá en sus implementaciones, y entonces será la infraestructura la que se encarge de conocer el dominio y de como acoplarse a el.
🧪 Usamos implementaciones fake de servicios para nuestros test
Usaremos implementaciones fake para poder testear sin tener que ejecutar el código real que tenemos en la infraestructura. Estaríamos evitando también tener que falsear el comportamiento de un servicio existente, algo que se puede complicar al tratar de mockear las dependencias y funcionamiento de esa implementación.
Testing en arquitectura hexagonal
Testing capa de aplicación y dominio (test unitario)
Los test unitarios son los que usaremos para comprobar que la lógica de negocio de nuestros casos de uso (capa de aplicación) y modelos o servicios de dominio se comportan como esperamos. Características principales:
- El objetivo de estos tests es el de validar que la implementación de nuestra lógica de negocio es correcta.
- Son los test más rápidos de ejecutar. En estos tests falsearemos la implementación a usar de todo componente de infraestructura. Es decir, allá donde definamos un puerto en nuestros casos de uso, inyectaremos un doble de test para que no hagan operaciones de entrada/salida pero poder validar la interacción del dominio con estos componentes. Importante falsear la interface de dominio y no el cliente final para evitar incurrir en el anti-patrón de Infrastructure Mocking.
- El test unitario será independiente del punto de entrada. Desde el momento en el que encapsulamos nuestros casos de uso en servicios de aplicación para poderlos reaprovechar desde múltiples puntos de entrada (controlador API HTTP o CLI), el test unitario invocará directamente al caso de uso para desacoplarse también del controlador.
- Al ser los más rápidos de ejecutar y estar centrados en la lógica de negocio, es en estos test donde ubicamos las comprobaciones más exhaustivas en cuanto a las distintas ramificaciones de nuestros casos de uso.
Gracias a que nuestro dominio no conoce detalles de implementación de la capa de infraestructura, solo define el contrato para que sea la infraestructura quien implemente dicho funcionamiento, tenemos la posibilidad de crear implementaciones específicas para nuestros test. Supongamos que tenemos un servicio que necesita un repositorio para persistir en base de datos, dado que no estamos acoplados a una implementación específica, en nuestros test podrías definir una nueva implementación que permita testear solo el comportamiento de la capa de aplicación y de dominio, ya que en este caso no necesitaríamos testear infraestructura en este tipo de test unitario.
Testing capa de infraestructura
Un tipo de test donde el objeto de test es alguna implementación de uno de nuestros puertos. Es decir, en el caso del test unitario, habríamos falseado mediante un doble (PlaylistRepositoryFake
) de test la interface de dominio PlaylistRepository
, mientras que en el test de integración lo que haremos será justamente testear la implementación de PlaylistRepositoryPgsql
para validar que se comporta como esperamos.
@Test
public void it_should_save_a_playlist()
{
repository().save("Hello, I am a playlist");
}
@Test
public function it_should_check_if_exists_a_playlist()
{
String playlist = "Hello, I am a playlist";
repository().save(playlist);
assertThat(repository().search($playlist)).isTrue();
}
Test de aceptación
Simulan ser un cliente de nuestra aplicación. Entrarán en juego todas las implementaciones reales para comprobar que todo el flujo y la integración con la infraestructura se producen satisfactoriamente. Con lo cuál, las características principales serían:
- El objetivo de estos tests es el de asegurar que la aplicación funciona correctamente y el flujo completo de las peticiones se puede realizar satisfactoriamente.
- Son los test más lentos de ejecutar ya que tienen un alcance mayor y sí ejecutan operaciones de entrada/salida como inserts en base de datos ya que usan las implementaciones reales de estos componentes.
- Aportan mayor valor debido al alcance que tienen (nos asegura que absolutamente todo está ejecutandose como esperamos)
- En nuestro caso, al implementar una API HTTP, simularemos peticiones HTTP y comprobaremos que las respuestas tienen el código HTTP y el contenido del cuerpo esperados.
- Al ser los test más lentos de ejecutar, sólo implementaremos una pequeña muestra de las distintas ramificaciones que pueden tomar nuestros casos de uso. Dejando para los test unitarios la responsabilidad de probar cada una de las casuísticas. Así evitaremos incurrir en el anti-patrón de test del cono de helado.