← Persistencia con JPA y PostgreSQL

Sesión 24 · Semana 12

Relaciones muchos 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

Ya manejas una relación uno a muchos. Hoy una entidad podrá relacionarse con varias de otra clase y viceversa, como tareas y etiquetas. Una tabla intermedia guarda cada asociación; quitar una asociación no debe eliminar la entidad compartida.

La tabla puente en el modelo relacional

En las sesiones anteriores vimos cómo una relación uno-a-muchos se resuelve fácilmente añadiendo una columna de clave foránea en la tabla hija (tareas.proyecto_id).

Sin embargo, en el mundo real las relaciones suelen ser muchos-a-muchos:

  • Una tarea puede tener múltiples etiquetas ("urgente", "seguridad", "backend").
  • Una misma etiqueta puede estar aplicada a cientos de tareas distintas.

En el álgebra relacional de PostgreSQL es físicamente imposible almacenar una lista de claves foráneas dentro de una columna. Para resolver una relación N:M, el motor necesita una tercera tabla: la tabla puente o tabla de unión (Join Table).

Estructura física de una relación N:M en PostgreSQL
  1. Tabla tareas (PK id)
  2. Tabla tareas_etiquetas (FK tarea_id, FK etiqueta_id)
  3. Tabla etiquetas (PK id)
CREATE TABLE tareas_etiquetas (
    tarea_id BIGINT NOT NULL REFERENCES tareas(id) ON DELETE CASCADE,
    etiqueta_id BIGINT NOT NULL REFERENCES etiquetas(id) ON DELETE RESTRICT,
    PRIMARY KEY (tarea_id, etiqueta_id)
);

La clave primaria de la tabla puente es una clave compuesta formada por los dos identificadores, garantizando que una tarea no pueda tener la misma etiqueta duplicada dos veces.

El dilema arquitectónico: ¿Relación directa o Entidad Intermedia?

La anotación @ManyToMany directa solo sirve bajo una condición muy estricta: cuando la relación no contiene ningún dato adicional aparte de los dos IDs.

Escenario Solución JPA Ejemplo en el mundo real
Asociación pura sin atributos @ManyToMany directo con @JoinTable. Tareas y Etiquetas, Usuarios y Roles de seguridad.
Asociación con atributos propios Entidad intermedia con dos relaciones @ManyToOne. Inscripción de Alumnos en Cursos (con fecha_matricula, calificacion), Asignación de Tareas a Empleados (con horas_estimadas, rol_desempenado).

Si tu tabla puente necesita columnas como creado_en, prioridad_etiqueta o asignado_por, debes crear una entidad Java intermedia completa (por ejemplo, TareaEtiqueta).

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre el modelo y elige una relación muchos a muchos real de tu dominio. Dibuja dos registros que compartan un elemento relacionado.
  2. Localiza los paquetes de entidades, repositorios, servicios y DTO. Implementarás la asociación en todas esas capas manteniendo los contratos anteriores.
  3. Prepara un caso de asociación nueva, otro de asociación repetida y otro de desasociación. Anota las filas que esperas en la tabla intermedia.

Paso 2 · El mapeo en JPA: @ManyToMany y @JoinTable

Crea model/Etiqueta.java antes de modificar Tarea y genera sus accesos para id, nombre y color. Si conservas la lista de textos etiquetas del experimento de la sesión 5, migra esos valores a entidades y sustituye ese campo: no declares a la vez List<String> y Set<Etiqueta> con el mismo nombre. Añade después la colección y los métodos de asociación a Tarea. Importa Set, HashSet y las anotaciones JPA utilizadas. Una asociación vincula dos entidades ya existentes; no crea una etiqueta por escribir su nombre en el DTO.

Creamos la entidad maestra para las etiquetas:

package com.ejemplo.gestor.model;

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "etiquetas")
public class Etiqueta {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 40)
    private String nombre;

    @Column(nullable = false, length = 7)
    private String colorHex; // Ej: "#FF5733"

    @ManyToMany(mappedBy = "etiquetas")
    private Set<Tarea> tareas = new HashSet<>();

    public Etiqueta() {}

    public Etiqueta(String nombre, String colorHex) {
        this.nombre = nombre;
        this.colorHex = colorHex;
    }

    // Getters y setters
}

En Tarea.java, configuramos el lado propietario declarando cómo se llama la tabla puente y sus columnas:

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
    name = "tareas_etiquetas",
    joinColumns = @JoinColumn(name = "tarea_id"),
    inverseJoinColumns = @JoinColumn(name = "etiqueta_id")
)
private Set<Etiqueta> etiquetas = new HashSet<>();

