core-persistence
Qué problema resuelve
La mayoría de los proyectos nuevos de EchoTechs corren sobre Postgres, pero varios clientes existentes corren sobre Oracle. Cada deployment usa un solo motor — el problema no es sincronizar dos bases, sino que el mismo código y las mismas migraciones funcionen idénticamente en ambos sin que el código de dominio sepa cuál está debajo.
core-persistence es la pieza más pequeña del core y también la más transversal: aporta una única clase
(CoreEntity) y un único changeset de Liquibase (la creación del esquema core), que todos los demás
módulos con tablas propias heredan e incluyen. Su valor está en las decisiones de portabilidad que fija:
si toda entidad del core usa el mismo tipo de id y el mismo mapeo de columnas, no hay divergencia posible
entre motores.
Cómo se agrega
Normalmente no hace falta declararlo: core-auth, core-storage, core-workflow y core-config ya
lo exponen como api(...). Declaralo explícitamente sólo si tus propias entidades extienden CoreEntity
sin depender de ningún otro módulo del core.
dependencies {
implementation("dev.echotechs.core:core-persistence:0.1.0-SNAPSHOT")
}
Trae transitivamente (todas como api): spring-boot-starter-data-jpa, liquibase-core,
spring-boot-liquibase, spring-boot-autoconfigure y spring-boot-persistence.
:::info @EntityScan vive en su propio artefacto en Boot 4.1
org.springframework.boot.persistence.autoconfigure.EntityScan — no el paquete autoconfigure.domain de
Boot 3.x. Por eso spring-boot-persistence es una dependencia explícita del módulo.
:::
API pública
CoreEntity
dev.echotechs.core.persistence.CoreEntity — base de toda entidad del core.
@MappedSuperclass
public abstract class CoreEntity {
@Id
@UuidGenerator
@JdbcTypeCode(SqlTypes.CHAR)
@Column(name = "id", updatable = false, nullable = false, length = 36)
private UUID id;
public UUID getId();
@Override public boolean equals(Object o);
@Override public int hashCode();
}
Es la única clase pública del módulo. Tres decisiones vienen empaquetadas en ella:
@UuidGenerator— el id es un UUID v4 generado en la aplicación, no por la base. Un UUID aleatorio no necesita ninguna estrategia de generación específica del vendor, así que Postgres y Oracle se comportan idénticamente con cero configuración divergente.@JdbcTypeCode(SqlTypes.CHAR)— fuerza la columna a la forma canónica de 36 caracteres, en vez del default dependiente del dialecto (uuidnativo en Postgres,RAW(16)binario en Oracle). Una sola forma de columna, DDL idéntico en ambos motores.equals/hashCodeseguros para proxies —equalscompara víaHibernate.getClass(...)(nogetClass()) para que una entidad y su proxy lazy se consideren iguales;hashCodees constante por clase, que es lo correcto cuando el id se asigna recién al persistir.
:::caution hashCode() es constante por clase
Objects.hashCode(getClass().hashCode()) devuelve el mismo valor para todas las instancias del mismo
tipo. Es correcto (una entidad no cambia de hash al recibir su id), pero significa que un HashSet grande
de entidades del mismo tipo degrada a búsqueda lineal. No metas miles de entidades en un HashSet.
:::
El changeset del esquema core
classpath:db/changelog/core-persistence/changelog-master.xml crea el esquema core:
<changeSet id="core-persistence-001-create-schema" author="platform-core" dbms="postgresql,h2">
<sql>CREATE SCHEMA IF NOT EXISTS core;</sql>
</changeSet>
No lo incluyas vos: cada módulo del core con tablas propias ya lo incluye primero desde su
changelog-master.xml, y Liquibase deduplica por id+author+file sin importar cuántas veces se
incluya transitivamente.
Fijate en dbms="postgresql,h2": en Oracle no corre. Ahí un esquema es un usuario de base de datos, y
crearlo es un paso de infraestructura (credenciales, cuotas de tablespace), no algo que una migración
portable deba hacer. Aprovisioná ese esquema fuera de banda antes de correr el changelog contra Oracle.
Ejemplo de uso
Adaptado de core-persistence/src/test/java/.../PortabilityProbe.java y su changeset, que es la entidad
que la matriz de CI usa para probar portabilidad:
package com.tuproyecto.purchase;
import dev.echotechs.core.persistence.CoreEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
@Entity
@Table(name = "purchase_request", schema = "app")
public class PurchaseRequest extends CoreEntity {
@Column(name = "title", nullable = false, length = 255)
private String title;
@Lob
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
protected PurchaseRequest() {
}
public PurchaseRequest(String title, String notes) {
this.title = title;
this.notes = notes;
}
public String getTitle() {
return title;
}
public String getNotes() {
return notes;
}
}
El changeset correspondiente — fijate en CHAR(36) para el id, que es lo que
@JdbcTypeCode(SqlTypes.CHAR) espera:
<changeSet id="app-001-purchase-request" author="tuproyecto">
<createTable tableName="purchase_request" schemaName="app">
<column name="id" type="CHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="title" type="VARCHAR(255)">
<constraints nullable="false"/>
</column>
<column name="notes" type="TEXT"/>
</createTable>
</changeSet>
Y el repositorio, sin nada especial:
public interface PurchaseRequestRepository extends JpaRepository<PurchaseRequest, UUID> {
}
Probar portabilidad en tu propio proyecto
El patrón que usa el módulo: una clase abstracta con las aserciones, y una subclase por motor que sólo
aporta el contenedor. Extraído de AbstractPortabilityIntegrationTest / PostgresPortabilityIntegrationTest:
@Testcontainers
class PostgresPortabilityIntegrationTest extends AbstractPortabilityIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.liquibase.change-log",
() -> "classpath:db/changelog/core-persistence-test/changelog-master.xml");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
}
Y la variante Oracle, con dos detalles que no son obvios:
@Container
static final OracleContainer ORACLE = new OracleContainer("gvenzl/oracle-free:slim-faststart")
.withUsername("core")
.withPassword("CoreTest123")
.withStartupTimeout(Duration.ofMinutes(5));
withUsername("core")hace que el esquema default del usuario satisfaga@Table(schema = "core")sin ningún aprovisionamiento extra — en Oracle un esquema es un usuario.- El timeout default de
OracleContaineres de 60 segundos, suficiente cuando la imagen (~2 GB) ya está cacheada, pero no para un pull en frío en un runner de CI nuevo.
Las aserciones que corren contra ambos motores cubren: que el id se genere como UUID en forma canónica,
que una columna TEXT acepte 5000 caracteres, y que Pageable produzca páginas consistentes.
Errores comunes
Schema-validation: missing table [core.xxx] al arrancar contra Oracle.
El changeset del esquema core no corre en Oracle (dbms="postgresql,h2"). Aprovisioná el usuario/esquema
fuera de banda antes del primer despliegue, o conectá con un usuario cuyo esquema default sea core.
Un String mapea a VARCHAR(255) y falla contra una columna TEXT.
Un campo String sin más se mapea a VARCHAR(255), y ddl-auto: validate lo rechaza contra una columna
TEXT/CLOB. Necesitás @Lob y columnDefinition:
@Lob
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
spring.jpa.hibernate.ddl-auto en algo distinto de validate.
Todos los application.yml de test del core usan validate. Si dejás que Hibernate genere el DDL, el
esquema real deja de ser el que describen los changesets y la matriz de portabilidad ya no prueba nada.
Las entidades del core no aparecen.
Cada módulo del core declara su propio @EntityScan/@EnableJpaRepositories apuntando a sus paquetes,
porque viven fuera del árbol de paquetes de tu aplicación. Si escribiste tu propio @EntityScan global,
asegurate de no estar sobrescribiendo esa configuración.
Notas de implementación
Diferencias entre platform-core-spec.md y lo que hace el código:
Generación de id: el spec pide secuencias, el código usa UUID aleatorio.
La sección 1.2 del spec dice: "Generación de ID por secuencia (SequenceStyleGenerator), no
IDENTITY". El código usa @UuidGenerator (v4, generado en la aplicación), sin secuencia ni IDENTITY.
El Javadoc de CoreEntity documenta el cambio como deliberado: un UUID aleatorio no necesita ninguna
estrategia de generación específica del vendor, lo cual cumple el objetivo de portabilidad del spec de
forma más directa que una secuencia. Es una divergencia intencional y documentada, no un descuido.
Separación de esquemas: el spec describe core + app, el código sólo crea core.
El spec (1.2) propone dos esquemas y, en Oracle, dos usuarios (CORE_OWNER, APP_OWNER) con grants
cruzados. El código sólo crea core, y sólo en Postgres/H2. El esquema app y todo el aprovisionamiento
de Oracle quedan como responsabilidad del proyecto de dominio / DBA.
No hay columnas de auditoría en CoreEntity.
CoreEntity aporta únicamente el id. No hay createdAt/updatedAt/createdBy heredados: cada entidad
del core que los necesita los declara por su cuenta (UserSession.createdAt, ApiKey.createdAt,
ConfigEntry.createdAt/updatedAt, etc.). El spec no los pedía explícitamente, pero conviene saberlo
antes de asumir que extender CoreEntity te da auditoría.
@Lob + columnDefinition = "TEXT" es la convención real para texto largo.
El spec dice "se guarda como TEXT/CLOB con serialización a nivel de aplicación", sin precisar el
mapeo. En el código el patrón consistente es @Lob junto con columnDefinition = "TEXT" — está así en
ApiKey.scopes, ServiceAccountClient.scopes, WorkflowTransition.reason, ConfigEntry.value y
PortabilityProbe.notes.