Saltar a contenido

Migración Seam/JBoss → Spring Boot: "shim, no reescritura"

Esta es, probablemente, la página más importante del onboarding. No describe qué hace el sistema sino por qué está armado como está. Casi toda decisión rara que vas a encontrar en el código —un import org.jboss.seam.* arriba de una entidad JPA, un EntityHome.update() que sólo hace flush, un handler SOAP que no se porta— tiene su explicación acá. Si la leés con atención, vas a dejar de "arreglar" cosas que están perfectas.

El punto de partida y la regla de oro

El sistema legacy es un backend de facturación electrónica que corría sobre JBoss EAP 7 + Seam 2.3.1.Final + JAX-WS, con Java 8 y namespace javax.*. Llevaba años en producción emitiendo CAE/CAEA reales. El objetivo de la migración no era modernizar la lógica: era cambiar la plataforma de ejecución (de JBoss a Spring Boot) sin que el comportamiento observable cambie un ápice.

Regla de oro (textual del CLAUDE.md del repo de código)

NO se altera la lógica de negocio. La salida funcional —CAE/CAEA emitidos, WSDL publicado, contrato REST, PDFs, emails— debe ser byte-equivalente o funcionalmente idéntica a la del sistema en producción. Si una tarea te obliga a cambiar comportamiento observable, pará y preguntá.

Esta regla es la que dicta toda la estrategia. Reescribir es arriesgado: cada línea reescrita es una oportunidad de introducir una diferencia con producción, y en facturación electrónica una diferencia significa un comprobante rechazado por AFIP. Entonces, la pregunta de diseño no fue "¿cómo reescribimos esto mejor?" sino "¿cuánto podemos NO tocar?".

El descubrimiento que lo cambió todo

Migrar de Seam suele ser doloroso porque Seam tiene features pegajosos: conversaciones de larga vida (@Begin/@End), outjection (@Out), inyección bidireccional, async propio. Esos features se enredan con todo y obligan a reescribir.

Pero alguien midió el acoplamiento real del código a Seam (el censo está en REFERENCE.md §1 del repo de código), y el resultado fue revelador:

Patrón Seam Archivos Implicancia
@Begin / @End (conversación) 0 El mayor dolor de Seam no existe en este código.
@Out (outjection) 0
@In (injection) 3 Sólo en 3 clases, todas rojas (se reescriben igual).
@Asynchronous (Seam async) 0 Usan Quartz directo, no el async de Seam.
@Name 31 Registro de componentes → se mapean a beans Spring.
Component.getInstance(...) 24 Accesor estático → puente al ApplicationContext.
.instance() 13 Accesores estáticos → se mantienen tal cual con el shim.
org.jboss.seam.* (imports) 50 Cubiertos por seam-compat o reescritura.

La conclusión: la dependencia a Seam es superficial y mecánica. Es básicamente un contenedor de inyección con scopes, una clase base de CRUD y una de queries. Todo eso se puede imitar.

La decisión: imitar, no reescribir

Si la dependencia a Seam es mecánica, entonces la respuesta correcta no es reescribir 24.000 líneas: es construir un shim que reimplemente ese subconjunto de Seam sobre Spring, y dejar el código de negocio intacto. Eso es exactamente seam-compat.

Qué es un shim (y por qué en org.jboss.seam)

Un shim es una capa fina que expone una API vieja pero por dentro usa tecnología nueva. El código que dependía de la API vieja no se entera del cambio.

La jugada más astuta de este proyecto es dónde vive el shim: en el paquete org.jboss.seam.* — el mismo nombre que el Seam original. ¿Por qué? Porque así el código de negocio, que tiene import org.jboss.seam.Component; y import org.jboss.seam.framework.EntityHome;, compila sin que se le toque una sola línea. El import resuelve contra nuestra reimplementación, no contra el Seam de JBoss.

graph LR
    subgraph Antes["Legacy (JBoss)"]
        L1["Negocio<br/>import org.jboss.seam.*"]
        S1["Seam 2.3.1<br/>(JBoss)"]
        L1 --> S1
    end

    subgraph Ahora["Migrado (Spring Boot)"]
        L2["Negocio<br/>(MISMO import org.jboss.seam.*)"]
        S2["seam-compat<br/>org.jboss.seam.* propio"]
        SP["Spring"]
        L2 --> S2 --> SP
    end

    Antes -.->|"el negocio<br/>no cambia"| Ahora

El test mental

Si abrís una clase de negocio y la ves importando org.jboss.seam.*, no es deuda técnica pendiente de migrar. Es la migración funcionando como fue diseñada. Tocar ese import sería romper la estrategia, no completarla.

El semáforo: verde, amarillo, rojo

