Atravesando el hexágono de la arquitectura de software

Jordi Tormo Llàcer
MercadonaIT
Published in
12 min readJun 17, 2024

Introducción

Hace más de dos años que en Mercadona IT hemos iniciado un cambio radical en nuestra informática, basado en construir todas las nuevas aplicaciones bajo la arquitectura CNA (Cloud Native Application) mediante el uso de microservicios, así como el inicio de la modernización de nuestros sistemas actuales.

Aquí podemos ver como ha evolucionado nuestra arquitectura a lo largo de los años:

Evolución de las arquitecturas en Mercadona

Para poder abordar un cambio de esa magnitud, nos estamos encontrando con innumerables retos que debemos abordar, entre ellos, la necesidad de determinar un patrón de arquitectura de software para nuestros microservicios que sea robusto y nos permita evolucionar para adaptarnos con facilidad y agilidad a futuros cambios tecnológicos. Además, que nos desacople completamente de la infraestructura y nos permita que nuestros microservicios sean portables entre distintas nubes.

En este artículo, nos vamos a centrar específicamente en el ámbito del backend, y dentro de él, vamos a hacer foco en los microservicios que contienen la lógica de negocio, obviando aquellos puramente técnicos.

Por ejemplo, podemos encontrar un sistema formado por tres microservicios: uno principal con la lógica empresarial acompañado por otros dos servicios técnicos, uno para ingestar desde un broker de mensajería y otro que implementa el patrón outbox para asegurar la transaccionalidad al publicar en un tópico.

Esquema de un sistema simple de microservicios de una aplicación

Centrándonos en el microservicio que contiene la lógica de negocio, debemos asegurarnos de blindar el código que resuelve las necesidades empresariales de aquellos que interactúan con las distintas piezas de infraestructura. Para lograrlo haremos uso de una arquitectura Clean que nos ayudará a garantizarlo.

Principios de una arquitectura Clean

La premisa fundamental de las arquitecturas Clean consiste en separar completamente el código encargado de resolver la lógica de negocio del código que se encarga de acceder a la infraestructura. Estas arquitecturas también se encargan de estructurarlo en capas diferenciadas, con reglas estrictas sobre cómo se deben comunicar entre sí.

Entre estas reglas, se siguen las directrices contenidas en los principios SOLID, promovidos por Robert C. Martin en su publicación “Design Principles and Design Patterns”.

Conjuntamente, estas reglas nos ayudan a desarrollar aplicaciones que no solo dan solución al negocio, sino que también perduran y escalan satisfactoriamente en un entorno tecnológico que no deja de evolucionar.

Existen distintos patrones de diseño de arquitecturas Clean que respetan estos principios (arquitectura por capas, MVC, MVVM, hexagonal, etc.) y en Mercadona hace años que empezamos a utilizar arquitectura por capas en el desarrollo de nuestras aplicaciones. Sin embargo, esta no nos garantizaba completamente la independencia de las mismas, debido al orden de las dependencias, lo cual, acababa acoplándonos en cierta manera al modelo de base de datos.

La arquitectura hexagonal

La arquitectura hexagonal, también conocida como arquitectura de puertos y adaptadores, fue dada a conocer por Alistair Cockburn, cuyo objetivo principal, como toda arquitectura Clean, es mejorar la modularidad, el mantenimiento y la flexibilidad creando componentes que tengan bajo acoplamiento.

En la actualidad, además de dar un salto a la arquitectura CNA, en Mercadona IT, hemos apostado por una arquitectura hexagonal en el diseño de nuestros microservicios, con la que conseguimos el tan deseado efecto de blindar nuestra capa de negocio. Esto es clave para crear aplicaciones robustas y fáciles de mantener en el largo plazo.

