Saltar al contenido principal
Versión: main (sin publicar)

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.

build.gradle.kts
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:

  1. @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.
  2. @JdbcTypeCode(SqlTypes.CHAR) — fuerza la columna a la forma canónica de 36 caracteres, en vez del default dependiente del dialecto (uuid nativo en Postgres, RAW(16) binario en Oracle). Una sola forma de columna, DDL idéntico en ambos motores.
  3. equals/hashCode seguros para proxiesequals compara vía Hibernate.getClass(...) (no getClass()) para que una entidad y su proxy lazy se consideren iguales; hashCode es 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:

Tu entidad de dominio
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:

db/changelog/app/001-purchase-request.xml
<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 OracleContainer es 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.