En el artículo de Programación Reactiva con Vertical Slicing te conté cómo mi esposo, me ayudó a organizar el código por feature. Hoy toca la siguiente pieza del rompecabezas: cómo asegurar esas features con Keycloak y separar el dominio de la infraestructura con Arquitectura Hexagonal, todo sobre Spring Boot 4.1 y Spring Security 7.1.
Si ya leíste el de Vertical Slicing, las novedades de Spring Boot 4.1 ya te son familiares. Aquí el foco está en dos capas nuevas: la Arquitectura Hexagonal para organizar el dominio, y Keycloak para la seguridad.
Hexagonal y Vertical Slicing: qué resuelve cada uno
Ya tenemos claro que Vertical Slicing organiza el código por feature — el QUÉ. La Arquitectura Hexagonal organiza cómo cada feature se conecta con el mundo exterior — el CÓMO.
En una cocina: Vertical Slicing dice “zona de desayunos, zona de almuerzos”. Hexagonal dice que en cada zona, el cocinero nunca sale a buscar ingredientes ni atiende clientes directamente. Hay meseros (adaptadores de entrada HTTP) y asistentes de despensa (adaptadores de salida a BD y APIs externas). El cocinero — el dominio — solo cocina.
En código: el dominio es Java puro, sin una sola anotación de Spring. La infraestructura lo envuelve pero nunca lo contamina. Si mañana cambias de Keycloak a Auth0, solo tocas la capa de infraestructura. La lógica de negocio no se entera.
La estructura de paquetes
src/main/java/com/empresa/pedidos/
├── domain/ ← CERO dependencias de frameworks
│ ├── model/
│ │ ├── Pedido.java ← entidad pura
│ │ └── PedidoCodigo.java ← value object
│ ├── port/
│ │ ├── in/CrearPedidoUseCase.java ← input port (interfaz)
│ │ └── out/PedidoRepository.java ← output port (interfaz)
│ └── exception/
│ └── PedidoDuplicadoException.java
├── application/usecase/
│ └── CrearPedidoService.java ← implementa use case, usa out-ports
├── infrastructure/
│ ├── web/CrearPedidoController.java ← adapter HTTP (driving)
│ ├── persistence/R2dbcPedidoRepo.java ← adapter BD (driven)
│ ├── client/InventarioClient.java ← @HttpExchange (driven)
│ └── security/
│ ├── SecurityConfig.java ← Spring Security 7.1
│ └── JwtRoleConverter.java
├── config/
└── Application.java
La regla inviolable: las flechas de dependencia solo apuntan hacia adentro. infrastructure conoce a application y domain. application solo conoce domain. domain no conoce a nadie.
Keycloak 26 en Docker
docker run -p 8180:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26 start-dev
Puerto 8180 para no chocar con el servicio en 8080. Luego en localhost:8180:
- Crear realm
mi-empresa - Crear client
mi-servicio(Access type: confidential) - Importante en Keycloak 26: verificar que el client scope incluya el mapper de
realm_access.roles. Sin esto, el claim de roles no aparece en el JWT. - Crear roles de realm:
ADMIN,USER - Crear usuarios de prueba y asignar roles
pom.xml y application.yml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<!-- dependencias -->
<dependency>spring-boot-starter-webflux</dependency>
<dependency>spring-boot-starter-data-r2dbc</dependency>
<dependency>spring-boot-starter-oauth2-resource-server</dependency>
<dependency>spring-boot-starter-opentelemetry</dependency>
<dependency>spring-boot-starter-validation</dependency>
spring:
threads:
virtual:
enabled: true
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8180/realms/mi-empresa/protocol/openid-connect/certs
issuer-uri: http://localhost:8180/realms/mi-empresa
webflux:
apiversion:
default-version: 1
management:
opentelemetry:
resource-attributes:
service.name: "pedidos-service"
SecurityConfig con Spring Security 7.1
La novedad de Spring Framework 7 y Spring Security 7.1 es la adopción de JSpecify para null safety. Los @NonNull ahora vienen del paquete org.jspecify.annotations. En el código de producción esto se refleja principalmente en las firmas de los métodos:
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain chain(
ServerHttpSecurity http,
@NonNull JwtRoleConverter converter) { // @NonNull de JSpecify
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(ex -> ex
.pathMatchers("/actuator/health").permitAll()
.pathMatchers(HttpMethod.GET, "/api/*/pedidos/**").hasAnyRole("USER","ADMIN")
.pathMatchers(HttpMethod.POST, "/api/*/pedidos/**").hasRole("ADMIN")
.pathMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(converter))
)
.build();
}
}
JwtRoleConverter — el puente entre Keycloak y Spring Security
Keycloak pone los roles en el claim realm_access.roles del JWT. Spring Security espera GrantedAuthority con prefijo ROLE_. Este convertidor hace ese puente:
@Component
public class JwtRoleConverter
implements Converter<Jwt, Mono<AbstractAuthenticationToken>> {
@Override
@NonNull
public Mono<AbstractAuthenticationToken> convert(@NonNull Jwt jwt) {
List<GrantedAuthority> authorities = Optional
.ofNullable(jwt.getClaimAsMap("realm_access"))
.map(ra -> (List<?>) ra.get("roles"))
.orElse(List.of())
.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toString().toUpperCase()))
.collect(Collectors.toList());
return Mono.just(new JwtAuthenticationToken(jwt, authorities, jwt.getSubject()));
}
}
El dominio puro (sin Spring — sin cambios conceptuales)
// domain/port/in/CrearPedidoUseCase.java
public interface CrearPedidoUseCase {
Mono<PedidoId> ejecutar(CrearPedidoCommand command);
}
// application/usecase/CrearPedidoService.java
@Service
@RequiredArgsConstructor
public class CrearPedidoService implements CrearPedidoUseCase {
private final PedidoRepository pedidoRepo; // out-port, no R2DBC directo
@Override
public Mono<PedidoId> ejecutar(CrearPedidoCommand cmd) {
return pedidoRepo.existeByCodigo(cmd.codigo())
.flatMap(existe -> existe
? Mono.error(new PedidoDuplicadoException(cmd.codigo()))
: pedidoRepo.guardar(Pedido.nuevo(cmd)))
.map(Pedido::id);
}
}
Obtener token y probar (Keycloak 26)
TOKEN=$(curl -s -X POST \
"http://localhost:8180/realms/mi-empresa/protocol/openid-connect/token" \
-d "client_id=mi-servicio&client_secret=<SECRET>" \
-d "username=admin_test&password=password&grant_type=password" \
| jq -r .access_token)
# Sin token → 401
curl -X POST http://localhost:8080/api/v1/pedidos -H "Content-Type: application/json" -d '{}'
# Con token de ADMIN → 201
curl -H "Authorization: Bearer $TOKEN" \
-X POST http://localhost:8080/api/v1/pedidos \
-H "Content-Type: application/json" \
-d '{"codigo":"P001","clienteId":"uuid","items":[{"sku":"A1","cantidad":2}]}'
Lo que garantiza esta arquitectura
La Hexagonal con Keycloak me enseñó algo que no esperaba: la seguridad es fácil de cambiar cuando el dominio es puro. El día que el equipo decida cambiar de Keycloak a otra solución, los archivos a tocar son SecurityConfig y JwtRoleConverter. La lógica de “un pedido no puede tener código duplicado” no se mueve. Ese es exactamente el punto.
¿Tienes alguna pregunta sobre cómo combinar Hexagonal con Vertical Slicing en el mismo proyecto, o sobre la migración de Spring Boot 3.x? Los comentarios son el lugar. ⚙️💜
Comentarios