Sus conceptos principales son, en primer lugar, su separación de responsabilidades, donde encontramos, en el centro, el dominio y los casos de uso de la aplicación, que contienen la lógica de negocio pura sin depender de ninguna tecnología. A su alrededor, en la parte externa del hexágono, se sitúan las clases que implementan la comunicación con la infraestructura tecnológica, que llamamos adaptadores. Y como último concepto, encontramos los puertos, que se encargan de conectar y de abstraer el núcleo de la aplicación con la infraestructura. Además, el uso de los puertos facilita el intercambio de los adaptadores, según necesidad, y permite también el testeo aislado de los mismos.

Entre las distintas formas de estructurar la arquitectura hexagonal, en Mercadona, específicamente, hemos adoptado la aproximación Driving-Driven, como podemos ver en la siguiente figura:

Esquema de la aproximación Driving-Driven de la arquitectura hexagonal

Módulos y componentes

La forma en la que hemos decidido implementar la aproximación Driving-Driven en nuestros proyectos, ha sido organizándola en distintos módulos. Hacerlo de esta forma nos ha dado la flexibilidad de poder intercambiarlos en tiempo de deploy.

La estructura clave la representamos como un hexágono y contiene el dominio de la aplicación en el centro y el resto de los módulos a su alrededor:

1. Núcleo de la aplicación (módulo application)

2. Driving Side (módulos conductores)

3. Driven Side (módulos conducidos)

Dividimos la zona más externa, aquella que es responsable de comunicarse con las piezas de infraestructura, en dos partes, a la izquierda el lado Driving y a la derecha el lado Driven.

En los módulos Driving es donde encontraremos aquellos controladores que desencadenan lógica de negocio y se representan en el lado izquierdo del hexágono. Un ejemplo sería un controlador que recoge una petición de una llamada REST o un mensaje recibido por un evento de Kafka y lo pasa a la aplicación a través de un puerto.

En los módulos Driven vamos a encontrar los adaptadores que son desencadenados a partir de la lógica de la aplicación. Por ejemplo, un adaptador de BBDD es llamado por la aplicación para poder obtener ciertos datos previamente persistidos.

Estos módulos se encuentran organizados en 3 bloques lógicos (dominio, aplicación e infraestructura) y el mecanismo para desacoplarlas es mediante el uso de puertos y adaptadores. Estos pueden ser definidos de la siguiente manera:

  • Puerto: definición de una interfaz pública, que los adaptadores deben cumplir para comunicar con la aplicación.
  • Adaptador: implementación concreta de un puerto para un contexto concreto.

A continuación, podemos observar un ejemplo de la disposición de los módulos y organización de directorios (scaffolding) de nuestros proyectos:

├── application/
│ ├── pom.xml
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── mercadona/
│ │ └── academy/
│ │ └── web/
│ │ ├── application/
│ │ │ ├── exceptions/
│ │ │ ├── ports/
│ │ │ │ ├── driven/
│ │ │ │ └── driving/
│ │ │ └── services/
│ │ └── domain/
│ └── test/
├── boot/
├── docker/
├── driven/
│ ├── kafka-producer/
│ └── repository-sql/
│ ├── pom.xml
│ ├── sql/
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── mercadona/
│ │ └── academy/
│ │ └── web/
│ │ └── repositories/
│ │ ├── adapters/
│ │ ├── config/
│ │ ├── mappers/
│ │ └── models/
│ └── test/
├── driving/
│ ├── api-rest/
│ │ └── src/
│ │ └── main/
│ │ └── java/
│ │ └── com/
│ │ └── mercadona/
│ │ └── academy/
│ │ └── web/
│ │ └── controllers/
│ │ ├── adapters/
│ │ └── mappers/
│ └── kafka-consumer/
│ └── test/

Cruzando el hexágono, ejemplo de uso de la arquitectura hexagonal en Mercadona IT

Veamos un ejemplo simplificado de implementación de la arquitectura hexagonal en un microservicio de backend de una aplicación Ad-hoc de Mercadona IT que resuelve una lógica de negocio.

