Hibernate 6 y JPA 3.x en Java: ORM moderno sin magia negra

JPA (Jakarta Persistence, antes Java Persistence API) es una especificación: un conjunto de interfaces, anotaciones y reglas que definen cómo debe funcionar un ORM en Java. Por su parte, Hibernate es la implementación más usada de esa especificación, con más del 90 % del mercado. También existen EclipseLink y OpenJPA, pero en la práctica casi nadie las usa.

La diferencia importa porque el código que solo usa anotaciones JPA estándar (@Entity, @Id, @Column) es portable entre implementaciones. El código que toca la API específica de Hibernate, como Session o HQL, ya no lo es. En proyectos nuevos lo habitual es usar JPA como capa de abstracción y dejar que Hibernate trabaje por debajo.

Entidades: POJOs con anotaciones

Una entidad JPA es una clase Java normal con anotaciones que describen cómo se mapea a la base de datos. No hereda de nada especial ni implementa ninguna interfaz rara.

@Entity
@Table(name = "usuarios")
public class Usuario {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String nombre;

    @Column(unique = true, nullable = false)
    private String email;

    private boolean activo = true;

    // Constructor sin argumentos requerido por JPA
    protected Usuario() {}

    public Usuario(String nombre, String email) {
        this.nombre = nombre;
        this.email = email;
    }

    // getters y setters...
}

Hay dos cosas a tener en cuenta aquí. Primero, desde Jakarta EE 9 el paquete cambió: ya no es import javax.persistence.* sino import jakarta.persistence.*. Si migras un proyecto antiguo a Hibernate 6, eso es lo primero que te va a dar guerra. Segundo, JPA exige un constructor sin argumentos, aunque puede ser protected para que el código de aplicación no lo use directamente.

El EntityManager y las transacciones

El EntityManager es el punto de entrada para todas las operaciones de persistencia: guardar, buscar, actualizar, borrar. En una aplicación standalone lo gestionas tú:

EntityManagerFactory emf = Persistence.createEntityManagerFactory("mi-unidad");
EntityManager em = emf.createEntityManager();

em.getTransaction().begin();
em.persist(new Usuario("Ana García", "[email protected]"));
em.getTransaction().commit();

em.close();
emf.close();

Con Spring el asunto se simplifica mucho. Anotas el método con @Transactional y Spring gestiona la transacción por ti. El EntityManager se inyecta con @PersistenceContext:

@Service
public class UsuarioServicio {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void crearUsuario(String nombre, String email) {
        em.persist(new Usuario(nombre, email));
        // al salir del método, Spring hace commit automáticamente
    }
}

Lo que pasa por debajo es que Hibernate mantiene un persistence context: un registro de todas las entidades que ha cargado en esa unidad de trabajo. Cuando llega el momento del flush, genera el SQL mínimo necesario para sincronizar el estado de esas entidades con la base de datos. No escribe en la BBDD a cada llamada, sino al final de la transacción (o cuando lo fuerces con em.flush()).

JPQL y el Criteria API

JPQL (Jakarta Persistence Query Language) es el lenguaje de consultas de JPA. La diferencia con SQL es que operas sobre entidades y atributos Java, no sobre tablas y columnas. Hibernate traduce JPQL a SQL concreto según la base de datos que uses.

// Consulta tipada: el compilador sabe que el resultado es List<Usuario>
TypedQuery<Usuario> consulta = em.createQuery(
    "SELECT u FROM Usuario u WHERE u.activo = true ORDER BY u.nombre",
    Usuario.class
);
List<Usuario> usuarios = consulta.getResultList();

Para consultas que vas a usar mucho, las named queries te permiten definirlas una vez en la entidad y reutilizarlas por nombre:

@NamedQuery(
    name = "Usuario.findActivos",
    query = "SELECT u FROM Usuario u WHERE u.activo = true"
)
@Entity
public class Usuario { ... }

El Criteria API es la alternativa type-safe para construir consultas dinámicas en tiempo de compilación. Es útil cuando los filtros cambian según los parámetros de entrada, pero la sintaxis es bastante verbosa:

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Usuario> cq = cb.createQuery(Usuario.class);
Root<Usuario> root = cq.from(Usuario.class);
cq.select(root).where(cb.isTrue(root.get("activo")));
List<Usuario> resultado = em.createQuery(cq).getResultList();

Para la mayoría de casos, JPQL es más legible. El Criteria API brilla cuando construyes filtros opcionales en función de lo que el usuario haya rellenado en un formulario de búsqueda.

Relaciones entre entidades

Mapear relaciones es donde JPA ahorra más trabajo, y también donde más se puede complicar si no entiendes lo que estás configurando.

Uno a muchos y muchos a uno

@Entity
public class Usuario {

    @OneToMany(mappedBy = "usuario", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Pedido> pedidos = new ArrayList<>();
}

@Entity
public class Pedido {

