← Persistencia con JPA y PostgreSQL

Sesión 23 · Semana 12

Relaciones uno a muchos

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:

  1. Pérdida total de navegación: si teniendo una tarea necesitas mostrar el nombre del proyecto al que pertenece, estás obligado a inyectar ProyectoRepository y hacer una consulta manual adicional. No puedes hacer tarea.getProyecto().getNombre().
  2. 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 el INSERT con una violación de clave foránea cruda.
  3. 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<>();
El lado inverso frente al lado propietario
  1. Proyecto (@OneToMany, mappedBy)
  2. Lado inverso (solo lectura de navegación)
  3. Tarea (@ManyToOne, @JoinColumn)
  4. 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

  1. 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.
  2. Crea dos registros principales y varios relacionados con las peticiones existentes. Anota sus ids para comprobar asignaciones y cambios.
  3. 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):

Mapeo objeto-relacional de @ManyToOne
  1. Clase Tarea (@ManyToOne)
  2. @JoinColumn(name = "proyecto_id")
  3. Tabla tareas (FK proyecto_id)
  4. 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 Tarea pueden pertenecer a una misma instancia de Proyecto.
@JoinColumn(name = "proyecto_id", nullable = false)
Especifica el nombre exacto de la columna física en la tabla tareas que actúa como clave foránea (FK). Al indicar nullable = false, Hibernate añade la restricción NOT NULL al 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 proyectoId en el servicio, la llamada a save() delegaría la validación en la restricción física de PostgreSQL, lanzando una DataIntegrityViolationException (que terminaría en un error 500 Internal Server Error o requeriría un manejador de excepciones complejo). Validarlo en el servicio permite emitir de inmediato un 404 Not Found limpio 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 Created con "id": 1.
POST http://localhost:8080/tareas
Content-Type: application/json

{
  "titulo": "Configurar @ManyToOne en entidades",
  "prioridad": "alta",
  "proyectoId": 1
}
  • Respuesta: 201 Created con "id": 1, "proyectoId": 1 y "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 OK con 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.

  1. Modifica Tarea.java:
    • Añade el atributo:
      @ManyToOne(fetch = FetchType.LAZY)
      @JoinColumn(name = "responsable_id") // nullable = true (puede nacer sin asignar)
      private Usuario responsable;
  2. Modifica TareaRequest para admitir opcionalmente Long responsableId.
  3. Modifica TareaResponse para incluir responsableId y responsableNombre (que serán null si la tarea no tiene responsable asignado).
  4. En TareaService:
    • Si request.responsableId() no es nulo, busca el usuario mediante UsuarioRepository (lanzando 404 si no existe) y asígnalo con tarea.setResponsable(usuario).
  5. Añade a TareaRepository: List<Tarea> findByResponsableId(Long responsableId);.
  6. Crea un usuario, asigna una tarea a ese usuario y comprueba que la columna responsable_id se 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 una UnsupportedOperationException.
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 un DELETE 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?

  1. Jackson empieza a serializar el Proyecto a JSON: escribe id, nombre y llega al campo tareas.
  2. Para cada Tarea de la lista, empieza a escribir sus campos: id, titulo y llega a su campo proyecto.
  3. Jackson serializa ese Proyecto: escribe sus campos y llega a tareas.
  4. 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 (psql o DBeaver) y ejecuta SELECT * FROM tareas WHERE id = 2;: la fila ha desaparecido. El mecanismo de orphanRemoval ha 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:

  1. Crea el DTO ProyectoConTareasRequest:
    public record ProyectoConTareasRequest(
        @NotBlank String nombre,
        String descripcion,
        List<String> titulosTareasIniciales
    ) {}
  2. En ProyectoService.crearConTareas(...):
    • Instancia el nuevo Proyecto.
    • Itera sobre la lista de títulos recibidos creando cada Tarea y añadiéndola con proyecto.agregarTarea(nuevaTarea).
    • Llama a proyectoRepo.save(proyecto).
    • Gracias a cascade = CascadeType.ALL, comprueba que Hibernate genera el INSERT del proyecto y a continuación todos los INSERT de las tareas asociadas.
  3. Expón el endpoint POST /proyectos/con-tareas devolviendo 201 Created y 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

  1. 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.
  2. 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.
Objetivo mínimoRelación @ManyToOne entre Tarea y Proyecto funcionando con FetchType.LAZY y FK verificada en PostgreSQL.
Si lo tienesConsulta de subrecurso GET /proyectos/{id}/tareas implementada, con DTOs planos y validación previa de existencia.
RetoRelación con 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");. Su id es null.
  • 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 asignado id = 1L.
  • ¿Qué ocurre si ejecutas pendientes.contains(t) si el hashCode() dependía del id? 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)?
Objetivo mínimoRelación bidireccional entre Proyecto y Tarea con mappedBy, helper methods y DTO de detalle funcionando.
Si lo tienesDesvinculación con orphanRemoval = true eliminando la fila en PostgreSQL mediante DELETE.
RetoCreación en cascada de proyecto con tareas iniciales en una transacción y análisis técnico de 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.