2026-03-01

Arquitectura Hexagonal en Java y Spring: Una Guía Completa

Domina la Arquitectura Hexagonal (Puertos y Adaptadores) en Java y Spring Boot. Aprende sus beneficios, cómo implementarla y su rol en microservicios y monolitos modulares.

La Arquitectura Hexagonal, también conocida como el patrón de Puertos y Adaptadores, fue introducida por Alistair Cockburn para resolver un problema común en el desarrollo de software: el entrelazado de la lógica de negocio con la infraestructura, las interfaces de usuario y los sistemas externos.

Cuando las reglas de negocio principales están estrechamente acopladas con la base de datos o el framework web (como Spring MVC), las pruebas se vuelven difíciles, la refactorización es arriesgada y cambiar de tecnología (por ejemplo, cambiar de una API REST a una API GraphQL) requiere reescribir partes significativas del sistema.

En esta guía completa, exploraremos los principios de la Arquitectura Hexagonal, cómo implementarla utilizando Java y Spring Boot, y cómo sirve como una base poderosa tanto para estrategias de Microservicios como de Monolitos Modulares.

Conceptos Clave de la Arquitectura Hexagonal

La arquitectura se visualiza como un hexágono, no porque el número seis tenga alguna propiedad mágica, sino para ilustrar que una aplicación tiene múltiples puntos de entrada y salida. La idea central es simple: el interior no debe depender del exterior.

Hay tres componentes principales que debe entender:

  1. El Dominio (El Centro): Aquí es donde vive su lógica de negocio. Contiene entidades, objetos de valor (value objects) y servicios de dominio. Está escrito en Java puro y tiene cero dependencias de frameworks externos, incluido Spring.
  2. Puertos (Las Interfaces): Los puertos actúan como el límite de su aplicación. Son abstracciones (normalmente interfaces de Java) que definen cómo el mundo exterior puede interactuar con el núcleo y cómo el núcleo puede interactuar con el mundo exterior.
    • Puertos de Entrada (Puertos Primarios / Inbound Ports): Definen los casos de uso de la aplicación (por ejemplo, CreateUserUseCase). Son invocados por el mundo exterior.
    • Puertos de Salida (Puertos Secundarios / Outbound Ports): Definen los servicios que el núcleo necesita del mundo exterior (por ejemplo, UserRepository). Son invocados por el núcleo.
  3. Adaptadores (La Implementación): Los adaptadores pertenecen a la capa de infraestructura. Adaptan las tecnologías externas a los puertos genéricos definidos por la aplicación.
    • Adaptadores Primarios (Driving Adapters): Ellos conducen la aplicación. Los ejemplos incluyen controladores REST de Spring (Spring REST Controllers), interfaces de línea de comandos (CLI) o consumidores de mensajes (Kafka, RabbitMQ) que invocan los Puertos de Entrada.
    • Adaptadores Secundarios (Driven Adapters): Son conducidos por la aplicación. Los ejemplos incluyen repositorios de Spring Data JPA, clientes HTTP externos o enviadores de correo electrónico que implementan los Puertos de Salida.

Implementando Arquitectura Hexagonal en Spring Boot

Veamos un ejemplo práctico: un sistema simple para gestionar cuentas bancarias.

1. La Capa de Dominio

El dominio debe ser puro. Sin @Entity, sin @Table, sin anotaciones de Spring.

// Domain Entity
public class BankAccount {
    private final Long id;
    private BigDecimal balance;

    public BankAccount(Long id, BigDecimal balance) {
        this.id = id;
        this.balance = balance;
    }

    public void deposit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Deposit must be positive");
        }
        this.balance = this.balance.add(amount);
    }

    public void withdraw(BigDecimal amount) {
        if (this.balance.compareTo(amount) < 0) {
            throw new IllegalStateException("Insufficient funds");
        }
        this.balance = this.balance.subtract(amount);
    }
    
    // Getters
}

2. La Capa de Aplicación (Puertos y Casos de Uso)

Esta capa orquesta los objetos de dominio y define los puertos.

El Puerto de Salida (Outbound Port):

// Outbound Port (driven by the core)
public interface BankAccountRepositoryPort {
    Optional<BankAccount> findById(Long id);
    BankAccount save(BankAccount account);
}

El Puerto de Entrada (Inbound Port):

// Inbound Port (drives the core)
public interface DepositMoneyUseCase {
    void execute(Long accountId, BigDecimal amount);
}

El Servicio de Aplicación (implementa el puerto de entrada):

// Pure Java service, no Spring annotations (we'll wire it later)
public class DepositMoneyService implements DepositMoneyUseCase {
    private final BankAccountRepositoryPort repositoryPort;

    public DepositMoneyService(BankAccountRepositoryPort repositoryPort) {
        this.repositoryPort = repositoryPort;
    }

