JPA y Asociaciones Avanzadas: Guía Completa para Clases
Apunte de JPA para clase. Cubre asociaciones, optimización de fetching y proyecciones. Incluye los errores que veo una y otra vez en code reviews, no solo la teoría.
I. Introducción a JPA y Tipos de Asociaciones
JPA (implementado casi siempre con Hibernate) hace ORM en Java: mapea objetos a tablas relacionales sin que tengas que escribir JDBC a mano. Es cómodo cuando lo entiendes, y un dolor de cabeza cuando no.
¿Qué son las Asociaciones?
Las asociaciones definen cómo se relacionan las entidades entre sí. Cuatro tipos:
| Tipo | Cuándo | Ejemplo |
|---|---|---|
@OneToOne | Una instancia se relaciona con exactamente una | Usuario y su perfil |
@OneToMany | Una instancia se relaciona con muchas | Orden y sus líneas |
@ManyToOne | Muchas instancias apuntan a una | Empleados de un departamento |
@ManyToMany | Muchas con muchas, bidireccional | Estudiantes y cursos |
Conceptos Fundamentales
Cuatro términos que necesitas tener claros antes de seguir:
- Lado propietario (Owner): el que gestiona la clave foránea en la base de datos.
- Lado inverso: el que usa
mappedByy no toca la FK. - FetchType: cuándo se cargan los datos relacionados.
LAZYlos carga bajo demanda,EAGERlos carga siempre. - CascadeType: qué operaciones se propagan de una entidad a sus relacionadas.
II. Relaciones Uno a Uno (@OneToOne)
Se usa cuando una entidad A tiene exactamente una entidad B asociada. Piensa en Usuario y su perfil, o Customer y sus datos adicionales.
Escenarios de Uso
Separación de datos: si hay campos que casi nunca accedes, ponlos en otra tabla y carga esa entidad solo cuando la necesites. Escalabilidad: si la entidad principal tiene mucha escritura, mover campos pesados a otra tabla reduce la contención. Seguridad: datos sensibles en tabla aparte con permisos distintos.
Comportamiento por Defecto
Cuidado:
@OneToOneusaFetchType.EAGERpor defecto. Esto carga la entidad relacionada siempre, sin importar si la necesitas. Casi siempre quieres cambiarlo aLAZY.
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
@MapsId es la mejor forma de hacer @OneToOne bidireccional. Comparte la primary key entre ambas tablas, permite lazy loading real en el lado inverso y no añade una FK extra. Si no lo usas, el lazy loading en el lado inverso no funciona: Hibernate no puede crear un proxy sin saber el ID.
@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 dos son dos caras de la misma moneda. Si Employee tiene @ManyToOne con Department, entonces Department tiene @OneToMany con Employee. Siempre en pares.
A. @ManyToOne - El Lado de la Clave Foránea
Se define en la entidad que contiene la FK. Es el lado propietario por definición.
Comportamiento por Defecto
Otra trampa:
@ManyToOnetambién usaFetchType.EAGERpor defecto. Cámbialo aLAZYsiempre. Sin excepciones.
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
Se define en la entidad que tiene la colección. Aquí el default sí es correcto:
A diferencia de las anteriores,
@OneToManyya viene conFetchType.LAZY. Déjalo así.
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)
En una asociación bidireccional, JPA exige que un lado sea el propietario y el otro el inverso.
Regla de Oro
En
@OneToMany/@ManyToOnebidireccional, el lado "Many" (@ManyToOne) SIEMPRE debe ser el propietario. No es negociable. El lado "Many" tiene la FK, y la FK debe gestionarse desde ahí.
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?
Si haces que @OneToMany sea el propietario (quitando mappedBy y poniendo @JoinColumn en el lado One), Hibernate necesita un INSERT y luego un UPDATE adicional para fijar la FK en cada elemento de la colección. Con @ManyToOne como propietario, el INSERT ya incluye la FK. Es la diferencia entre N+1 y 2N+1 consultas.
// ❌ 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)
@ManyToMany es la asociación que más dolores de cabeza da. Funciona, pero tiene trampas en cada esquina.
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
Cinco cosas que compruebo siempre en code review de @ManyToMany:
Primero, usa Set, no List. Si usas List, Hibernate elimina todas las filas de la tabla de unión y las reinserta cada vez que quitas un elemento. Con Set, solo borra la fila afectada. La diferencia en performance es brutal en colecciones grandes.
Segundo, implementa métodos de utilidad (addCourse, removeCourse) que sincronicen ambos lados. Si no lo haces, el contexto de persistencia se queda inconsistente y aparecen bugs imposibles de rastrear.
Tercero, FetchType.LAZY ya es el default. No lo cambies.
Cuarto, limita el cascade a PERSIST y MERGE. Nunca uses CascadeType.REMOVE en @ManyToMany: borrar un curso podría eliminar estudiantes que están inscritos en otros cursos también.
Quinto, implementa equals y hashCode basados en ID o clave de negocio. Sin esto, Set no funciona correctamente y aparecen duplicados.
¿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)
Si necesitas atributos en la relación (fecha de inscripción, calificación), no uses @ManyToMany. Crea una entidad de enlace con @EmbeddedId:
@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 N+1 es el problema de performance más común en JPA. Cargas una lista de entidades con una consulta, y luego por cada entidad se ejecuta otra consulta para cargar su asociación lazy. Diez órdenes, once consultas. Cien órdenes, ciento una.
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)
JOIN FETCH le dice a Hibernate que cargue la asociación en la misma consulta. Una consulta en vez de N+1.
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 hacen lo mismo que JOIN FETCH pero de forma declarativa. Útiles cuando tienes varias consultas que necesitan cargar las mismas asociaciones.
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 no elimina el N+1, lo reduce. En vez de una consulta por entidad, carga en lotes. Si tienes 50 órdenes y batch size de 25, hace 3 consultas en vez de 51.
@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
Si intentas hacer JOIN FETCH de dos o más colecciones List a la vez, Hibernate explota. No puede desduplicar filas de varios bags en el mismo result set.
// ❌ Esto falla
@Query("SELECT o FROM Order o JOIN FETCH o.lines JOIN FETCH o.payments")
List<Order> findWithLinesAndPayments();Soluciones
Cambia los List por Set:
@OneToMany(mappedBy = "order")
private Set<OrderLine> lines = new HashSet<>();
@OneToMany(mappedBy = "order")
private Set<Payment> payments = new HashSet<>();O separa en múltiples consultas:
@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 tu consulta con varios JOIN genera un producto cartesiano enorme, el JOIN FETCH empeora las cosas:
54 órdenes × 54 líneas × 54 pagos = 157,464 filasTres consultas simples suman 162 filas en total. Mucho más eficiente.
Mi regla práctica: JOIN FETCH para 1 o 2 asociaciones. Para más, batch fetching o consultas separadas.
VI. Proyecciones: Optimizando la Recuperación de Datos
Las proyecciones consisten en recuperar solo las columnas que necesitas, en vez de hidratar entidades completas. Para listados, dashboards y APIs, marca la diferencia entre una consulta que carga 3 columnas y una que carga 20 joins.
A. Proyecciones Basadas en Interfaces
Spring Data JPA genera un proxy automáticamente. Cero código extra:
// 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);
}Funciona, pero no puedes personalizar equals ni hashCode, y para consultas complejas es menos eficiente que un DTO.
B. Proyecciones Basadas en Clases (DTOs)
Más control, mejor rendimiento. La opción que uso en producción.
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
Te permiten elegir el tipo de retorno en runtime. Útil para APIs que sirven la misma entidad con distintos niveles de detalle:
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 donde no quieres crear un DTO:
@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 | Cuándo |
|---|---|---|---|
| Interface | Simple, automático | Sin equals/hashCode, usa 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 extra | Poco legible | Consultas ad-hoc |
VII. Consultas Nativas (Native Queries)
Cuando JPQL no llega, SQL directo. Es menos portable pero tienes acceso a todo lo que tu base de datos ofrece.
A. JPQL vs Native Queries
| Característica | JPQL | Native Query |
|---|---|---|
| Abstracción | Independiente de BD | Específico de BD |
| Complejidad | Limitado para queries complejos | 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, necesitas un Custom Repository con ResultTransformer. Mapeas las filas a mano:
// 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
Funciones específicas de base de datos como JSONB, arrays, window functions (ROW_NUMBER, RANK) o CTEs con WITH: native queries. Consultas simples CRUD: quédate con JPQL o Query Methods. Portabilidad entre motores: JPQL, siempre.
VIII. Custom Repositories
Cuando los métodos derivados de Spring Data no son suficientes, implementa un repositorio personalizado. El patrón es siempre el mismo: una interfaz custom, una implementación cuyo nombre termina en Impl, y tu repositorio principal extiende ambas.
// 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
FetchType.LAZY en todo lo que no sea @OneToMany (que ya viene lazy por defecto). El lado @ManyToOne siempre es el propietario en bidireccionales. Set en @ManyToMany, nunca List. Métodos de utilidad para sincronizar ambos lados. @MapsId para @OneToOne bidireccional. Y nunca CascadeType.REMOVE en @ManyToMany: borrar un registro compartido arrastra datos que no deberías tocar.
Fetching
JOIN FETCH para una o dos asociaciones. Entity Graphs cuando quieres algo más declarativo y reutilizable. Batch fetching para múltiples colecciones sin chocar con MultipleBagFetchException. Y cuidado con encadenar muchos JOIN FETCH sobre List: te llevas el error de bags. A veces tres consultas simples son mejores que un producto cartesiano de cien mil filas.
Proyecciones
Para lectura, usa proyecciones. No hidrates entidades completas si solo necesitas cinco columnas. Records para DTOs: son inmutables, concisos y claros. Interface projections para casos simples donde no quieres escribir una clase. Custom repository cuando necesitas control total.
X. Checklist de Revisión de Código
Lista que paso cuando reviso PRs con 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?
Basado en la documentación de Hibernate ORM y Spring Data JPA, más los blogs de Vlad Mihalcea y Thorben Janssen, que son la referencia en este tema.