Ingeniería inversa de entidades JPA
En el documento anterior vimos cómo Spring Boot se comunica con una base de datos a través de JDBC, Hibernate y Spring Data JPA. Ahora invertimos la dirección: en lugar de escribir las clases de entidad a mano y dejar que Hibernate genere el esquema, partimos de un esquema SQL existente y generamos las clases de entidad en tiempo de compilación.
El Problema
Escribir clases de entidades JPA a mano desde un esquema de base de datos existente es tedioso. Si tu esquema tiene 20 tablas conseguís 20 archivos de entidades para crear, anotar, y mantener sincronizados cada vez que el esquema cambia. Faltá una columna, obtené un nullable incorrecto, u olvídáte una relación y estás debuggeando en runtime.
La alternativa: generar las entidades en tiempo de compilación directamente desde el esquema SQL. Sin necesidad de una base de datos corriendo. Sin escritura de entidades manual. El build lee el esquema, levanta una instancia H2 in-memory, la introspecciona, y escribe los archivos fuente de entidades antes de que tu código ni siquiera compile.
Cómo Funciona
- Una configuración de dependencias Gradle dedicada
hibernateToolstrae las herramientas de generación de código, completamente aislada de tu classpath de compilación y runtime. - La tarea Gradle
generateEntitiescarga tu esquema SQL en una base de datos H2 in-memory, conecta vía JDBC, y usa Hibernate Tools para introspeccionar el esquema y escribir archivos fuente de entidades enbuild/generated/sources/hibernate/. - Ese directorio generado se registra como una root de fuente adicional, entonces las entidades compilan transparentemente junto a tu código escrito a mano.
- La tarea se configura para correr antes de
compileJava/compileKotlin/compileGroovy, entonces los tipos generados siempre están disponibles en tiempo de compilación.
Archivos Nuevos
Las adiciones resaltadas caen en dos grupos: archivos de recursos que conducen la generación (hibernate.reveng.xml, hibernate-tools.properties, Pojo.ftl, los archivos de esquema SQL y data) y los cambios en build.gradle que conectan todo.
Paso 1 — Agregá Archivos de Recursos
sakila-schema.sql
Esta es la fuente de verdad. El ejemplo que venimos siguiendo en esta guía usa la base de datos de muestra Sakila, pero el setup mismo aplica a cualquier esquema con el que estés trabajando. Solo reemplazá sakila-schema.sql por tu propio archivo DDL y actualizá la tarea en consecuencia.
Hibernate Tools nunca se conecta a tu base de datos real. Solo lee este archivo SQL, lo carga en una instancia H2 in-memory, y la introspecciona. Este archivo debe reflejar fielmente el esquema de producción. Si producción tiene una columna que tu archivo SQL no tiene, la entidad generada no la tendrá. Si los tipos no coinciden, los mapeos generados estarán mal. Tratá este archivo con el cuidado que le darías a la base de datos misma.
hibernate.reveng.xml
- Java
- Kotlin
- Groovy
El bloque <type-mapping> sobreescribe los mapeos de tipo JDBC-a-Java por defecto. Sin él, Hibernate Tools mapearía TIMESTAMP a un raw java.sql.Timestamp y TINYINT a un primitive byte. Las sobreescrituras te dan equivalentes java.time.* limpios y tipos boxed apropiados en su lugar.
La línea <table-filter> le dice a Hibernate Tools que haga reverse-engineer de cada tabla en el esquema PUBLIC, que es todo de Sakila.
hibernate-tools.properties
- Java
- Kotlin
- Groovy
hibernate.connection.driver_class=org.h2.Driver
hibernate.connection.username=sa
hibernate.connection.password=
hibernate.dialect=org.hibernate.dialect.H2Dialect
hibernate.connection.provider_class=org.hibernate.connection.DriverManagerConnectionProvider
hibernate.connection.driver_class=org.h2.Driver
hibernate.connection.username=sa
hibernate.connection.password=
hibernate.dialect=org.hibernate.dialect.H2Dialect
hibernate.connection.provider_class=org.hibernate.connection.DriverManagerConnectionProvider
hibernate.connection.driver_class=org.h2.Driver
hibernate.connection.username=sa
hibernate.connection.password=
hibernate.dialect=org.hibernate.dialect.H2Dialect
hibernate.connection.provider_class=org.hibernate.connection.DriverManagerConnectionProvider
Este archivo suministra los detalles de conexión JDBC estáticos. Fijate que no hay hibernate.connection.url acá. Esa propiedad se inyecta dinámicamente por la tarea Gradle en tiempo de generación, apuntando al archivo de esquema SQL vía una connection string H2 INIT=RUNSCRIPT. Mantenerlo fuera de este archivo evita hardcodear una ruta absoluta.
Pojo.ftl
- Java
- Kotlin
- Groovy
Un template FreeMarker que controla la forma exacta de cada clase de entidad generada. Hibernate Tools lo llama una vez por tabla y pasa un objeto pojo describiendo las columnas y relaciones de la tabla. El template decide qué anotaciones emitir, cómo manejar claves compuestas, y cómo conectar relaciones @OneToMany y @ManyToOne.
Los tres variantes de lenguaje comparten la misma lógica estructural — detección de clave compuesta, manejo de @Id / @EmbeddedId / @ManyToOne / @OneToMany / @Column — pero difieren en qué emiten:
- Java usa Lombok (
@Getter,@Setter,@Builder, etc.) y camposprivate. - Kotlin tiene un helper FreeMarker
toKotlinType()que convierte nombres de tipos Java a equivalentes Kotlin (Integer→Int,Set<X>→MutableSet<X>). Las clases de claves compuestas se vuelvendata classpara igualdad estructural; las entidades regulares son plainclasspara evitar problemas de proxying de JPA. - Groovy reemplaza Lombok por Groovy AST transforms (
@CompileStatic,@Builder,@EqualsAndHashCode) y elimina la visibilidad explícita de campo ya que el mecanismo de propiedad de Groovy maneja eso.
Porque Hibernate Tools siempre emite archivos .java, las tareas de Kotlin y Groovy incluyen un paso post-generación que convierte .java a .kt y .java a .groovy respectivamente. La tarea de Java se saltea esto enteramente.
Paso 2 — Conectá build.gradle
- Java
- Kotlin
- Groovy
configurations {
compileOnly {
extendsFrom annotationProcessor
}
hibernateTools
}
def h2Version = '2.4.240'
def hibernateVersion = '7.2.6.Final'
hibernateTools "com.h2database:h2:${h2Version}"
hibernateTools "org.hibernate.tool:hibernate-tools-ant:${hibernateVersion}"
hibernateTools "org.hibernate.orm:hibernate-core:${hibernateVersion}"
developmentOnly "com.h2database:h2:${h2Version}"
testRuntimeOnly "com.h2database:h2:${h2Version}"
developmentOnly 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
import java.net.URLClassLoader
import java.util.Properties
val hibernateTools by configurations.creating
val h2Version = "2.4.240"
val hibernateVersion = "7.2.6.Final"
hibernateTools("com.h2database:h2:$h2Version")
hibernateTools("org.hibernate.tool:hibernate-tools-ant:$hibernateVersion")
hibernateTools("org.hibernate.orm:hibernate-core:$hibernateVersion")
developmentOnly("com.h2database:h2:$h2Version")
testRuntimeOnly("com.h2database:h2:$h2Version")
developmentOnly("org.springframework.boot:spring-boot-h2console")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
val hibernateGeneratedSourcesDir = layout.buildDirectory.dir("generated/sources/hibernate")
configurations {
compileOnly {
extendsFrom annotationProcessor
}
hibernateTools
}
def h2Version = '2.4.240'
def hibernateVersion = '7.2.6.Final'
hibernateTools "com.h2database:h2:${h2Version}"
hibernateTools "org.hibernate.tool:hibernate-tools-ant:${hibernateVersion}"
hibernateTools "org.hibernate.orm:hibernate-core:${hibernateVersion}"
developmentOnly "com.h2database:h2:${h2Version}"
testRuntimeOnly "com.h2database:h2:${h2Version}"
developmentOnly 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
spotless {
groovy {
target 'src/**/*.groovy'
importOrder()
removeSemicolons()
greclipse().configFile('greclipse.properties')
}
groovyGradle {
target '*.gradle'
greclipse().configFile('greclipse.properties')
}
}
tasks.named('compileGroovy') {
dependsOn 'generateEntities'
}
Hay algunas cosas que vale la pena destacar:
- Configuración
hibernateToolses un bucket de dependencias Gradle separado que existe solo para generación de código. Contiene el Hibernate Tools Ant runner, Hibernate ORM core, y H2, ninguno de los cuales pertenece en el classpath de compilación o runtime de tu aplicación. Mantenerlos acá significa que hacen su trabajo en tiempo de build y desaparecen. - Dependencias de runtime y compilación agregan los jars estándares de Spring Data JPA y H2 que tu aplicación necesita en runtime.
Tarea generateEntities hace el trabajo en tres pasos:
- Lee
hibernate-tools.properties, agrega unahibernate.connection.urldinámica que apunta H2 al archivo de esquema SQL víaINIT=RUNSCRIPT FROM '...', y escribe las propiedades combinadas a un archivo temporal. - Llama a la tarea Ant
HibernateToolTask, pasando ese archivo temporal de propiedades,hibernate.reveng.xml, el directorio de salida, y el directorio de templates FreeMarker. Hibernate Tools hace el resto. - Renombra los archivos
.javagenerados a.kto.groovydonde sea necesario (solo Kotlin y Groovy).
Registro de source set agrega build/generated/sources/hibernate/ al source set apropiado para que el compilador lo detecte. Kotlin conecta generateEntities antes de compileKotlin y la tarea de generación de kapt stub, ya que el procesamiento de anotaciones corre incluso más temprano y también necesita los tipos generados disponibles.
Qué se Genera
Después de correr ./gradlew generateEntities (o cualquier tarea que dependa de ella, como build), encontrarás archivos fuente de entidades bajo build/generated/sources/hibernate/<group>/<name>/generated/entity/
Cada tabla en el esquema se vuelve una clase de entidad, compilada transparentemente junto a tu código escrito a mano. No referencías este directorio manualmente. El registro del source set se encarga de eso.
Sobre los comentarios // TODO: Unknown mapping type "EnhancedBasicValue"
Es probable que veas comentarios como este esparcidos por los archivos generados:
// TODO: Unknown mapping type "EnhancedBasicValue" for property firstName
private String firstName;
EnhancedBasicValue es un tipo interno de Hibernate ORM 7.x que reemplazó al antiguo BasicValue. El template FreeMarker de hibernate-tools-ant que genera el código fuente Java todavía no lo reconoce. Es una brecha de compatibilidad entre la versión de las herramientas y la del ORM. Cuando el template encuentra un tipo de mapeo no reconocido, se limita a emitir el campo sin anotaciones y deja un comentario TODO en lugar de generar la anotación @Column(name = "FIRST_NAME") completa.
Por qué las pruebas siguen pasando
El SpringPhysicalNamingStrategy por defecto de Spring Boot convierte automáticamente los nombres de campo camelCase a snake_case en los nombres de columna: firstName → first_name. H2 es case-insensitive y PostgreSQL almacena nombres de columna en minúsculas por defecto, así que ambos coinciden con lo que produce la estrategia. Las anotaciones @Column faltantes no generan ningún desajuste.
Cuándo se convertiría en un problema
- Si un nombre de columna no sigue la convención camelCase → snake_case.
- Si necesitás anotaciones
@Columnexplícitas para documentación o para sobreescribir la estrategia de nombres.
Para un proyecto de demo que usa la estrategia de nombres por defecto contra Sakila (que sigue snake_case consistentemente), las entidades generadas funcionan correctamente tal cual están. Los TODOs son ruido de una incompatibilidad conocida entre hibernate-tools y Hibernate 7.x. Podés ignorarlos.
Cambios de Esquema en el Mundo Real
Cómo Spring habla con las bases de datos cubre enfoques code-first vs. database-first en detalle. La versión corta: code-first es cómodo para desarrollo local, database-first (Flyway, Liquibase) es más seguro para producción.
Con ingeniería inversa, el proceso se acerca más al enfoque database-first — pero el paso de "ponerse al día" es automatizado. Supongamos que producción necesita una columna nueva, last_updated en la tabla film. Actualizás sakila-schema.sql para incluir la columna nueva, entonces corrés ./gradlew generateEntities (o solo build). La tarea re-introspecciona el esquema y regenera todos los archivos de entidades. El nuevo campo aparece automáticamente en la entidad — no lo tocás a mano.
La contrapartida es que vos sos el dueño del archivo SQL. Cuando la base de datos real cambia, necesitás mantener sakila-schema.sql sincronizado, sino las entidades generadas se desvían de la realidad. En la práctica esto significa que tu migración de esquema (Flyway, Liquibase, o un script SQL plano) y la actualización a tu archivo SQL local deberían pasar juntos, idealmente en el mismo commit.
Visión Honesta: Probablemente No Verás Esto en la Naturaleza
En todos los codebases reales que se cruzan en el camino, las entidades fueron escritas a mano. El enfoque de ingeniería inversa apareció exactamente una vez en un proyecto de producción.
La mayoría de equipos o comenzaron su proyecto desde cero (esquema y entidades crecen juntos) o heredaron un codebase legacy donde alguien ya escribió las entidades años atrás. El caso de uso para generarlas desde SQL en tiempo de build es genuinamente acotado: tenés un esquema existente que no controlás, necesitás entidades JPA rápido, y no querés escribir cientos de campos a mano.
Entonces mantené esto en tu toolkit. Entendé qué hace el tooling y cómo se configura. Solo no te sorprendas si pasás una carrera entera y nunca lo encontrás en un codebase.