La capa de compatibilidad Seam (el shim)¶
Esta página explica cómo funciona por dentro la infraestructura que hace posible la estrategia maestra del proyecto: shim, no reescritura. El código de negocio sigue importando org.jboss.seam.*, sigue escribiendo @Name, @Scope, Component.getInstance(...) y extends EntityHome, pero por debajo ya no corre Seam 2.3.1 sobre JBoss: corre Spring Boot 4 sobre Java 17. Nadie tocó esos imports. Eso es lo que vamos a desarmar acá.
Si venís de afuera y querés el qué (la lista de qué clase es verde, amarilla o roja), está en REFERENCE.md §4. Si querés el porqué histórico de la decisión, mirá migracion-seam-spring.md. Acá nos metemos en el cómo técnico.
La idea en una frase
Seam 2.3 era, en el fondo, un contenedor de componentes con scopes propios y una API estática (Component.getInstance). Spring es exactamente eso, pero con otro vocabulario. El shim es un diccionario de traducción entre los dos, escrito en ~20 clases chiquitas, que vive a propósito en el paquete org.jboss.seam.
1. Por qué el shim es viable acá (y no en cualquier app Seam)¶
El acoplamiento a Seam de este sistema es superficial y mecánico. Esto no es un deseo: está medido sobre el código real (REFERENCE.md §1). Lo que normalmente hace que migrar Seam sea un infierno —las conversaciones (@Begin/@End) y la outjection (@Out)— acá simplemente no existe.
| Patrón Seam | Ocurrencias | Qué implica para el shim |
|---|---|---|
@Begin / @End (conversación) |
0 | El mayor dolor de Seam no existe. No hay que emular el scope CONVERSATION. |
@Out (outjection) |
0 | No hay que reinyectar resultados al contexto. |
@In (injection) |
8 (en 3 clases) | Todas en clases rojas (EmailHome, TrxToPdfHome, Authenticator). El shim no necesita resolver @In. |
@Name |
31 | Cada uno se vuelve un bean de Spring. Núcleo del shim. |
@Scope |
24 | EVENT×19, STATELESS×3, SESSION×1, APPLICATION×1. Se mapean a scopes de Spring. |
Component.getInstance(...) |
25 | Se redirigen al ApplicationContext. |
.instance() (accessor estático) |
111 | Se mantienen tal cual: por dentro llaman a Component.getInstance. |
extends EntityHome / EntityQuery |
1 + 1 | ParentEntityHome / ParentEntityQuery reescritos sobre JPA. |
La conclusión operativa: el subconjunto de Seam que esta app usa es chico y sin estado conversacional. Por eso alcanza con reimplementar ese subconjunto. Lo que no se usa, no se implementa.
2. Dónde vive cada cosa¶
El shim está partido en dos paquetes, a propósito:
org.jboss.seam.*→ la API que el negocio importa. Mismas firmas que Seam 2.3. El código legacy no nota la diferencia.com.tipre.tifacturaonlinemanager.config.*→ la infraestructura Spring que respalda esa API (registro de beans, scope, transacciones).
src/main/java/
├── org/jboss/seam/ # la fachada (mismas firmas que Seam)
│ ├── Component.java # bridge a ApplicationContext
│ ├── ScopeType.java # enum de scopes Seam
│ ├── annotations/ # @Name @Scope @In @Transactional ...
│ ├── framework/ # Controller, Home, EntityHome, Query, EntityQuery
│ └── log/ # Log, Logging, Slf4jSeamLog
│
└── com/tipre/tifacturaonlinemanager/config/ # la maquinaria Spring (oculta)
├── SeamCompatConfig.java # @Configuration que cablea todo
├── SeamComponentRegistrar.java # @Name+@Scope -> BeanDefinition
├── SeamEventScope.java # scope "seamEvent" (ThreadLocal)
├── SeamEventScopeFilter.java # limpia el scope al fin de cada request
├── SeamBridgeInitializer.java # inyecta el ApplicationContext en Component
└── SeamTransactionAnnotationParser.java # @Transactional Seam -> tx de Spring
El paquete org.jboss.seam es nuestro, no de JBoss
No hay ni un JAR de Seam en el classpath. Las clases bajo org.jboss.seam son código propio del proyecto que imita la API. El nombre de paquete se conserva para que los import org.jboss.seam.* del legacy compilen sin tocarse. Es un truco de namespace, no una dependencia.
3. El registro de componentes: @Name → bean Spring¶
El corazón del shim es SeamComponentRegistrar. Es un BeanDefinitionRegistryPostProcessor: un gancho de Spring que corre antes de instanciar beans y que puede registrar definiciones nuevas en el contenedor. Esto es lo que hacía el contenedor de Seam al arrancar (escanear @Name y darlos de alta), traducido a la jerga de Spring.
El flujo es directo:
- Escanea el paquete de negocio (
com.tipre.tifacturaonlinemanager) buscando clases anotadas con@Name. - Por cada una, lee el
@Name(el nombre del bean) y el@Scope(si falta, asumeEVENT). - Construye un
GenericBeanDefinitioncon la clase y el scope mapeado, y lo registra bajo el nombre del@Name.
// SeamComponentRegistrar.postProcessBeanDefinitionRegistry (resumido)
for (BeanDefinition candidate : scanner.findCandidateComponents(BASE)) {
Class<?> clazz = Class.forName(candidate.getBeanClassName());
Name name = clazz.getAnnotation(Name.class);
Scope scope = clazz.getAnnotation(Scope.class);
ScopeType st = scope != null ? scope.value() : ScopeType.EVENT; // default EVENT
GenericBeanDefinition def = new GenericBeanDefinition();
def.setBeanClass(clazz);
def.setScope(mapScope(st));
registry.registerBeanDefinition(name.value(), def); // bean registrado por @Name
}
El detalle que importa: el bean se llama igual que el @Name. Así, cuando más tarde el código hace Component.getInstance("trxHome"), Spring tiene un bean exactamente con ese nombre. La equivalencia @Name("trxHome") ⇔ ctx.getBean("trxHome") es el eje sobre el que gira todo.
Por qué default EVENT
En Seam, un @Name sin @Scope no caía en singleton: el scope por defecto dependía del tipo de componente. En esta app, los componentes sin @Scope se tratan como EVENT (instancia por invocación), que es lo correcto para los *Home/*List con estado mutable por request. Poner singleton ahí sería un bug de concurrencia silencioso.
4. Los scopes: el mapa de traducción¶
ScopeType (la enumeración de Seam) tiene nueve valores, pero esta app sólo usa cuatro. El método mapScope traduce cada uno a un scope de Spring:
ScopeType Seam |
Scope Spring | Semántica | Uso en la app |
|---|---|---|---|
EVENT |
"seamEvent" (custom) |
Una instancia por invocación (request WS/REST/job), descartada al terminar | 19 componentes (la mayoría de *Home/*List) |
STATELESS |
SCOPE_PROTOTYPE |
Instancia nueva en cada resolución, sin estado compartido | 3 componentes |
APPLICATION |
SCOPE_SINGLETON |
Una sola instancia para toda la app | 1 componente |
SESSION |
"session" |
Instancia por sesión HTTP | 1 componente |
El scope clave —y el único que Spring no trae de fábrica— es EVENT.
4.1. El scope EVENT: por qué hubo que escribirlo a mano¶
En Seam, el contexto EVENT era "lo que dura una invocación": una request, una llamada SOAP, un disparo de job. Una instancia viva durante ese span, y al terminar se tira. Spring no tiene ese scope nativo (tiene request, pero está atado al ciclo HTTP de Servlet, y acá hay invocaciones que no son HTTP: los jobs de Quartz, por ejemplo).
SeamEventScope implementa ese contrato con un ThreadLocal<Map<String,Object>>: una caja de instancias por hilo.
public class SeamEventScope implements Scope {
private static final ThreadLocal<Map<String, Object>> STORE =
ThreadLocal.withInitial(HashMap::new);
@Override public Object get(String name, ObjectFactory<?> objectFactory) {
return STORE.get().computeIfAbsent(name, k -> objectFactory.getObject());
}
// ...
public static void clear() { /* corre callbacks de destrucción + limpia el ThreadLocal */ }
}
Cada hilo que procesa una invocación ve su propio Map. La primera vez que se pide "trxHome" en ese hilo, se crea y se guarda; las llamadas siguientes en el mismo hilo devuelven la misma instancia. Eso replica exactamente la semántica EVENT de Seam: estado compartido dentro de la invocación, aislamiento entre invocaciones.
El ThreadLocal HAY que limpiarlo, o se filtra estado entre requests
Los thread pools reutilizan hilos. Si no vaciás el Map al terminar la invocación, la próxima request que toque ese hilo se encuentra los *Home de la anterior —con su entidad managed adentro— y eso es contaminación de datos entre clientes. La limpieza es obligatoria, no opcional.
SeamEventScope.clear() es ese saneamiento. Se dispara desde dos lugares según cómo entró la invocación:
- Por HTTP (SOAP/REST):
SeamEventScopeFilter, unOncePerRequestFilter, llama aclear()en elfinallyde cada request. - Por job (Quartz): el wrapper del job hace la misma llamada al terminar.
// SeamEventScopeFilter
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
try { chain.doFilter(req, res); }
finally { SeamEventScope.clear(); } // sin esto, fuga de estado entre requests
}
5. Component.getInstance(): el bridge estático¶
El legacy resuelve dependencias con una API estática: Component.getInstance("trxHome"). No hay inyección por constructor en el código viejo; hay un service-locator global. Spring, en cambio, vive de inyección. El puente entre los dos es la clase Component.
public final class Component {
private static volatile ApplicationContext ctx;
public static void setApplicationContext(ApplicationContext applicationContext) { ctx = applicationContext; }
public static Object getInstance(String name) { return getInstance(name, true); }
public static Object getInstance(String name, boolean create) {
requireCtx();
if (!create && !ctx.containsBean(name)) return null; // create=false -> null si no existe
return ctx.getBean(name); // create=true -> resuelve del contexto
}
}
Component no es más que una fachada estática sobre el ApplicationContext. getInstance(name) ≡ ctx.getBean(name). El flag create:
create = true(el default): pide el bean. Si tiene scope EVENT, Spring lo crea en elMapdel hilo si no estaba; si lo estaba, devuelve el de la invocación.create = false: sólo devuelve el bean si ya existe; si no,null. Replica elgetInstance(name, false)de Seam, que no auto-creaba.
¿Y cómo llega el ApplicationContext adentro de una clase estática? Por SeamBridgeInitializer, un ApplicationContextAware que al arrancar la app le pasa el contexto a Component:
public class SeamBridgeInitializer implements ApplicationContextAware {
public void setApplicationContext(ApplicationContext ctx) { Component.setApplicationContext(ctx); }
}
Este es el orden de arranque que hace que todo encaje:
sequenceDiagram
participant Boot as Spring Boot (arranque)
participant Reg as SeamComponentRegistrar
participant Ctx as ApplicationContext
participant Bridge as SeamBridgeInitializer
participant Comp as Component (estático)
Boot->>Reg: postProcessBeanDefinitionRegistry()
Reg->>Ctx: registra beans desde @Name + @Scope
Reg->>Ctx: registerScope("seamEvent", new SeamEventScope())
Boot->>Ctx: refresh() (instancia singletons)
Ctx->>Bridge: setApplicationContext(ctx)
Bridge->>Comp: Component.setApplicationContext(ctx)
Note over Comp: A partir de acá<br/>getInstance(name) == ctx.getBean(name)
Y así es como el accessor .instance() que el legacy usa 111 veces sigue funcionando intacto:
@Name("trxHome")
@Scope(ScopeType.EVENT)
public class TrxHome extends ParentEntityHome<Trx> {
public static TrxHome instance() {
return (TrxHome) Component.getInstance("trxHome"); // por dentro: ctx.getBean("trxHome")
}
}
El programador del legacy escribe TrxHome.instance(). Lo que ejecuta es ctx.getBean("trxHome"). Cero cambios en el código de negocio.
6. El framework org.jboss.seam.framework: Controller, Home, Query¶
Seam traía una jerarquía de clases base para el patrón Home/Query (CRUD sobre una entidad). El shim la reimplementa sobre JPA puro. Es la zona "amarilla" del proyecto: clases base que el negocio extiende sin saber que cambiaron por debajo.
6.1. Controller: logging + acceso al EntityManager¶
Controller es la raíz. Da dos cosas: los métodos de log (info, debug, error, ...) con la sintaxis #0, #1 de Seam, y el acceso al EntityManager:
public abstract class Controller implements Serializable {
public EntityManager getEntityManager() {
return (EntityManager) Component.getInstance("entityManager"); // un bean Spring, ver §7
}
protected void info(Object o, Object... p) { getLog().info(o, p); }
// ... debug/warn/error/trace, todos delegando en org.jboss.seam.log.Log
}
Ese Component.getInstance("entityManager") es el hilo que conecta el framework con el bean EntityManager definido en la config. Lo vemos en §7.
6.2. EntityHome: el CRUD, con los flush explícitos¶
EntityHome<E> es la pieza más delicada del shim, porque encierra dos invariantes de comportamiento (ver invariantes.md). Resuelve la clase de entidad por reflexión sobre el genérico, gestiona la instancia y el id, y expone persist/update/remove:
public String persist() {
getEntityManager().persist(getInstance());
getEntityManager().flush(); // flush EXPLÍCITO (porque el EM está en modo MANUAL)
this.id = readGeneratedId(getInstance());
return "persisted";
}
public String update() {
getEntityManager().flush(); // INVARIANTE: SOLO flush, NO merge
return "updated";
}
public String remove() {
getEntityManager().remove(getInstance());
getEntityManager().flush();
return "removed";
}
Hay dos cosas que no se pueden tocar acá:
persist()yremove()hacenflush()explícito. El EntityManager corre en modoCOMMIT(equivalente práctico a MANUAL), o sea no auto-flushea a mitad de transacción. SiEntityHomeno flusheara a mano, los cambios no irían a la DB en el momento que el legacy espera.update()SOLO haceflush(). No hacemerge(). El legacy asume que la entidad ya está managed en el EntityManager del evento. Agregar unmerge()cambiaría el comportamiento observable (reataría grafos detached, dispararía cascadas distintas). Replicar exacto.
No agregues merge() a update()
Es la tentación obvia para un dev de Spring: "esto debería ser merge". No. El contrato del legacy es flush-sin-merge. Ver REFERENCE.md §5.2 e invariantes.md. Romperlo es romper la paridad.
6.3. EntityQuery: EJBQL + restricciones EL evaluadas a mano¶
EntityQuery<E> reconstruye el motor de queries declarativas de Seam. El componente declara un EJBQL base y una lista de restrictionExpressionStrings con expresiones EL (#{bean.prop}); en runtime, EntityQuery evalúa cada EL, descarta las que dan null, y arma el WHERE dinámico.
public List<E> getResultList() {
Build b = build(false); // arma el JPQL con las restricciones no-nulas
Query q = getEntityManager().createQuery(b.jpql);
applyParams(q, b); applyPaging(q); applyHints(q); // params, firstResult/maxResults, cache hints
return q.getResultList();
}
El evaluador de EL es deliberadamente mínimo (no es un EL completo de JSF): parte la expresión por puntos y resuelve cada segmento contra el contenedor vía Component.getInstance(...) y getters por reflexión.
private Object evalEl(String el) {
String expr = el; // "#{trxList.enteFacturador.id}"
if (expr.startsWith("#{") && expr.endsWith("}")) expr = expr.substring(2, expr.length() - 1).trim();
String[] parts = expr.split("\\."); // [trxList, enteFacturador, id]
Object current = Component.getInstance(parts[0], true); // resuelve el primer segmento como bean
for (int i = 1; i < parts.length && current != null; i++) current = getProperty(current, parts[i]);
return current; // null -> la restricción se omite
}
La regla "si el valor es null, la restricción no se agrega al WHERE" es directamente la semántica de Seam: las restricciones son opcionales y se activan sólo cuando el bean tiene el valor seteado. Es lo que permite que TrxList funcione como un filtro acumulable.
7. El bean entityManager y el modo de flush MANUAL¶
Acá se cierra el círculo. Controller.getEntityManager() pide Component.getInstance("entityManager"). Ese bean lo define SeamCompatConfig:
@Bean(name = "entityManager")
public EntityManager seamEntityManager(EntityManagerFactory emf) {
EntityManager em = SharedEntityManagerCreator.createSharedEntityManager(emf);
try { em.setFlushMode(FlushModeType.COMMIT); } catch (Exception ignored) {} // MANUAL ~ COMMIT
return em;
}
Dos decisiones cargadas de intención:
SharedEntityManagerCreator: crea un EntityManager compartido (un proxy thread-bound). Cada hilo ve el EM atado a su transacción Spring activa. Es el equivalente almanaged-persistence-contextque el legacy declaraba encomponents.xml.FlushModeType.COMMIT: el EM no auto-flushea ante cada query; sólo descarga al commit. En JPA no existe un modo "MANUAL" exacto;COMMITes el equivalente práctico. Esto es lo que obliga aEntityHomea flushear a mano (§6.2). PonerAUTOrompería el invariante de flush MANUAL.
El config de Seam, hecho código
En el legacy esto vivía en components.xml:
managed-persistence-context name="entityManager" scope="event" default-flush-mode="MANUAL".
Esas tres propiedades —nombre entityManager, scope de evento (vía el shared EM thread-bound), flush MANUAL— son exactamente lo que reproduce este @Bean.
8. @Transactional de Seam → transacciones de Spring¶
El legacy anota métodos con @org.jboss.seam.annotations.Transactional. Spring sólo entiende @org.springframework.transaction.annotation.Transactional. El puente es SeamTransactionAnnotationParser, un TransactionAnnotationParser que le enseña a Spring a reconocer la anotación de Seam.
public TransactionAttribute parseTransactionAnnotation(AnnotatedElement element) {
Transactional ann = AnnotatedElementUtils.findMergedAnnotation(element, Transactional.class);
if (ann == null) return null;
RuleBasedTransactionAttribute rule = new RuleBasedTransactionAttribute();
rule.setPropagationBehaviorName("PROPAGATION_" + map(ann.value()).name());
rule.getRollbackRules().add(new RollbackRuleAttribute(Throwable.class)); // Seam: rollback hasta en checked
return rule;
}
Dos matices que reproducen la semántica de Seam:
- Mapeo de propagación: el
TransactionPropagationTypede Seam (REQUIRED,SUPPORTS,MANDATORY,NEVER,REQUIRES_NEW) se traduce uno a uno alPropagationde Spring. - Rollback en checked exceptions: Spring por defecto no hace rollback ante excepciones checked. Seam sí. La regla
RollbackRuleAttribute(Throwable.class)fuerza rollback ante cualquierThrowable, igualando el comportamiento de Seam.
SeamCompatConfig cablea este parser dentro de un TransactionInterceptor y un advisor de infraestructura, todo marcado ROLE_INFRASTRUCTURE para que conviva sin chocar con el @EnableTransactionManagement estándar.
El @Transactional de Seam en el negocio nuevo es casi un marker
En clases reescritas como FacturacionService, el @Transactional que abre la transacción real es el de Spring (org.springframework.transaction.annotation.Transactional). El de Seam, donde sobrevive en código portado (p.ej. ParentEntityManager), queda como un marcador que este parser hace inocuo pero compatible. La transacción de verdad la maneja el JpaTransactionManager.
9. Mapa mental: qué traduce cada clase¶
Si tuvieras que recordar una sola tabla de esta página, es esta. Es el diccionario Seam ⇄ Spring del shim.
| Concepto Seam (lo que ve el negocio) | Quién lo implementa | A qué equivale en Spring |
|---|---|---|
@Name("x") |
SeamComponentRegistrar |
Bean registrado con nombre "x" |
@Scope(EVENT) |
SeamEventScope + registrar |
Scope custom "seamEvent" (ThreadLocal por invocación) |
@Scope(STATELESS / APPLICATION / SESSION) |
mapScope |
prototype / singleton / session |
Component.getInstance("x") |
Component + SeamBridgeInitializer |
applicationContext.getBean("x") |
.instance() (accessor estático) |
el propio componente | Llama a Component.getInstance → getBean |
extends Controller |
org.jboss.seam.framework.Controller |
Base con logging #0 + getEntityManager() |
extends EntityHome |
org.jboss.seam.framework.EntityHome |
CRUD JPA con flush explícito, update sin merge |
extends EntityQuery |
org.jboss.seam.framework.EntityQuery |
EJBQL + restricciones EL evaluadas a mano |
entityManager (managed PC, flush MANUAL) |
SeamCompatConfig.seamEntityManager |
Shared EM thread-bound, FlushModeType.COMMIT |
@Transactional (Seam) |
SeamTransactionAnnotationParser |
TransactionAttribute de Spring, rollback en checked |
| limpieza del scope EVENT | SeamEventScopeFilter + wrapper de job |
OncePerRequestFilter.finally → clear() |
Para seguir¶
- El comportamiento que el shim debe preservar al pie de la letra: invariantes.md.
- Cómo este shim sostiene los flujos de negocio reales (CAE/CAEA): ciclo-facturacion.md.
- El porqué y la historia de la decisión de migración: migracion-seam-spring.md.
- La referencia de persistencia (EntityManager, cache, dialecto): ../reference/persistencia.md.