Java EE / Jakarta EE

Jakarta Persistence (JPA 3.1) : entités, mapping et contexte de persistance

Intermédiaire11 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

Jakarta Persistence est le standard de mapping objet-relationnel qu'implémentent Hibernate et EclipseLink, et la couche de persistance sur laquelle repose Spring Data JPA. Ce guide explique l'unité de persistance et l'EntityManager, les états par lesquels passe une entité, la façon de mapper identifiants, colonnes, valeurs embarquées et relations, ce que font réellement le lazy loading et les cascades, et comment le verrouillage optimiste protège les mises à jour, sans oublier les pièges qui provoquent des requêtes N+1, des LazyInitializationException et des mises à jour perdues.

Ce que vous allez apprendre

  • Configurer une unité de persistance et obtenir un EntityManager géré par le conteneur
  • Décrire le cycle de vie d'une entité et ce que font persist, merge, remove, detach et flush
  • Mapper les identifiants, les attributs simples, les embeddables, les enums et les types java.time
  • Mapper les associations one-to-many, many-to-one, one-to-one et many-to-many avec le bon côté propriétaire
  • Choisir en connaissance de cause les stratégies de chargement, les cascades, les mappings d'héritage et le verrouillage optimiste

Les bases de données relationnelles stockent des lignes ; les programmes Java manipulent des graphes d'objets. Toute application qui utilise les deux a besoin d'un mapping entre ces mondes, et l'écrire à la main (du SQL pour chaque opération, du code pour transformer un result set en objets, la gestion de l'identité) est répétitif et source d'erreurs. Jakarta Persistence (JPA) standardise ce mapping et le runtime qui l'applique : vous déclarez une fois comment Order correspond à la table orders, puis vous travaillez avec des objets. Ce runtime n'a rien de magique pour autant : un EntityManager suit les objets qu'il a chargés, décide quand les réécrire en base et émet du SQL que vous n'avez pas écrit. La plupart des problèmes JPA viennent de ce qu'on ignore ce qu'il fait à notre place ; ce guide le rend visible.

Unités de persistance et EntityManager

Une unité de persistance est un ensemble nommé de classes d'entités accompagné de la configuration nécessaire pour atteindre une base de données, déclaré dans META-INF/persistence.xml. Dans un conteneur, la source de données est une référence JNDI et les transactions sont celles de JTA :

<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
                                 https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd"
             version="3.0">
  <persistence-unit name="shop" transaction-type="JTA">
    <jta-data-source>java:/jdbc/ShopDS</jta-data-source>
    <!-- entity classes are discovered by scanning the archive; list them only outside a container -->
    <properties>
      <property name="jakarta.persistence.schema-generation.database.action" value="none"/>
      <property name="hibernate.show_sql" value="false"/>   <!-- provider-specific; harmless if the provider is EclipseLink -->
    </properties>
  </persistence-unit>
</persistence>

transaction-type="RESOURCE_LOCAL", avec les propriétés jakarta.persistence.jdbc.url, user et password, est la forme autonome utilisée dans les tests et les programmes Java SE : vous y créez un EntityManagerFactory avec Persistence.createEntityManagerFactory("shop") et gérez vous-même les EntityTransaction. Dans un serveur Jakarta EE, vous injectez le gestionnaire et le conteneur s'occupe du reste :

@PersistenceContext(unitName = "shop")
EntityManager em;          // container-managed, transaction-scoped: one persistence context per JTA transaction

L'EntityManager est une façade devant un contexte de persistance : une table de correspondance entre une identité (classe d'entité plus clé primaire) et l'unique instance managed de cette ligne. Dans un même contexte, deux appels à em.find(Order.class, 1L) renvoient le même objet, et aucune ligne n'est représentée deux fois. Un contexte à portée de transaction vit exactement aussi longtemps que la transaction JTA et il est flushé (les changements en attente sont traduits en SQL) juste avant le commit. Les contextes gérés par l'application et les contextes étendus existent pour des cas particuliers (beans stateful, traitements batch), mais le contexte à portée de transaction est le défaut, et c'est celui qu'il faut comprendre en premier.

Le cycle de vie d'une entité

Une instance d'entité se trouve toujours dans l'un des quatre états, et les méthodes de l'EntityManager la font passer de l'un à l'autre :

