Arquitectura hexagonal en Java con Spring Boot 4: puertos, adaptadores y errores comunes
Cuando la versión anterior de este contenido se escribió, Spring Boot 3.x marcaba el ecosistema y casi todo el material en español sobre este tema repetía el mismo diagrama de anillos con el mismo OrderController de ejemplo. Spring Boot 4.1 ya está publicado, el suelo mínimo de Java subió a 17 y aparecieron piezas nuevas, como los HTTP Service Clients, que cambian cómo se escriben los adaptadores de salida hacia otros servicios. El problema de fondo, separar las reglas de negocio de los detalles de infraestructura, sigue siendo exactamente el mismo, y sigue siendo la razón por la que a este patrón le cuesta explicarse bien: la mayoría del contenido se queda en el diagrama y no entra en dónde se rompe el aislamiento cuando el proyecto crece.
Qué resuelve la arquitectura hexagonal, y qué no
La arquitectura hexagonal, o de puertos y adaptadores, organiza una aplicación en tres capas con una regla de dependencia en un solo sentido: el dominio (entidades y reglas de negocio) no importa nada de fuera; los puertos son interfaces que el dominio declara para lo que necesita del exterior (persistir un pedido, notificar un evento) o para lo que el exterior necesita de él (crear un pedido); los adaptadores son las implementaciones concretas de esos puertos, como un repositorio JPA, un controlador REST o un cliente HTTP hacia otro servicio. El patrón no resuelve por sí solo el rendimiento ni la escalabilidad de la aplicación; lo que compra es poder cambiar de base de datos, de framework web o de proveedor de mensajería sin tocar una línea de la lógica de negocio, y poder testear esa lógica sin levantar Spring.
Proyecto base: Spring Boot 4.1 y el nuevo suelo de Java
Spring Boot 4.1 requiere Java 17 como mínimo y admite hasta Java 26, con Spring Framework 7.0.8 o superior como dependencia base, según la documentación oficial de requisitos del sistema de Spring Boot. Genera el proyecto desde Spring Initializr con Spring Web, Spring Data JPA y el driver de tu base de datos; hexagonal no necesita ninguna dependencia extra porque es una decisión de organización de paquetes, no una librería que se añade al pom.xml.
com.example.pedidos
├── dominio
│ ├── modelo (Pedido, LineaPedido: POJOs sin anotaciones de Spring)
│ └── puerto
│ ├── entrada (CrearPedidoUseCase)
│ └── salida (PedidoRepositorioPuerto, NotificadorPuerto)
├── aplicacion
│ └── servicio (implementaciones de los puertos de entrada)
└── infraestructura
├── rest (adaptador de entrada: controladores)
├── persistencia (adaptador de salida: JPA)
└── cliente (adaptador de salida: llamadas a otros servicios)
El dominio no importa nada de Spring
El núcleo es Java puro. Un puerto de salida es una interfaz que el dominio necesita, sin ninguna anotación de framework:
package com.example.pedidos.dominio.puerto.salida;
import com.example.pedidos.dominio.modelo.Pedido;
import java.util.Optional;
public interface PedidoRepositorioPuerto {
Pedido guardar(Pedido pedido);
Optional<Pedido> buscarPorId(Long id);
}
El puerto de entrada define lo que el mundo exterior puede pedirle al dominio:
package com.example.pedidos.dominio.puerto.entrada;
import com.example.pedidos.dominio.modelo.Pedido;
import java.math.BigDecimal;
public interface CrearPedidoUseCase {
Pedido crear(String cliente, BigDecimal total);
}
La implementación de ese caso de uso vive en la capa de aplicación y sigue sin depender de Spring salvo por la anotación de inyección de dependencias:
package com.example.pedidos.aplicacion.servicio;
import com.example.pedidos.dominio.modelo.Pedido;
import com.example.pedidos.dominio.puerto.entrada.CrearPedidoUseCase;
import com.example.pedidos.dominio.puerto.salida.PedidoRepositorioPuerto;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class CrearPedidoServicio implements CrearPedidoUseCase {
private final PedidoRepositorioPuerto repositorio;
public CrearPedidoServicio(PedidoRepositorioPuerto repositorio) {
this.repositorio = repositorio;
}
@Override
public Pedido crear(String cliente, BigDecimal total) {
if (total.signum() <= 0) {
throw new IllegalArgumentException("El total de un pedido debe ser positivo");
}
Pedido pedido = new Pedido(cliente, total);
return repositorio.guardar(pedido);
}
}
Adaptador de entrada: el controlador traduce, no decide
El error más común, y el que más aparece en el contenido genérico sobre este tema, es meter validaciones de negocio dentro del controlador. Aquí el controlador solo traduce HTTP a una llamada al puerto de entrada:
package com.example.pedidos.infraestructura.rest;
import com.example.pedidos.dominio.modelo.Pedido;
import com.example.pedidos.dominio.puerto.entrada.CrearPedidoUseCase;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/pedidos")
public class PedidoController {
private final CrearPedidoUseCase crearPedido;
public PedidoController(CrearPedidoUseCase crearPedido) {
this.crearPedido = crearPedido;
}
@PostMapping
public ResponseEntity<Pedido> crear(@RequestBody CrearPedidoRequest request) {
Pedido pedido = crearPedido.crear(request.cliente(), request.total());
return ResponseEntity.status(HttpStatus.CREATED).body(pedido);
}
public record CrearPedidoRequest(String cliente, BigDecimal total) {}
}
Adaptador de salida: persistencia sin filtrar la entidad JPA al dominio
La trampa aquí es dejar que @Entity se convierta en el modelo de dominio. El adaptador traduce entre la entidad JPA y el objeto de dominio, y es el único punto del sistema que conoce ambos:
package com.example.pedidos.infraestructura.persistencia;
import com.example.pedidos.dominio.modelo.Pedido;
import com.example.pedidos.dominio.puerto.salida.PedidoRepositorioPuerto;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class PedidoRepositorioJpaAdapter implements PedidoRepositorioPuerto {
private final PedidoJpaRepository jpaRepository;
public PedidoRepositorioJpaAdapter(PedidoJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Pedido guardar(Pedido pedido) {
PedidoEntity entity = PedidoEntity.desdeDominio(pedido);
PedidoEntity guardada = jpaRepository.save(entity);
return guardada.aDominio();
}
@Override
public Optional<Pedido> buscarPorId(Long id) {
return jpaRepository.findById(id).map(PedidoEntity::aDominio);
}
}
Adaptadores hacia otros servicios: lo que cambia con los HTTP Service Clients
Spring Boot 4.0 añadió auto-configuración y propiedades para HTTP Service Clients: se anota una interfaz Java plana con los métodos HTTP que necesitas y Spring genera la implementación en tiempo de ejecución, según las notas de la versión 4.0 de Spring Boot. Para un puerto de salida que llama a un servicio externo, por ejemplo uno de facturación, esto reduce el adaptador a la declaración del contrato, sin escribir a mano el cliente HTTP:
package com.example.pedidos.infraestructura.cliente;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
public interface FacturacionHttpClient {
@PostExchange("/facturas")
FacturaResponse crearFactura(@RequestBody FacturaRequest request);
@GetExchange("/facturas/{id}")
FacturaResponse buscarFactura(@PathVariable String id);
}
La clase FacturacionAdapter implements NotificadorPuerto pasa a ser una implementación delgada que delega en esta interfaz inyectada por Spring, manteniendo intacta la regla de dependencia: el dominio sigue sin saber que existe HTTP, ni que el servicio de facturación es externo.
Tests que no necesitan levantar Spring
La ganancia real de este patrón se mide en la velocidad de los tests. El caso de uso se testea con un doble del puerto, sin contexto de Spring ni base de datos real:
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
class CrearPedidoServicioTest {
@Test
void rechaza_totales_negativos() {
PedidoRepositorioPuerto repoFalso = Mockito.mock(PedidoRepositorioPuerto.class);
CrearPedidoServicio servicio = new CrearPedidoServicio(repoFalso);
assertThrows(IllegalArgumentException.class,
() -> servicio.crear("Ana", new BigDecimal("-10")));
Mockito.verifyNoInteractions(repoFalso);
}
}
Ese test corre en milisegundos porque no hay contexto de Spring que arrancar ni base de datos que levantar. Los adaptadores, controlador y repositorio JPA, sí se testean con @SpringBootTest o @DataJpaTest, pero esas pruebas son las únicas que necesitan infraestructura real, y son las únicas que deberían tardar más de unos milisegundos en tu suite.
Errores que rompen el aislamiento sin que se note
- Anotar el puerto con
@Serviceo@Repository: convierte una interfaz de dominio en un concepto de Spring; el dominio deja de ser portable a otro framework el día que lo necesites, que es justo lo que este patrón prometía evitar. - Dejar que la entidad JPA salga del adaptador de persistencia: si
PedidoEntityaparece en la firma de un método del dominio o del controlador, el puerto ya no aísla nada, porque cambiar de ORM implica tocar código fuera de la capa de infraestructura. - Meter validación de negocio en el DTO del controlador: anotaciones como
@NotNullen elrecordde la petición validan forma, no reglas de negocio; la regla "el total debe ser positivo" pertenece al servicio de dominio, como en el ejemplo de arriba, no al adaptador de entrada. - Un puerto por cada método en vez de por responsabilidad: crear una interfaz distinta para cada operación CRUD multiplica archivos sin aportar aislamiento real; agrupa en el mismo puerto los métodos que pertenecen a la misma responsabilidad de negocio.
- Probar el dominio a través del controlador: si la única forma de testear una regla de negocio es levantando
@SpringBootTesty llamando al endpoint, el aislamiento ya se perdió antes de escribir el primer test, aunque el paquete se llamedominio.