No todo el código encaja en el shim. El proyecto clasificó cada clase legacy por riesgo (detalle en REFERENCE.md §4):

graph TD
    CODE["Código legacy<br/>~24.327 LOC"]
    GREEN["🟢 VERDE — lift &amp; shift<br/>0 cambios de lógica"]
    YELLOW["🟡 AMARILLO — vía seam-compat<br/>0 cambios de fuente"]
    RED["🔴 ROJO — reescritura funcional<br/>(sólo estas)"]

    CODE --> GREEN
    CODE --> YELLOW
    CODE --> RED
Color Qué incluye Qué se hace
🟢 Verde Stubs JAXB/JAX-WS de AFIP (fev1.dif.afip.gov.ar.*), el modelo (model/**), el cliente AFIP (AfipCAE, AfipWSAA, LoginCMSManager), homes y queries con su lógica custom. Lift & shift. Se mueve tal cual; sólo el cambio mecánico javax→jakarta.
🟡 Amarillo Todo lo que usa @Name+@Scope+.instance()+Component.getInstance+Log/Logging+@Transactional de Seam, y las clases base ParentEntityManager/Home/Query. Corre vía seam-compat, 0 cambios de fuente.
🔴 Rojo EmailHome (Seam Mail), un path de TrxToPdfHome (Seam Renderer), Authenticator/seguridad (Seam Identity + Drools), la UI admin (JSF/RichFaces), el ExceptionMapper JAX-RS. Reescritura funcional. Sólo estas, porque están atadas a partes de Seam que no vale la pena imitar.

La gracia del semáforo: el rojo es chico. La inmensa mayoría del código es verde o amarillo, es decir, no se toca. Sólo se reescribe lo que está pegado a Seam Faces/Mail/Document/Security, que no son negocio sino infraestructura de presentación y seguridad.

Cómo funciona el shim por dentro

seam-compat no es magia: son unas pocas clases bien elegidas. Los puentes principales (verificables en el repo de código):

Pieza Seam Reimplementación Mecanismo
Component.getInstance(name) org/jboss/seam/Component.java Guarda un ApplicationContext estático (lo inyecta config/SeamBridgeInitializer) y traduce a ctx.getBean(name).
@Name + @Scope config/SeamComponentRegistrar.java Un BeanDefinitionRegistryPostProcessor escanea las clases con @Name en el paquete de negocio, las registra como beans Spring y mapea el scope.
Scopes Seam SeamComponentRegistrar.mapScope APPLICATION→singleton, STATELESS→prototype, SESSION→session, EVENT→"seamEvent" (scope custom).
Scope EVENT config/SeamEventScope.java + SeamEventScopeFilter.java Scope custom por hilo, limpiado al final de cada request por un servlet filter.
EntityHome / EntityQuery / Controller org/jboss/seam/framework/* Clases base que el negocio extiende, con la semántica exacta del legacy.
@org.jboss.seam.annotations.Transactional config/SeamTransactionAnnotationParser.java Hace que Spring reconozca la anotación de Seam y mapee su TransactionPropagationType.
Logging.getLog(...) org/jboss/seam/log/* API de logging de Seam, redirigida al logging real.

La pieza más sutil es EntityHome, porque ahí viven invariantes de comportamiento. Mirá la reimplementación de update():

public String update() {
    getEntityManager().flush();   // INVARIANTE 5.2: SOLO flush, sin merge
    return "updated";
}

El legacy asume que la entidad ya está managed en el EntityManager del evento, así que update() sólo hace flush, no merge. Si "mejoráramos" esto agregando un merge(), cambiaríamos comportamiento. El shim lo replica exacto. Lo mismo con persist(), que hace flush explícito porque el sistema opera en modo flush MANUAL. Estas y otras reglas están en Invariantes sagradas; el funcionamiento completo del shim, en La capa seam-compat.

El shim crece por demanda

seam-compat no reimplementa todo Seam: sólo el subconjunto que la app usa. Si al injertar una clase de negocio algo no compila porque falta una API de Seam, la respuesta es agregar esa API al shim, nunca tocar el negocio. El shim arrancó validado con un piloto y un puñado de tests verdes, y se extiende según lo pide el código real.

El camino real: no fue un solo salto

La migración no saltó de JBoss a Spring Boot 4 de una. Fue por fases, para mantener el "verde" en cada paso. El stack atravesó varias versiones intermedias (camino documentado en CLAUDE.md / REFERENCE.md del repo de código):

graph LR
    LEG["Legacy<br/>JBoss EAP 7 · Seam 2.3.1<br/>Java 8 · javax · Hib 4.3"]
    F1["Fase 1<br/>Spring Boot 2.7.18<br/>Java 11 · javax (se mantiene)<br/>Hib 5.6"]
    F2["Fase 2<br/>Spring Boot 3.5<br/>jakarta · Hib 6 · CXF 4.1"]
    F3["Fase 3 (en main)<br/>Spring Boot 4.0.7 · Spring 7<br/>Java 17 · Hib 7.2 · CXF 4.2"]

    LEG --> F1 --> F2 --> F3
Componente Legacy Actual (en main)
Runtime JBoss EAP 7 (EAR) Spring Boot 4.0.7 (Spring Framework 7)
Namespace javax.* jakarta.* (los javax.* del JDK se mantienen)
JDK 8 17
JPA / Hibernate JPA 2.1 / Hibernate 4.3.6 Jakarta Persistence 3.2 / Hibernate 7.2.x
SOAP CXF 2.4.6 (contenedor) CXF 4.2.2 (cxf-spring-boot-starter-jaxws)
REST RESTEasy Spring MVC @RestController
Scheduling Quartz 2.3 + Seam async spring-boot-starter-quartz
Firma CMS BouncyCastle 1.46 jdk16 bcprov/bcpkix-jdk18on 1.78.1
Seguridad Seam Security + Drools Spring Security (auth DB, sin Drools)

Las heridas de cada salto

Cada fase tuvo sus roturas concretas: Hibernate 6 quitó SQLServer2012Dialect/registerColumnType; Hibernate 7 quitó QueryHints (→ HibernateHints); Spring Boot 4 fragmentó los módulos de test (resttestclient, webmvc-test) y reemplazó @MockBean por @MockitoBean. Son cambios de infraestructura, no de negocio — exactamente el tipo de cosa que la estrategia permite tocar.

El pom.xml como mapa de la migración

Un detalle elegante: el pom.xml está organizado para crecer junto con la migración. Arranca con el conjunto mínimo de dependencias que compila el seam-compat + el piloto y deja mvn test verde. Las dependencias pesadas (CXF, BouncyCastle, PDFBox, Quartz, mail…) están comentadas y rotuladas por SPEC: cuando llegás a un SPEC, descomentás SOLO ese bloque.

<!-- [SPEC-010] Firma CMS / WSAA (reemplaza bcprov/bcmail-jdk16 1.46). API nueva 1.78. -->
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk18on</artifactId>
    <version>${bouncycastle.version}</version>
</dependency>

Así nada explota al empezar, y el pom documenta el orden de la migración. El detalle de las 16 SPECs y los hitos está en el ROADMAP.md del repo de código.

Las tres barreras que aseguran la paridad

Para que "no alterar el comportamiento" sea verificable y no una promesa, el proyecto tiene tres barreras:

graph TD
    CHANGE["Un cambio entra al repo"]
    WSDL{"¿Altera el<br/>WSDL publicado?"}
    INV{"¿Respeta las<br/>invariantes?"}
    PAR{"¿Pasa la suite<br/>de paridad?"}
    OK["Se mergea"]
    STOP["BLOQUEADO — pará y revisá"]

    CHANGE --> WSDL
    WSDL -->|"sí"| STOP
    WSDL -->|"no"| INV
    INV -->|"no"| STOP
    INV -->|"sí"| PAR
    PAR -->|"no"| STOP
    PAR -->|"sí"| OK
  1. Diff de WSDL bloqueante (SPEC-008). El WSDL servido se compara contra el baseline de producción; si difiere, no se mergea. Por eso CXF publica con setWsdlLocation apuntando al WSDL baseline, no al que regeneraría.
  2. Invariantes de comportamiento. Reglas no negociables (flush MANUAL, update() sólo flush, firma SHA1, idempotencia, lookups id desc+maxResults(1), el string de versión v20250328…). Ver Invariantes sagradas.
  3. Suite de paridad (SPEC-016). Tests que comparan la salida (CAE, PDF, email) contra un golden set de facturas reales y contra homologación AFIP. Es la red de seguridad final.

El reflejo correcto cuando algo falla

En un proyecto normal, si un test falla, arreglás la lógica. Acá es al revés. Si un test de paridad falla, el fix iguala al legacy —aunque el legacy te parezca subóptimo. Y si una clase de negocio no compila, casi siempre falta algo en seam-compat: lo agregás ahí. El instinto de "mejorar" es, en este proyecto, el principal riesgo.

Resumen en una frase

Se descubrió que la dependencia a Seam era mecánica, así que en vez de reescribir 24.000 líneas se construyó un shim (seam-compat) que imita Seam sobre Spring en el mismo paquete org.jboss.seam, dejando el negocio intacto; sólo se reescribió la minoría "roja" atada a Seam Faces/Mail/Security, y tres barreras (WSDL, invariantes, paridad) garantizan que la salida sea idéntica a producción.

Por dónde seguir