En este ejemplo básico, planteamos el supuesto de dar de alta un producto, a través de una petición entrante, que luego es necesario persistir; durante el proceso, nuestro caso de uso comprobará además si se trata de un alta nueva o un producto que ya existía, para notificar al usuario según sea el caso. Si es la primera vez, se producirá un mensaje en un bróker de mensajería asíncrono para el resto de los microservicios de la solución, en cambio, si el producto ya ha sido dado de alta lo notificará por correo a un usuario.

Diagrama de ejemplo de la arquitectura hexagonal y la integración con la infraestructura

Para dar solución al ejemplo, vamos a respetar la estructura hexagonal pasando por los distintos módulos, haciendo uso de los adaptadores y puertos necesarios, tal y como vemos en la siguiente figura:

Esquema de objetos y ficheros de código utilizados en la arquitectura hexagonal

Para empezar, partiremos del contrato API haciendo uso de la aproximación Contract-First que habrá generado los artefactos y los habrá dejado disponibles en el repositorio de artefactos conteniendo las clases de nuestra API.

En este ejemplo, el desencadenante de la ejecución será el adaptador REST, que hará uso del puerto Driving.

Esquema del adaptador REST que desencadena la ejecución y hace uso del puerto Driving

Importaremos el artefacto servidor, el cual será implementado por nuestro adaptador de entrada (REST). Además, dentro del adaptador declararemos dos variables privadas, el servicio (caso de uso), y el mapper (Mapstruct).

Aquí podemos ver un fragmento de código del Adaptador Driving que recibe la petición.

package com.mercadona.academy.web.controllers.adapters;

import javax.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import com.mercadona.academy.web.controllers.mappers.ProductDTOMapper;
import com.mercadona.academy.web.application.ports.driving.ProductServicePort;
import com.mercadona.framework.cna.api.products.definition.server.ProductsApi;
import com.mercadona.framework.cna.api.products.model.ProductRequest;
import com.mercadona.framework.cna.api.products.model.ProductResourceCollectionResponse;
import com.mercadona.framework.cna.api.products.model.ProductResourceResponse;

@Slf4j
@RestController
@AllArgsConstructor
@RequestMapping
public class ProductControllerAdapter implements ProductsApi {
private final ProductServicePort productService;
private final ProductDTOMapper productDTOMapper;

@Override
public ResponseEntity<ProductResourceResponse> createProduct(@Valid @RequestBody ProductRequest productRequest) {
var productEntity = productDTOMapper.from(productRequest);
var productSaved = productService.createProduct(productEntity);
var productResourceResponse = productDTOMapper.toProductResourceResponse(productSaved);
return ResponseEntity.status(HttpStatus.CREATED).body(productResourceResponse);

}
}

Este adaptador hace uso del puerto ProductServicePort para entrar dentro de la aplicación.

Dicho puerto se encuentra definido como una interfaz en el módulo de aplicación dentro de la carpeta Driving, tal y como podemos ver en el siguiente fragmento:

package com.mercadona.academy.web.application.ports.driving;

import com.mercadona.academy.web.domain.Product;

public interface ProductServicePort {
Product createProduct(Product product);
}

Este puerto es implementado por el caso de uso ProductServiceUseCase, que será el que contenga la lógica de negocio de nuestro ejemplo. Consiste en almacenar el producto en BBDD, en el caso de ser un alta nueva, notificarlo al resto de microservicios de la solución, y en caso de que el producto ya existiese previamente, alertar a un usuario concreto.

package com.mercadona.academy.web.application.services;

import com.mercadona.academy.web.application.ports.driven.NotificationPort;
import com.mercadona.academy.web.application.ports.driven.ProductRepositoryPort;
import com.mercadona.academy.web.domain.Product;
import com.mercadona.academy.web.application.ports.driving.ProductServicePort;
import com.mercadona.academy.web.application.ports.driven.InternalProducerPort;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@AllArgsConstructor
public class ProductServiceUseCase implements ProductServicePort {

private final ProductRepositoryPort productRepository;
private final InternalProducerPort internalProducerPort;
private final NotificationPort notificationPort;

@Override
public Product createProduct(Product product) {
// Save the product structure
var productReturn = productRepository.save(product);
if (productReturn.isSent()) {
// publish only if product is sent
internalProducerPort.produce(productReturn);
} else {
// notificate only if product is not sent
notificationPort.produce(productReturn);
}
return productReturn;
}
}