ÉtatSignificationComment on y arrive
New (transient)Un objet ordinaire ; le contexte ignore tout de luinew Order()
ManagedSuivi par le contexte ; ses modifications sont détectées et écrites au flushpersist, find, getReference, un résultat de requête, la valeur renvoyée par merge
DetachedÉtait managed par un contexte désormais fermé, ou a été détaché explicitementFin de la transaction, detach, clear, sérialisation
RemovedProgrammé pour un DELETE au flushremove sur une instance managed

La conséquence la plus importante est le dirty checking automatique : vous n'appelez jamais update. Chargez une entité, modifiez un champ dans la transaction, et l'UPDATE part au flush, y compris pour des entités chargées par une requête et modifiées au passage. merge fait le pont entre l'état détaché et l'état managed, avec un détail que tout le monde rate une fois : il renvoie la copie managed et laisse l'argument détaché.

@Transactional
public Order rename(Long id, String newRef) {
    Order order = em.find(Order.class, id);   // managed
    order.setReference(newRef);               // dirty; UPDATE issued at flush, no explicit call
    return order;                              // becomes detached when the transaction commits
}

@Transactional
public Order update(Order detachedFromForm) {
    Order managed = em.merge(detachedFromForm);   // copies state onto the managed instance
    managed.setUpdatedAt(Instant.now());          // affects the row; detachedFromForm is untouched
    return managed;
}

getReference renvoie un proxy sans interroger la base, idéal quand vous avez seulement besoin de renseigner une clé étrangère. refresh recharge depuis la base en abandonnant les modifications. flush force l'envoi du SQL plus tôt (une requête en FlushModeType.AUTO, le mode par défaut, flushe de toute façon si elle risque de lire des changements en attente), et clear détache tout, ce qui permet aux traitements batch de garder une consommation mémoire bornée.

Mapper une classe sur une table

Une entité est une classe non finale dotée d'un constructeur sans argument (au moins protected), annotée @Entity et possédant un identifiant. Les champs sont mappés par défaut (tout champ non transient devient une colonne portant son nom) : les annotations ne servent donc que là où le défaut ne convient pas.

import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import jakarta.persistence.*;

@Entity
@Table(name = "orders",                                 // "order" is a reserved word in SQL
       indexes = @Index(name = "ix_orders_customer", columnList = "customer_id"))
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "orders_seq")
    @SequenceGenerator(name = "orders_seq", sequenceName = "orders_seq", allocationSize = 50)
    private Long id;

    @Column(nullable = false, length = 20, unique = true)
    private String reference;

    @Enumerated(EnumType.STRING)                        // never ORDINAL: reordering the enum corrupts data
    @Column(nullable = false, length = 16)
    private OrderStatus status = OrderStatus.NEW;

    @Column(nullable = false)
    private Instant placedAt = Instant.now();           // java.time maps natively since JPA 2.2

    @Embedded
    private Address shippingAddress;                    // columns inlined into the orders table

    @Convert(converter = MoneyConverter.class)          // custom type <-> column, via AttributeConverter
    private Money total;

    @Version
    private long version;                               // optimistic locking, see below

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    @OrderBy("lineNo")
    private List<OrderLine> lines = new ArrayList<>();

    protected Order() { }                               // required by the specification

    public Order(Customer customer) { this.customer = customer; }

    public void addLine(Product product, int quantity) {
        var line = new OrderLine(this, product, quantity, lines.size() + 1);
        lines.add(line);                                // keep both sides consistent
    }
    // getters; setters only where the domain allows change
}

Identifiants

Choisissez la stratégie de génération en pensant à la base de données :

  • SEQUENCE : le meilleur choix par défaut pour PostgreSQL, Oracle et toute base qui dispose de séquences. Avec allocationSize, le provider récupère un bloc d'ids d'un coup, si bien que chaque insertion n'exige pas son propre aller-retour. Gardez une taille d'allocation identique à l'INCREMENT BY de la séquence, sinon vous aurez des trous et, avec certains providers, des collisions.
  • IDENTITY : colonnes auto-incrémentées (MySQL, SQL Server). Simple, mais l'id n'est connu qu'après l'INSERT, ce qui oblige le provider à insérer immédiatement lors du persist et désactive le batching des insertions.
  • UUID : nouveauté de Persistence 3.1, le provider génère un java.util.UUID. Les ids sont uniques globalement et peuvent être attribués côté client, au prix de clés de 16 octets et d'une moins bonne localité d'index sur certaines bases.
  • TABLE : portable mais lent ; à éviter.
  • AUTO : laisse le provider choisir ; mieux vaut être explicite, car ce choix influe sur le batching et sur le schéma.

Les clés composites sont prises en charge via @EmbeddedId ou @IdClass ; on les rencontre dans les schémas legacy, et mieux vaut les éviter dans les nouveaux.

Attributs simples, embeddables et convertisseurs

@Column contrôle le nom, la nullabilité, la longueur et la précision ; @Basic(fetch = LAZY) sur un gros @Lob demande au provider d'en différer le chargement, même si tous ne le respectent pas sans instrumentation du bytecode. @Transient exclut un champ. Les classes @Embeddable regroupent des colonnes qui vont ensemble (une adresse, un montant) sans leur consacrer de table, et @AttributeOverride renomme leurs colonnes quand le même embeddable apparaît deux fois. Un AttributeConverter<X, Y> mappe n'importe quel type Java sur un type de colonne pris en charge par JDBC : c'est la manière standard de stocker des value objects, des chaînes chiffrées ou des blobs JSON. @Temporal n'est nécessaire que pour les anciens champs java.util.Date ; utilisez LocalDate, LocalDateTime, Instant ou OffsetDateTime.

Les relations et le côté propriétaire

C'est sur les associations que les mappings dérapent. La règle qui dissipe l'essentiel de la confusion : la base de données n'a qu'une clé étrangère, donc un seul côté de l'association la possède, et seules les modifications faites de ce côté propriétaire sont écrites.

  • @ManyToOne est toujours le côté propriétaire ; la clé étrangère se trouve dans sa table. @JoinColumn la nomme.
  • @OneToMany(mappedBy = "order") est le côté inverse. Ajouter une ligne à order.getLines() seul n'écrit rien ; c'est line.setOrder(order) qui renseigne la clé étrangère. Des méthodes utilitaires comme addLine gardent les deux côtés synchronisés.
  • Un @OneToMany unidirectionnel sans mappedBy utilise par défaut une table de jointure, ce qui en surprend plus d'un ; ajoutez @JoinColumn pour utiliser à la place une clé étrangère dans la table enfant.
  • @OneToOne place la clé étrangère du côté propriétaire ; ne rendez le côté inverse lazy que si vous acceptez que le provider doive peut-être quand même faire une requête pour savoir si la cible existe.
  • @ManyToMany utilise une table de jointure des deux côtés ; @JoinTable la nomme. Dès que la table de jointure a besoin de ses propres colonnes (une date, une quantité), elle devient une entité à part entière avec deux associations @ManyToOne.

Chargement (fetching)

Valeurs par défaut : @ManyToOne et @OneToOne sont EAGER, les collections sont LAZY. Pour la plupart des applications, ces défauts pèchent par excès d'eager : un many-to-one eager sur chaque entité signifie que chaque requête entraîne les clients, leurs adresses, et ainsi de suite. Déclarez fetch = FetchType.LAZY sur les associations to-one et chargez explicitement ce dont un cas d'usage a besoin avec JOIN FETCH ou un entity graph, tous deux traités dans le guide sur les requêtes. Le lazy loading ne fonctionne que tant que le contexte est ouvert, d'où la LazyInitializationException que lève Hibernate quand une vue accède à order.getLines() après la fin de la transaction. Le problème N+1 en est le reflet : parcourir cent commandes en touchant getCustomer() sur chacune déclenche cent selects supplémentaires. Dans les deux cas, la correction se fait dans la requête, pas en basculant tout en eager.

Cascades et suppression des orphelins

cascade propage une opération de l'EntityManager le long d'une association : CascadeType.PERSIST sur lines signifie que persister la commande persiste ses nouvelles lignes. ALL convient à une composition (les lignes n'existent pas sans leur commande) et devient dangereux pour tout ce qui est partagé : cascade = ALL sur Order.customer supprimerait le client en même temps que la commande. orphanRemoval = true supprime en plus un enfant dès qu'il est retiré de la collection, ce qui transforme order.getLines().remove(line) en DELETE. Ensemble, ils permettent de traiter un agrégat comme une seule unité.

Héritage et mappings partagés

Une entité peut hériter d'une autre entité, et JPA propose trois organisations des tables :

StratégieTablesCompromis
SINGLE_TABLE (par défaut)Une table pour toute la hiérarchie, avec une colonne discriminanteRequêtes les plus rapides, sans jointure ; les colonnes des sous-classes doivent accepter NULL
JOINEDUne table par classe, jointes sur la clé primaireNormalisé, sans colonnes nullables imposées ; chaque requête polymorphe fait des jointures
TABLE_PER_CLASSUne table complète par classe concrètePas de jointure pour les requêtes sur une classe concrète ; les requêtes polymorphes exigent un UNION, et la génération d'identifiants est restreinte

Déclarez la stratégie avec @Inheritance sur la racine et @DiscriminatorValue sur les sous-classes. Bien souvent, ce qu'il vous faut n'est pas l'héritage d'entités mais une @MappedSuperclass : une classe de base comme AuditedEntity avec id, version, createdAt et updatedAt, dont les champs sont mappés dans la table de chaque sous-classe mais qui n'est pas elle-même interrogeable.

Verrouillage optimiste avec @Version

Deux utilisateurs chargent la même commande, la modifient tous les deux et enregistrent : sans protection, la seconde écriture écrase silencieusement la première, c'est la mise à jour perdue (lost update). Un champ @Version (un nombre ou un horodatage) amène le provider à ajouter WHERE version = ? à chaque UPDATE et à incrémenter la version ; si aucune ligne n'est affectée, la ligne a changé entre-temps et une OptimisticLockException est levée au flush ou au commit. Toute entité susceptible d'être modifiée en concurrence devrait en avoir un. Faites transiter la version jusqu'au client (un champ caché dans un formulaire, un champ dans un payload JSON) puis revenir jusqu'au merge, sinon la vérification compare la version fraîchement chargée avec elle-même et ne protège rien. Le verrouillage pessimiste (em.find(Order.class, id, LockModeType.PESSIMISTIC_WRITE), soit un SELECT … FOR UPDATE) est l'alternative pour des mises à jour courtes et très disputées, comme une décrémentation de stock.

Providers et génération du schéma

Hibernate ORM 6.x et EclipseLink 4.x implémentent Persistence 3.1 ; tous deux sont fournis avec les principaux serveurs (Hibernate dans WildFly, EclipseLink dans GlassFish et Payara, l'un ou l'autre dans Open Liberty). Tenez-vous-en à l'API JPA et vous pourrez changer de provider ; utilisez les extensions en connaissance de cause : @BatchSize, @Formula, les natural ids et le HQL plus riche d'Hibernate sont utiles, mais chacun a un coût en portabilité.

La propriété standard jakarta.persistence.schema-generation.database.action (none, create, drop, drop-and-create) peut générer les tables à partir de vos mappings ; la propriété propre à Hibernate, hibernate.hbm2ddl.auto, y ajoute update et validate. La génération est pratique pour les tests et les prototypes, et une mauvaise façon de faire tourner la production : le DDL généré n'a ni les contraintes, ni les choix d'index, ni l'historique de migration dont un vrai schéma a besoin. Gérez les schémas de production avec des scripts de migration versionnés (Flyway, Liquibase), réglez la propriété standard sur none, et utilisez le validate d'Hibernate si vous voulez que les mappings soient vérifiés contre le schéma réel au démarrage.

Erreurs courantes

  • Des associations to-one eager partout. Chaque requête charge tout un arbre. Passez-les en LAZY et chargez selon le cas d'usage.
  • Accéder à des collections lazy hors de la transaction. LazyInitializationException dans les vues et les sérialiseurs. Chargez-les dans la requête ou convertissez en DTO dans le service.
  • Oublier le côté propriétaire. Ajouter un élément à la collection mappedBy n'écrit rien. Renseignez le many-to-one, ou utilisez des méthodes utilitaires qui font les deux.
  • EnumType.ORDINAL. Insérer une constante au milieu de l'enum change le sens de toutes les valeurs stockées. Utilisez STRING.
  • cascade = ALL sur des entités partagées. Supprimer une commande supprime son client. Ne cascadez que le long des compositions.
  • Un hashCode fondé sur un id généré. Il change après persist ; les entités disparaissent des sets. Utilisez une clé métier ou un UUID stable attribué dans le constructeur.
  • Un allocationSize différent de l'incrément de la séquence. Des trous au mieux, des violations de clé unique au pire. Gardez-les égaux.
  • La génération de schéma en production. Des contraintes manquantes et aucun historique de migration. Utilisez Flyway ou Liquibase.

À retenir

  • Une unité de persistance définit les entités et la source de données ; un EntityManager géré par le conteneur vous fournit un contexte de persistance à la portée de la transaction JTA.
  • Une entité est new, managed, detached ou removed ; les entités managed font l'objet d'un dirty checking et sont écrites au flush, et merge renvoie la copie managed au lieu de rattacher votre objet.
  • Mappez les identifiants en SEQUENCE quand la base le permet ; IDENTITY désactive le batching et UUID est standard depuis Persistence 3.1.
  • Le côté propriétaire d'une association détient la clé étrangère ; gardez les deux côtés des associations bidirectionnelles cohérents grâce à des méthodes utilitaires.
  • Rendez les associations to-one lazy et chargez explicitement selon le cas d'usage ; réservez cascades et suppression des orphelins aux compositions.
  • Utilisez @MappedSuperclass pour les champs d'audit partagés, et l'héritage d'entités seulement quand le modèle est réellement polymorphe.
  • Ajoutez @Version aux entités modifiables et faites transiter la version par l'interface ou l'API pour éviter les mises à jour perdues.

Sources et lectures complémentaires

Questions fréquentes

Quelle différence entre JPA et Hibernate ?
JPA (Jakarta Persistence) est la spécification : les annotations, l'API EntityManager, JPQL et l'API Criteria. Hibernate ORM en est l'implémentation la plus répandue, aux côtés d'EclipseLink, l'implémentation compatible qui sert à certifier la spécification. Du code écrit contre l'API JPA tourne sur l'une comme sur l'autre ; les annotations propres à Hibernate et les méthodes de Session, non. Depuis Hibernate 6, les deux se sont encore rapprochés : le HQL d'Hibernate est un sur-ensemble de JPQL.
Pourquoi est-ce que j'obtiens une LazyInitializationException ?
Vous avez accédé à une association lazy après la fermeture du contexte de persistance qui avait chargé l'entité, typiquement dans une page Faces ou un sérialiseur REST, une fois la transaction terminée. Corrigez-le au niveau de la requête : un JOIN FETCH ou un entity graph pour charger dans la transaction ce dont la vue a besoin, ou une conversion en DTO avant de renvoyer le résultat. Tout passer en EAGER n'est pas une solution : cela reporte le coût sur toutes les requêtes.
Faut-il utiliser merge ou persist pour enregistrer une entité ?
persist pour un objet neuf que vous avez créé avec new : il devient managed et un INSERT est planifié. merge pour un objet détaché (issu d'une transaction précédente, d'un formulaire ou d'une requête désérialisée) : il copie l'état sur une instance managed et renvoie cette instance, tandis que l'objet passé en argument reste détaché. Appeler persist sur une entité détachée lève une exception ; appeler merge sur une entité neuve fonctionne, mais déclenche un SELECT supplémentaire.
Mes entités ont-elles besoin d'equals et de hashCode ?
Seulement si vous les placez dans un Set ou les utilisez comme clés de map, ce que font les mappings many-to-many et one-to-many en Set. Basez-les sur une clé métier ou sur l'identifiant une fois qu'il est attribué, jamais sur tous les champs, et jamais sur un id généré avant persist : le hash changerait au moment de la génération de l'id et l'objet disparaîtrait de tout HashSet qui le contenait déjà.
JPQL, API Criteria et requêtes natives avec Jakarta PersistenceInterroger des entités en JPQL (jointures, fetch joins, agrégats, projections, pagination), créer des requêtes Criteria typées et passer au SQL natif si besoin.Intermédiaire9 min de lectureJakarta Transactions (JTA 2) : transactions gérées par le conteneur, propagation et XALe fonctionnement des transactions JTA dans un serveur Jakarta EE : isolation, propagation @Transactional, règles de rollback, UserTransaction et commit XA.Intermédiaire11 min de lectureEnterprise Beans 4 : session beans stateless, stateful et singletonCe qu'apportent les session beans EJB (pooling, transactions, sécurité, timers, appels asynchrones), leurs différences et quand préférer un bean CDI.Intermédiaire10 min de lectureSpringIntégrer JPA et Hibernate à SpringConfigurer EntityManagerFactory et JpaTransactionManager en Java, utiliser l'EntityManager partagé et éviter le lazy loading, les N+1 et Open Session in View.Intermédiaire9 min de lectureSpring BootSpring Data JPA avec Spring Boot : repositories, requêtes, transactions et migrationsSpring Data JPA sur Spring Boot 3 et Hibernate 6 : repositories, méthodes dérivées et @Query, pagination, projections, transactions, N+1 et Flyway.Intermédiaire8 min de lectureDonnéesConception de bases de données et normalisation : des entités à la FNBCModéliser entités, clés et dépendances fonctionnelles, passer de la 1FN à la FNBC sur un exemple, choisir ses clés et dénormaliser à bon escient.Débutant9 min de lecture