Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado la base de datos en producción. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Tus entidades persisten por separado. Hoy representarás una relación uno a muchos: por ejemplo, un proyecto contiene varias tareas y cada tarea pertenece a un proyecto. Una clave foránea guarda esa referencia en la tabla; los DTO decidirán cómo se muestra por HTTP.
De un identificador numérico a un grafo de entidades
Hasta ahora, en nuestra entidad Tarea guardábamos una referencia débil:
// MODELADO PRIMITIVO (procedural)
public class Tarea {
private Long id;
private String titulo;
private Long proyectoId; // Un simple número
}
Guardar un Long proyectoId funciona a nivel de base de datos relacional, pero en Java introduce tres problemas de diseño muy serios:
- Pérdida total de navegación: si teniendo una tarea necesitas mostrar el nombre del proyecto al que pertenece, estás obligado a inyectar
ProyectoRepositoryy hacer una consulta manual adicional. No puedes hacertarea.getProyecto().getNombre(). - Validación manual: la aplicación permite guardar
proyectoId = 9999. Salvo que escribas código defensivo a mano en cada servicio, la incoherencia no se detectará hasta que PostgreSQL rechace elINSERTcon una violación de clave foránea cruda. - Desajuste de paradigma: los objetos se relacionan mediante referencias directas en memoria, no mediante claves foráneas numéricas.
La regla más importante de JPA: FetchType.LAZY
Fíjate en el atributo fetch = FetchType.LAZY. Esta es la decisión de rendimiento más trascendente que tomarás en persistencia.
JPA ofrece dos estrategias de carga para relaciones:
| Estrategia | Cómo funciona | Riesgo en producción |
|---|---|---|
FetchType.EAGER (Ansioso) |
Al cargar una Tarea, Hibernate carga inmediatamente el Proyecto asociado mediante un JOIN o una segunda consulta SQL. |
Catastrófico. Si listas 100 tareas, Hibernate puede disparar 100 consultas adicionales para cargar cada proyecto (el problema N+1). |
FetchType.LAZY (Perezoso) |
Al cargar una Tarea, Hibernate no consulta la tabla de proyectos. En su lugar, coloca un objeto simulado (Proxy de Hibernate). Solo viajará a PostgreSQL si alguien llama a tarea.getProyecto().getNombre(). |
Óptimo. Solo se paga el coste de consultar los datos que el caso de uso realmente necesita. |
Por defecto JPA hace trampa: cámbialo siempre a LAZY
En la especificación estándar de JPA, la anotación @ManyToOne viene por defecto con FetchType.EAGER. Es una de las peores decisiones históricas de diseño del estándar.
Regla innegociable en este curso: toda anotación @ManyToOne y @OneToOne que escribas debe llevar explícitamente fetch = FetchType.LAZY.
La ilusión de la bidireccionalidad
En un modelo relacional físico en PostgreSQL, las relaciones bidireccionales no existen. Solo existe una tabla con una columna de clave foránea (tareas.proyecto_id). Una fila de la tabla tareas sabe a qué proyecto apunta; la tabla proyectos no almacena ninguna lista de IDs ni sabe físicamente quién la apunta.
Sin embargo, en el paradigma orientado a objetos de Java, resulta muy intuitivo poder navegar en las dos direcciones:
- Saber a qué proyecto pertenece una tarea:
tarea.getProyecto().getNombre(). - Saber qué tareas tiene un proyecto:
proyecto.getTareas().size().
Para conseguir esta navegación inversa en Java sin crear tablas intermedias, añadimos en Proyecto:
@OneToMany(mappedBy = "proyecto", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Tarea> tareas = new ArrayList<>();
- Proyecto (@OneToMany, mappedBy)
- Lado inverso (solo lectura de navegación)
- Tarea (@ManyToOne, @JoinColumn)
- Lado propietario (escribe la FK física)
El significado exacto de mappedBy
El parámetro mappedBy = "proyecto" le dice a Hibernate: «Esta entidad es el lado inverso. La clave foránea física no reside en su tabla; la gestiona el atributo denominado proyecto dentro de la clase Tarea».
Cualquier modificación que hagas sobre la lista tareas de un proyecto será ignorada por la base de datos a menos que también se actualice la referencia tarea.setProyecto(...).
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre las dos entidades, sus repositorios y sus DTO. Elige en tu dominio una relación equivalente y dibuja qué lado contiene la referencia al otro.
- Crea dos registros principales y varios relacionados con las peticiones existentes. Anota sus ids para comprobar asignaciones y cambios.
- Localiza dónde se comprueba que existe el recurso al que vas a asociar otro. Esa decisión pertenece al servicio, no al mapper.
Paso 2 · El lado propietario: @ManyToOne y @JoinColumn
En Tarea.java, sustituye el atributo persistente Long proyectoId por la referencia Proyecto proyecto anotada; no dejes dos atributos escribiendo la misma columna proyecto_id. Conserva proyectoId en el DTO de entrada, porque el cliente sigue enviando un número. En los métodos del servicio carga el proyecto por ese id y asigna la referencia a la tarea. Ajusta los mappers y las pruebas antes de arrancar: donde antes se leía el número del modelo, ahora se obtiene desde tarea.getProyecto().getId().
En JPA, la entidad que mapea la clave foránea física se denomina lado propietario (Owning Side):
- Clase Tarea (@ManyToOne)
- @JoinColumn(name = "proyecto_id")
- Tabla tareas (FK proyecto_id)
- Tabla proyectos (PK id)
@Entity
@Table(name = "tareas")
public class Tarea {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String titulo;
@Column(nullable = false, length = 20)
private String prioridad;
private boolean completada;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "proyecto_id", nullable = false)
private Proyecto proyecto;
// Constructores, getters y setters
}
@ManyToOne- Declara que muchas instancias de
Tareapueden pertenecer a una misma instancia deProyecto. @JoinColumn(name = "proyecto_id", nullable = false)- Especifica el nombre exacto de la columna física en la tabla
tareasque actúa como clave foránea (FK). Al indicarnullable = false, Hibernate añade la restricciónNOT NULLal generar el esquema o validarlo. optional = false- Regla a nivel de JPA que indica que una tarea no puede existir en el contexto de persistencia sin un proyecto asociado.
Paso 3 · Cómo viaja la relación en la API: DTOs limpios
Ahora que Tarea contiene un objeto Proyecto, surge la duda: ¿cómo deben ser los DTOs de petición y respuesta?
El cliente web no envía un objeto proyecto entero con su fecha de creación y descripción; solo envía su identificador:
public record TareaRequest(
@NotBlank(message = "El título es obligatorio")
@Size(max = 120, message = "Máximo 120 caracteres")
String titulo,
@NotBlank(message = "La prioridad es obligatoria")
String prioridad,
@NotNull(message = "El id del proyecto es obligatorio")
Long proyectoId
) {}
En la respuesta proyectamos los datos útiles para la vista, aplanando la relación para no forzar a la interfaz a lidiar con objetos anidados innecesarios:
public record TareaResponse(
Long id,
String titulo,
String prioridad,
boolean completada,
Long proyectoId,
String proyectoNombre
) {}
En TareaService, el caso de uso de creación valida la existencia del proyecto antes de asociarlo:
@Service
public class TareaService {
private final TareaRepository tareaRepo;
private final ProyectoRepository proyectoRepo;
public TareaService(TareaRepository tareaRepo, ProyectoRepository proyectoRepo) {
this.tareaRepo = tareaRepo;
this.proyectoRepo = proyectoRepo;
}
@Transactional
public Tarea crear(Tarea tarea, Long proyectoId) {
// 1. Validar que el proyecto existe; si no, lanzar excepción de dominio (404)
Proyecto proyecto = proyectoRepo.findById(proyectoId)
.orElseThrow(() -> new RecursoNoEncontradoException("proyecto", proyectoId));
// 2. Asociar el objeto en el lado propietario
tarea.setProyecto(proyecto);
tarea.setCompletada(false);
// 3. Persistir
return tareaRepo.save(tarea);
}
}
- Por qué validamos el proyecto antes de guardar
- Si no comprobáramos la existencia de
proyectoIden el servicio, la llamada asave()delegaría la validación en la restricción física de PostgreSQL, lanzando unaDataIntegrityViolationException(que terminaría en un error500 Internal Server Erroro requeriría un manejador de excepciones complejo). Validarlo en el servicio permite emitir de inmediato un404 Not Foundlimpio con el mensaje exacto: "No existe proyecto con id 88".
Paso 4 · Conectar el controlador y la consulta por proyecto
Abre TareaRepository.java y añade la consulta derivada para obtener todas las tareas de un proyecto:
// Spring Data navega automáticamente por la propiedad: proyecto.id
List<Tarea> findByProyectoId(Long proyectoId);
Ahora abre ProyectoController.java. Añade el subrecurso REST para consultar todas las tareas pertenecientes a un proyecto concreto:
@GetMapping("/{id}/tareas")
public List<TareaResponse> listarTareasDelProyecto(@PathVariable Long id) {
// 1. Asegurar que el proyecto existe
proyectoService.obtener(id);
// 2. Obtener las tareas del proyecto
List<Tarea> tareas = tareaService.listarPorProyecto(id);
// 3. Mapear a DTOs de respuesta
return TareaMapper.aRespuestas(tareas);
}
Paso 5 · Navegación e integridad en acción
Arranca la aplicación y ejecuta las siguientes pruebas en tu cliente HTTP:
POST http://localhost:8080/proyectos
Content-Type: application/json
{
"nombre": "Rediseño Portal Corporativo",
"descripcion": "Migración a arquitectura por capas y PostgreSQL"
}
- Respuesta:
201 Createdcon"id": 1.
POST http://localhost:8080/tareas
Content-Type: application/json
{
"titulo": "Configurar @ManyToOne en entidades",
"prioridad": "alta",
"proyectoId": 1
}
- Respuesta:
201 Createdcon"id": 1,"proyectoId": 1y"proyectoNombre": "Rediseño Portal Corporativo". - Consola SQL de Hibernate:
Hibernate:
insert
into
tareas
(completada, prioridad, proyecto_id, titulo)
values
(?, ?, ?, ?)
Observa cómo la columna proyecto_id se rellena con el valor 1.
POST http://localhost:8080/tareas
Content-Type: application/json
{
"titulo": "Tarea fantasma",
"prioridad": "baja",
"proyectoId": 999
}
- Respuesta:
404 Not Found. - Cuerpo:
{"title": "Not Found", "status": 404, "detail": "No existe proyecto con id 999"}. - En la consola SQL no se ejecuta ningún INSERT. La integridad se preservó en la capa de negocio.
Ejecuta GET http://localhost:8080/proyectos/1/tareas.
- Respuesta:
200 OKcon un array JSON que contiene la tarea creada. - Consola SQL:
Hibernate:
select
t1_0.id,
t1_0.completada,
t1_0.prioridad,
t1_0.proyecto_id,
t1_0.titulo
from
tareas t1_0
where
t1_0.proyecto_id=?
Paso 6 · Asignar un responsable a la tarea
Antes de declarar Usuario responsable, comprueba que existe model/Usuario.java con id, nombre y sus accesos, y que UsuarioRepository extiende JpaRepository<Usuario, Long>. Si faltan, créalos con el mismo procedimiento de entidad y repositorio de la sesión 20 y prepara dos usuarios de desarrollo. Amplía el constructor de TareaService para recibir ese repositorio; cuando llegue responsableId, busca el usuario y rechaza el id inexistente. Un responsable ausente queda a null. Actualiza todas las construcciones del DTO de salida al añadir sus nuevos componentes.
- Modifica
Tarea.java:- Añade el atributo:
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "responsable_id") // nullable = true (puede nacer sin asignar) private Usuario responsable;
- Añade el atributo:
- Modifica
TareaRequestpara admitir opcionalmenteLong responsableId. - Modifica
TareaResponsepara incluirresponsableIdyresponsableNombre(que seránnullsi la tarea no tiene responsable asignado). - En
TareaService:- Si
request.responsableId()no es nulo, busca el usuario medianteUsuarioRepository(lanzando404si no existe) y asígnalo contarea.setResponsable(usuario).
- Si
- Añade a
TareaRepository:List<Tarea> findByResponsableId(Long responsableId);. - Crea un usuario, asigna una tarea a ese usuario y comprueba que la columna
responsable_idse persiste correctamente en PostgreSQL.
Relaciones bidireccionales
Paso 7 · Mantener coherentes ambos lados de una relación bidireccional
Como en Java tenemos dos referencias independientes en memoria RAM, es facilísimo romper la coherencia de nuestro propio grafo de objetos:
// CÓDIGO PELIGROSO: desincroniza la memoria
Tarea tarea = new Tarea("Nueva funcionalidad", "alta");
tarea.setProyecto(proyecto);
// ¡Olvidamos añadirla a la lista: proyecto.getTareas().add(tarea)!
Al terminar la transacción, Hibernate guardará la tarea en PostgreSQL porque el lado propietario (tarea.setProyecto) se actualizó. Ahora bien, si en esa misma transacción de negocio alguien consulta proyecto.getTareas(), la nueva tarea no estará en la lista. Tu aplicación dirá que el proyecto tiene 0 tareas cuando en la base de datos ya hay 1.
Para blindar nuestra entidad contra este fallo, prohibimos manipular la lista directamente e implementamos métodos de sincronización (Helper Methods):
@Entity
@Table(name = "proyectos")
public class Proyecto {
// ... campos id, nombre, etc.
@OneToMany(mappedBy = "proyecto", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Tarea> tareas = new ArrayList<>();
// Getter que devuelve una vista no modificable para proteger la encapsulación
public List<Tarea> getTareas() {
return Collections.unmodifiableList(tareas);
}
// Método helper de asociación bidireccional
public void agregarTarea(Tarea tarea) {
tareas.add(tarea);
tarea.setProyecto(this);
}
// Método helper de desvinculación
public void eliminarTarea(Tarea tarea) {
tareas.remove(tarea);
tarea.setProyecto(null);
}
}
Collections.unmodifiableList(tareas)- Evita que un programador despistado haga
proyecto.getTareas().add(tarea)desde fuera sin actualizar la referencia inversa. Si alguien lo intenta, Java lanza de inmediato unaUnsupportedOperationException. orphanRemoval = true- Si sacas una tarea de la lista mediante
proyecto.eliminarTarea(t), Hibernate detecta que la tarea se ha quedado "huérfana" (ya no pertenece a su proyecto padre) y genera automáticamente unDELETE FROM tareas WHERE id = ?en PostgreSQL. cascade = CascadeType.ALL- Propaga las operaciones del padre a los hijos: si persistes un proyecto nuevo que ya contiene tres tareas añadidas con
agregarTarea, Hibernate guardará automáticamente el proyecto y las tres tareas en la misma transacción.
Paso 8 · Evitar referencias circulares al convertir relaciones a JSON
Si devuelves entidades @Entity directamente en un @RestController, las relaciones bidireccionales provocarán una catástrofe garantizada:
java.lang.StackOverflowError
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField...
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields...
¿Qué ha ocurrido?
- Jackson empieza a serializar el
Proyectoa JSON: escribeid,nombrey llega al campotareas. - Para cada
Tareade la lista, empieza a escribir sus campos:id,tituloy llega a su campoproyecto. - Jackson serializa ese
Proyecto: escribe sus campos y llega atareas. - Jackson serializa cada
Tarea… y entra en un bucle infinito que agota la pila de llamadas (call stack) de la JVM en un milisegundo.
Los DTOs eliminan la recursión de raíz
Muchos tutoriales intentan parchear este problema llenando las entidades de anotaciones como @JsonIgnore, @JsonManagedReference o @JsonBackReference. Es una pésima solución que mezcla detalles de serialización HTTP dentro del modelo de persistencia.
La solución arquitectónica correcta es la que venimos aplicando: las entidades jamás se serializan a JSON. El controlador devuelve DTOs planos diseñados para la vista, donde los ciclos no existen.
Paso 9 · Diseñar el DTO de detalle del proyecto
Diseñamos un DTO específico para consultar un proyecto junto al resumen de sus tareas:
package com.ejemplo.gestor.dto;
import java.util.List;
public record ProyectoDetalleResponse(
Long id,
String nombre,
String descripcion,
boolean activo,
int totalTareas,
List<TareaResumenResponse> tareas
) {
public record TareaResumenResponse(
Long id,
String titulo,
String prioridad,
boolean completada
) {}
}
Observa la clave del diseño: TareaResumenResponse no incluye ninguna referencia a Proyecto. El ciclo queda roto de forma natural y limpia.
Paso 10 · Mapear y exponer en el Service y Controller
Añade aDetalle al mapper existente, importa los DTO utilizados y conserva sus demás conversiones. Añade después obtenerConDetalle a ProyectoService, que inicializa la colección dentro de la transacción, y por último el endpoint del controlador. Comprueba un proyecto con dos tareas y otro sin tareas: ambos deben devolver detalle válido. Esta inicialización explícita permite convertir después sin mantener abierta la conexión durante la respuesta; en la sesión 26 revisarás cuántas consultas ha costado.
public static ProyectoDetalleResponse aDetalle(Proyecto proyecto) {
List<ProyectoDetalleResponse.TareaResumenResponse> tareasResumen = proyecto.getTareas().stream()
.map(t -> new ProyectoDetalleResponse.TareaResumenResponse(
t.getId(),
t.getTitulo(),
t.getPrioridad(),
t.isCompletada()))
.toList();
return new ProyectoDetalleResponse(
proyecto.getId(),
proyecto.getNombre(),
proyecto.getDescripcion(),
proyecto.isActivo(),
tareasResumen.size(),
tareasResumen
);
}
En ProyectoService.java:
@Transactional(readOnly = true)
public Proyecto obtenerConDetalle(Long id) {
// Al estar dentro de @Transactional, la lista perezosa getTareas() se inicializa sin error
Proyecto proyecto = obtener(id);
// Forzamos la inicialización accediendo al tamaño mientras la sesión está abierta
proyecto.getTareas().size();
return proyecto;
}
En ProyectoController.java:
@GetMapping("/{id}/detalle")
public ProyectoDetalleResponse obtenerDetalle(@PathVariable Long id) {
Proyecto proyecto = servicio.obtenerConDetalle(id);
return ProyectoMapper.aDetalle(proyecto);
}
Paso 11 · El ciclo sin recursión y el borrado de huérfanos
Arranca la aplicación y ejecuta las siguientes pruebas:
Ejecuta GET http://localhost:8080/proyectos/1/detalle.
Respuesta limpia 200 OK sin recursión ni errores:
{
"id": 1,
"nombre": "Rediseño Portal Corporativo",
"descripcion": "Migración a arquitectura por capas y PostgreSQL",
"activo": true,
"totalTareas": 2,
"tareas": [
{
"id": 1,
"titulo": "Configurar @ManyToOne en entidades",
"prioridad": "alta",
"completada": false
},
{
"id": 2,
"titulo": "Escribir tests con @DataJpaTest",
"prioridad": "media",
"completada": true
}
]
}
Añade en ProyectoService un caso de uso para desvincular una tarea:
@Transactional
public void desvincularTarea(Long proyectoId, Long tareaId) {
Proyecto proyecto = obtener(proyectoId);
Tarea tarea = tareaRepo.findById(tareaId)
.orElseThrow(() -> new RecursoNoEncontradoException("tarea", tareaId));
// Usamos el helper method: saca la tarea de la lista y pone su proyecto a null
proyecto.eliminarTarea(tarea);
// Al salir de la transacción con orphanRemoval = true, Hibernate genera el DELETE
}
Añade el endpoint en ProyectoController:
@DeleteMapping("/{id}/tareas/{tareaId}")
public ResponseEntity<Void> desvincularTarea(
@PathVariable Long id,
@PathVariable Long tareaId) {
servicio.desvincularTarea(id, tareaId);
return ResponseEntity.noContent().build();
}
Ejecuta DELETE http://localhost:8080/proyectos/1/tareas/2.
- Respuesta:
204 No Content. - Consola SQL de Hibernate:
Hibernate:
delete
from
tareas
where
id=?
- Abre tu cliente de base de datos (
psqlo DBeaver) y ejecutaSELECT * FROM tareas WHERE id = 2;: la fila ha desaparecido. El mecanismo deorphanRemovalha limpiado la base de datos automáticamente.
Paso 12 · Operaciones de lote sobre el proyecto
Implementa en ProyectoService y ProyectoController un caso de uso para crear un proyecto junto a un lote inicial de tareas en una sola petición HTTP:
- Crea el DTO
ProyectoConTareasRequest:public record ProyectoConTareasRequest( @NotBlank String nombre, String descripcion, List<String> titulosTareasIniciales ) {} - En
ProyectoService.crearConTareas(...):- Instancia el nuevo
Proyecto. - Itera sobre la lista de títulos recibidos creando cada
Tareay añadiéndola conproyecto.agregarTarea(nuevaTarea). - Llama a
proyectoRepo.save(proyecto). - Gracias a
cascade = CascadeType.ALL, comprueba que Hibernate genera elINSERTdel proyecto y a continuación todos losINSERTde las tareas asociadas.
- Instancia el nuevo
- Expón el endpoint
POST /proyectos/con-tareasdevolviendo201 Createdy verifica en PostgreSQL que todas las filas se han insertado en una única transacción atómica.
Paso 13 · Comprobar y registrar el resultado del proyecto
- Asocia y consulta registros por HTTP y comprueba la clave foránea en SQL. Prueba también un id relacionado inexistente y verifica que se rechaza sin guardar una asociación inválida.
- Consulta el detalle de ambos lados y comprueba que el JSON termina y no repite objetos indefinidamente. Revisa expresamente qué ocurre al desasociar o borrar.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · La temida LazyInitializationException
Investiga el error más famoso del ecosistema Spring y Hibernate:
Cuando configuras fetch = FetchType.LAZY, Hibernate no rellena tarea.getProyecto() con los datos reales; rellena el campo con un Proxy (un objeto intermediario generado dinámicamente con ByteBuddy que extiende Proyecto).
- Si intentas llamar a
tarea.getProyecto().getNombre()cuando la transacción de base de datos ya está cerrada (por ejemplo, dentro del controlador o en una capa de serialización JSON que olvidó los DTOs), Hibernate intentará abrir una conexión para consultar los datos del proyecto. - Como la sesión original ya se ha cerrado, Hibernate lanza la catastrófica:
org.hibernate.LazyInitializationException: could not initialize proxy [com.ejemplo.gestor.model.Proyecto#1] - no Session - Explica por qué el uso estricto de DTOs y mappers dentro de la frontera transaccional del servicio erradica este problema para siempre.
- Investiga qué es la propiedad
spring.jpa.open-in-view=true(OSIV), por qué Spring Boot la trae activada por defecto para novatos y por qué en proyectos de alto rendimiento se desactiva de forma inmediata.
@ManyToOne entre Tarea y Proyecto funcionando con FetchType.LAZY y FK verificada en PostgreSQL.GET /proyectos/{id}/tareas implementada, con DTOs planos y validación previa de existencia.Usuario completada y justificación técnica de la LazyInitializationException y los peligros de Open-In-View.Ver respuestas
1 · En la tabla del lado "muchos" (en nuestro caso, la columna proyecto_id dentro de la tabla tareas).
2 · Un Proxy de Hibernate (una subclase generada por reflexión que solo contiene el identificador y carga los demás campos bajo demanda).
3 · Es el problema de rendimiento que ocurre cuando consultar una lista de N elementos dispara N consultas SQL adicionales para cargar sus dependencias; se combate usando FetchType.LAZY o consultas con JOIN FETCH.
4 · Porque se intenta acceder a los datos de un Proxy perezoso cuando la sesión de persistencia (conexión y transacción) que lo gestionaba ya ha sido cerrada.
Reto · equals() y hashCode() en entidades JPA
Investiga uno de los temas más debatidos de la ingeniería Java:
Muchos desarrolladores generan los métodos equals() y hashCode() basándose en el atributo id:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Tarea tarea)) return false;
return id != null && id.equals(tarea.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
- Imagina que creas una tarea nueva:
Tarea t = new Tarea("Login", "alta");. Suidesnull. - Metes esa tarea en un
Set<Tarea> pendientes = new HashSet<>(); pendientes.add(t);. - Persistes la tarea en la base de datos:
em.persist(t); em.flush();. Ahora PostgreSQL le ha asignadoid = 1L. - ¿Qué ocurre si ejecutas
pendientes.contains(t)si elhashCode()dependía delid? El objeto sigue estando en el conjunto, pero Java ya no lo encuentra porque su código hash cambió de valor mientras estaba dentro de la tabla hash. - Investiga la recomendación oficial de Hibernate: ¿por qué se recomienda mantener un
hashCode()constante basado en la clase o en una clave de negocio natural inmutable (Natural Business Key)?
Proyecto y Tarea con mappedBy, helper methods y DTO de detalle funcionando.orphanRemoval = true eliminando la fila en PostgreSQL mediante DELETE.equals/hashCode con IDs mutables.Ver respuestas
1 · Porque en el modelo relacional cualquier fila puede asociarse con otra buscando por su clave foránea en cualquier dirección con una cláusula JOIN, mientras que en Java la navegación entre punteros en memoria es estrictamente unidireccional.
2 · Que la base de datos nunca se enterará del cambio, porque el lado propietario que mapea la clave foránea física (Tarea.proyecto) no fue actualizado.
3 · Emite automáticamente una sentencia SQL DELETE para borrar físicamente de la tabla la entidad hija que ha dejado de pertenecer a la colección del padre.
4 · Porque los DTOs de salida aplanan los datos y no incluyen referencias circulares hacia la entidad contenedora, rompiendo el ciclo de inspección de Jackson.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
La base de datos conserva la relación, la API devuelve una representación acotada y el borrado respeta la integridad.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.