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.
@EntityScan vive en su propio artefacto en Boot 4.1org.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();
}
Era la única clase pública del módulo hasta la convención de catálogos (spec 1.2, ver más abajo) — ahora
comparte el módulo con dev.echotechs.core.persistence.catalog.*. Tres decisiones vienen empaquetadas en
CoreEntity:
@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.
hashCode() es constante por claseObjects.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.
Convención de catálogos (spec 1.2) — dev.echotechs.core.persistence.catalog
Verificado end-to-end contra Postgres y Oracle reales (Testcontainers), no sólo compilado — la
combinación @SoftDelete + bulk delete tenía un comportamiento real que un test superficial no habría
agarrado (ver la nota debajo).
public interface Activatable {
boolean isActive();
void activate();
void deactivate();
}
Sin ninguna relación con soft/hard delete — activo/inactivo es una decisión de negocio ("¿se sigue ofreciendo para uso nuevo?"), no una decisión técnica de si la fila existe.
@MappedSuperclass
@SoftDelete(columnName = "deleted")
public abstract class SoftDeletableEntity extends CoreEntity {}
@SoftDelete es nativo de Hibernate (6.4+; este repo corre 7.4.1.Final vía el BOM de Boot 4.1) — convierte
cualquier delete()/remove() en un UPDATE, y excluye filas marcadas de toda query futura contra esa
entidad, automáticamente.
@NoRepositoryBean
public interface CatalogRepository<T> extends JpaRepository<T, UUID>, JpaSpecificationExecutor<T> {
void purge(UUID id);
}
purge() no tiene implementación genérica — y no puede tenerlaEl diseño original (calcado del spec) era @Query("DELETE FROM #{#entityName} e WHERE e.id = :id"). Roto
en la práctica: el filtro de @SoftDelete se aplica también a DELETE/UPDATE en bulk JPQL, no sólo a
SELECT — así que ese DELETE no encuentra nunca la fila que ya está soft-deleted (que es exactamente el
caso de uso real de purge: vaciar la papelera). Verificado con un test que primero soft-borra y después
llama purge() — contaba 1 fila física después del "hard delete" hasta que se cambió el enfoque.
La única forma real de bypasear el filtro es SQL nativo contra la tabla física, y eso necesita el nombre de
tabla real — no hay forma 100% genérica. Cada repositorio concreto implementa su propio purge():
public interface UserAccountRepository extends CatalogRepository<UserAccount> {
@Override
@Modifying
@Query(value = "DELETE FROM core.user_account WHERE id = :#{#id.toString()}", nativeQuery = true)
void purge(UUID id);
}
El :#{#id.toString()} (SpEL) también es necesario: una query nativa no aplica la conversión
@JdbcTypeCode(CHAR) de la entidad, así que un UUID pasado directo se manda como el tipo uuid nativo de
Postgres y falla contra una columna CHAR ("operator does not exist: character = uuid").
Requiere una transacción activa en el punto de llamada (es una query @Modifying) — a diferencia de
save()/delete(), que Spring Data envuelve en su propia transacción implícita por método,
@Modifying/@Query no lo hace. Llamalo desde un método de servicio @Transactional.
public record CatalogFilters(String text, Boolean active) {}
public abstract class CatalogServiceBase<T> {
public Page<T> list(CatalogFilters filters, Pageable pageable);
protected Specification<T> activeFilter(Boolean active); // default: sin filtro
protected Specification<T> textSearch(String text); // default: sin filtro
protected Specification<T> customFilters(CatalogFilters filters); // default: sin filtro
}
Specification.and(null) no es un no-op en esta versiónLa cadena clásica Specification.where(spec).and(other) asume que .and(null) se ignora — en Spring Data
JPA 4.1 lanza IllegalArgumentException: Other specification must not be null. CatalogServiceBase.list
guarda explícitamente contra null antes de combinar, en vez de encadenar directo.
UserAccount (core-auth, ver su propia página) es el primer consumidor real: implementa Activatable
(desactivar, no borrar) sin @SoftDelete (el spec pide desactivar ahí, no papelera).
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>
Incluilo vos mismo, una sola vez, primero en el changelog master de tu proyecto — antes de los
changelogs de core-auth/core-storage/core-workflow/core-config que uses:
<include file="db/changelog/core-persistence/changelog-master.xml"/>
<include file="db/changelog/core-auth/changelog-master.xml"/>
<include file="db/changelog/core-storage/changelog-master.xml"/>
<!-- tus propios changesets debajo -->
Ninguno de core-auth/core-storage/core-workflow/core-config incluye este changelog desde su propio
changelog-master.xml — y es a propósito. Liquibase deduplica changesets repetidos entre corridas
(uno ya ejecutado no se repite), pero no dentro de un mismo parseo: si cada módulo se auto-incluyera y
tu proyecto combinara más de uno, Liquibase vería el mismo id+author+file cuatro veces en un solo
árbol y fallaría con ValidationFailedException: duplicate identifiers — exactamente lo que pasó al
armar reference-app, que combina los cuatro. La responsabilidad de incluir este changelog es de tu
proyecto, una sola vez.
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.