Spring Framework

Intégrer JPA et Hibernate à Spring

Intermédiaire9 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Spring ne remplace pas JPA : il l'assemble — l'EntityManagerFactory, l'EntityManager lié à la transaction, la traduction des exceptions et le cycle de vie du contexte de persistance. À l'issue de ce guide, vous saurez mettre en place Hibernate ORM 6 sous Spring Framework 6 sans persistence.xml, comprendre ce que @PersistenceContext injecte réellement et reconnaître les erreurs de contexte de persistance qui provoquent des LazyInitializationException, des requêtes N+1 et des mises à jour perdues.

Ce que vous allez apprendre

  • Configurer LocalContainerEntityManagerFactoryBean et JpaTransactionManager en configuration Java
  • Expliquer ce qu'est le proxy d'EntityManager partagé et comment il se rattache à une transaction Spring
  • Écrire un repository avec EntityManager et bénéficier gratuitement de la traduction en DataAccessException
  • Diagnostiquer une LazyInitializationException et des selects N+1, puis les corriger avec des fetch joins ou des entity graphs
  • Décider s'il faut activer Open EntityManager in View et en comprendre le coût

Jakarta Persistence (JPA) définit l'API de mapping objet-relationnel — les entités, l'EntityManager, JPQL, l'API Criteria — et Hibernate ORM en est l'implémentation que presque tout le monde utilise. Le rôle de Spring est plus restreint que ce qu'imaginent les débutants : il ne mappe pas les objets et ne génère pas de SQL. Il crée l'EntityManagerFactory, fournit à chaque transaction son propre EntityManager, traduit les exceptions de persistance dans sa propre hiérarchie et gère la transaction à l'intérieur de laquelle vit un contexte de persistance JPA. Maîtrisez ces quatre points et le code JPA que vous écrivez est le même que partout ailleurs. Ce guide cible Spring Framework 6.x avec Hibernate ORM 6 et le namespace jakarta.persistence sur Java 17 ou plus récent ; la couche de repositories Spring Data JPA qui s'appuie dessus est traitée dans le sujet Spring Boot.

Configurer l'unité de persistance en Java

La méthode Java EE classique consiste à écrire un persistence.xml qui nomme une unité de persistance, ses classes et son provider. Avec Spring, vous pouvez vous passer de ce fichier et décrire l'unité sous forme de beans :

@Configuration
@EnableTransactionManagement
public class PersistenceConfig {

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
        var emf = new LocalContainerEntityManagerFactoryBean();
        emf.setDataSource(dataSource);
        emf.setPackagesToScan("com.example.shop.domain");     // finds @Entity classes
        emf.setJpaVendorAdapter(new HibernateJpaVendorAdapter());

        var props = new Properties();
        props.put("hibernate.hbm2ddl.auto", "validate");        // never "update" in production
        props.put("hibernate.jdbc.batch_size", "50");
        props.put("hibernate.order_inserts", "true");
        emf.setJpaProperties(props);
        return emf;
    }

    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }

    @Bean
    public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
        return new PersistenceExceptionTranslationPostProcessor();
    }
}

LocalContainerEntityManagerFactoryBean est un FactoryBean : le conteneur expose sous le nom du bean le produit, une EntityManagerFactory, ce qui explique que la méthode du transaction manager puisse demander directement une EntityManagerFactory. Le mot « container » dans le nom désigne Spring agissant comme conteneur JPA — il effectue le bootstrap que ferait un serveur d'applications, y compris le scan du classpath et, si un weaver est configuré, l'enrichissement du bytecode au chargement. L'ancien LocalEntityManagerFactoryBean s'appuie sur un simple Persistence.createEntityManagerFactory et ne convient qu'aux tests.

HibernateJpaVendorAdapter fixe des valeurs par défaut propres à Hibernate et installe un HibernateJpaDialect, grâce auquel JpaTransactionManager peut appliquer les niveaux d'isolation, exposer la connexion JDBC sous-jacente et traduire les exceptions Hibernate. Les propriétés du provider passent telles quelles par setJpaProperties ; dans Hibernate 6, hibernate.dialect est détecté à partir de la connexion et n'a presque jamais besoin d'être renseigné.

L'EntityManager partagé

Un EntityManager JPA n'est pas thread-safe et représente un contexte de persistance : une unité de travail avec son cache de premier niveau d'entités managées. Dans une configuration gérée par le conteneur, vous n'en créez jamais vous-même. Vous injectez plutôt un proxy :

@Repository
public class JpaProductRepository implements ProductRepository {

    @PersistenceContext
    private EntityManager em;      // shared, thread-safe proxy

