Implementar arquitectura hexagonal en Quarkus

Implementar arquitectura hexagonal en Quarkus

¿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;
    }
    
}