JPA y Asociaciones Avanzadas: Guía Completa para Clases
Este apunte proporciona una clase completa sobre los conceptos clave de JPA, enfocándose en las asociaciones, optimización de fetching y técnicas de proyección, incluyendo ejemplos básicos, escenarios y errores comunes.
I. Introducción a JPA y Tipos de Asociaciones
JPA (Java Persistence API), comúnmente implementado con Hibernate, es una herramienta fundamental para el mapeo objeto-relacional (ORM) en Java. Permite gestionar la persistencia de objetos Java en bases de datos relacionales de manera transparente.
¿Qué son las Asociaciones?
Las asociaciones en JPA definen cómo las entidades están relacionadas entre sí en el modelo de dominio. Representan las relaciones del mundo real entre objetos de negocio.
| Tipo de Asociación | Descripción | Ejemplo Real |
|---|---|---|
@OneToOne | Una instancia se relaciona con exactamente una | Usuario → Perfil |
@OneToMany | Una instancia se relaciona con muchas | Orden → Líneas de Orden |
@ManyToOne | Muchas instancias se relacionan con una | Empleados → Departamento |
@ManyToMany | Muchas instancias se relacionan con muchas | Estudiantes ↔ Cursos |
Conceptos Fundamentales
Antes de profundizar, es importante entender estos conceptos:
- Lado Propietario (Owner): El lado que gestiona la clave foránea (FK) en la base de datos
- Lado Inverso: El lado que usa
mappedByy no gestiona la FK - FetchType: Define cuándo se cargan los datos (
LAZYvsEAGER) - CascadeType: Define qué operaciones se propagan a entidades relacionadas
II. Relaciones Uno a Uno (@OneToOne)
La relación @OneToOne se utiliza cuando una instancia de la Entidad A está asociada exactamente con una instancia de la Entidad B.
Escenarios de Uso
- Separación de datos: Campos raramente accedidos en tabla separada
- Escalabilidad: Entidad principal con altas operaciones de escritura
- Seguridad: Datos sensibles en tabla separada con diferentes permisos
Comportamiento por Defecto
⚠️ Por defecto,
@OneToOneutilizaFetchType.EAGER, lo cual puede causar problemas de rendimiento.
Ejemplo Básico: Unidireccional
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
private UserProfile profile;
// Getters y setters
}
@Entity
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
// Getters y setters
}Ejemplo Avanzado: Bidireccional con @MapsId
La técnica @MapsId es la mejor práctica para @OneToOne bidireccional porque:
- Comparte la clave primaria entre ambas tablas
- Permite carga perezosa real en el lado inverso
- Reduce el almacenamiento (no hay FK adicional)
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@OneToOne(mappedBy = "customer", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private CustomerDetails details;
// Método de utilidad para mantener sincronización
public void setDetails(CustomerDetails details) {
if (details == null) {
if (this.details != null) {
this.details.setCustomer(null);
}
} else {
details.setCustomer(this);
}
this.details = details;
}
}
@Entity
public class CustomerDetails {
@Id
private Long id; // Misma PK que Customer
private String address;
private String phone;
private LocalDate birthDate;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // Mapea la PK de Customer como FK y PK
@JoinColumn(name = "customer_id")
private Customer customer;
// Getters y setters
}SQL Generado
CREATE TABLE customer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255),
email VARCHAR(255)
);
CREATE TABLE customer_details (
customer_id BIGINT PRIMARY KEY, -- PK y FK al mismo tiempo
address VARCHAR(255),
phone VARCHAR(255),
birth_date DATE,
FOREIGN KEY (customer_id) REFERENCES customer(id)
);Errores Comunes en @OneToOne
| Error | Causa | Solución |
|---|---|---|
| N+1 con EAGER | FetchType.EAGER por defecto | Usar FetchType.LAZY |
| Lazy no funciona en lado inverso | Hibernate no puede crear proxy | Usar @MapsId o bytecode enhancement |
| Datos huérfanos | No se eliminan detalles al eliminar padre | Usar orphanRemoval = true |
III. Relaciones Uno a Muchos (@OneToMany) y Muchos a Uno (@ManyToOne)
Estas asociaciones son intrínsecamente duales: si una entidad tiene @ManyToOne, la relación vista desde el otro lado es @OneToMany.
A. @ManyToOne - El Lado de la Clave Foránea
Esta asociación se define en el lado de la entidad que contiene la clave foránea (FK). Muchas entidades secundarias se relacionan con una entidad principal.
Comportamiento por Defecto
⚠️ Por defecto,
@ManyToOneutilizaFetchType.EAGER. Siempre cambiarlo aLAZY.
Ejemplo Básico
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // ¡Siempre LAZY!
@JoinColumn(name = "department_id", nullable = false)
private Department department;
// Getters y setters
}B. @OneToMany - El Lado de la Colección
Esta asociación se define en el lado de la entidad que posee la colección de entidades relacionadas.
Comportamiento por Defecto
✅ Por defecto,
@OneToManyutilizaFetchType.LAZY, que es lo correcto.
Ejemplo Básico
@Entity
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Employee> employees = new ArrayList<>();
// Getters y setters
}C. Bidireccionalidad y Propiedad (Ownership)
Cuando se implementa una asociación bidireccional, JPA impone que un lado sea el propietario y el otro sea el inverso.
Regla de Oro
🔑 En una asociación bidireccional
@OneToMany/@ManyToOne, el lado "Many" (@ManyToOne) SIEMPRE debe ser el propietario.
Ejemplo Completo: Order y OrderLine
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime orderDate;
private String status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
// ✅ Métodos de utilidad para mantener sincronización bidireccional
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void removeLine(OrderLine line) {
lines.remove(line);
line.setOrder(null);
}
}
@Entity
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private Integer quantity;
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order; // Este lado gestiona la FK
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderLine)) return false;
OrderLine that = (OrderLine) o;
return id != null && id.equals(that.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}SQL Generado
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_date DATETIME,
status VARCHAR(50)
);
CREATE TABLE order_line (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_name VARCHAR(255),
quantity INT,
price DECIMAL(10,2),
order_id BIGINT NOT NULL, -- FK gestionada por @ManyToOne
FOREIGN KEY (order_id) REFERENCES orders(id)
);Pitfall de Performance: ¿Por qué el lado Many debe ser Owner?
| Escenario | Consultas SQL | Explicación |
|---|---|---|
@ManyToOne como owner | N + 1 | 1 INSERT por cada OrderLine |
@OneToMany como owner | 2N + 1 | 1 INSERT + 1 UPDATE de FK por cada OrderLine |
// ❌ MAL: @OneToMany como owner (sin mappedBy)
@OneToMany
@JoinColumn(name = "order_id") // Esto hace que Order sea owner
private List<OrderLine> lines;
// Resultado: INSERT order_line + UPDATE order_line SET order_id = ?// ✅ BIEN: @ManyToOne como owner
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
// Resultado: INSERT order_line (con order_id incluido)Errores Comunes en @OneToMany / @ManyToOne
| Error | Causa | Solución |
|---|---|---|
| Inconsistencia en contexto | No sincronizar ambos lados | Usar métodos de utilidad (addLine, removeLine) |
LazyInitializationException | Acceder a colección fuera de transacción | Usar JOIN FETCH o @Transactional |
| Duplicados en colección | No implementar equals/hashCode | Implementar basado en ID o clave de negocio |
| Performance degradada | @ManyToOne con EAGER | Siempre usar FetchType.LAZY |
IV. Relaciones Muchos a Muchos (@ManyToMany)
Las asociaciones @ManyToMany son muy comunes pero presentan varios pitfalls que se deben evitar cuidadosamente.
Ejemplo Básico: Bidireccional
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>(); // ✅ Usar Set, NO List
// Métodos de utilidad
public void addCourse(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void removeCourse(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Course)) return false;
Course course = (Course) o;
return id != null && id.equals(course.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}SQL Generado
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255)
);
CREATE TABLE course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255)
);
CREATE TABLE student_course (
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES student(id),
FOREIGN KEY (course_id) REFERENCES course(id)
);Mejores Prácticas y Errores Comunes
| # | Práctica | Implementación | Error Potencial |
|---|---|---|---|
| 1 | Usar Set | Set<Course> courses = new HashSet<>() | ❌ List causa eliminación masiva y reinserción |
| 2 | Métodos de utilidad | addCourse(), removeCourse() | ❌ Contexto inconsistente si no se sincronizan ambos lados |
| 3 | FetchType.LAZY | Valor por defecto, no cambiar | ❌ EAGER causa problemas de rendimiento |
| 4 | Evitar CascadeType peligroso | Solo PERSIST y MERGE | ❌ REMOVE puede eliminar datos compartidos |
| 5 | Implementar equals/hashCode | Basado en ID o clave de negocio | ❌ Duplicados y comportamiento errático |
¿Por qué NO usar List en @ManyToMany?
// ❌ MAL: Usando List
@ManyToMany
private List<Course> courses = new ArrayList<>();
// Al eliminar UN curso, Hibernate ejecuta:
// DELETE FROM student_course WHERE student_id = ? -- ¡TODOS!
// INSERT INTO student_course VALUES (?, ?) -- Reinserta los restantes
// INSERT INTO student_course VALUES (?, ?)
// ...// ✅ BIEN: Usando Set
@ManyToMany
private Set<Course> courses = new HashSet<>();
// Al eliminar UN curso, Hibernate ejecuta:
// DELETE FROM student_course WHERE student_id = ? AND course_id = ? -- Solo unoPatrón Avanzado: Entidad de Enlace (Link Entity)
Cuando necesitas atributos adicionales en la relación (fecha de inscripción, calificación, etc.), usa una entidad de enlace:
@Entity
public class Enrollment {
@EmbeddedId
private EnrollmentId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("studentId")
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("courseId")
@JoinColumn(name = "course_id")
private Course course;
private LocalDate enrollmentDate;
private Double grade;
// Constructor, getters, setters
}
@Embeddable
public class EnrollmentId implements Serializable {
private Long studentId;
private Long courseId;
// equals, hashCode
}V. Carga de Datos Eficiente: El Problema N+1 y Estrategias de Fetching
A. El Problema N+1 SELECT
El problema N+1 ocurre cuando se ejecuta:
- 1 consulta para obtener las entidades principales
- N consultas adicionales para cargar las asociaciones de cada entidad
Ejemplo del Problema
// Código que causa N+1
List<Order> orders = orderRepository.findAll(); // 1 consulta
for (Order order : orders) {
// Cada acceso a lines dispara una consulta adicional
System.out.println(order.getLines().size()); // N consultas
}-- Consulta 1: Obtener órdenes
SELECT * FROM orders;
-- Consultas N: Una por cada orden
SELECT * FROM order_line WHERE order_id = 1;
SELECT * FROM order_line WHERE order_id = 2;
SELECT * FROM order_line WHERE order_id = 3;
-- ... N vecesB. Solución 1: JOIN FETCH (JPQL)
La cláusula JOIN FETCH indica a Hibernate qué asociaciones inicializar inmediatamente.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lines WHERE o.status = :status")
List<Order> findByStatusWithLines(@Param("status") String status);
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.lines l " +
"JOIN FETCH o.customer " +
"WHERE o.orderDate >= :date")
List<Order> findRecentOrdersWithDetails(@Param("date") LocalDateTime date);
}-- Una sola consulta con JOIN
SELECT DISTINCT o.*, l.*, c.*
FROM orders o
INNER JOIN order_line l ON o.id = l.order_id
INNER JOIN customer c ON o.customer_id = c.id
WHERE o.order_date >= ?C. Solución 2: Entity Graphs
Los Entity Graphs permiten definir qué asociaciones cargar de forma declarativa.
Definición con @NamedEntityGraph
@Entity
@NamedEntityGraph(
name = "Order.withLinesAndCustomer",
attributeNodes = {
@NamedAttributeNode("lines"),
@NamedAttributeNode("customer")
}
)
public class Order {
// ...
}Uso en Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(value = "Order.withLinesAndCustomer")
List<Order> findByStatus(String status);
// O definir inline
@EntityGraph(attributePaths = {"lines", "customer"})
Optional<Order> findById(Long id);
}Uso Programático
@Service
@Transactional(readOnly = true)
public class OrderService {
@PersistenceContext
private EntityManager em;
public List<Order> findOrdersWithDetails() {
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("lines");
graph.addSubgraph("customer").addAttributeNodes("details");
return em.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
}D. Solución 3: Batch Fetching
El batch fetching reduce N+1 a N/batchSize + 1 consultas.
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 25) // Carga hasta 25 colecciones por consulta
private List<OrderLine> lines;
}-- En lugar de N consultas individuales:
SELECT * FROM order_line WHERE order_id IN (1, 2, 3, ..., 25);
SELECT * FROM order_line WHERE order_id IN (26, 27, 28, ..., 50);
-- etc.Configuración Global
# application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25E. Pitfall: MultipleBagFetchException
❌ Error:
cannot simultaneously fetch multiple bags
Ocurre al intentar hacer JOIN FETCH de múltiples colecciones List (bags).
// ❌ Esto falla
@Query("SELECT o FROM Order o JOIN FETCH o.lines JOIN FETCH o.payments")
List<Order> findWithLinesAndPayments();Soluciones
- Convertir
ListaSet:
@OneToMany(mappedBy = "order")
private Set<OrderLine> lines = new HashSet<>();
@OneToMany(mappedBy = "order")
private Set<Payment> payments = new HashSet<>();- Múltiples consultas separadas:
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lines")
List<Order> findWithLines();
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.payments WHERE o IN :orders")
List<Order> fetchPayments(@Param("orders") List<Order> orders);F. Cuándo NO Usar JOIN FETCH
⚠️ A veces, N+1 es mejor que un JOIN masivo.
Si una consulta con múltiples JOINs genera un producto cartesiano enorme:
54 órdenes × 54 líneas × 54 pagos = 157,464 filasEs más eficiente hacer 3 consultas simples:
54 + 54 + 54 = 162 filas totalesRegla práctica: Usa JOIN FETCH para 1-2 asociaciones. Para más, considera batch fetching o consultas separadas.
VI. Proyecciones: Optimizando la Recuperación de Datos
Las proyecciones permiten recuperar solo los datos necesarios, mejorando rendimiento y reduciendo uso de memoria.
A. Proyecciones Basadas en Interfaces
La forma más simple. Spring Data JPA crea un proxy automáticamente.
// Definir la proyección
public interface OrderSummary {
Long getId();
LocalDateTime getOrderDate();
String getStatus();
// Propiedad calculada con SpEL
@Value("#{target.lines.size()}")
int getLineCount();
}
// Usar en repository
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatus(String status);
@Query("SELECT o.id as id, o.orderDate as orderDate, o.status as status " +
"FROM Order o WHERE o.customer.id = :customerId")
List<OrderSummary> findSummariesByCustomer(@Param("customerId") Long customerId);
}Limitaciones
- No se puede personalizar
equals()/hashCode() - Menos eficiente que DTOs para consultas complejas
B. Proyecciones Basadas en Clases (DTOs)
Más control y mejor rendimiento para consultas complejas.
Usando Records (Java 16+)
public record OrderDTO(
Long id,
LocalDateTime orderDate,
String status,
String customerName,
BigDecimal totalAmount
) {}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT new com.example.dto.OrderDTO(
o.id,
o.orderDate,
o.status,
c.name,
SUM(l.price * l.quantity)
)
FROM Order o
JOIN o.customer c
JOIN o.lines l
WHERE o.status = :status
GROUP BY o.id, o.orderDate, o.status, c.name
""")
List<OrderDTO> findOrderSummaries(@Param("status") String status);
}Usando POJO Tradicional
public class OrderDetailDTO {
private Long orderId;
private String customerName;
private List<LineDTO> lines;
public OrderDetailDTO(Long orderId, String customerName) {
this.orderId = orderId;
this.customerName = customerName;
this.lines = new ArrayList<>();
}
// Getters, setters
}
public class LineDTO {
private String productName;
private Integer quantity;
private BigDecimal price;
public LineDTO(String productName, Integer quantity, BigDecimal price) {
this.productName = productName;
this.quantity = quantity;
this.price = price;
}
}C. Proyecciones Dinámicas
Permiten elegir el tipo de proyección en tiempo de ejecución.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Método genérico que acepta cualquier tipo de proyección
<T> List<T> findByStatus(String status, Class<T> type);
<T> Optional<T> findById(Long id, Class<T> type);
}
// Uso
List<OrderSummary> summaries = orderRepository.findByStatus("PENDING", OrderSummary.class);
List<OrderDTO> dtos = orderRepository.findByStatus("PENDING", OrderDTO.class);
List<Order> entities = orderRepository.findByStatus("PENDING", Order.class);D. Tuple Projections
Para consultas ad-hoc sin crear DTOs.
@Query("SELECT o.id, o.status, COUNT(l) FROM Order o LEFT JOIN o.lines l GROUP BY o.id, o.status")
List<Object[]> findOrderStats();
// O usando Tuple
@Query("SELECT o.id as id, o.status as status, COUNT(l) as lineCount " +
"FROM Order o LEFT JOIN o.lines l GROUP BY o.id, o.status")
List<Tuple> findOrderStatsTuple();
// Uso
List<Tuple> stats = orderRepository.findOrderStatsTuple();
for (Tuple t : stats) {
Long id = t.get("id", Long.class);
String status = t.get("status", String.class);
Long count = t.get("lineCount", Long.class);
}Comparación de Proyecciones
| Tipo | Ventajas | Desventajas | Uso Recomendado |
|---|---|---|---|
| Interface | Simple, automático | Sin equals/hashCode, reflexión | Consultas simples |
| DTO/Record | Control total, eficiente | Más código | Consultas complejas |
| Dinámica | Flexible | Menos type-safe | APIs con múltiples vistas |
| Tuple | Sin clases adicionales | Poco legible | Consultas ad-hoc |
VII. Consultas Nativas (Native Queries)
Las consultas nativas permiten ejecutar SQL directamente, necesario cuando JPQL no es suficiente.
A. JPQL vs Native Queries
| Característica | JPQL | Native Query |
|---|---|---|
| Abstracción | Independiente de BD | Específico de BD |
| Complejidad | Limitado para queries complejas | SQL completo disponible |
| Portabilidad | Alta | Baja |
| Funciones BD | Limitadas | Todas disponibles |
B. Sintaxis Básica
public interface OrderRepository extends JpaRepository<Order, Long> {
// Native query simple
@Query(value = "SELECT * FROM orders WHERE status = ?1", nativeQuery = true)
List<Order> findByStatusNative(String status);
// Con @NativeQuery (Spring Data 3.x)
@NativeQuery("SELECT * FROM orders WHERE YEAR(order_date) = :year")
List<Order> findByYear(@Param("year") int year);
// Con paginación
@Query(
value = "SELECT * FROM orders WHERE status = :status",
countQuery = "SELECT COUNT(*) FROM orders WHERE status = :status",
nativeQuery = true
)
Page<Order> findByStatusPaged(@Param("status") String status, Pageable pageable);
}C. Proyecciones con Native Queries
// Proyección a interface
public interface OrderNativeSummary {
Long getId();
String getStatus();
Integer getLineCount();
}
@Query(value = """
SELECT o.id, o.status, COUNT(l.id) as lineCount
FROM orders o
LEFT JOIN order_line l ON o.id = l.order_id
GROUP BY o.id, o.status
""", nativeQuery = true)
List<OrderNativeSummary> findNativeSummaries();
// Proyección a Map
@NativeQuery("SELECT * FROM orders WHERE id = :id")
Map<String, Object> findRawById(@Param("id") Long id);D. DTOs Jerárquicos con Native Queries
Para estructuras complejas, usa un Custom Repository con ResultTransformer.
// DTO jerárquico
public class OrderWithLinesDTO {
private Long id;
private String status;
private List<LineDTO> lines = new ArrayList<>();
}
// Custom Repository Implementation
@Repository
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
@SuppressWarnings("unchecked")
public List<OrderWithLinesDTO> findOrdersWithLinesNative() {
String sql = """
SELECT o.id as orderId, o.status,
l.id as lineId, l.product_name, l.quantity, l.price
FROM orders o
LEFT JOIN order_line l ON o.id = l.order_id
ORDER BY o.id
""";
List<Object[]> results = em.createNativeQuery(sql).getResultList();
Map<Long, OrderWithLinesDTO> orderMap = new LinkedHashMap<>();
for (Object[] row : results) {
Long orderId = ((Number) row[0]).longValue();
OrderWithLinesDTO order = orderMap.computeIfAbsent(orderId, id -> {
OrderWithLinesDTO dto = new OrderWithLinesDTO();
dto.setId(id);
dto.setStatus((String) row[1]);
return dto;
});
if (row[2] != null) { // Si hay línea
LineDTO line = new LineDTO(
(String) row[3],
((Number) row[4]).intValue(),
(BigDecimal) row[5]
);
order.getLines().add(line);
}
}
return new ArrayList<>(orderMap.values());
}
}E. Cuándo Usar Native Queries
| Escenario | Recomendación |
|---|---|
| Funciones específicas de BD (JSONB, arrays) | ✅ Native |
| Window functions (ROW_NUMBER, RANK) | ✅ Native |
| CTEs (WITH clause) | ✅ Native |
| Consultas simples CRUD | ❌ Usar JPQL o Query Methods |
| Portabilidad entre BDs | ❌ Usar JPQL |
VIII. Custom Repositories
Cuando las opciones estándar no son suficientes, implementa un repositorio personalizado.
Estructura
// 1. Interface con métodos custom
public interface OrderRepositoryCustom {
List<Order> findOrdersDynamic(OrderSearchCriteria criteria);
List<OrderWithLinesDTO> findOrdersWithLinesNative();
}
// 2. Implementación (nombre DEBE terminar en "Impl")
@Repository
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public List<Order> findOrdersDynamic(OrderSearchCriteria criteria) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> order = query.from(Order.class);
List<Predicate> predicates = new ArrayList<>();
if (criteria.getStatus() != null) {
predicates.add(cb.equal(order.get("status"), criteria.getStatus()));
}
if (criteria.getFromDate() != null) {
predicates.add(cb.greaterThanOrEqualTo(
order.get("orderDate"), criteria.getFromDate()));
}
if (criteria.getCustomerId() != null) {
predicates.add(cb.equal(
order.get("customer").get("id"), criteria.getCustomerId()));
}
// JOIN FETCH dinámico
if (criteria.isFetchLines()) {
order.fetch("lines", JoinType.LEFT);
}
query.where(predicates.toArray(new Predicate[0]));
query.distinct(true);
return em.createQuery(query).getResultList();
}
}
// 3. Repository principal extiende ambos
public interface OrderRepository extends
JpaRepository<Order, Long>,
OrderRepositoryCustom {
// Query methods estándar aquí
}IX. Resumen de Mejores Prácticas
Asociaciones
| Práctica | Descripción |
|---|---|
✅ Siempre FetchType.LAZY | Excepto casos muy específicos |
✅ @ManyToOne como owner | En relaciones bidireccionales |
✅ Set para @ManyToMany | Evita eliminación masiva |
| ✅ Métodos de utilidad | Mantener sincronización bidireccional |
✅ @MapsId para @OneToOne | Mejor rendimiento y lazy loading |
❌ CascadeType.REMOVE en @ManyToMany | Puede eliminar datos compartidos |
❌ List en @ManyToMany | Causa eliminación y reinserción masiva |
Fetching
| Práctica | Descripción |
|---|---|
✅ JOIN FETCH para 1-2 asociaciones | Solución más común para N+1 |
| ✅ Entity Graphs para casos declarativos | Más legible que JPQL |
| ✅ Batch fetching para múltiples colecciones | Evita MultipleBagFetchException |
❌ Múltiples JOIN FETCH con List | Causa MultipleBagFetchException |
| ❌ JOIN masivos con producto cartesiano | A veces N+1 es mejor |
Proyecciones
| Práctica | Descripción |
|---|---|
| ✅ Proyecciones para lectura | Mejor rendimiento que entidades completas |
| ✅ Records para DTOs | Inmutables y concisos |
| ✅ Interface projections para casos simples | Menos código |
| ✅ Custom repository para casos complejos | Control total |
X. Checklist de Revisión de Código
Usa esta lista al revisar código JPA:
- ¿Todas las asociaciones
@ManyToOney@OneToOnetienenFetchType.LAZY? - ¿Las colecciones
@ManyToManyusanSeten lugar deList? - ¿El lado
@ManyToOnees el owner en relaciones bidireccionales? - ¿Existen métodos de utilidad para sincronizar asociaciones bidireccionales?
- ¿Se implementa
equals()yhashCode()correctamente en entidades? - ¿Las consultas que cargan colecciones usan
JOIN FETCHo Entity Graphs? - ¿Se usan proyecciones para consultas de solo lectura?
- ¿Se evita
CascadeType.REMOVEen@ManyToMany? - ¿Las consultas con múltiples JOINs no generan productos cartesianos enormes?
Este apunte se basa en la documentación oficial de Hibernate ORM, Spring Data JPA, y las mejores prácticas establecidas por expertos como Vlad Mihalcea y Thorben Janssen. Para profundizar, consulta la documentación oficial y los recursos mencionados.