Código & Tech Julio 2026 • 13 min de lectura

Arquitectura Hexagonal con Keycloak 26 y Spring Boot 4.1: microservicios seguros en 2026

Arquitectura Hexagonal con Keycloak 26 y Spring Boot 4.1: microservicios seguros en 2026

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:

  1. Crear realm mi-empresa
  2. Crear client mi-servicio (Access type: confidential)
  3. 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.
  4. Crear roles de realm: ADMIN, USER
  5. 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. ⚙️💜

¿Te fue útil este artículo?

Mami en Código es un espacio gratuito y sin publicidad invasiva. Si lograste resolver un bug, aprendiste algo nuevo o simplemente te gustó lo que leíste, puedes apoyar este proyecto invitándome un café virtual.

Invítame un Café 💜

Este artículo puede contener enlaces de afiliado. Al comprar a través de ellos, apoyas este blog sin costo adicional para ti. ¡Gracias! 💜

Comentarios

Cargando comentarios...