public Set<Etiqueta> getEtiquetas() {
    return Collections.unmodifiableSet(etiquetas);
}

public void agregarEtiqueta(Etiqueta etiqueta) {
    this.etiquetas.add(etiqueta);
    etiqueta.getTareas().add(this);
}

public void quitarEtiqueta(Etiqueta etiqueta) {
    this.etiquetas.remove(etiqueta);
    etiqueta.getTareas().remove(this);
}
joinColumns
Especifica la columna de la tabla puente que apunta a la entidad actual (tarea_id hacia tareas.id).
inverseJoinColumns
Especifica la columna de la tabla puente que apunta a la otra entidad (etiqueta_id hacia etiquetas.id).

Paso 3 · Usar Set para representar asociaciones sin duplicados

Este es otro de los errores más costosos de rendimiento en aplicaciones Spring Boot con JPA:

// ANTIPATRÓN GRAVE: usar List en @ManyToMany
private List<Etiqueta> etiquetas = new ArrayList<>();

Si usas List, la especificación de Hibernate no puede saber qué fila concreta ha cambiado porque una lista permite elementos repetidos y depende de índices posicionales.

¿Qué hace Hibernate cuando tienes 20 etiquetas en una tarea y eliminas una?

  1. Ejecuta: DELETE FROM tareas_etiquetas WHERE tarea_id = 5;, que elimina las veinte filas en una sola sentencia.
  2. A continuación, ejecuta 19 sentencias INSERT una a una para reinsertar las que quedaban.

Al cambiar a Set<Etiqueta>, Hibernate sabe que los elementos son matemáticamente únicos y emite únicamente:

DELETE FROM tareas_etiquetas WHERE tarea_id = 5 AND etiqueta_id = 2;

Una sola sentencia atómica y eficiente.

La regla de oro de las colecciones N:M

En relaciones @ManyToMany se utiliza siempre Set y nunca List.

Además, inicializa siempre la colección directamente en la declaración del atributo (= new HashSet<>()) para evitar excepciones NullPointerException al acceder a entidades recién instanciadas.

Paso 4 · Evitar borrar entidades compartidas con CascadeType.REMOVE

En el trabajo anterior aprendimos que un Proyecto puede tener cascade = CascadeType.ALL sobre sus tareas porque si el proyecto se destruye, sus tareas pierden sentido.

En una relación @ManyToMany, el borrado en cascada está terminantemente prohibido:

// PELIGRO: NUNCA hagas esto en @ManyToMany
@ManyToMany(cascade = CascadeType.ALL) // o CascadeType.REMOVE
private Set<Etiqueta> etiquetas;

¿Qué ocurriría si borras una tarea que tenía la etiqueta "BUG"? Hibernate interpretaría que debe propagar el borrado y ejecutaría: DELETE FROM etiquetas WHERE nombre = 'BUG';

¡Acabarías borrando la etiqueta del catálogo maestro de la empresa, rompiendo todas las demás tareas del sistema que compartían esa misma etiqueta!

Paso 5 · Crear EtiquetaRepository y Servicio

Para poder obtener los ids usados en las asociaciones, añade primero el alta de etiquetas. Crea controller/EtiquetaController.java con este archivo completo. Los records de entrada y salida están dentro de la clase. En Etiqueta genera getId(), getNombre(), getColorHex() y getTareas() si todavía faltan; este último devuelve la colección usada por los métodos de asociación.

package com.ejemplo.gestor.controller;

import com.ejemplo.gestor.model.Etiqueta;
import com.ejemplo.gestor.repository.EtiquetaRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;

@RestController
@RequestMapping("/etiquetas")
public class EtiquetaController {
    private final EtiquetaRepository repositorio;
    public EtiquetaController(EtiquetaRepository repositorio) {
        this.repositorio = repositorio;
    }
    public record Entrada(@NotBlank @Size(max = 40) String nombre,
        @NotBlank @Pattern(regexp = "#[0-9a-fA-F]{6}") String colorHex) {}
    public record Salida(Long id, String nombre, String colorHex) {}

    @PostMapping
    public ResponseEntity<Salida> crear(@Valid @RequestBody Entrada entrada) {
        if (repositorio.existsByNombreIgnoreCase(entrada.nombre())) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "Ya existe esa etiqueta");
        }
        var etiqueta = repositorio.save(new Etiqueta(entrada.nombre(), entrada.colorHex()));
        return ResponseEntity.created(URI.create("/etiquetas/" + etiqueta.getId()))
            .body(new Salida(etiqueta.getId(), etiqueta.getNombre(), etiqueta.getColorHex()));
    }
}

