Zademy

JPA y Asociaciones Avanzadas: Guía Completa para Clases

JPA; Hibernate; Spring Data; ORM; Performance
3119 palabras

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ónDescripciónEjemplo Real
@OneToOneUna instancia se relaciona con exactamente unaUsuario → Perfil
@OneToManyUna instancia se relaciona con muchasOrden → Líneas de Orden
@ManyToOneMuchas instancias se relacionan con unaEmpleados → Departamento
@ManyToManyMuchas instancias se relacionan con muchasEstudiantes ↔ 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 mappedBy y no gestiona la FK
  • FetchType: Define cuándo se cargan los datos (LAZY vs EAGER)
  • 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, @OneToOne utiliza FetchType.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

ErrorCausaSolución
N+1 con EAGERFetchType.EAGER por defectoUsar FetchType.LAZY
Lazy no funciona en lado inversoHibernate no puede crear proxyUsar @MapsId o bytecode enhancement
Datos huérfanosNo se eliminan detalles al eliminar padreUsar 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, @ManyToOne utiliza FetchType.EAGER. Siempre cambiarlo a LAZY.

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, @OneToMany utiliza FetchType.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?

EscenarioConsultas SQLExplicación
@ManyToOne como ownerN + 11 INSERT por cada OrderLine
@OneToMany como owner2N + 11 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

ErrorCausaSolución
Inconsistencia en contextoNo sincronizar ambos ladosUsar métodos de utilidad (addLine, removeLine)
LazyInitializationExceptionAcceder a colección fuera de transacciónUsar JOIN FETCH o @Transactional
Duplicados en colecciónNo implementar equals/hashCodeImplementar basado en ID o clave de negocio
Performance degradada@ManyToOne con EAGERSiempre 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ácticaImplementaciónError Potencial
1Usar SetSet<Course> courses = new HashSet<>()List causa eliminación masiva y reinserción
2Métodos de utilidadaddCourse(), removeCourse()❌ Contexto inconsistente si no se sincronizan ambos lados
3FetchType.LAZYValor por defecto, no cambiarEAGER causa problemas de rendimiento
4Evitar CascadeType peligrosoSolo PERSIST y MERGEREMOVE puede eliminar datos compartidos
5Implementar equals/hashCodeBasado 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 uno

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 veces

B. 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=25

E. 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

  1. Convertir List a Set:
@OneToMany(mappedBy = "order")
private Set<OrderLine> lines = new HashSet<>();

@OneToMany(mappedBy = "order")
private Set<Payment> payments = new HashSet<>();
  1. 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 filas

Es más eficiente hacer 3 consultas simples:

54 + 54 + 54 = 162 filas totales

Regla 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

TipoVentajasDesventajasUso Recomendado
InterfaceSimple, automáticoSin equals/hashCode, reflexiónConsultas simples
DTO/RecordControl total, eficienteMás códigoConsultas complejas
DinámicaFlexibleMenos type-safeAPIs con múltiples vistas
TupleSin clases adicionalesPoco legibleConsultas 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ísticaJPQLNative Query
AbstracciónIndependiente de BDEspecífico de BD
ComplejidadLimitado para queries complejasSQL completo disponible
PortabilidadAltaBaja
Funciones BDLimitadasTodas 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

EscenarioRecomendació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ácticaDescripción
✅ Siempre FetchType.LAZYExcepto casos muy específicos
@ManyToOne como ownerEn relaciones bidireccionales
Set para @ManyToManyEvita eliminación masiva
✅ Métodos de utilidadMantener sincronización bidireccional
@MapsId para @OneToOneMejor rendimiento y lazy loading
CascadeType.REMOVE en @ManyToManyPuede eliminar datos compartidos
List en @ManyToManyCausa eliminación y reinserción masiva

Fetching

PrácticaDescripción
JOIN FETCH para 1-2 asociacionesSolución más común para N+1
✅ Entity Graphs para casos declarativosMás legible que JPQL
✅ Batch fetching para múltiples coleccionesEvita MultipleBagFetchException
❌ Múltiples JOIN FETCH con ListCausa MultipleBagFetchException
❌ JOIN masivos con producto cartesianoA veces N+1 es mejor

Proyecciones

PrácticaDescripción
✅ Proyecciones para lecturaMejor rendimiento que entidades completas
✅ Records para DTOsInmutables y concisos
✅ Interface projections para casos simplesMenos código
✅ Custom repository para casos complejosControl total

X. Checklist de Revisión de Código

Usa esta lista al revisar código JPA:

  • ¿Todas las asociaciones @ManyToOne y @OneToOne tienen FetchType.LAZY?
  • ¿Las colecciones @ManyToMany usan Set en lugar de List?
  • ¿El lado @ManyToOne es el owner en relaciones bidireccionales?
  • ¿Existen métodos de utilidad para sincronizar asociaciones bidireccionales?
  • ¿Se implementa equals() y hashCode() correctamente en entidades?
  • ¿Las consultas que cargan colecciones usan JOIN FETCH o Entity Graphs?
  • ¿Se usan proyecciones para consultas de solo lectura?
  • ¿Se evita CascadeType.REMOVE en @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.