    @ManyToOne
    @JoinColumn(name = "usuario_id")
    private Usuario usuario;
}

El atributo mappedBy le dice a JPA que el lado propietario de la relación es Pedido, que es quien tiene la columna de clave foránea en la tabla. El cascade = CascadeType.ALL hace que al guardar un usuario sus pedidos se guarden también. Y orphanRemoval = true significa que si eliminas un pedido de la lista, Hibernate lo borra de la BBDD sin que tengas que hacer nada más.

Muchos a muchos

@Entity
public class Producto {

    @ManyToMany
    @JoinTable(
        name = "producto_etiqueta",
        joinColumns = @JoinColumn(name = "producto_id"),
        inverseJoinColumns = @JoinColumn(name = "etiqueta_id")
    )
    private Set<Etiqueta> etiquetas = new HashSet<>();
}

Hibernate crea la tabla intermedia automáticamente. Si la tabla intermedia necesita atributos propios, lo más limpio es modelarla como una entidad independiente con su propia clave primaria.

El problema N+1 y cómo resolverlo

El problema N+1 es la trampa más clásica de los ORM. Imagina que cargas 100 usuarios y luego accedes a los pedidos de cada uno en un bucle. Si la relación es lazy (que es el valor por defecto en @OneToMany), Hibernate lanza una query para cargar los usuarios y después una query por cada usuario para cargar sus pedidos: 1 + 100 = 101 queries.

La solución más directa es un JOIN FETCH en JPQL:

SELECT u FROM Usuario u LEFT JOIN FETCH u.pedidos WHERE u.activo = true

Eso trae usuarios y pedidos en una sola query. También puedes usar @EntityGraph en el repositorio para declarar qué relaciones cargar de forma eager sin tocar la consulta JPQL:

@EntityGraph(attributePaths = {"pedidos"})
List<Usuario> findByActivoTrue();

Otra opción es @BatchSize: en vez de una query por usuario, Hibernate carga los pedidos en lotes del tamaño que le digas.

@OneToMany(mappedBy = "usuario")
@BatchSize(size = 50)
private List<Pedido> pedidos;

Con @BatchSize(size = 50), 100 usuarios generan 3 queries en vez de 101. No es tan eficiente como el JOIN FETCH, pero en relaciones donde no siempre necesitas los hijos reduce el problema notablemente.

Hibernate 6: el nuevo motor de SQL

Hibernate 6, lanzado en 2022, fue una reescritura importante. El cambio más técnico es el SQM (Semantic Query Model), un nuevo motor de generación de SQL que sustituye al anterior. El resultado práctico es SQL más limpio, mejor soporte de funciones de ventana y un manejo más correcto de las estrategias de herencia.

Otra mejora notable es el soporte nativo de tipos Java modernos. Antes, para mapear un LocalDate o un UUID necesitabas configuraciones extra o un UserType personalizado. Desde Hibernate 6 funcionan sin más:

@Entity
public class Evento {

    @Id
    private UUID id;

    private LocalDateTime fechaInicio;

    @Enumerated(EnumType.STRING)
    private EstadoEvento estado;
}

También llegaron las embedded collections: colecciones de tipos básicos o embeddables directamente en la entidad, sin necesidad de una entidad hija. Útil para listas de valores simples que no merecen su propia tabla con relación formal.

Spring Data JPA: repositorios sin SQL

Si usas Spring Boot, casi seguro que trabajas con Spring Data JPA por encima de Hibernate. La idea central es que defines una interfaz y Spring genera la implementación automáticamente:

public interface UsuarioRepository extends JpaRepository<Usuario, Long> {

    List<Usuario> findByActivoTrue();

    Optional<Usuario> findByEmail(String email);

    List<Usuario> findByNombreContainingIgnoreCase(String termino);

    Page<Usuario> findByActivoTrue(Pageable pageable);
}

Spring Data interpreta el nombre del método y genera la query JPQL. findByActivoTrue() se convierte en WHERE u.activo = true, findByNombreContainingIgnoreCase en un LIKE con LOWER(), y el Pageable añade paginación automática.

Cuando el nombre del método se vuelve demasiado largo o necesitas algo más específico, puedes añadir la consulta manualmente:

@Query("SELECT u FROM Usuario u WHERE u.nombre LIKE %:termino% AND u.activo = true")
List<Usuario> buscarActivos(@Param("termino") String termino);

// Para consultas nativas (SQL puro):
@Query(value = "SELECT * FROM usuarios WHERE YEAR(created_at) = :año", nativeQuery = true)
List<Usuario> findByAño(@Param("año") int año);

Spring Data JPA se lleva bien con Flyway para gestionar el esquema de la BBDD: tú defines las entidades, Hibernate valida que el esquema coincida, y Flyway aplica las migraciones SQL de forma versionada. Y si quieres sacarle más rendimiento a las consultas repetitivas, Hibernate admite una caché de segundo nivel con Redis para no ir a la BBDD cada vez que pides el mismo objeto.

Imagen: Pexels / cottonbro studio

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP