Identificar en qué estado está una entidad y qué hará Hibernate con ella.
Aprovechar el dirty checking sin llamadas a save() innecesarias.
Explicar cómo funciona @Transactional por dentro y sus trampas.
Gestionar el esquema con migraciones versionadas en lugar de ddl-auto.
1
Los 4 Estados de una Entidad en el EntityManager
Transient (nueva en memoria con new), Managed (vigilada por la sesión activa de Hibernate), Detached (transacción cerrada; cambios ya no se sincronizan) y Removed (marcada para DELETE).
En una línea Al llamar a repo.findById(id), la entidad entra directamente al estado MANAGED.
Los cuatro estados en una transacciónjava
@Transactionalpublicvoidciclo() {
vartarea=newTarea("Leer JEP 444"); // NUEVA: Hibernate no la conoce
em.persist(tarea); // GESTIONADA: se insertará en el flushTareaa= em.find(Tarea.class, 7L); // GESTIONADA: SELECTTareab= em.find(Tarea.class, 7L); // misma instancia, sin SELECT
System.out.println(a == b); // true: identidad garantizada
em.remove(a); // ELIMINADA: DELETE en el flush
} // commit -> flush -> el contexto se cierrapublicvoidfuera() {
Tareat= repo.findById(7L).orElseThrow(); // la transacción de findById ya terminó
t.renombrar("Nuevo título"); // SEPARADA: este cambio NO se guarda
}
Cómo funciona por dentro
El contexto de persistencia es un mapa de entidades gestionadas que vive mientras dura la transacción (en Spring, por defecto, una por transacción). Una entidad puede estar en cuatro estados respecto a él.
Nueva (transient): creada con new, Hibernate no sabe que existe.
Gestionada (managed): está en el contexto, ya sea porque la cargaste con find o una consulta, o porque llamaste a persist. Hibernate vigila sus cambios.
Separada (detached): estuvo gestionada pero el contexto se cerró. Tiene id, pero sus cambios ya no se sincronizan.
Eliminada (removed): marcada con remove; se borrará en el próximo flush.
El contexto también garantiza identidad: dentro de una transacción, cargar dos veces la misma fila devuelve exactamente el mismo objeto Java y la segunda vez no hay consulta. Por eso se le llama caché de primer nivel.
Es la base de todo el funcionamiento del ORM; sin entender los estados, se producen fallos inexplicables de actualización.
Creer que llamar a repo.save() es obligatorio para guardar modificaciones en una entidad Managed.
Idéntico en 17 y 21.
Se conecta conHibernateEntityManagerDirty Checking
2
Dirty Checking: Sincronización Automática con la BD
Al finalizar un método @Transactional, Hibernate compara el estado actual de todas las entidades Managed contra el snapshot que tomó al cargarlas. Si detecta diferencias, emite el UPDATE SQL automáticamente.
En una líneatarea.actualizarTitulo("Nuevo"); dentro de @Transactional actualiza la BD sin llamar a save().
Actualizar sin save()java
@ServiceclassTareaService {
privatefinal TareaRepository repo;
TareaService(TareaRepository repo) { this.repo = repo; }
@Transactionalpublicvoidcompletar(long id, Clock reloj) {
Tareatarea= repo.findById(id)
.orElseThrow(() -> newTareaNoEncontradaException(id));
tarea.completar(reloj); // solo se modifica el objeto
} // commit: flush detecta el cambio y emite el UPDATE@Transactional(readOnly = true)// sin snapshots ni dirty checkingpublicList<TareaResponse> pendientes() {
return repo.findByCompletadaFalse().stream().map(TareaResponse::desde).toList();
}
}
// Con spring.jpa.show-sql=true verás:// select t1_0.id, t1_0.titulo, ... from tarea t1_0 where t1_0.id=?// update tarea set completada=?, completada_en=?, titulo=? where id=?
Cómo funciona por dentro
Cuando Hibernate carga una entidad, guarda una copia de sus valores (snapshot). Antes de confirmar la transacción ejecuta un flush: compara cada entidad gestionada con su snapshot y genera un UPDATE por cada una que haya cambiado. A esto se le llama dirty checking.
La consecuencia es que dentro de una transacción no hace falta llamar a save() para guardar cambios de una entidad cargada: basta con modificarla. Llamar a save() no rompe nada, pero si la entidad está separada, Spring Data hace merge, que puede lanzar un SELECT adicional.
El flush ocurre al hacer commit, antes de ejecutar una consulta JPQL que podría verse afectada por cambios pendientes, o si llamas a flush() a mano. Con @Transactional(readOnly = true) Hibernate desactiva el dirty checking y ahorra memoria y CPU, porque no necesita snapshots.
Ahorra cientos de llamadas redundantes a repo.save() y garantiza que solo se modifiquen los campos que realmente cambiaron.
Poner repo.save(entidad) al final de un método transaccional tras modificar un setter.
Optimizado en Hibernate 6 (Spring Boot 3).
Se conecta con@TransactionalEntityManagerRendimiento
3
Proxies Dinámicos de @Transactional
Spring envuelve tu servicio en un proxy CGLIB. Al entrar abre la transacción de base de datos; al salir sin excepciones hace commit; si ocurre una RuntimeException hace rollback.
En una línea@Transactional public void transferir(...) { ... }
Lo que hace el proxy, en pseudocódigojava
// Lo que Spring genera conceptualmente para TareaServiceclassTareaService$$ProxyextendsTareaService {
privatefinal TareaService objetivo;
privatefinal PlatformTransactionManager tm;
@Overridepublicvoidcompletar(long id, Clock reloj) {
TransactionStatustx= tm.getTransaction(newDefaultTransactionDefinition());
try {
objetivo.completar(id, reloj); // tu código
tm.commit(tx);
} catch (RuntimeException | Error e) {
tm.rollback(tx); // rollback por defectothrow e;
} catch (Exception checked) {
tm.commit(tx); // ¡las comprobadas hacen COMMIT por defecto!throw checked;
}
}
}
// Auditoría que debe persistir aunque falle la operación principal@Transactional(propagation = Propagation.REQUIRES_NEW)publicvoidregistrarIntento(String accion) { auditoriaRepo.save(newAuditoria(accion)); }
Cómo funciona por dentro
Spring no modifica tu clase. Al crear el bean, detecta @Transactional y registra en su lugar un proxy que envuelve al objeto real. Cuando otro bean llama a un método, en realidad llama al proxy, que abre la transacción (o se une a una existente), delega en tu objeto y, al volver, hace commit; si salió una excepción no comprobada, hace rollback.
De ahí salen las reglas que hay que conocer: solo funciona en llamadas que atraviesan el proxy (desde otro bean), no en llamadas internas con this; los métodos private no se interceptan; y la propagación por defecto (REQUIRED) hace que un método transaccional llamado desde otro se una a la transacción existente, de modo que una excepción en el interno marca toda la transacción para rollback.
Otras propagaciones útiles: REQUIRES_NEW suspende la actual y abre otra independiente (por ejemplo, para registrar una auditoría aunque la operación principal falle).
Permite desentenderse de la gestión manual de transacciones que sufrimos en el Día 5 con JDBC.
Llamar a un método @Transactional desde otro método dentro de la misma clase (this.metodo()), donde el proxy no interviene y la transacción NO se crea.
Idéntico en 17 y 21.
Se conecta conAOP ProxiesRollbackACID
4
Por qué las Entidades JPA NO pueden ser Records
JPA e Hibernate requieren que las entidades tengan constructor sin argumentos, campos mutables para lazy loading y clases que puedan ser extendidas por proxies dinámicos (los records son final e inmutables).
En una líneaClase con atributos privados, constructor protegido sin args y métodos de negocio.
Entidad JPA bien construidajava
@Entity@Table(name = "tarea")publicclassTarea {
@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;
@Column(nullable = false, length = 120)private String titulo;
privateboolean completada;
private Instant completadaEn;
@ManyToOne(fetch = FetchType.LAZY, optional = false)// LAZY: ManyToOne es EAGER por defectoprivate Proyecto proyecto;
@Versionprivatelong version; // bloqueo optimistaprotectedTarea() { } // para JPA, no para tipublicTarea(String titulo, Proyecto proyecto) {
this.titulo = Objects.requireNonNull(titulo);
this.proyecto = Objects.requireNonNull(proyecto);
}
publicvoidcompletar(Clock reloj) {
if (completada) thrownewIllegalStateException("Ya completada");
completada = true;
completadaEn = Instant.now(reloj);
}
@Overridepublicbooleanequals(Object o) {
return o instanceof Tarea t && id != null && id.equals(t.id);
}
@OverridepublicinthashCode() { return Tarea.class.hashCode(); } // estable antes y después de persistir
}
Cómo funciona por dentro
JPA exige de una entidad cosas que un record no puede ofrecer. Necesita un constructor sin argumentos para instanciarla por reflexión antes de rellenar los campos, y un record solo tiene el constructor canónico. Necesita campos modificables para asignar el id generado y para el dirty checking, y los de un record son final. Y para la carga perezosa crea subclases proxy de la entidad, imposible si la clase es final, como lo es todo record.
Una entidad bien escrita tiene un constructor protegido sin argumentos para JPA, un constructor público que garantiza un estado válido, métodos de negocio en lugar de setters y un equals/hashCode estable. Este último es delicado: si usas el id generado, cambia al persistir. Una opción segura es comparar por id solo si no es nulo y usar un hashCode constante por clase.
Los records sí brillan alrededor de las entidades: como DTOs, como proyecciones de consultas y como objetos de valor embebibles (@Embeddable admite records desde Hibernate 6.2).
Pregunta recurrente en entrevistas: los records son para DTOs; las entidades de base de datos deben ser clases normales.
Poner @Entity sobre un public record TareaEntity(...) y sorprenderse de que falle al compilar.
Aplica tanto a Java 17 como a Java 21.
Se conecta conRecords vs EntitiesHibernate ProxiesArquitectura
5
Desactivar ddl-auto=update en Entornos Reales
Dejar que Hibernate altere el esquema en producción es un peligro crítico de pérdida de datos. En producción debe usarse validate o none junto con Flyway.
En una líneaspring.jpa.hibernate.ddl-auto=validate en application.yml.
Flyway en lugar de ddl-autoyaml
# application.ymlspring:jpa:hibernate:ddl-auto:validate# Hibernate solo comprueba, nunca modificaopen-in-view:false# evita consultas perezosas desde la vista/serializaciónflyway:enabled:true# con flyway-core en el classpath se ejecuta al arrancar# src/main/resources/db/migration/V1__crear_tarea.sql# CREATE TABLE tarea (# id BIGSERIAL PRIMARY KEY,# titulo VARCHAR(120) NOT NULL,# completada BOOLEAN NOT NULL DEFAULT FALSE,# completada_en TIMESTAMPTZ,# version BIGINT NOT NULL DEFAULT 0# );## src/main/resources/db/migration/V2__anadir_prioridad.sql# ALTER TABLE tarea ADD COLUMN prioridad SMALLINT NOT NULL DEFAULT 5;
Cómo funciona por dentro
spring.jpa.hibernate.ddl-auto=update hace que Hibernate compare las entidades con la base de datos al arrancar y añada lo que falta. Es cómodo en un prototipo y peligroso en cualquier otro sitio: nunca borra columnas ni las renombra (un renombrado crea una columna nueva vacía), no migra datos, no deja historial de qué cambió ni cuándo, y dos instancias arrancando a la vez pueden pelearse.
En entornos reales el esquema se gestiona con migraciones versionadas: Flyway o Liquibase. Cada cambio es un fichero SQL con número de versión (V3__anadir_prioridad.sql) que se sube al repositorio con el código, se revisa en el pull request y se aplica una sola vez en orden. Flyway guarda en la tabla flyway_schema_history qué migraciones se aplicaron y con qué checksum.
Con migraciones, Hibernate debe quedarse en validate (comprueba que entidades y esquema coinciden y falla al arrancar si no) o en none.
Garantiza que la base de datos se modifique mediante scripts SQL auditados y versionados.
Perder una columna o tabla en producción porque Hibernate decidió recrear el esquema al arrancar.
Idéntico en 17 y 21.
Se conecta conFlywayDevOpsProducción
Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.
Pregunta a responder. ¿Por qué el constructor sin argumentos de la entidad puede ser protected?
Resultado esperado. Entidad TareaEntity bien encapsulada con repositorio JpaRepository.
Bloque 2: Comprobación de Dirty Checking (25 - 50 min)
Qué estudiar. Ciclo de vida Managed y funcionamiento del snapshot del EntityManager.
Pregunta a responder. ¿Cómo verificar en los logs SQL que Hibernate emite el UPDATE sin llamar a save()?
Resultado esperado. Test de integración comprobando que la modificación persiste sin llamar a save().
Bloque 3: Trampas de Proxies de @Transactional (50 - 80 min)
Qué estudiar. Proxies de Spring, rollback automático y llamadas internas.
Pregunta a responder. ¿Cómo separar métodos transaccionales en servicios independientes para evitar auto-invocación?
Resultado esperado. Demostración de rollback con excepción no comprobada.
Cierre y Consolidación (80 - 90 min)
Qué estudiar. Repaso activo de los 4 estados de la entidad.
Pregunta a responder. Definir qué significa que una entidad esté Detached.
Resultado esperado. Notas de estudio registradas en la base de datos local.
Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.
Explícalo desde cero
Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.
Por qué no hace falta llamar a repo.save() al modificar una entidad cargada
Lo que debes entender
El EntityManager funciona como una memoria caché de primer nivel con una cámara fotográfica. Al leer la entidad le saca una "foto" (snapshot). Si modificas un campo dentro de @Transactional, al terminar el método compara la foto con el objeto real: si ve cambios ("suciedad" o dirty), escribe el UPDATE SQL en la BD.
Analogía para contarlo
Un editor de documentos en la nube con autoguardado: mientras escribes en el documento abierto (Managed), al cerrar la pestaña se guardan los cambios automáticamente sin necesidad de pulsar un botón de guardar.
Lo que suele explicarse mal
Enseñar JPA como si fuera un repositorio CRUD simple donde siempre hay que llamar a save() para todo.
Señal de que aún no lo dominas
Si ves código con repo.save(tarea) justo después de tarea.setEstado("HECHO") dentro de un método transaccional.
Preguntas trampa
Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.
Nivel 1 (Básico)¿Por qué las entidades JPA requieren obligatoriamente un constructor sin argumentos (no-args constructor)?Ver respuesta
Respuesta razonada
Porque Hibernate crea las entidades por reflexión: primero instancia un objeto vacío con el constructor sin argumentos y después asigna cada campo con los valores leídos del ResultSet. No puede saber qué constructor con parámetros usar ni en qué orden. También lo necesita para generar los proxies de carga perezosa, que son subclases de tu entidad.
No tiene por qué ser público: la especificación permite protected (y Hibernate acepta incluso package-private), lo que impide que tu código lo use por error y cree entidades en estado inválido.
Qué está evaluando la pregunta
Porque Hibernate utiliza reflexión para instanciar la clase vacía al recuperar filas de la base de datos antes de poblar sus campos mediante getters o reflection directa.
Nivel 2 (Intermedio)¿Por qué por defecto @Transactional NO hace rollback si se lanza una excepción comprobada (checked exception como IOException)?Ver respuesta
Respuesta razonada
Es una convención heredada de EJB: se considera que una excepción comprobada representa un resultado de negocio previsto (por ejemplo, «saldo insuficiente» modelado como checked) y no un fallo del sistema, así que la transacción hace commit de lo que se hizo hasta ese punto. Las RuntimeException y los Error se consideran fallos inesperados y provocan rollback.
Es una fuente clásica de bugs: lanzas una IOException, crees que se deshizo todo y en la base de datos han quedado datos a medias. Se cambia con @Transactional(rollbackFor = Exception.class) o, mejor, usando excepciones no comprobadas en la lógica de negocio, que es lo que recomienda el propio ecosistema Spring.
Qué está evaluando la pregunta
Por diseño histórico de Spring, el rollback automático solo se dispara ante excepciones no comprobadas (RuntimeException o Error). Para incluir comprobadas se debe especificar @Transactional(rollbackFor = Exception.class).
Nivel 3 (Entrevista Senior)¿Qué es el problema de la llamada auto-referenciada (self-invocation) en Spring y cómo afecta a @Transactional y @Cacheable?Ver respuesta
Respuesta razonada
@Transactional, @Cacheable, @Async y similares funcionan mediante un proxy que envuelve al bean. Si un método del bean llama a otro método del mismo bean con this.otroMetodo(), la llamada no pasa por el proxy y la anotación se ignora en silencio: no hay transacción nueva, ni caché, ni ejecución asíncrona.
Soluciones, de mejor a peor: mover el método anotado a otro bean (suele indicar que tenía otra responsabilidad), usar TransactionTemplate para controlar la transacción de forma programática, inyectar el propio bean con @Lazy y llamar a través de él, o activar el tejido de AspectJ en compilación, que modifica el bytecode y sí intercepta llamadas internas.
Qué está evaluando la pregunta
Si la ClaseA tiene el método1() y método2() con @Transactional, y método1() llama a this.método2(), la llamada se ejecuta directamente en la instancia de la JVM sin pasar por el proxy de Spring, por lo que las anotaciones de método2() son ignoradas por completo.
Reto del tutor
Un candidato escribe: public TareaEntity actualizar(Long id, String titulo) { var t = repo.findById(id).get(); t.setTitulo(titulo); return repo.save(t); } en un servicio sin @Transactional. Evalúa el código.
Lo que una respuesta suele tener bien
Recupera la entidad, la modifica y la guarda.
Lo que suele quedarse en la superficie
Decir que "falta validar si existe".
El concepto de fondo que suele faltar
No usar @Transactional (lo que hace que la entidad se vuelva Detached fuera de la transacción) y forzar un repo.save() innecesario, además de usar .get() ciegamente.
Cómo lo respondería alguien senior en una entrevista
El código tiene 3 fallos graves: (1) Usar findById().get() lanza NoSuchElementException si el ID no existe (debe usarse .orElseThrow()); (2) Al no tener @Transactional, la sesión se abre y cierra en el findById, dejando la entidad Detached y requiriendo un save() que hace un merge innecesario; (3) Con @Transactional, el repo.save(t) sobra por completo gracias al Dirty Checking, ejecutándose de forma atómica y más eficiente.
Proyecto «Pedidos» · paso 14 de 30
Persistencia con Spring Data JPA
Sustituir el JDBC manual por entidades JPA y repositorios de Spring Data.
De qué va el proyecto
El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.
Qué hacer
Mapea Pedido, LineaPedido y Cliente como entidades y crea sus repositorios con JpaRepository.
Marca los métodos de PedidoService que modifican datos con @Transactional.
Mantén los DTOs de la API separados de las entidades.
Está listo cuando
Confirmar un pedido cambia su estado en la base de datos sin llamar explícitamente a save().
La API no devuelve entidades JPA en ninguna respuesta.
Katas: ejercicios cortos
Reglas de oro
En métodos @Transactional, no llames a repo.save() tras modificar una entidad Managed; confía en el Dirty Checking.
Las entidades de base de datos no deben ser records; los records son para DTOs.
Evita las llamadas auto-referenciadas dentro de la misma clase para métodos con @Transactional.