    @Override
    public Optional<Product> findBySku(String sku) {
        return em.createQuery("select p from Product p where p.sku = :sku", Product.class)
                 .setParameter("sku", sku)
                 .getResultStream()
                 .findFirst();
    }

    @Override
    public Product save(Product product) {
        if (product.getId() == null) {
            em.persist(product);   // becomes managed; INSERT at flush
            return product;
        }
        return em.merge(product);  // copies state onto a managed instance; returns that instance
    }
}

@PersistenceContext est traité par le PersistenceAnnotationBeanPostProcessor de Spring, enregistré automatiquement par les contextes pilotés par annotations. Ce qu'il injecte est un proxy d'EntityManager partagé. À chaque appel de méthode, le proxy cherche l'EntityManager lié à la transaction Spring courante sur le thread courant ; s'il en trouve un, il lui délègue l'appel. Sinon, les opérations de lecture obtiennent un EntityManager éphémère fermé juste après l'appel, et les opérations d'écriture comme persist lèvent une TransactionRequiredException. C'est ce mécanisme qui permet d'utiliser sans risque, depuis n'importe quel thread de requête, un repository doté d'un seul champ injecté ; c'est aussi pourquoi la transaction doit être démarrée par Spring — un appel JPA em.getTransaction().begin() sur le proxy est refusé, car c'est Spring qui contrôle la frontière transactionnelle.

Conséquence pour la conception : un contexte de persistance vit exactement aussi longtemps que la transaction Spring. Les entités chargées dans une méthode de service @Transactional restent managées jusqu'au retour de la méthode et au commit de la transaction ; ensuite elles sont détachées, et accéder à une association lazy non initialisée lève une LazyInitializationException.

@PersistenceContext fonctionne sur les champs et les setters, mais pas sur les constructeurs. Pour l'injection par constructeur, déclarez un SharedEntityManagerBean dans la configuration et injectez EntityManager normalement — Spring Data JPA enregistre automatiquement un bean équivalent, ce qui explique que @Autowired EntityManager soit courant dans les projets Boot.

Transactions et contexte de persistance

JpaTransactionManager fait pour JPA ce que JdbcTransactionManager fait pour JDBC : à l'entrée d'une méthode @Transactional, il crée un EntityManager, démarre une transaction dessus et lie les deux au thread ; à la sortie, il effectue le flush, le commit et la fermeture. Comme l'EntityManager est lié au thread, un JdbcTemplate utilisant la même DataSource dans cette transaction voit la même connexion — le dialecte l'expose. Mélanger JPA pour les écritures et JDBC pour les requêtes de reporting dans une même transaction fonctionne donc, et c'est un pattern tout à fait légitime.

@Service
public class CatalogService {

    private final ProductRepository products;
    private final PriceHistoryRepository history;

    public CatalogService(ProductRepository products, PriceHistoryRepository history) {
        this.products = products;
        this.history = history;
    }

    @Transactional
    public void reprice(String sku, Money newPrice) {
        Product p = products.findBySku(sku).orElseThrow(() -> new UnknownSkuException(sku));
        history.record(p, p.getPrice(), newPrice);
        p.setPrice(newPrice);           // no save() call: dirty checking issues the UPDATE at commit
    }

    @Transactional(readOnly = true)
    public List<ProductSummary> summaries() {
        return products.findSummaries();
    }
}

La ligne setPrice sans sauvegarde explicite est le réflexe caractéristique de JPA sous Spring : une entité managée est suivie et, au flush, Hibernate compare son état courant à l'instantané pris au chargement puis émet l'UPDATE nécessaire. Les développeurs venant de JDBC ajoutent souvent un em.merge(p) superflu, sans conséquence pour une entité managée mais révélateur d'un malentendu.

Ici, readOnly = true est plus qu'une simple indication. JpaTransactionManager le transmet à la Session Hibernate, qui passe le flush mode à MANUAL et marque les entités chargées en lecture seule, ce qui évite la création des instantanés et le dirty checking. Sur une page qui charge des milliers de lignes, le gain est mesurable.

Traduction des exceptions

Hibernate lève des sous-classes de jakarta.persistence.PersistenceException et ses propres types HibernateException. PersistenceExceptionTranslationPostProcessor enveloppe chaque bean @Repository dans un proxy qui les convertit en exceptions de la hiérarchie DataAccessException de Spring : un service intercepte ainsi une DataIntegrityViolationException venant d'un repository JPA exactement comme si elle venait d'un repository JDBC, et une OptimisticLockException remonte sous forme d'ObjectOptimisticLockingFailureException. Spring Boot enregistre ce post-processor pour vous ; en configuration Spring Framework pure, c'est à vous de le déclarer, comme plus haut. Notez que les exceptions levées au commit — une violation de contrainte différée, un échec de verrouillage optimiste détecté au flush — sont traduites par JpaTransactionManager lui-même : elles arrivent donc traduites même sans le post-processor.

