En este tutorial vamos a implementar una arquitectura hexagonal de puertos y adaptadores con Java y Spring Boot, intentando resaltar algunos pequeños detalles y su impacto. Importante: se asume que ya se tiene el conocimiento teórico sobre esta arquitectura.
Índice de contenidos
- Introducción
- Preparando el proyecto
- Creando las capas
- Capa Domain
- Capa Application
- Capa Infrastructure
- Conclusiones
Introducción
Existen varios planteamientos de arquitecturas englobadas dentro de las «limpias», nuestro compañero Alejandro Acebes explica algunas de ellas aquí (si no conoces la teoría, te recomiendo que primero veas el artículo de Alejandro Acebes). En este tutorial nos vamos a centrar en la de puertos y adaptadores, y realizaremos una de las propuestas más extendidas que organiza el código por capa de infraestructura, dominio y aplicación.
Preparando el proyecto
Vamos a crear un nuevo proyecto con Java y Spring Boot, para ello podemos generarlo desde la web https://start.spring.io/. La idea sería crear un sencillo proyecto que disponga de un mecanismo de exposición de nuestro sistema al exterior (api rest) y otro de comunicación de nuestro sistema con terceros (bbdd), por lo que voy a generar el proyecto configurado con Maven, Java y Spring Boot con la dependencia de starter-web y starter-data-jpa, y H2 (la persistencia de datos la haré en un H2 para simplificar las configuraciones).
Creando las capas
Vamos a separar las capas de nuestro sistema en infraestructura, aplicación y dominio, pero para conseguir una restricción física y no solo conceptual en el código, vamos a modularizar la aplicación, con esto conseguimos un límite físico para que nadie que trabaje en el proyecto pueda realizar un import de una capa externa en una interna
Para crear los módulos, podemos utilizar nuestro IDE o crear los módulos manualmente, para ello necesitamos crear una carpeta en la raíz del proyecto y agregar el archivo pom.xml con la configuración. Aquí pongo como ejemplo el pom para domain:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <artifactId>client-api</artifactId> <groupId>com.adictos.tutorial</groupId> <version>0.0.1-SNAPSHOT</version> </parent> <artifactId>client-api-domain</artifactId> <packaging>jar</packaging> <name>client-api-domain</name> </project>
Tendríamos que hacer lo mismo para application e infrastructure, y adicionalmente agregar en el pom.xml raíz el packaging correcto, en nuestro caso pom, que sirve para definir la configuración del archivo donde se establece, como un proyecto padre que contiene múltiples submódulos, por lo que también necesitamos especificar los módulos que contiene, quedando de esta forma:
<packaging>pom</packaging> <modules> <module>hexagonal-domain</module> <module>hexagonal-application</module> <module>hexagonal-infrastructure</module> </modules>
Ya que he creado el proyecto con el starter de spring y me ha generado el código para inicializar el proyecto spring (y esto debería ir en infrastructure), voy a aprovechar y moveré la carpeta src que nos ha quedado en la raíz, al módulo de infrastructure
Nuestro proyecto se debería ver ahora así:
Domain
En la propuesta original de Hexagonal no se especifica como organizar la parte interna del hexágono (lógica core y de aplicación), por lo que sería totalmente válido solo definir una capa de application/core y una de infrastructure, en mi caso prefiero tener una de dominio que será la capa de modelado de negocio. He visto propuestas donde usan esta capa para definir los puertos secundarios, yo prefiero definir los puertos primarios y secundarios en la capa application.
Primero creamos las carpetas de fuentes y modelamos una entidad de dominio muy básica que llamaremos Client y tendrá simplemente un identificador, nombre y apellido
public record Client(Long id, String name, String lastName) { }
Application
En esta capa definiremos los puertos (primarios y secundarios), y será donde orquestaremos toda nuestra lógica de negocio. Por lo que aquí se implementarán los adapters de nuestros puertos primarios (puntos de entrada a nuestro sistema) y se usarán los puertos secundarios (puntos de comunicación de nuestro sistema con componentes externos: bbdd, apis…)
A la hora de estructurar los paquetes en este módulo, se pueden realizar diferentes enfoques, en mi caso particular, voy a decantarme por una estructura de recurso/componente, o «quien eres»/»que eres» por lo que generaré un paquete client.port y ahí definiré los puertos.
Primero defino un primary port con dos métodos, uno para crear y otro para coger todos los clientes existentes:
public interface ClientInteractionPort { Client create(Client client); List<Client> findAll(); }
Los nombres de los puertos primarios se pueden enfocar de varias formas, ClientPrimaryPort, ClientServicePort, ClientInPort, ClientUseCasePort… En mi caso he decidido llamarlo ClientInteractionPort, pero utilizar el que mejor os encaje.
Cómo veis, hago referencia al modelo de Client que hemos creado en la capa de dominio, por lo que necesitamos importar la dependencia en la capa de application, para eso agregamos en el pom de application el siguiente código:
<dependencies> <dependency> <groupId>com.adictos.tutorial</groupId> <artifactId>client-api-domain</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency> </dependencies>
Ahora voy a definir el puerto secundario, remarcar que dependiendo del nombre escogido para el primario deberíais ajustar el nombre del secundario, por ejemplo si decidís llamar ClientInPort, deberíais llamar al secundario ClientOutPort, en mi caso, voy a representar el primario con la palabra interaction y el secundario como puerto a secas:
public interface ClientPort { Client create(Client client); List<Client> findAll(); }
¡Con esto ya tenemos en nuestra capa de aplicación todo lo necesario para generar nuestra lógica de negocio!, así que vamos a crear el adapter de nuestro primary port:
@Singleton public class ClientInteractionAdapter implements ClientInteractionPort { private final ClientPort port; public ClientInteractionAdapter(ClientPort port) { this.port = port; } @Override public Client create(Client client) { return port.create(client); } @Override public List<Client> findAll() { return port.findAll(); } }
Como nuestra lógica de negocio ahora mismo es muy básica, el adapter/caso de uso, básicamente hace de pasarela entre las peticiones a nuestro sistema, y las interacciones de nuestro sistema con fuentes externas. Mirar lo interesante del planteamiento, sin tener que pensar en nada de infraestructura (base de datos, apis rest…), ya podemos implementar la lógica de negocio y criterios de aceptación.
Para los que vengáis de trabajar con spring, esto podría ser lo equivalente a un @Service clásico. Cómo quiero que esta clase sea un bean y no quiero acoplarme al framework, he utilizado la anotación estándar de Jakarta, pero podríamos perfectamente crear nuestra propia anotación y utilizarla en vez de @Singleton.
Infrastructure
En esta capa ira todo lo relativo a lo que Robert C. Martin denomina «detalles de implementación», las cosas de las que nuestro core se debería abstraer y por las cuales cualquier cambio en esta capa no debería afectar en absoluto en la capa de domain o application. Aquí irán todos los temas relacionados con frameworks, bbdd, apis, etc.
En esta capa estará todo lo relativo a spring, por lo que todas las dependencias de spring deberían estar en este pom, además aprovechamos y también agregamos la dependencia de domain y de application
<dependencies> <dependency> <groupId>com.adictos.tutorial</groupId> <artifactId>client-api-domain</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency> <dependency> <groupId>com.adictos.tutorial</groupId> <artifactId>client-api-application</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency> <!-- ...spring boot dependencies... --> </dependencies>
Lo primero que vamos a crear son los modelos de interacción con nuestra api rest. He decidido crear uno que representa una petición de creación y otro para el modelo de respuesta de un cliente
public record ClientCreateReq(String name, String lastName) { public static Client toDomain(ClientCreateReq client) { return new Client(null, client.name, client.lastName); } }
public record ClientRes(Long id, String name, String lastName) { public static ClientRes toResponse(Client client) { return new ClientRes(client.id(), client.name(), client.lastName()); } public static List<ClientRes> toResponse(List<Client> clients) { return clients.stream().map(ClientRes::toResponse).toList(); } }
Para los que os gustan más los modelos anémicos o no os gusta acoplar responsabilidad de mapeo en una entidad, podéis hacer la lógica de transformación de los modelos en un mapper, yo lo he hecho así por simplicidad.
Ahora creamos el controller:
@RestController @RequestMapping("clients") public class ClientController { private final ClientInteractionPort port; public ClientController(ClientInteractionPort port) { this.port = port; } @PostMapping() public ResponseEntity<ClientRes> create(@RequestBody ClientCreateReq clientToCreate) { var client = port.create(ClientCreateReq.toDomain(clientToCreate)); return ResponseEntity.status(HttpStatus.CREATED).body(ClientRes.toResponse(client)); } @GetMapping() public List<ClientRes> getAll() { return ClientRes.toResponse(port.findAll()); } }
Lo que intentamos es tener siempre toda la transformación de modelos dentro de la capa de infrastructure y solo comunicarnos con application con los modelos de dominio
Agregamos una clase de configuración para que Spring sea capaz de detectar las implementaciones que hemos anotado con @Singleton (si habéis creado vuestra propia anotación solo tenéis que referenciar a vuestra anotación).
@Configuration @ComponentScan(basePackages = "com.adictos.tutorial.application", includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, value = Singleton.class) ) public class ApplicationConfig {}
Ya solo nos queda crear la implementación de los adapter referentes a la bbdd, lo primero creamos la entidad de bbdd:
@Entity @Table(name = "client") public class ClientEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String lastName; ..... (getters and setters) }
Yo en este caso crearé el repository extendiendo de JpaRepository directamente, aquí podríais desacoplaros si creáis vuestra propia clase de persistencia, pero ya que tenemos el puerto que solo expone los métodos que me interesan, por agilidad lo usaré directamente con spring data
public interface ClientJpaRepository extends JpaRepository<ClientEntity, Long> {}
Y por último creamos la implementación del ClientPort
@Component public class ClientJpaAdapter implements ClientPort { public ClientJpaAdapter(ClientJpaRepository repository) { this.repository = repository; } private final ClientJpaRepository repository; @Override public Client create(Client client) { return ClientMapper.toDomain(repository.save(ClientMapper.toEntity(client))); } @Override public List<Client> findAll() { return ClientMapper.toDomain(repository.findAll()); } }
Un dato importante es que en la capa de infraestructura sí podemos definir la implementación concreta, en mi caso JPA. Ya que estamos desacoplados de nuestra lógica de negocio, eso nos dará mucha claridad para entender mejor el código o diferenciar entre diferentes implementaciones para un mismo puerto.
Conclusiones
Cómo hemos visto, tenemos desacoplada nuestra lógica de negocio del uso de frameworks, bbdd etc., hemos creado un límite físico que impide a los programadores poder utilizar partes de capas exteriores en interiores.
Además, este planteamiento puede ser un buen primer acercamiento para gente que no esté habituada a trabajar con arquitectura hexagonal.
Contras: Lo malo de este enfoque es, por un lado, que tenemos todos los casos de uso dentro de una misma implementación (el Interaction), y esto desde mi punto de vista es un error, porque crea una clase con muchísimas responsabilidades.
Otro punto negativo es, que aunque tenemos todas las dependencias de springboot en un mismo módulo (infrastructure), existe la posibilidad de llamar desde un Controller a un Repository directamente sin pasar por la capa de application, por lo tanto, aquí no existe restricción física y solo conceptual.
En un siguiente artículo plantearé otra posible implementación de una arquitectura de puertos y adaptadores que da solución a los puntos negativos mencionados.
Os comparto el repositorio de GitHub donde podéis ver el código más en detalle