Tal y como podemos ver aquí, el servicio de aplicación es el componente que contiene la lógica y el que desencadenará la acción y la dirigirá hacia la salida usando el puerto Driven.

Inversión de dependencias:

Llegados a este punto, además podemos observar el aporte de valor en la arquitectura hexagonal de uno de los principios SOLID, la inversión de dependencias, donde vemos que, a pesar de que el control de flujo de la aplicación es hacia el exterior del hexágono, las dependencias del código fuente son inversas, ya que son los adaptadores los que dependen del dominio y no al revés. Además, para desacoplarlo, la dependencia se implementará a través de una abstracción, haciendo uso del puerto Driven.

En el siguiente fragmento de código podemos ver uno de los puertos de salida, concretamente el puerto de persistencia de datos.

package com.mercadona.academy.web.application.ports.driven;

import com.mercadona.academy.web.domain.Product;
import com.mercadona.framework.cna.commons.interfaces.CNACrudRepository;

public interface ProductRepositoryPort extends CNACrudRepository<Product, Long> {
}

Por último, el puerto Driven es implementado por el adaptador de salida para que pueda ser usado por el servicio.

Esquema del adaptador de salida que implementa el puerto Driven

Aquí podemos ver un pequeño fragmento del código correspondiente al adaptador Driven.

package com.mercadona.academy.web.repositories.adapters;

import com.mercadona.academy.web.domain.Product;
import org.springframework.stereotype.Service;
import com.mercadona.academy.web.repositories.mappers.ProductMapper;
import com.mercadona.academy.web.repositories.ProductMOJpaRepository;
import com.mercadona.academy.web.application.ports.driven.ProductRepositoryPort;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@AllArgsConstructor
public class ProductRepositoryAdapter implements ProductRepositoryPort {

private final ProductMOJpaRepository repository;
private final ProductMapper mapper;

@Override
public Product save(Product product) {
var productModel = mapper.toModel(product);
var productSaved = repository.save(productModel);
return mapper.fromModel(productSaved);
}
}

De esta forma conseguimos cruzar el hexágono, manteniendo el desacoplamiento de los módulos mediante el uso de los puertos y adaptadores.

Como se puede ver en los fragmentos de código anteriores también hacemos uso de los mappers para realizar la transformación de datos entre módulos.

Ejemplo de modificación:

A continuación, vamos a poner a prueba nuestro microservicio y el valor que nos aporta haberlo implementado respetando la arquitectura hexagonal, lo que nos permitirá hacer un cambio de la parte de infraestructura sin tener que modificar código en el módulo de aplicación, desacoplando así la lógica empresarial.

En este supuesto, vamos a imaginar, que hemos recibido un cambio, en el que, a partir de este momento, los productos serán dados de alta en un sistema que genera eventos, y mediante EDA, a partir de ahora, nos suscribiremos a un tópico, por lo que nuestra entrada dejará de ser a través de una petición REST para pasar a ser un tópico de Kafka.

Esquema que ejemplifica el cambio de adaptador de entrada

Para abordar el cambio, creamos un adaptador nuevo con la implementación específica de Kafka.

A continuación, podemos ver el código correspondiente al nuevo adaptador de entrada.

package com.mercadona.academy.web.consumer.adapter;

import apps.fwkcna.ProductsEventPrivateKey;
import apps.fwkcna.ProductsEventPrivateValue;
import com.mercadona.academy.web.application.ports.driving.ProductServicePort;
import com.mercadona.academy.web.consumer.mapper.ProductEventMapper;
import com.mercadona.academy.web.domain.Product;
import com.mercadona.framework.cna.lib.kafka.consumer.MercadonaKafkaConsumerListener;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class ProductConsumerAdapter
extends MercadonaKafkaConsumerListener<ProductsEventPrivateKey, ProductsEventPrivateValue> {

private final ProductServicePort productService;
private final ProductEventMapper mapper;

public ProductConsumerAdapter(
@Value("${kafka.consumer.topics.input-0}") final String[] topics,
ProductServicePort productService,
ProductEventMapper mapper) {

super(topics);

this.productService = productService;
this.mapper = mapper;

}

@Override
public void consume(ConsumerRecord<ProductsEventPrivateKey, ProductsEventPrivateValue> consumerRecord) {

Product product = mapper.productEventToProduct(consumerRecord.value());
productService.createProduct(product);

}
}