Antes de conservar esta versión, extrae la comprobación y el save a EtiquetaService, siguiendo el patrón que ya aplicaste en la UD4: constructor con EtiquetaRepository y método crear(nombre, colorHex) que devuelve la etiqueta guardada. Inyecta ese servicio en el controlador y deja en él validación de entrada, Location y DTO. Ejecuta primero POST /etiquetas con {"nombre":"Backend","colorHex":"#336699"}; guarda su id. Repite el nombre y comprueba 409. Si tu manejador global captura Exception, añade un manejador específico de ResponseStatusException que respete su estado antes de realizar esa prueba.

Crea EtiquetaRepository y añádelo al constructor existente de TareaService, conservando sus otros colaboradores. Antes de probar asociaciones necesitas crear etiquetas: prepara también su DTO de entrada, servicio de alta y POST de controlador, siguiendo el recorrido nombre/color → entidad → save → DTO con id. Comprueba ese POST por separado y guarda dos ids. Después añade los métodos de asociación del bloque y utiliza esos ids, no números inventados.

package com.ejemplo.gestor.repository;

import com.ejemplo.gestor.model.Etiqueta;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface EtiquetaRepository extends JpaRepository<Etiqueta, Long> {
    Optional<Etiqueta> findByNombreIgnoreCase(String nombre);
    boolean existsByNombreIgnoreCase(String nombre);
}

En TareaService.java, implementamos el caso de uso para etiquetar una tarea:

@Transactional
public TareaResponse asignarEtiqueta(Long tareaId, Long etiquetaId) {
    Tarea tarea = obtener(tareaId);
    Etiqueta etiqueta = etiquetaRepo.findById(etiquetaId)
            .orElseThrow(() -> new RecursoNoEncontradoException("etiqueta", etiquetaId));

    tarea.agregarEtiqueta(etiqueta);
    return TareaMapper.aRespuesta(tarea); // Convertimos antes de cerrar la transacción
}

@Transactional
public Tarea desasignarEtiqueta(Long tareaId, Long etiquetaId) {
    Tarea tarea = obtener(tareaId);
    Etiqueta etiqueta = etiquetaRepo.findById(etiquetaId)
            .orElseThrow(() -> new RecursoNoEncontradoException("etiqueta", etiquetaId));

    tarea.quitarEtiqueta(etiqueta);
    return tarea;
}

Paso 6 · Exponer en DTOs y Controladores

En TareaMapper.aRespuesta, actualiza el constructor a los siete componentes del record mostrado debajo. Importa Collectors de java.util.stream y usa este cuerpo, conservando los otros métodos del mapper:

return new TareaResponse(tarea.getId(), tarea.getTitulo(), tarea.getPrioridad(),
    tarea.isCompletada(), tarea.getProyecto().getId(), tarea.getProyecto().getNombre(),
    tarea.getEtiquetas().stream().map(Etiqueta::getNombre).collect(Collectors.toSet()));

Importa también tu entidad Etiqueta. asignarEtiqueta devuelve ahora TareaResponse para hacer esta conversión dentro de la transacción; importa DTO y mapper en el servicio. Cambia el controlador para devolver directamente ese resultado, como muestra el bloque siguiente.

Añade el componente etiquetas al DTO conservando los demás campos y actualiza todos los lugares que llaman a su constructor. En el mapper transforma Set<Etiqueta> en nombres; no devuelvas la colección de entidades JPA. Carga la relación antes de salir de la transacción o convierte allí a una respuesta independiente de JPA. Agrega después los endpoints al controlador. Al desasociar, comprueba tanto la ausencia del vínculo como la conservación de la etiqueta compartida.

public record TareaResponse(
    Long id,
    String titulo,
    String prioridad,
    boolean completada,
    Long proyectoId,
    String proyectoNombre,
    Set<String> etiquetas
) {}

Añadimos en TareaController.java los endpoints de asociación:

@PostMapping("/{id}/etiquetas/{etiquetaId}")
public TareaResponse agregarEtiqueta(
        @PathVariable Long id,
        @PathVariable Long etiquetaId) {
    return servicio.asignarEtiqueta(id, etiquetaId);
}

@DeleteMapping("/{id}/etiquetas/{etiquetaId}")
public ResponseEntity<Void> quitarEtiqueta(
        @PathVariable Long id,
        @PathVariable Long etiquetaId) {
    servicio.desasignarEtiqueta(id, etiquetaId);
    return ResponseEntity.noContent().build();
}

