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).
- Tabla tareas (PK id)
- Tabla tareas_etiquetas (FK tarea_id, FK etiqueta_id)
- 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
- Abre el modelo y elige una relación muchos a muchos real de tu dominio. Dibuja dos registros que compartan un elemento relacionado.
- Localiza los paquetes de entidades, repositorios, servicios y DTO. Implementarás la asociación en todas esas capas manteniendo los contratos anteriores.
- 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_idhaciatareas.id). inverseJoinColumns- Especifica la columna de la tabla puente que apunta a la otra entidad (
etiqueta_idhaciaetiquetas.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?
- Ejecuta:
DELETE FROM tareas_etiquetas WHERE tarea_id = 5;, que elimina las veinte filas en una sola sentencia. - A continuación, ejecuta 19 sentencias
INSERTuna 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/1POST 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_etiquetasse 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:
- Añade a
TareaRepository:// Spring Data realiza el JOIN automático entre tareas y etiquetas List<Tarea> findByEtiquetasNombreIgnoreCase(String nombreEtiqueta); - Añade en
TareaServiceel métodobuscarPorEtiqueta(String nombre). - Conéctalo al endpoint
GET /tareas?etiqueta=urgente. - Comprueba en la consola SQL que Hibernate genera una sentencia
INNER JOIN tareas_etiquetasyINNER JOIN etiquetascon la condiciónWHERE LOWER(etiquetas.nombre) = LOWER(?).
Paso 9 · Comprobar y registrar el resultado del proyecto
- Asocia el mismo elemento a dos registros y comprueba las filas intermedias. Repetir una asociación no debe crear un vínculo duplicado.
- 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
@ManyToManydirecta es totalmente incapaz de persistir esos dos campos en la tabla intermedia? - Investiga cómo se diseña este modelo mediante una entidad intermedia:
- La clase
@Embeddable TareaEtiquetaIdque agrupaLong tareaIdyLong etiquetaId. - La entidad
@Entity TareaEtiquetacon@EmbeddedId TareaEtiquetaId idy dos relaciones@ManyToOne @MapsId.
- La clase
- Explica qué ventaja tiene este patrón de descomposición frente al
@ManyToManysimple y por qué en proyectos empresariales grandes es el estándar dominante.
Etiqueta creada y vinculada con @ManyToMany y @JoinTable a Tarea usando Set.@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.