ADR-018 — Upgrade a Spring Boot 4 (framework 7, security 7, hibernate 7, jackson 3) + consola boot-ui (solo dev)¶
Status: accepted (operador aprobó el upgrade y la consola de desarrollo, 2026-08-21)
Context: Spring Boot 3.4 salió de soporte OSS en 2025-12; el lanzamiento a producción
es 2026-09-01, así que seguir en 3.4 sin parches de seguridad hasta esa fecha no es
aceptable. Además se pidió sumar boot-ui (consola de desarrollo de Julien Dubois:
introspección de salud/métricas/JPA/seguridad en el navegador), que requiere Spring Boot 4.
Alternatives:
Quedarse en Spring Boot 3.5 (última minor de la línea 3.x, soportada más tiempo que 3.4):
evita el trabajo de migración de Jackson 3 / Spring Framework 7, pero boot-ui no corre
ahí — quedaría afuera del alcance pedido.
Spring Boot 4.x (elegida): versión GA más nueva resoluble en Maven Central al momento del
upgrade, 4.1.1 (verificado contra maven-metadata.xml de
spring-boot-starter-parent — 4.1.1 es la última release, 4.2.0-M1 es milestone, no GA).
Es la única línea que soporta boot-ui.
Decision:backend/pom.xml parent → spring-boot-starter-parent:4.1.1. Se agrega
com.julien-dubois.bootui:bootui-spring-boot-starter:1.14.1 (última 1.x en Maven Central al
momento del upgrade) con su comportamiento AUTO por defecto: activa solo con perfil
dev/local (o con spring-boot-devtools presente), se auto-apaga en prod/production,
y rechaza requests no-loopback aunque esté activa (bootui.allow-non-localhost=false por
defecto). El perfil e2e (application-e2e.properties, nuevo — reservado por ADR-017 pero
sin contenido hasta ahora) no fija bootui.enabled a mano: AUTO ya resuelve sola los
tres casos que importan (ver Consequences).
Consequences:
Jackson 3: Spring Boot 4 trae Jackson 3, que renombra los paquetes de jackson-core y
jackson-databind de com.fasterxml.jackson.* a tools.jackson.* (las anotaciones,
com.fasterxml.jackson.annotation.*, se quedan donde estaban — no cambian). ObjectMapper,
TypeReference y la excepción de (de)serialización se mudan; JsonProcessingException
(checked, extendía IOException) desaparece a favor de JacksonException (unchecked).
Afecta SorteoService, SeedMinimo, GestorApiTest, SeedMinimoTest.
spring.jackson.property-naming-strategy: SNAKE_CASE sigue siendo la property correcta y
el contrato snake_case de toda la API sigue intacto — confirmado con la suite completa y a
mano contra el jar real (POST /api/gestor/campanias con body snake_case).
Flyway modularizado: Boot 4 separó la autoconfiguración de Flyway a un módulo propio
(spring-boot-flyway / spring-boot-starter-flyway / spring-boot-starter-flyway-test).
FlywayMigrationStrategy se muda de org.springframework.boot.autoconfigure.flyway a
org.springframework.boot.flyway.autoconfigure. backend/pom.xml reemplaza la dependencia
directa flyway-core por spring-boot-starter-flyway (que la trae transitiva, versión
12.4.0 gestionada por Boot); flyway-sqlserver se queda como estaba (Boot no la gestiona).
MockMvc modularizado: Boot 4 separó el soporte de test de MockMvc a
spring-boot-starter-webmvc-test / spring-boot-webmvc-test. AutoConfigureMockMvc se
muda de org.springframework.boot.test.autoconfigure.web.servlet a
org.springframework.boot.webmvc.test.autoconfigure. MockMvc,
MockMvcRequestBuilders, MockMvcResultMatchers (paquete org.springframework.test.web.servlet,
de Spring Framework, no de Boot) no cambiaron. No hizo falta
spring-boot-starter-data-jpa-test: ningún test de este repo usa @DataJpaTest.
Regresión real de Spring Framework 7 en resolución de @ControllerAdvice
(HIGH, detectada por el smoke manual, no por la suite): sin @Order explícito, Spring
elige entre @ControllerAdvice candidatos por orden de registro del bean, no por
especificidad del basePackages. Bajo Spring Framework 7 ese orden cambió lo suficiente
como para que el catch-all de Exception de GlobalExceptionHandler ganara la carrera
antes que el DataIntegrityViolationException específico de GestorExceptionHandler — el
alta duplicada de "campaña vigente" volvía 500 en vez de 409. La suite completa
(123 tests, incluido EsquemaInvariantesTest que prueba la traducción de excepción a nivel
repositorio) pasaba igual, porque nadie ejercitaba el camino completo controller→advice con
una inserción real duplicada — GestorExceptionHandlerTest sólo invoca el handler aislado.
Se agregó @Order(HIGHEST_PRECEDENCE) a GestorExceptionHandler y
@Order(LOWEST_PRECEDENCE) explícito a GlobalExceptionHandler, más un test de regresión
end-to-end (GestorApiTest.crearCampania_duplicadaVigente_409) que pega contra el
controller real, no contra el handler aislado.
boot-ui no ensancha la seguridad existente: detecta Spring Security y se auto-registra
permitAll SOLO en /bootui, /bootui/**, /bootui/api, /bootui/api/** (log propio de
BootUiSpringSecurityAutoConfiguration lo confirma); /api/gestor/** sigue en 401 sin
auth, /api/salud sigue público, verificado a mano.
Trae actuator + micrometer + opentelemetry + archunit transitivos (bootui-spring-boot-starter
depende de spring-boot-starter-actuator, spring-boot-micrometer-tracing-opentelemetry,
micrometer-tracing-bridge-otel, archunit — todos en scope compile, siempre en el jar,
no solo en un perfil dev). Es necesario para que la introspección de boot-ui funcione;
los endpoints de actuator expuestos por web siguen limitados al default de Boot
(health, info) — no se cambió management.endpoints.web.exposure.include.
Los assets estáticos de la consola (HTML/JS/CSS) quedan descargables incluso con
bootui.enabled en AUTO-apagado o en prod (confirmado a mano: GET /bootui/index.html
→ 200 con prod solo, sin dev). bootui-ui empaqueta su SPA bajo
classpath:/META-INF/resources/bootui/ (patrón webjar), que Spring sirve como recurso
estático genérico apenas el jar está en el classpath — independiente del enabled de la
librería. La API funcional (/bootui/api/**, la única que expone datos vivos de la app) y
el filtro loopback-only SÍ están correctamente gateados y devuelven 404/403 fuera de
dev/local — el shell estático es HTML/JS inerte sin backend que lo alimente, severidad
baja (revela que la herramienta está en el classpath, no expone datos), pero se documenta
en vez de ocultarlo.
application-e2e.properties no fija bootui.enabled=OFF: se evaluó hacerlo (como pide
un enfoque ingenuo de "doble capa de defensa") pero se descartó porque con
--spring.profiles.active=dev,e2e el último perfil activo pisa al anterior — un OFF fijo
en e2e ganaría sobre el dev explícito y dejaría la consola inalcanzable incluso cuando
alguien la pide a propósito para depurar una corrida E2E, rompiendo ese caso de uso.
bootui.enabled=AUTO (default de la librería, sin pisar nada) ya resuelve los tres casos
reales sin este problema: e2e sola (CI) apagada, prod,e2e apagada, dev,e2e prendida —
los tres confirmados a mano contra el jar empaquetado real.
backend/pom.xml: parent spring-boot-starter-parent:4.1.1; flyway-core reemplazado por
spring-boot-starter-flyway; spring-boot-starter-webmvc-test agregado (scope test);
com.julien-dubois.bootui:bootui-spring-boot-starter:1.14.1 agregado; jacoco-maven-plugin
a 0.8.15 (soporte de class files más nuevos).
Imports migrados a los paquetes nuevos de Jackson 3 (tools.jackson.*) y del Flyway/MockMvc
modularizados de Boot 4, en SorteoService, SeedMinimo y los tests que los usan.
@Order explícito en GestorExceptionHandler (HIGHEST_PRECEDENCE) y
GlobalExceptionHandler (LOWEST_PRECEDENCE) — fix forzado por la regresión de Spring
Framework 7 descripta arriba.
backend/src/main/resources/application-e2e.properties (nuevo, sin contenido de
bootui.enabled — ver Consequences).
docs/SMOKE-M6.md §0: nota de cómo abrir la consola en dev y por qué prod queda apagada sola.
Tests: GestorApiTest.crearCampania_duplicadaVigente_409 (regresión del fix de @Order),
BootUiConsoleTest (apagada fuera de dev/local vía /bootui/api/health; prendida + loopback-only
con perfil dev vía MockMvc con remoteAddr no-loopback).
[x] mvn -q test (SP_TEST_DB_URL → SistemaPremios_test3) verde: 127 tests, 0 failures,
0 errors, 22 clases de test.
[x] mvn -q package -DskipTests empaqueta backend + landing + gestor en un solo jar
(perfil full).
[x] Smoke contra el jar en :8093 (perfil e2e, DB SistemaPremios_test3):
/api/salud 200, /gestor/ 200, /p/x 200, /api/gestor/panel sin auth 401,
alta duplicada de campaña vigente 409 (no 500 — regresión encontrada y corregida).
[x] Smoke --spring.profiles.active=dev,e2e: GET /bootui 200 desde localhost; rechaza
(403) un caller no-loopback (X-Forwarded-For spoofeado, mismo mecanismo de confianza de
proxy que ya documentaba JD-B1 para el rate limit); /api/gestor/** sigue protegido.
[x] Smoke --spring.profiles.active=prod,e2e: GET /bootui 404 (capa API/loopback
correctamente apagada; el shell estático queda servible como recurso inerte, documentado
arriba).
[ ] Compresión gzip en /gestor/ — no verificable: server.compression nunca estuvo
configurado en este repo (ni antes ni después del upgrade); hallazgo pre-existente, fuera
de alcance de este ADR, no es una regresión del upgrade.