Paso 7 · El ciclo N:M en PostgreSQL

Arranca la aplicación y ejecuta las siguientes pruebas:

POST http://localhost:8080/etiquetas
Content-Type: application/json

{
  "nombre": "backend",
  "colorHex": "#3498DB"
}
POST http://localhost:8080/etiquetas
Content-Type: application/json

{
  "nombre": "urgente",
  "colorHex": "#E74C3C"
}

Ejecuta:

  • POST http://localhost:8080/tareas/1/etiquetas/1
  • POST http://localhost:8080/tareas/1/etiquetas/2

Observa la consola de Spring Boot:

Hibernate:
    insert
    into
        tareas_etiquetas
        (tarea_id, etiqueta_id)
    values
        (?, ?)

La respuesta HTTP devuelve:

{
  "id": 1,
  "titulo": "Configurar @ManyToOne en entidades",
  "etiquetas": ["backend", "urgente"]
}

Ejecuta en tu cliente SQL:

SELECT * FROM tareas_etiquetas;

Verás dos filas: (1, 1) y (1, 2).

Borra la tarea 1 con DELETE http://localhost:8080/tareas/1.

  • En PostgreSQL, la tabla intermedia tareas_etiquetas se limpia automáticamente.
  • Ejecuta SELECT * FROM etiquetas;: las etiquetas “backend” y “urgente” siguen existiendo intactas.

Paso 8 · Filtrar tareas por etiqueta

Implementa la búsqueda de tareas asociadas a una etiqueta concreta:

  1. Añade a TareaRepository:
    // Spring Data realiza el JOIN automático entre tareas y etiquetas
    List<Tarea> findByEtiquetasNombreIgnoreCase(String nombreEtiqueta);
  2. Añade en TareaService el método buscarPorEtiqueta(String nombre).
  3. Conéctalo al endpoint GET /tareas?etiqueta=urgente.
  4. Comprueba en la consola SQL que Hibernate genera una sentencia INNER JOIN tareas_etiquetas y INNER JOIN etiquetas con la condición WHERE LOWER(etiquetas.nombre) = LOWER(?).

Paso 9 · Comprobar y registrar el resultado del proyecto

  1. Asocia el mismo elemento a dos registros y comprueba las filas intermedias. Repetir una asociación no debe crear un vínculo duplicado.
  2. Desasocia o elimina uno de los registros y verifica que el elemento compartido sigue existiendo y asociado al otro.

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 entidad intermedia con clave compuesta

Investiga cómo resolver el caso en el que la relación N:M necesita atributos de negocio propios:

Imagina que una etiqueta no solo se asocia a una tarea, sino que debemos guardar LocalDateTime fechaAsignacion y String motivo:

  • ¿Por qué una anotación @ManyToMany directa es totalmente incapaz de persistir esos dos campos en la tabla intermedia?
  • Investiga cómo se diseña este modelo mediante una entidad intermedia:
    1. La clase @Embeddable TareaEtiquetaId que agrupa Long tareaId y Long etiquetaId.
    2. La entidad @Entity TareaEtiqueta con @EmbeddedId TareaEtiquetaId id y dos relaciones @ManyToOne @MapsId.
  • Explica qué ventaja tiene este patrón de descomposición frente al @ManyToMany simple y por qué en proyectos empresariales grandes es el estándar dominante.
Objetivo mínimoEntidad Etiqueta creada y vinculada con @ManyToMany y @JoinTable a Tarea usando Set.
Si lo tienesAsignación y desasignación funcionando por HTTP, DTOs con etiquetas y consulta filtrada por nombre de etiqueta.
RetoEl diseño conceptual de la entidad intermedia descompuesta con @EmbeddedId documentado y justificado.
Ver respuestas

1 · Las dos columnas de clave foránea que apuntan a las claves primarias de cada una de las tablas relacionadas (ej: tarea_id y etiqueta_id).

2 · Provoca que Hibernate elimine todas las filas de la tabla puente correspondientes a la entidad y las vuelva a insertar todas de nuevo una a una.

3 · Porque al borrar una tarea hija se eliminarían también las etiquetas maestras asociadas, rompiendo las demás tareas que estuvieran usando esa misma etiqueta.

4 · En el momento en que la relación necesita almacenar atributos propios de negocio (como fecha de asignación, usuario que asignó, rol o estado del vínculo).

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Borrar un recurso principal no elimina los elementos compartidos por otros; la tabla de unión queda coherente.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.