Si has mantenido una aplicación Spring Boot durante suficiente tiempo, es muy probable que hayas visto reglas de negocio, controladores y entidades JPA mezclados en el mismo lugar. Funciona, hasta el día en que necesitas cambiar de base de datos, escribir pruebas unitarias decentes o simplemente comprender dónde termina la lógica de negocio y dónde comienza el framework. Fue precisamente este tipo de fricción lo que me llevó a estudiar a fondo la Arquitectura Hexagonal, y en este artículo quiero mostrar cómo apliqué estos conceptos en una aplicación Spring Boot real.
¿Qué es la Arquitectura Hexagonal?
La Arquitectura Hexagonal (también llamada Puertos y Adaptadores) fue propuesta por Alistair Cockburn a principios de la década de 2000. La idea central es simple: el núcleo de la aplicación —las reglas de negocio— no debe depender de nada externo. Ni de la base de datos, ni del framework web, ni de ninguna biblioteca de terceros.
Para lograr esto, el núcleo define puertos (interfaces) que describen lo que necesita o lo que ofrece. El mundo exterior se conecta a estos puertos a través de adaptadores, que implementan la integración concreta con una tecnología específica.
Existen dos tipos de puertos:
- Puertos de entrada (puertos primarios o driving ports): definen los casos de uso que ofrece la aplicación. Un controlador REST, por ejemplo, es un adaptador que invoca un puerto de entrada.
- Puertos de salida (puertos secundarios o driven ports): definen lo que el núcleo necesita del mundo exterior, como persistir datos o invocar un servicio de terceros. Un repositorio JPA es un adaptador que implementa un puerto de salida.
El resultado práctico es que el dominio nunca importa nada de Spring, de Hibernate ni de ninguna otra infraestructura. Solo conoce interfaces.
¿Por qué aplicar esto en un proyecto Spring Boot?
Spring Boot facilita enormemente construir todo encima del framework: @Entity en la misma clase que representa la regla de negocio, @Service llamando directamente a @Repository, todo fuertemente acoplado. Funciona bien al principio, pero pasa factura a medida que el sistema crece:
- Probar la regla de negocio exige levantar el contexto de Spring o una base de datos.
- Cambiar de proveedor (base de datos, sistema de mensajería, API externa) se convierte en una refactorización masiva en lugar de un simple reemplazo de pieza.
- La lógica de negocio queda dispersa entre controladores, servicios y entidades.
La Arquitectura Hexagonal resuelve esto aislando el dominio de cualquier decisión de infraestructura. La contrapartida es más código de “plomería” (interfaces, mapeadores), lo cual vale la pena principalmente en dominios complejos y sistemas de larga vida útil. Para un CRUD simple que no crecerá, puede representar una complejidad innecesaria. Profundizo más en esto en la conclusión.
Estructura de carpetas
Esta es la estructura de directorios que utilizo como punto de partida:
src/main/java/com/exemplo/pedidos/
├── domain/
│ └── Pedido.java # entidad de dominio (record), sin anotaciones del framework
├── application/
│ ├── port/
│ │ ├── in/
│ │ │ └── CriarPedidoUseCase.java # puerto de entrada
│ │ └── out/
│ │ └── PedidoRepositoryPort.java # puerto de salida
│ └── service/
│ └── CriarPedidoService.java # implementa el caso de uso
└── adapter/
├── in/
│ └── web/
│ └── PedidoController.java # adaptador de entrada (REST)
└── out/
└── persistence/
├── PedidoJpaEntity.java
├── PedidoJpaRepository.java
└── PedidoRepositoryAdapter.java # adaptador de salida (JPA)
Observa que domain no depende de nada. application depende exclusivamente de domain. Solo los paquetes adapter conocen Spring, JPA y HTTP.
Implementando en la práctica
Los siguientes ejemplos utilizan Java 25 (LTS) y Spring Boot 4.1, construido sobre Spring Framework 7, la generación actual del ecosistema, con una línea base mínima en Java 17 pero diseñada para aprovechar las características más modernas del lenguaje.
Usemos un ejemplo sencillo: crear un pedido.
1. El dominio, libre de anotaciones del framework:
package com.exemplo.pedidos.domain;
import java.math.BigDecimal;
public record Pedido(String id, String clienteId, BigDecimal valorTotal) {
public Pedido {
if (valorTotal.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Valor do pedido deve ser positivo");
}
}
}
Aquí aprovecho una característica que se volvió aún más natural en las versiones modernas de Java: Pedido es un record. La validación se sigue realizando antes de que el objeto exista mediante el constructor compacto, pero sin heredar mutabilidad, sin el repetitivo this.campo = campo y sin getters manuales: id(), clienteId() y valorTotal() se generan automáticamente. Es ideal para un dominio que debería ser inmutable por naturaleza.
2. El puerto de entrada, el contrato del caso de uso:
package com.exemplo.pedidos.application.port.in;
import com.exemplo.pedidos.domain.Pedido;
import java.math.BigDecimal;
public interface CriarPedidoUseCase {
Pedido criar(String clienteId, BigDecimal valorTotal);
}
3. El puerto de salida, lo que el dominio necesita de la infraestructura:
package com.exemplo.pedidos.application.port.out;
import com.exemplo.pedidos.domain.Pedido;
public interface PedidoRepositoryPort {
Pedido salvar(Pedido pedido);
}
4. El servicio de aplicación, que implementa el caso de uso:
package com.exemplo.pedidos.application.service;
import com.exemplo.pedidos.application.port.in.CriarPedidoUseCase;
import com.exemplo.pedidos.application.port.out.PedidoRepositoryPort;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.UUID;
@Service
public class CriarPedidoService implements CriarPedidoUseCase {
private final PedidoRepositoryPort repository;
public CriarPedidoService(PedidoRepositoryPort repository) {
this.repository = repository;
}
@Override
public Pedido criar(String clienteId, BigDecimal valorTotal) {
Pedido pedido = new Pedido(UUID.randomUUID().toString(), clienteId, valorTotal);
return repository.salvar(pedido);
}
}
Nota que @Service se encuentra aquí, no en el dominio. Esta es la única clase en la capa de aplicación que conoce Spring, y solo para ser gestionada como bean.
5. El adaptador de entrada, el controlador REST:
package com.exemplo.pedidos.adapter.in.web;
import com.exemplo.pedidos.application.port.in.CriarPedidoUseCase;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/pedidos")
public class PedidoController {
private final CriarPedidoUseCase criarPedidoUseCase;
public PedidoController(CriarPedidoUseCase criarPedidoUseCase) {
this.criarPedidoUseCase = criarPedidoUseCase;
}
@PostMapping
public PedidoResponse criar(@RequestBody CriarPedidoRequest request) {
Pedido pedido = criarPedidoUseCase.criar(request.clienteId(), request.valorTotal());
return new PedidoResponse(pedido.id(), pedido.clienteId(), pedido.valorTotal());
}
record CriarPedidoRequest(String clienteId, BigDecimal valorTotal) {}
record PedidoResponse(String id, String clienteId, BigDecimal valorTotal) {}
}
El controlador depende del puerto (CriarPedidoUseCase), nunca de la implementación concreta. Esto permite, por ejemplo, cambiar CriarPedidoService por cualquier otra implementación sin tocar el controlador.
6. El adaptador de salida / persistencia con JPA:
package com.exemplo.pedidos.adapter.out.persistence;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "pedidos")
public class PedidoJpaEntity {
@Id
private String id;
private String clienteId;
private BigDecimal valorTotal;
protected PedidoJpaEntity() {}
public PedidoJpaEntity(String id, String clienteId, BigDecimal valorTotal) {
this.id = id;
this.clienteId = clienteId;
this.valorTotal = valorTotal;
}
// getters omitidos por brevedad
}
package com.exemplo.pedidos.adapter.out.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PedidoJpaRepository extends JpaRepository<PedidoJpaEntity, String> {}
package com.exemplo.pedidos.adapter.out.persistence;
import com.exemplo.pedidos.application.port.out.PedidoRepositoryPort;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.stereotype.Component;
@Component
public class PedidoRepositoryAdapter implements PedidoRepositoryPort {
private final PedidoJpaRepository jpaRepository;
public PedidoRepositoryAdapter(PedidoJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Pedido salvar(Pedido pedido) {
PedidoJpaEntity entity = new PedidoJpaEntity(
pedido.id(), pedido.clienteId(), pedido.valorTotal());
jpaRepository.save(entity);
return pedido;
}
}
Observa que existen dos representaciones del pedido: Pedido (dominio) y PedidoJpaEntity (persistencia). Parece redundante, pero es precisamente esto lo que evita que Hibernate se filtre en tus reglas de negocio. Si mañana una anotación @Column necesita cambiar debido a una particularidad de la base de datos, el dominio ni siquiera se entera.
Probando la arquitectura
El beneficio más tangible aparece al escribir pruebas. CriarPedidoService se puede probar sin levantar ningún contexto de Spring:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class CriarPedidoServiceTest {
@Test
void deveCriarPedidoComValorValido() {
PedidoRepositoryPort repositoryMock = mock(PedidoRepositoryPort.class);
when(repositoryMock.salvar(any())).thenAnswer(inv -> inv.getArgument(0));
CriarPedidoService service = new CriarPedidoService(repositoryMock);
Pedido pedido = service.criar("cliente-1", new BigDecimal("150.00"));
assertEquals("cliente-1", pedido.clienteId());
}
}
Esta prueba se ejecuta en milisegundos, sin base de datos, sin contexto de Spring y sin mocks pesados. Por su parte, el adaptador de persistencia recibe una prueba de integración aislada usando @DataJpaTest, y el controlador una prueba con @WebMvcTest. Cada capa se prueba en su nivel adecuado.
Ventajas y cuándo vale la pena
Ventajas:
- Dominio aislado y comprobable sin infraestructura.
- Cambiar tecnologías (base de datos, mensajería, APIs externas) sin modificar las reglas de negocio.
- Fronteras claras: cada capa sabe con precisión de qué puede y de qué no puede depender.
Cuándo pensarlo dos veces:
- Proyectos pequeños con una única integración y ciclo de vida corto pueden sufrir más por el exceso de interfaces y clases de lo que ganan con el aislamiento.
- Equipos sin familiaridad con el patrón tienden a aplicar las capas de manera mecánica, sin entender el “por qué”, generando más boilerplate que valor real.
Al final, la Arquitectura Hexagonal no es una solución mágica; es una herramienta para dominios que van a crecer y evolucionar durante años. Empecé a aplicarla pensando en la capacidad de prueba, y lo que no esperaba era lo claro que resultaría, tanto para mí como para el equipo, dónde reside realmente cada regla de negocio. Si estás evaluando si vale la pena adoptarla en tu proyecto, hazte esta pregunta primero: ¿este dominio va a crecer y cambiar su infraestructura con el tiempo, o es algo puntual? La respuesta guiará la decisión mucho mejor que cualquier dogma arquitectónico.