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 & 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
- 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
setWsdlLocationapuntando al WSDL baseline, no al que regeneraría. - Invariantes de comportamiento. Reglas no negociables (flush MANUAL,
update()sólo flush, firma SHA1, idempotencia, lookupsid desc+maxResults(1), el string de versiónv20250328…). Ver Invariantes sagradas. - 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 paqueteorg.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¶
- La capa seam-compat — el shim por dentro, clase por clase.
- Invariantes sagradas — el detalle de lo que no se toca.
- Arquitectura del sistema — cómo encaja el shim en el todo.
- Glosario del dominio — el vocabulario de la migración y de AFIP.
- Backend TiFacturaOnlineNext — los datos exactos del stack destino.