También añadimos la nueva dependencia en el pom del proyecto para incluir el módulo necesario de nuestro adaptador de Kafka.

<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/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.mercadona.academy.web</groupId>
<artifactId>academy-back-web</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>academy-back-web</name>

<!-- Provides dependency and plugin management for the given Spring Boot version -->
<parent>
<groupId>com.mercadona.framework.cna</groupId>
<artifactId>parent-seed</artifactId>
<version>4.20.0</version>
</parent>

<dependencyManagement>
<dependencies>
<!-- <dependency>-->
<!-- <groupId>com.mercadona.academy.web</groupId>-->
<!-- <artifactId>academy-back-web-api-rest</artifactId>-->
<!-- <version>${project.version}</version>-->
<!-- </dependency>-->
<dependency>
<groupId>com.mercadona.academy.web</groupId>
<artifactId>academy-back-web-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.mercadona.academy.web</groupId>
<artifactId>academy-back-web-repository-sql</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.mercadona.academy.web</groupId>
<artifactId>academy-back-web-kafka-producer</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.mercadona.academy.web</groupId>
<artifactId>academy-back-web-kafka-consumer</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<modules>
<!-- Core application-->
<module>application</module>
<!-- Adapter implementations-->
<module>driving/api-rest</module>
<module>driven/repository-sql</module>
<module>driven/kafka-producer</module>
<module>driving/kafka-consumer</module>
<!-- Runtime -->
<module>boot</module>
</modules>

</project>

Conclusiones

Tal y como hemos visto en el ejemplo anterior, cambiando solo el adaptador e indicando las dependencias específicas en el fichero pom del proyecto, habremos logrado el cambio tecnológico, sin tocar nada del código relacionado con la lógica de negocio.

A pesar de que implica cierto nivel de complejidad, el uso de la arquitectura hexagonal proporciona grandes beneficios a nuestras aplicaciones, como, por ejemplo: desacoplamiento, escalabilidad, mantenibilidad y testabilidad.

El uso de una arquitectura hexagonal también beneficia a que los evolutivos sean más fáciles de abordar. Como hemos visto en el ejemplo, añadir o cambiar la infraestructura no ha supuesto tocar otros módulos, al igual que si el cambio tuviese lugar en la lógica de negocio, únicamente afectaría al caso de uso en cuestión y no a los adaptadores ya existentes ajenos al mismo.

La implementación de la arquitectura hexagonal, junto con otras metodologías de arquitectura como DDD, asegura la estabilidad y escalabilidad a largo plazo, lo que aporta un valor significativo en el desarrollo de software en una gran empresa. En Mercadona IT, debido al volumen de microservicios que desarrollamos, que asciende a cientos, necesitamos facilitar y estandarizar esta implementación. Además, y como hemos comentado al principio de este artículo, el uso de la arquitectura hexagonal, también nos facilita mucho tener nuestros microservicios preparados para ser portables entre distintas infraestructuras nube.

Por esta razón y teniendo en cuenta el valor que aporta en relación con el esfuerzo adicional que conlleva implementar el patrón de arquitectura hexagonal hemos decidido crear un scaffolding homogéneo que la implemente. Este scaffolding se ofrece a todos los desarrolladores a través de nuestro Framework Spring Boot, mediante un arquetipo de proyecto multimódulo diseñado específicamente para este propósito. Por último, cabe destacar la labor de formación interna que estamos impulsando internamente desde los Solutions Architects.

--

--