Saltar a contenido

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:

  1. Escanea el paquete de negocio (com.tipre.tifacturaonlinemanager) buscando clases anotadas con @Name.
  2. Por cada una, lee el @Name (el nombre del bean) y el @Scope (si falta, asume EVENT).
  3. Construye un GenericBeanDefinition con 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, un OncePerRequestFilter, llama a clear() en el finally de 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 el Map del 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 el getInstance(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() y remove() hacen flush() explícito. El EntityManager corre en modo COMMIT (equivalente práctico a MANUAL), o sea no auto-flushea a mitad de transacción. Si EntityHome no flusheara a mano, los cambios no irían a la DB en el momento que el legacy espera.
  • update() SOLO hace flush(). No hace merge(). El legacy asume que la entidad ya está managed en el EntityManager del evento. Agregar un merge() 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 al managed-persistence-context que el legacy declaraba en components.xml.
  • FlushModeType.COMMIT: el EM no auto-flushea ante cada query; sólo descarga al commit. En JPA no existe un modo "MANUAL" exacto; COMMIT es el equivalente práctico. Esto es lo que obliga a EntityHome a flushear a mano (§6.2). Poner AUTO romperí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 TransactionPropagationType de Seam (REQUIRED, SUPPORTS, MANDATORY, NEVER, REQUIRES_NEW) se traduce uno a uno al Propagation de Spring.
  • Rollback en checked exceptions: Spring por defecto no hace rollback ante excepciones checked. Seam sí. La regla RollbackRuleAttribute(Throwable.class) fuerza rollback ante cualquier Throwable, 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.getInstancegetBean
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.finallyclear()

Para seguir