Implementar arquitectura hexagonal en Quarkus
- Carlos Brignardello
- 15 Oct, 2024
¿Qué es Arquitectura hexagonal?
La arquitectura hexagonal es una arquitectura de software que entra en el grupo de arquitecturas limpias. Consiste en separar una aplicación en varias capas, donde cada una de ellas tiene diferentes responsabilidades.
Contiene reglas sobre cómo se separa la aplicación y cómo se comunican las distintas piezas entre sí. También es conocida como arquitectura de puertos y adaptadores.
En este artículo la implementaremos específicamente para el framework Quarkus. Si necesitas entender más a detalle lo que es la arquitectura hexagonal, puedes encontrar en mi blog un artículo completo al respecto y, con casi toda probabilidad, un video.
Conceptos clave
Inyección de dependencias
Difícilmente vas a entender lo que es la arquitectura hexagonal en un proyecto Java si no aprendes primero qué es la inyección de dependencias, ya que literalmente te parecerá que muchas cosas funcionan o se conectan de la nada.
De forma resumida, debes saber que en Quarkus la inyección de dependencias se realiza utilizando decoradores en las piezas que serán inyectadas (principalmente en el caso de las clases), también usando la declaración @Inject
dentro de la clase que hará uso de la inyección.
Algo así:
public interface CreateItemAInputPort {
void createItemA(ItemAModel itemAModel);
}
@ApplicationScoped
public class CreateItemAUseCase implements CreateItemAInputPort {
@Inject
CreateItemAService createItemAService;
@Inject
CreateItemAOutputPort createItemAOutputPort;
@Override
public void createItemA(ItemAModel itemA) {
createItemAOutputPort.createItemA(itemA);
}
}
Para conocer más a fondo sobre inyección de dependencias, prueba a buscar en el blog, debería subir algo en algún momento y sacar su respectivo video.
Puertos
Los puertos nos permiten comunicar las distintas capas entre sí. Existen de entrada y de salida.
Puerto de entrada
public interface CreateItemAInputPort {
void createItemA(ItemAModel itemAModel);
}
Puerto de salida
public interface CreateItemAOutputPort {
void createItemA(ItemAModel itemAEntity);
}
Panache
Panache ORM lo utilizamos en el proyecto para manejar la capa de persistencia, prácticamente hace todo el trabajo por nosotros, entregándonos métodos preimplementados que podemos usar, como por ejemplo persistir o listar varios resultados.
@ApplicationScoped
public class ItemARepository implements PanacheRepositoryBase<ItemAEntity, Long>{
}
Mapper
El mapper nos permite manejar en la aplicación modelos, DTOs, entidades o lo que realmente necesitemos. Es más customizable lo que recibimos, lo que devolvemos, lo que persistimos y lo que manipulamos en nuestra lógica de negocio.
@Mapper(componentModel = ComponentModel.CDI, nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface ItemAMapper {
ItemAEntity modelToEntity(ItemAModel itemAModel);
ItemAModel entityToModel(ItemAEntity itemAEntity);
List<ItemAEntity> modelsToEntities(List<ItemAModel> itemAModels);
List<ItemAModel> entitiesToModels(List<ItemAEntity> itemAEntities);
ItemAModel dtoToModel(ItemADto itemADto);
ItemADto modelToDto(ItemAModel itemAModel);
List<ItemAModel> dtosToModels(List<ItemADto> itemADtos);
List<ItemADto> modelsToDtos(List<ItemAModel> itemAModels);
}
Implementación
Dependencias a instalar
- Lombok (org.projectlombok)
- Mapstruct (org.mapstruct)
- Rest jackson (quarkus-rest-jackson)
- JDBC postgresql (quarkus-jdbc-postgresql)
- Hibernate ORM (quarkus-hibernate-orm)
Estructura de carpetas
El proyecto cuenta con la siguiente estructura de directorios:
Domain
- models
- ports
- input: directorio con interfaces de entrada.
- output: directorio con interfaces de salida.
- services
Aplication
- useCase
- mappers
Infraestructure
- adapters
- dto
- entities
- repository
- rest
Vertical slicing
El vertical slicing corresponde a separar cada recurso de nuestro microservicio en directorios independientes, cada directorio tendrá en su interior la estructura de carpetas domain, infrastructure y application.
Resource A
- application
- domain
- infraestructure
Resource B
- application
- domain
- infraestructure
Capa de infraestructura
En la capa de infraestructura recibimos peticiones y, al mismo tiempo, devolvemos las peticiones recibidas. Aquí gestionamos las llamadas y el manejo de la capa de persistencia. En esta capa utilizamos los adaptadores para manejar la lógica de negocio en algo que la base de datos pueda interpretar.
Rest
En este directorio se definen los controllers y toda la configuración para recibir y devolver peticiones API REST. Si analizamos este snippet de código, podemos darnos cuenta de que el tipo de dato recibido es el que configuramos como DTO.
También podemos ver cómo convertimos este DTO en un modelo, que es lo que entiende la capa de dominio con la ayuda de un mapper. Una vez es casteado, lo comunicamos mediante un puerto al use case (capa de aplicación).
@Path("/api/v1/item-a")
public class CreateItemAController {
@Inject
CreateItemAInputPort createItemAInputPort;
@Inject
ItemAMapper itemAMapper;
@POST
@Path("/")
public Response add(
ItemADto itemADto
){
ItemAModel itemAModel = itemAMapper.dtoToModel(itemADto);
createItemAInputPort.createItemA(itemAModel);
return Response.ok(itemAModel).build();
}
}
Dto
En el directorio dto se incluyen aquellas clases que contienen el esquema de datos recibidos por el controller o devuelto por la aplicación hacia afuera.
@Getter
@Setter
@ToString
public class ItemADto {
private String name;
}
Entities
Lo mismo ocurre con el directorio entities, que contiene clases con el esquema de la base de datos. Los datos del dominio son casteados para comunicar a la capa de persistencia y por el ORM de la aplicación.
Si prestamos atención en el ejemplo, podemos ver cómo se usan decoradores para configurar el ORM de la aplicación, de tal modo que esta clase se reflejará en la base de datos.
@Entity
@Table(name = "item-a")
@Getter
@Setter
public class ItemAEntity {
@Id
@GeneratedValue
private Long id;
private String name;
private Date created_at;
private Date updated_at;
}
Adapters
Los adapters son un símil al service de la capa de dominio, pero para la infraestructura. Comunican los resultados de la lógica de negocio para hacerlos llegar transformados a la capa de persistencia.
Si observamos el snippet de código, podemos notar cómo se transforma el modelo a entidad, que es lo que la capa de persistencia puede entender llamando a la capa de persistencia, que es nuestro repository.
@ApplicationScoped
@Transactional
public class CreateItemAAdapter implements CreateItemAOutputPort{
@Inject
ItemARepository itemARepository;
@Inject
ItemAMapper itemAMapper;
@Override
public void createItemA(ItemAModel itemAModel) {
ItemAEntity itemAEntity = itemAMapper.modelToEntity(itemAModel);
itemARepository.persist(itemAEntity);
}
}
Repository
Esta pieza de código contiene la implementación necesaria para la capa de persistencia, casi siempre usando un ORM y un repository pattern.
En nuestro caso particular se está usando Panache ORM, que viene por defecto en Quarkus.
@ApplicationScoped
public class ItemARepository implements PanacheRepositoryBase<ItemAEntity, Long>{
}
Capa de aplicación
En esta capa están los casos de uso que relacionan la lógica externa con la capa de dominio. Es una especie de capa intermedia.
Use case
Contiene las funcionalidades independientes por cada método o acción dentro de la aplicación.
Si prestamos atención, podremos ver que este archivo comunica hacia el dominio por medio del service y hacia afuera por medio de un puerto (interfaz de salida).
@ApplicationScoped
public class CreateItemAUseCase implements CreateItemAInputPort {
@Inject
CreateItemAService createItemAService;
@Inject
CreateItemAOutputPort createItemAOutputPort;
@Override
public void createItemA(ItemAModel itemA) {
createItemAOutputPort.createItemA(itemA);
}
}
En este ejemplo práctico no hacemos uso del service debido a que la lógica de negocio es muy reducida, pero en caso de ser necesario, todo lo que nuestra aplicación necesite iría ahí.
Mapper
Contiene la configuración necesaria para transformar los datos entrantes en unos que sean capaces de ser interpretados por la lógica de negocio y otros que puedan ser persistidos o traídos desde el sistema de persistencia.
@Mapper(componentModel = ComponentModel.CDI, nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface ItemAMapper {
ItemAEntity modelToEntity(ItemAModel itemAModel);
ItemAModel entityToModel(ItemAEntity itemAEntity);
List<ItemAEntity> modelsToEntities(List<ItemAModel> itemAModels);
List<ItemAModel> entitiesToModels(List<ItemAEntity> itemAEntities);
ItemAModel dtoToModel(ItemADto itemADto);
ItemADto modelToDto(ItemAModel itemAModel);
List<ItemAModel> dtosToModels(List<ItemADto> itemADtos);
List<ItemADto> modelsToDtos(List<ItemAModel> itemAModels);
}
Capa de dominio
La capa de dominio es el corazón de nuestra aplicación donde va todo el peso de la logica de negocio, tenemos nuestros modelos, los puertos y el service que es una clase que contiene la mayor carga de código.
Models
Son clases que interactuarán con la lógica de negocio.
@Getter
@Setter
@ToString
public class ItemAModel {
private String name;
}
Ports
Son las interfaces que serán utilizadas para ser implementadas y que se comunicarán entre capas.
public interface CreateItemAInputPort {
void createItemA(ItemAModel itemAModel);
}
Services
Clases con el grueso de la lógica de negocio, todos los casos custom y la lógica de programación específica para cada recurso se encuentra aquí.
En este caso, como no tenemos una lógica compleja y todo lo hacen las integraciones, simplemente retornamos el modelo. (Corrección de tilde en “cómo” porque en este contexto no es exclamativa o interrogativa).
@ApplicationScoped
public class CreateItemAService {
public ItemAModel createItemA(ItemAModel itemA){
return itemA;
}
}