Lazy loading, N+1 et stratégies de chargement

Par défaut, JPA charge @ManyToOne et @OneToOne en mode eager, et @OneToMany et @ManyToMany en mode lazy. La plupart des équipes expérimentées rendent tout lazy (fetch = FetchType.LAZY y compris sur les associations to-one) et décident du chargement requête par requête : une association to-one eager est chargée à chaque accès au parent, qu'on en ait besoin ou non, et les chargements eager se multiplient à travers les associations imbriquées.

Les associations lazy engendrent deux modes de défaillance :

  • LazyInitializationException lorsqu'on accède pour la première fois à une association après la fermeture du contexte de persistance — typiquement dans un template de vue ou dans un sérialiseur JSON exécuté après le retour de la méthode de service.
  • Selects N+1 lorsque le code parcourt une liste de N parents et touche une association lazy sur chacun, ce qui produit une requête pour la liste et N autres pour les enfants.

Les deux se corrigent de la même façon : indiquez à la requête ce dont vous avez besoin.

// JPQL fetch join: one query, children initialised
List<Order> orders = em.createQuery("""
        select distinct o from Order o
        join fetch o.lines l
        join fetch l.product
        where o.customer.id = :customer
        """, Order.class)
        .setParameter("customer", customerId)
        .getResultList();

// Entity graph: same effect, reusable and composable
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addSubgraph("lines").addAttributeNodes("product");
List<Order> sameOrders = em.createQuery("select o from Order o where o.customer.id = :customer", Order.class)
        .setParameter("customer", customerId)
        .setHint("jakarta.persistence.fetchgraph", graph)
        .getResultList();

Faire un fetch join sur deux collections dans une même requête engendre un produit cartésien, et Hibernate 6 le refuse pour les bags ; chargez une seule collection par requête ou placez @BatchSize sur l'association pour qu'Hibernate charge les enfants de nombreux parents par lots IN (...). Pour les écrans en lecture seule, la réponse la plus propre consiste souvent à ne pas charger d'entités du tout : une expression constructeur JPQL — select new com.example.ProductSummary(p.sku, p.name, p.price) from Product p — renvoie exactement les colonnes dont l'écran a besoin, sans aucun proxy lazy sur lequel trébucher.

Open EntityManager in View

Le pattern Open EntityManager in View (OpenEntityManagerInViewFilter ou OpenEntityManagerInViewInterceptor dans Spring MVC) maintient le contexte de persistance ouvert pendant toute la requête HTTP, si bien que les associations lazy peuvent encore être initialisées pendant le rendu d'un template de vue. Il fait disparaître les LazyInitializationException, et c'est pourquoi Spring Boot l'active par défaut pour les applications web, en le signalant par un warning dans les logs.

Le coût est bien réel. Les connexions à la base restent occupées pendant tout le rendu, y compris pendant les appels distants effectués par la vue ; les requêtes N+1 deviennent invisibles parce qu'elles fonctionnent, simplement lentement ; et la frontière transactionnelle de la couche service ne définit plus les données auxquelles la requête peut accéder. Pour une application dotée d'une couche service claire et d'une API REST qui renvoie des DTO, désactivez-le et laissez l'exception vous indiquer où une requête est incomplète. Pour une application rendue côté serveur et maintenue par une petite équipe, c'est un compromis raisonnable, à condition que quelqu'un surveille le nombre de requêtes.

Utiliser Hibernate directement

Spring fournit toujours un support Hibernate natif dans spring-orm : LocalSessionFactoryBean construit une SessionFactory et HibernateTransactionManager la pilote. Dans du code neuf, préférez le bootstrap JPA présenté plus haut et ne descendez vers Hibernate que là où JPA n'a pas d'équivalent :

Session session = em.unwrap(Session.class);
Product p = session.byNaturalId(Product.class).using("sku", sku).load();

unwrap ne coûte rien et laisse le cas particulier bien visible. HibernateTransactionManager garde son utilité dans un seul scénario : une application legacy qui utilise déjà Session partout et ne peut pas être réécrite d'un coup — JpaTransactionManager peut lui aussi exposer la session via unwrap, donc même dans ce cas le bootstrap JPA suffit généralement.

Erreurs fréquentes

Accéder à une association lazy après la transaction. Chargez-la dans la requête, convertissez en DTO dans le service ou étendez la transaction pour couvrir l'usage — n'activez jamais Open EntityManager in View uniquement pour faire taire l'exception sans en comprendre le coût.