    @Override
    public void execute(Long accountId, BigDecimal amount) {
        BankAccount account = repositoryPort.findById(accountId)
            .orElseThrow(() -> new IllegalArgumentException("Account not found"));
            
        account.deposit(amount);
        repositoryPort.save(account);
    }
}

3. La Capa de Infraestructura (Adaptadores)

Aquí es donde viven Spring Boot y las dependencias externas.

Adaptador Secundario (Base de Datos):

@Component
public class JpaBankAccountAdapter implements BankAccountRepositoryPort {
    private final SpringDataBankAccountRepository jpaRepository;

    public JpaBankAccountAdapter(SpringDataBankAccountRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public Optional<BankAccount> findById(Long id) {
        return jpaRepository.findById(id).map(this::toDomainEntity);
    }

    @Override
    public BankAccount save(BankAccount account) {
        BankAccountEntity entity = toJpaEntity(account);
        BankAccountEntity saved = jpaRepository.save(entity);
        return toDomainEntity(saved);
    }
    
    // Mapping logic omitted for brevity
}

// Spring Data JPA interface
interface SpringDataBankAccountRepository extends JpaRepository<BankAccountEntity, Long> {}

Adaptador Primario (REST Controller):

@RestController
@RequestMapping("/accounts")
public class BankAccountController {
    // Controller depends only on the Inbound Port
    private final DepositMoneyUseCase depositMoneyUseCase;

    public BankAccountController(DepositMoneyUseCase depositMoneyUseCase) {
        this.depositMoneyUseCase = depositMoneyUseCase;
    }

    @PostMapping("/{id}/deposit")
    public ResponseEntity<Void> deposit(@PathVariable Long id, @RequestBody DepositRequest request) {
        depositMoneyUseCase.execute(id, request.getAmount());
        return ResponseEntity.ok().build();
    }
}

4. Conectando Todo (Wiring)

Dado que DepositMoneyService no tiene anotaciones de Spring para mantenerlo puro, lo configuramos manualmente:

@Configuration
public class DomainConfiguration {

    @Bean
    public DepositMoneyUseCase depositMoneyUseCase(BankAccountRepositoryPort repositoryPort) {
        return new DepositMoneyService(repositoryPort);
    }
}

5. Estructura de Paquetes Recomendada

La distribución de los paquetes debe hacer visibles los límites arquitectónicos a primera vista. Una convención ampliamente adoptada es reflejar las tres capas directamente en los nombres de los paquetes:

com/example/bankaccount/
├── domain/
│   └── model/
│       └── BankAccount.java
├── application/
│   ├── port/
│   │   ├── in/
│   │   │   └── DepositMoneyUseCase.java
│   │   └── out/
│   │       └── BankAccountRepositoryPort.java
│   └── service/
│       └── DepositMoneyService.java
└── infrastructure/
    ├── adapter/
    │   ├── in/
    │   │   └── rest/
    │   │       └── BankAccountController.java
    │   └── out/
    │       └── persistence/
    │           ├── JpaBankAccountAdapter.java
    │           ├── BankAccountEntity.java
    │           └── SpringDataBankAccountRepository.java
    └── config/
        └── DomainConfiguration.java

La regla de dependencia en una frase: domain no sabe nada, application sabe solo de domain, e infrastructure sabe de todo pero nada depende de ella.

Pruebas en Aislamiento: El Mayor Beneficio

La facilidad de pruebas es el beneficio más inmediato y concreto de esta arquitectura. Debido a que DepositMoneyService depende únicamente de la interfaz BankAccountRepositoryPort — y no de Spring Data, JPA o cualquier driver de base de datos — puede probar toda su lógica de negocio con una prueba JUnit 5 común y un mock de Mockito. Sin @SpringBootTest, sin bases de datos integradas, sin red. La prueba se ejecuta en milisegundos.

class DepositMoneyServiceTest {

    private BankAccountRepositoryPort repositoryPort;
    private DepositMoneyService service;

    @BeforeEach
    void setUp() {
        repositoryPort = mock(BankAccountRepositoryPort.class);
        service = new DepositMoneyService(repositoryPort);
    }

    @Test
    void shouldDepositMoneySuccessfully() {
        BankAccount account = new BankAccount(1L, new BigDecimal("100.00"));
        when(repositoryPort.findById(1L)).thenReturn(Optional.of(account));
        when(repositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0));

        service.execute(1L, new BigDecimal("50.00"));

        ArgumentCaptor<BankAccount> captor = ArgumentCaptor.forClass(BankAccount.class);
        verify(repositoryPort).save(captor.capture());
        assertEquals(new BigDecimal("150.00"), captor.getValue().getBalance());
    }

    @Test
    void shouldThrowWhenAccountNotFound() {
        when(repositoryPort.findById(anyLong())).thenReturn(Optional.empty());

        assertThrows(IllegalArgumentException.class,
            () -> service.execute(999L, new BigDecimal("50.00")));
    }
}

Esta prueba es rápida, determinista y evalúa exactamente la regla de negocio en completo aislamiento. El adaptador JPA, el controlador REST y el contexto de Spring son completamente irrelevantes para verificar que deposit() sume la cantidad correcta.

Arquitectura Hexagonal en Microservicios

La arquitectura hexagonal se adapta increíblemente bien a los Microservicios. En un entorno distribuido, los servicios se comunican a través de varios protocolos (REST, gRPC, RabbitMQ, Kafka).

Si acopla estrechamente su lógica de negocio a un protocolo específico (por ejemplo, esperando un HttpServletRequest en su capa de servicio), cambiarlo se convertirá en una pesadilla.

Con la Arquitectura Hexagonal:

  • Su lógica de dominio principal permanece ajena a si la petición provino de una llamada HTTP, un evento asíncrono de Kafka o una tarea programada (Cron).
  • Puede montar fácilmente múltiples Adaptadores Primarios en el mismo Puerto de Entrada (por ejemplo, tanto un adaptador de API REST como un adaptador Kafka Listener que invoquen DepositMoneyUseCase).
  • La testabilidad se dispara. Puede probar la lógica central de su microservicio inyectando mocks en los Puertos de Salida, evitando por completo la necesidad de levantar bases de datos de prueba o servidores HTTP simulados durante las pruebas unitarias.

El siguiente ejemplo ilustra con qué facilidad puede agregar un consumidor de Kafka como un segundo adaptador primario. El dominio y el servicio de aplicación permanecen completamente sin cambios:

@Component
public class KafkaDepositAdapter {
    private final DepositMoneyUseCase depositMoneyUseCase;

    public KafkaDepositAdapter(DepositMoneyUseCase depositMoneyUseCase) {
        this.depositMoneyUseCase = depositMoneyUseCase;
    }

    @KafkaListener(topics = "deposit-requests", groupId = "bank-service")
    public void onDepositRequest(DepositRequestEvent event) {
        depositMoneyUseCase.execute(event.getAccountId(), event.getAmount());
    }
}

DepositMoneyService no sabe, ni le importa, si el estímulo provino de una llamada HTTP o de un evento de Kafka. Puede agregar un adaptador gRPC, un CLI o una tarea programada de la misma manera, con cero impacto en el dominio o en sus pruebas.

Una Estrategia Poderosa para el Monolito Modular

Aunque los Microservicios resuelven desafíos de escalabilidad organizacional, introducen una complejidad operativa significativa (transacciones distribuidas, latencia de red, despliegues complejos). Una alternativa moderna es el Monolito Modular.

Un Monolito Modular es una única aplicación desplegable dividida en módulos de negocio estrictamente encapsulados (por ejemplo, Billing, Inventory, Shipping).

La Arquitectura Hexagonal es el arma secreta para evitar que un Monolito Modular se degrade en una "Gran Bola de Lodo" (Big Ball of Mud):

  1. Módulos como Hexágonos: Cada módulo está estructurado como su propio hexágono independiente.
  2. Límites Estrictos: Los módulos se comunican entre sí exclusivamente a través de sus Puertos de Entrada. El módulo Billing no puede consultar directamente la base de datos de Inventory. Debe llamar a las interfaces de casos de uso expuestas por el módulo Inventory.
  3. Adaptabilidad Interna: Los detalles internos de un módulo (si utiliza JPA o JDBC, relacional o NoSQL) están completamente ocultos y son manejados por Adaptadores Secundarios internos.
  4. La Ruta de Migración: La mayor ventaja es que si un módulo específico (digamos, Shipping) requiere escalabilidad independiente y necesita ser extraído a un Microservicio, la ruta de migración es trivial. Conserva el Dominio y los Puertos internos exactamente iguales y simplemente reemplaza el adaptador de componente a componente interno por un adaptador de red (REST/gRPC).

Conclusión

La Arquitectura Hexagonal requiere más estructura inicial que un enfoque clásico por capas: debe definir interfaces de puerto, escribir mappers entre objetos de dominio y entidades JPA, y conectar beans manualmente. Para un endpoint CRUD simple, esto puede parecer verboso.

El beneficio se vuelve claro en el momento en que se enfrenta a un cambio real: cambiar la base de datos significa solo reemplazar un adaptador; agregar un punto de entrada de Kafka significa escribir una nueva clase; probar la lógica de negocio significa escribir una prueba JUnit común sin contexto de aplicación. Estos no son beneficios hipotéticos: los ejemplos de código en esta guía demuestran los tres.

Al mantener pura la lógica de negocio central y aislar todos los aspectos de infraestructura detrás de los adaptadores, construye software que es robusto, testeable de forma independiente e independiente del framework; ya sea que viva hoy en un Monolito Modular ajustado o se extraiga a un Microservicio independiente mañana.