Spring Boot Relational CRUD (Nivel Relacional)
Overview
Extiende el patrón Pro CRUD para manejar relaciones entre entidades. Esta skill enseña a construir dos entidades con relación @OneToMany / @ManyToOne, mapear DTOs anidados con MapStruct (uses), y conectar las relaciones manualmente en el Service con setAutor(). El proyecto de referencia es un CRUD completo de Autores y Libros.
When to Use
- Cuando necesitas crear un CRUD con dos o más entidades relacionadas (por ejemplo: Autor → Libros, Departamento → Empleados, Categoría → Productos).
- Cuando quieres practicar relaciones JPA de forma clara y directa.
- Cuando tus DTOs de respuesta deben incluir listas anidadas de otras entidades.
- Trigger phrases: "crear CRUD con relaciones JPA", "implementar OneToMany entre [Entidad1] y [Entidad2]", "CRUD relacional con DTOs anidados".
Prerequisites
Esta skill requiere TODO lo que usa el Pro CRUD (MapStruct, Lombok, Validation, JPA, H2), más el conocimiento previo del patrón DTO y el GlobalExceptionHandler. Se recomienda haber completado el Pro CRUD antes de usar esta skill.
Instructions
Follow these steps strictly. This skill creates TWO related entities simultaneously.
⚠️ CONVENCIÓN DE NOMBRES: A lo largo de esta guía, [Padre] representa la entidad "uno" (ej: Autor, Departamento, Categoría) y [Hijo] representa la entidad "muchos" (ej: Libro, Empleado, Producto).
⚠️ REGLA DE DOCUMENTACIÓN EDUCATIVA: Al escribir el código, DEBES incluir comentarios Javadoc concisos en las clases explicando el "por qué" de las decisiones (ej: por qué se ignora autor en el LibroMapper, por qué no incluimos Autor dentro de LibroResponseDTO).
⚠️ REGLA ESTRICTA DE EJECUCIÓN: Debes programar exactamente en este orden secuencial:
- Entidades y Repositorios (ambas)
- Base de Datos (
application.properties) - DTOs (los 4: Request y Response de cada entidad)
- Mappers (los 2, con
usespara conectarlos) - Excepciones Globales
- Services (interfaces e implementaciones)
- Controllers (los 2)
- Archivos de Pruebas HTTP
Presentación del Plan (OBLIGATORIO) 🛑
Antes de escribir CUALQUIER código, MUESTRALE al usuario el siguiente plan y espera su aprobación.
0. Configuración Inicial
Verifica las dependencias estándar en pom.xml (spring-boot-starter-web, data-jpa, validation, lombok, mapstruct) y la configuración del maven-compiler-plugin con los annotationProcessorPaths para Lombok y MapStruct.
Configura la base de datos H2 en application.properties:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true
1. Entidades y Repositorios (Las Dos Tablas)
Crea AMBAS entidades en el paquete .model y sus repositorios en .repository.
Entidad Padre [Padre].java:
/**
* Entidad JPA que representa un [Padre].
* Un [padre] puede tener muchos [hijos] (relación OneToMany).
* cascade = ALL: las operaciones sobre el [padre] se propagan a sus [hijos].
* orphanRemoval = true: si un [hijo] se desasocia, se elimina de la BD.
*/
@Entity
@Getter @Setter @AllArgsConstructor @NoArgsConstructor
@Table(name = "[padres]")
public class [Padre] {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... campos propios del padre ...
@OneToMany(mappedBy = "[padre]", cascade = CascadeType.ALL, orphanRemoval = true)
private List<[Hijo]> [hijos] = new ArrayList<>();
}
Entidad Hijo [Hijo].java:
/**
* Entidad JPA que representa un [Hijo].
* Cada [hijo] pertenece a un único [padre] (relación ManyToOne).
* La columna [padre]_id es la FK que conecta con la tabla [padres].
*/
@Entity
@Getter @Setter @AllArgsConstructor @NoArgsConstructor
@Table(name = "[hijos]")
public class [Hijo] {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... campos propios del hijo ...
@ManyToOne
@JoinColumn(name = "[padre]_id", nullable = false)
private [Padre] [padre];
}
Repositorios: Ambos extienden JpaRepository<[Entity], Long>.
2. DTOs (Las 4 Cajas)
Crea 4 DTOs como record en el paquete .dto:
[Padre]RequestDTO: Solo los campos del padre con validaciones (@NotBlank, etc.). SIN id, SIN lista de hijos.[Padre]ResponseDTO: Conid+ campos del padre +List<[Hijo]ResponseDTO> [hijos]. ← Este es el DTO anidado.[Hijo]RequestDTO: Campos del hijo con validaciones +Long [padre]Id. ← No el objeto completo, solo el ID.[Hijo]ResponseDTO: Conid+ campos del hijo. SIN incluir el objeto [Padre] para evitar recursión infinita en el JSON.
⚠️ REGLA CRÍTICA: El [Hijo]ResponseDTO NUNCA debe contener un campo de tipo [Padre] o [Padre]ResponseDTO. Esto causaría recursión infinita: Autor → Libros → Autor → Libros → ...
3. Mappers (El Puente Doble)
Crea 2 mappers en el paquete .mapper:
[Hijo]Mapper.java (SE CREA PRIMERO porque el Padre lo necesita):
/**
* Ignora "autor" en toEntity y updateEntity porque el DTO
* solo trae autorId (Long), no el objeto completo.
* El Service lo asigna manualmente con setAutor().
*/
@Mapper(componentModel = "spring")
public interface [Hijo]Mapper {
[Hijo]ResponseDTO toResponseDTO([Hijo] entity);
@Mapping(target = "id", ignore = true)
@Mapping(target = "[padre]", ignore = true) // ← CRÍTICO
[Hijo] toEntity([Hijo]RequestDTO dto);
@Mapping(target = "id", ignore = true)
@Mapping(target = "[padre]", ignore = true) // ← CRÍTICO
void updateEntity([Hijo]RequestDTO dto, @MappingTarget [Hijo] entity);
}
[Padre]Mapper.java:
/**
* Usa [Hijo]Mapper para convertir automáticamente la lista de [hijos].
* Ignora "libros" en toEntity y updateEntity porque el RequestDTO no trae hijos.
*/
@Mapper(componentModel = "spring", uses = {[Hijo]Mapper.class}) // ← CLAVE
public interface [Padre]Mapper {
[Padre]ResponseDTO toResponseDTO([Padre] entity);
@Mapping(target = "id", ignore = true)
@Mapping(target = "[hijos]", ignore = true)
[Padre] toEntity([Padre]RequestDTO dto);
@Mapping(target = "id", ignore = true)
@Mapping(target = "[hijos]", ignore = true)
void updateEntity([Padre]RequestDTO dto, @MappingTarget [Padre] entity);
}
⚠️ CONCEPTOS CLAVE PARA EL USUARIO:
uses = {[Hijo]Mapper.class}: Le dice a MapStruct que cuando necesite convertir un[Hijo]→[Hijo]ResponseDTO, delegue al[Hijo]Mapper.ignore = "[padre]": El DTO trae[padre]Id(Long), pero la entidad espera un objeto[Padre]. MapStruct no sabe convertir uno en otro, así que lo ignoramos y lo asignamos manualmente en el Service.
4. Manejo Global de Errores
Crea las clases en el paquete .exception:
ErrorResponse.java: Unrecordcon(String error, String detalle, LocalDateTime fecha).ResourceNotFoundException.java: ExtiendeRuntimeExceptionpara errores 404.GlobalExceptionHandler.java: Con@RestControllerAdvice. Captura:ResourceNotFoundException→ 404 Not FoundMethodArgumentNotValidException→ 400 Bad Request (mapa de errores por campo)Exceptiongenérica → 500 Internal Server Error
5. Service Layer (La Conexión de la Relación) ⭐
Aquí está el corazón de esta skill: cómo se conecta la relación en el Service.
I[Padre]Service + [Padre]ServiceImpl: CRUD estándar igual que el Pro CRUD. Nada especial.
I[Hijo]Service + [Hijo]ServiceImpl:
/**
* Al crear un [hijo], busca al [Padre] por [padre]Id y lo asigna con set[Padre]().
* Esta es la pieza clave que conecta la relación ManyToOne en el Service.
*/
@Service
@RequiredArgsConstructor
public class [Hijo]ServiceImpl implements I[Hijo]Service {
private final [Hijo]Repository [hijo]Repo;
private final [Hijo]Mapper [hijo]Mapper;
private final [Padre]Repository [padre]Repo; // ← Inyecta el repo del PADRE
@Override
public [Hijo]ResponseDTO crear[Hijo]([Hijo]RequestDTO dto) {
// 1. Buscar al padre por ID (si no existe, lanzar 404)
[Padre] [padre] = [padre]Repo.findById(dto.[padre]Id())
.orElseThrow(() -> new ResourceNotFoundException("[Padre] no encontrado con ID: " + dto.[padre]Id()));
// 2. Convertir el DTO a entidad (sin el padre, MapStruct lo ignoró)
[Hijo] nuevo[Hijo] = [hijo]Mapper.toEntity(dto);
// 3. ⭐ ASIGNAR LA RELACIÓN MANUALMENTE
nuevo[Hijo].set[Padre]([padre]);
// 4. Guardar y retornar como DTO
[Hijo] [hijo]Guardado = [hijo]Repo.save(nuevo[Hijo]);
return [hijo]Mapper.toResponseDTO([hijo]Guardado);
}
// ... demás métodos CRUD estándar ...
}
⚠️ EXPLICACIÓN CLAVE PARA EL USUARIO: La línea nuevo[Hijo].set[Padre]([padre]) es toda la magia. Sin esta línea, la columna FK [padre]_id quedaría null y la BD lanzaría error. El flujo es:
- El cliente envía
[padre]Id: 1en el JSON - El Service busca al
[Padre]con ID 1 en la BD - Si existe, se lo asigna al
[Hijo]conset[Padre]() - JPA automáticamente llena la columna FK al hacer
save()
6. Controllers
Crea 2 controllers:
[Padre]Controller:@RequestMapping("/api/[padres]")— CRUD estándar con@Valid.[Hijo]Controller:@RequestMapping("/api/[hijos]")— CRUD estándar con@Valid.
Ambos siguen el mismo patrón del Pro CRUD. Los Controllers no saben nada de relaciones; eso lo maneja el Service.
7. Pruebas HTTP
Crea una carpeta http/ en la raíz del proyecto con 2 archivos:
[padres].http: CRUD completo del padre + pruebas de error (404, validaciones).[hijos].http: CRUD completo del hijo + prueba de autor inexistente (404) + prueba estrella:GET /api/[padres]/1debe mostrar al padre con sus hijos anidados en el JSON.
⚠️ ORDEN DE EJECUCIÓN obligatorio:
- Primero crear los padres
- Luego crear los hijos (necesitan el
[padre]Id) - Finalmente, consultar un padre para ver los hijos anidados
Mejores Prácticas Integradas
- Enfoque Educativo: Todo el código incluye Javadocs concisos que explican el "por qué" de cada decisión.
- Evitar Recursión Infinita: El
[Hijo]ResponseDTONUNCA contiene al[Padre]. La relación se ve solo desde el lado del padre. - FK Manual en Service: La relación
set[Padre]()se asigna en el Service, no en el Mapper. MapStruct no sabe convertir unLongen un objeto JPA. - Mapper con
uses: El[Padre]Mapperdelega al[Hijo]Mapperpara convertir listas automáticamente. - DRY con método helper: Un método privado
buscar[Entity]PorId()centraliza la búsqueda con manejo de 404.