hibernate.hbm2ddl.auto=update en production. Ce mode ne sait ni renommer ni supprimer de manière sûre, s'écarte silencieusement du schéma voulu et ne garde aucun historique. Utilisez un outil de migration (Flyway ou Liquibase) et réglez-le sur validate pour que le démarrage échoue en cas de dérive.

Des associations to-one eager partout. Chaque chargement d'une Order charge aussi son Customer, l'Address du client, et ainsi de suite. Rendez les associations lazy par défaut.

Appeler save/merge en boucle sur des entités managées. C'est superflu. Modifiez l'instance managée ; Hibernate se charge de flusher les changements.

Oublier @Version. Deux utilisateurs qui modifient la même ligne : la dernière écriture l'emporte, en silence. Une colonne @Version transforme le conflit en ObjectOptimisticLockingFailureException, que l'interface peut gérer.

S'attendre à ce que em.persist exécute l'INSERT immédiatement. Le SQL est différé jusqu'au flush — au commit, avant une requête qui a besoin de résultats cohérents, ou lors d'un em.flush() explicite. Les identifiants générés en IDENTITY font exception, car Hibernate doit insérer pour connaître la clé, ce qui désactive aussi le batching des insertions pour cette entité ; préférez SEQUENCE.

De grosses transactions qui chargent des milliers d'entités. Le contexte de persistance conserve chaque entité managée jusqu'au commit, et le dirty checking les parcourt toutes au flush. Traitez par lots, appelez clear() entre deux lots, ou utilisez une stateless session pour les traitements de masse.

Points clés à retenir

  • Spring amorce JPA avec LocalContainerEntityManagerFactoryBean et pilote les transactions avec JpaTransactionManager ; aucun persistence.xml n'est nécessaire.
  • @PersistenceContext injecte un proxy partagé qui délègue à l'EntityManager lié à la transaction Spring courante ; les écritures hors transaction échouent, et c'est voulu.
  • Le contexte de persistance vit exactement aussi longtemps que la transaction ; les entités managées sont soumises au dirty checking et flushées au commit, les entités détachées non.
  • @Repository associé à PersistenceExceptionTranslationPostProcessor fournit les mêmes types DataAccessException qu'avec JDBC.
  • Rendez les associations lazy et chargez explicitement avec des fetch joins, des entity graphs ou des projections DTO ; c'est la solution à la fois pour LazyInitializationException et pour le N+1.
  • Open EntityManager in View échange du temps de détention des connexions et des coûts de requêtes cachés contre du confort ; décidez-le en connaissance de cause.

Sources et lectures complémentaires

Questions fréquentes

Ai-je encore besoin d'un persistence.xml avec Spring ?
Non. LocalContainerEntityManagerFactoryBean sait construire l'unité de persistance entièrement à partir de la configuration Java : vous lui fournissez la DataSource, les packages où chercher les classes @Entity et le vendor adapter, et vous passez les propriétés du provider via setJpaProperties. Un persistence.xml présent reste pris en compte, ce qui est pratique lors d'une migration depuis un serveur d'applications Jakarta EE.
Quelle différence entre @PersistenceContext et un EntityManager injecté par @Autowired ?
@PersistenceContext est traité par le PersistenceAnnotationBeanPostProcessor de Spring et injecte un proxy partagé thread-safe qui délègue à l'EntityManager lié à la transaction. Un simple @Autowired ne fonctionne que si un bean de type EntityManager existe ; Spring Data JPA enregistre justement un tel bean proxy partagé, d'où l'impression que les deux sont interchangeables dans les projets Spring Boot. Dans une application Spring Framework sans Boot, utilisez @PersistenceContext ou déclarez vous-même un SharedEntityManagerBean.
Pourquoi ai-je « No EntityManager with actual transaction available » sur un persist ?
Le proxy d'EntityManager partagé exige une transaction active gérée par Spring pour les opérations d'écriture, car en dehors d'une transaction il n'existe aucun contexte de persistance pour conserver l'entité jusqu'au flush. Annotez la méthode de service appelante avec @Transactional, vérifiez que l'appel passe bien par le proxy — et non par auto-invocation — et que @EnableTransactionManagement est présent.
Faut-il utiliser l'API Session d'Hibernate ou l'EntityManager de JPA ?
Codez contre EntityManager et JPQL autant que possible : la couche de persistance reste portable et cohérente avec Spring Data JPA et tous les tutoriels actuels. Quand vous avez besoin d'une fonctionnalité propre à Hibernate, comme StatelessSession, la recherche par natural id ou les filtres, appelez entityManager.unwrap(Session.class) à cet endroit précis plutôt que de configurer globalement la SessionFactory native.