☕ JPA – Mapping, Requêtes, Transactions & Performance
Guide IDEO-Lab : JPA (Jakarta Persistence) du modèle de données aux optimisations (fetch, cache, batching, N+1).
Vue d’ensemble JPA
Rôle, composants (EntityManager), contexte de persistance, providers.
JakartaORMEntityManagerArchitecture & Cycle de vie
States (new/managed/detached/removed), flush, dirty checking.
LifecycleFlushDirtyConfiguration
persistence.xml, datasource, dialect, ddl auto, logs SQL.
persistence.xmlDatasourceDDLEntités & Annotations
@Entity, @Table, @Id, @GeneratedValue, contraintes & colonnes.
@Entity@Id@ColumnClés primaires
IDENTITY/SEQUENCE/TABLE, UUID, composite keys, embeddables.
SEQUENCEUUID@EmbeddedIdMapping avancé
Embeddable, converters, enums, inherited mapping, audit.
@Embeddable@ConverterInheritanceRelations (Cardinalités)
@OneToMany, @ManyToOne, @ManyToMany, @OneToOne + owning side.
RelationsOwning sideJoinFetch, N+1 & Graphs
LAZY/EAGER, join fetch, entity graphs, batch size.
N+1JOIN FETCHEntityGraphCascade & Orphan Removal
CascadeType, orphanRemoval, pièges fréquents, delete semantics.
CascadeorphanRemovalDeleteJPQL
SELECT, joins, params, pagination, projections, DTO.
JPQLDTOPaginationCriteria API
Queries type-safe, dynamic filters, predicates, ordering.
CriteriaType-safeDynamicSQL natif
NativeQuery, mapping résultats, performances, limites.
Native SQLMappingPerfTransactions
JTA vs RESOURCE_LOCAL, propagation (Spring), isolation & locks.
TXIsolationLockOptimistic/Pessimistic Locking
@Version, LockModeType, collisions, retries, deadlocks.
@VersionOptimisticPessimisticFlush, Clear & Batching
Flush mode, clear, batch inserts/updates, stateless patterns.
FlushBatchClearCaches (L1/L2)
First-level cache, second-level, query cache, invalidation.
L1L2Query cacheSchema / DDL
ddl-auto, migrations (Flyway/Liquibase), naming strategies.
DDLFlywayLiquibaseTests & Debug
H2/Testcontainers, logs SQL, explain plans, assertions.
TestLogsExplainProviders (Hibernate…)
Ce que JPA standardise vs ce que le provider ajoute.
HibernateEclipseLinkProviderSpring Data JPA
Repositories, derived queries, paging, specs, projections.
RepositorySpecificationsPagingCheat-sheet JPA
Rappels express : mapping, fetch, transactions, queries.
cheatbest practicesJPA = Standard de persistance
JPA (aujourd’hui Jakarta Persistence) est une spécification : elle définit les interfaces et les annotations pour mapper des objets Java vers des tables SQL. Le moteur concret est un provider (ex : Hibernate, EclipseLink).
Principes
- Entity mapping : classes ↔ tables, champs ↔ colonnes.
- Unit of Work : un contexte suit les entités et synchronise en base (flush).
- Lazy loading : chargement différé des relations.
- JPQL : requêtes orientées entités (portable).
Flux “request → DB” (conceptuel)
Client request | Service layer | Transaction boundary | EntityManager (persistence context) | Provider (Hibernate / EclipseLink) | JDBC | Database
JPA ne “remplace” pas SQL : il l’organise. Tu continues à penser index, plans, cardinalités.
Les briques indispensables
| Brique | Rôle | À retenir |
|---|---|---|
| Entity | Objet persistant | Identité stable + mapping propre |
| EntityManager | API centrale | persist/find/merge/remove + queries |
| Persistence Context | Cache L1 + suivi | dirty checking, flush |
| Transaction | Atomicité | délimite quand on flush/commit |
| Provider | Implémentation | perf + fonctionnalités “vendor” |
Si tu “sens” du flou : pense “un EntityManager = une session de travail” avec un cache L1, qui pousse vers la base lors du flush/commit.
Cas d’usage typiques
- Applications métiers CRUD avec règles de gestion complexes.
- Domain model riche (relations, invariants, agrégats).
- Accès DB standardisé + possibilité d’optimiser par provider.
Ce que JPA ne “fait pas” tout seul
- Choisir les bons index, partitions, paramètres DB.
- Éviter les N+1 si tu ne maîtrises pas fetch/joins.
- Garantir la perf : tu dois profiler et lire des plans.
Les 4 états (à connaître par cœur)
| État | Définition | Impact |
|---|---|---|
| New | Objet Java non persisté | Aucun SQL tant que persist() |
| Managed | Attaché au contexte | Modifs détectées → UPDATE au flush |
| Detached | Hors contexte | Plus de suivi, merge() pour rattacher |
| Removed | Marqué pour suppression | DELETE au flush/commit |
Diagramme simple
new --> persist() --> managed --> remove() --> removed
| |
+-- detach/clear --+--> detached -- merge() --> managedBeaucoup de bugs viennent de “detached entities” + merge mal compris.
Flush vs Commit
Flush = synchroniser l’état mémoire vers la base (SQL envoyé), sans forcément valider la transaction. Commit = valider la transaction DB.
Dirty checking
En état managed, le provider compare l’état initial à l’état final et génère l’UPDATE si nécessaire.
// English-only example
em.getTransaction().begin();
Customer c = em.find(Customer.class, 10L); // managed
c.setStatus("ACTIVE"); // dirty
em.flush(); // sends UPDATE (still in TX)
em.getTransaction().commit();À grande échelle : flush/clear périodiques + batching, sinon explosion mémoire.
Pièges fréquents
- LazyInitializationException : accès à une relation lazy hors contexte.
- N+1 : itération sur une collection lazy qui déclenche 1 requête par ligne.
- merge() : peut dupliquer du travail (SELECT + UPDATE) si mal utilisé.
- equals/hashCode : dangereux si basé sur ID non assigné ou mutable.
Règle de base
Décide explicitement où se termine la transaction, et quelles relations doivent être chargées avant de sortir de ce scope.
Minimal persistence.xml
En Java SE (RESOURCE_LOCAL), c’est le point d’entrée historique. En Spring Boot, tu passes souvent par application.yml, mais comprendre persistence.xml reste utile.
org.hibernate.jpa.HibernatePersistenceProvider com.example.Customer
Check-list configuration
| Point | Pourquoi |
|---|---|
| Pool de connexions | Perf + stabilité (HikariCP etc.) |
| Dialect correct | SQL généré + types + pagination |
| Timezone | Dates/instants cohérents |
| ddl-auto | Jamais “create” en prod |
| Batching | Insert/update massifs |
En prod : validate + migrations (Flyway/Liquibase).
Voir le SQL “utile”
- Activer logs SQL (et paramètres bind), mais attention au bruit.
- Mettre un slow query log côté DB, et corréler avec trace applicative.
- Sur incidents : extraire les requêtes réelles et faire EXPLAIN (ANALYZE).
# English-only sample (Spring Boot) spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.orm.jdbc.bind=TRACE
DDL : stratégie saine
| Mode | Usage | Risque |
|---|---|---|
| create | POC jetable | Destruction schéma |
| update | Dev local | Drift silencieux |
| validate | Pré-prod/prod | Bloque si mismatch (bien) |
| none | Prod strict | À coupler migrations |
Reco : Flyway ou Liquibase pour versionner le schéma, et JPA pour valider/mapper.
Mapping minimal
// English-only sample
import jakarta.persistence.*;
@Entity
@Table(name = "customer")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false)
private String status;
// getters/setters
}Bonnes pratiques immédiates
- @Table(name=...) explicite (évite surprises de naming).
- @Column(nullable/length) pour aligner contrat métier.
- Évite les entités “anémique” si le domaine est riche.
- Ne surcharge pas l’entité avec de la logique I/O.
Le mapping doit refléter les contraintes DB, pas les masquer.
Contraintes : où les mettre ?
Les annotations JPA sont utiles, mais la DB reste la source d’autorité. Pour la validation métier : Bean Validation (jakarta.validation) + contraintes DB.
// English-only sample @Column(unique = true, nullable = false) private String email; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private CustomerTier tier;
Important : “unique=true” côté JPA n’est pas une stratégie d’indexing en soi : en prod, passe par migrations.
Dates / Instants
- Préférer Instant / OffsetDateTime / LocalDate selon besoin.
- Fixer la timezone (DB + JVM + app) pour éviter “heisenbugs”.
Enums
Toujours EnumType.STRING (stable) sauf cas particulier.
Choisir une stratégie
| Stratégie | DB typique | Note perf |
|---|---|---|
| IDENTITY | MySQL/MariaDB | Batching plus compliqué (id obtenu à l’insert) |
| SEQUENCE | PostgreSQL/Oracle | Très bon pour batching (allocation) |
| TABLE | Générique | Souvent moins performant |
// English-only sample @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq") @SequenceGenerator(name = "customer_seq", sequenceName = "customer_seq", allocationSize = 50) private Long id;
allocationSize réduit les allers-retours DB pour générer des IDs.
UUID : quand et pourquoi
- Utile en systèmes distribués (génération côté app).
- Attention : index plus gros, locality faible, impact perf sur inserts.
- Mitigation : UUID “ordered” / ULID / UUIDv7 (selon stack).
// English-only sample @Id @Column(columnDefinition = "uuid") private java.util.UUID id;
Composite keys (prudence)
Souvent mieux : une clé technique + contrainte unique métier. Mais si tu dois : @EmbeddedId.
// English-only sample
@Embeddable
public class OrderLineId implements java.io.Serializable {
private Long orderId;
private Integer lineNo;
}
@Entity
public class OrderLine {
@EmbeddedId
private OrderLineId id;
}@Embeddable : regrouper proprement
// English-only sample
@Embeddable
public class Address {
private String street;
private String city;
private String zip;
}
@Entity
public class Customer {
@Embedded
private Address billingAddress;
}Utile pour valeur-objets, lisibilité, et réduction de duplication.
@Converter : types métier ↔ types DB
// English-only sample @Converter(autoApply = false) public class MoneyConverter implements AttributeConverter{ public Long convertToDatabaseColumn(Money attribute) { return attribute == null ? null : attribute.cents(); } public Money convertToEntityAttribute(Long dbData) { return dbData == null ? null : Money.ofCents(dbData); } }
Très pratique pour encapsuler un modèle (Money, Email, JsonMap, etc.).
Inheritance strategies
| Strategy | DB shape | Trade-off |
|---|---|---|
| SINGLE_TABLE | One table + discriminator | Simple, nullable columns |
| JOINED | One table per class | Normalized, joins cost |
| TABLE_PER_CLASS | Separate tables | Unions, often slow |
Owning side : règle clé
Dans une relation bidirectionnelle, un seul côté possède la clé étrangère (ou la table de jointure). C’est le owning side. L’autre est mappedBy.
// English-only sample
@Entity
class Order {
@OneToMany(mappedBy = "order")
private List lines = new ArrayList<>();
}
@Entity
class OrderLine {
@ManyToOne(optional = false)
@JoinColumn(name = "order_id")
private Order order; // owning side (FK here)
} Si tu updates uniquement le côté inverse, la FK ne bougera pas.
OneToMany : préfère “ManyToOne + collection inverse”
Le mapping “@OneToMany” côté parent seul peut créer une table de jointure implicite selon provider. Le pattern stable : FK côté enfant (ManyToOne) + collection inverse.
Helper methods (cohérence)
// English-only sample
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void removeLine(OrderLine line) {
lines.remove(line);
line.setOrder(null);
}ManyToMany : prudence
- Bien en lecture simple, mais complexe dès que la jointure porte des attributs.
- Si la table de jointure a des colonnes métier : modèle-la comme entité (ex: Membership).
// English-only sample
@Entity
class Membership {
@ManyToOne User user;
@ManyToOne Group group;
@Column String role;
}Le N+1 en une phrase
1 requête charge la liste (N lignes), puis N requêtes chargent une relation lazy par ligne. Résultat : latence + charge DB + saturation pool.
// English-only sample Listorders = em.createQuery("select o from Order o", Order.class).getResultList(); for (Order o : orders) { o.getLines().size(); // triggers N queries if lines is LAZY }
Symptômes
- Explosion du nombre de requêtes.
- Temps de réponse proportionnel au volume.
- Logs SQL “mitraillette”.
Solution #1 : JOIN FETCH (ciblé)
// English-only sample Listorders = em.createQuery( "select distinct o from Order o " + "left join fetch o.lines " + "where o.status = :s", Order.class) .setParameter("s", "OPEN") .getResultList();
“distinct” évite les doublons d’objets racine quand la jointure multiplie les lignes.
Limites
- Pagination + join fetch collection = souvent problématique.
- Risque de charger trop de données (cartésien).
Solution #2 : EntityGraph (pilotage propre)
// English-only sample EntityGraphgraph = em.createEntityGraph(Order.class); graph.addAttributeNodes("customer"); Subgraph sg = graph.addSubgraph("lines"); sg.addAttributeNodes("product"); Order o = em.find(Order.class, 10L, Map.of("jakarta.persistence.fetchgraph", graph));
Solution #3 : Batch fetching
L’idée : quand une collection lazy est demandée, le provider la charge pour plusieurs parents en une requête IN.
# English-only sample (Hibernate) hibernate.default_batch_fetch_size=50
Que propage “cascade” ?
| Type | Propagation | Note |
|---|---|---|
| PERSIST | persist() sur enfants | Création graphe |
| MERGE | merge() sur enfants | Rattachement |
| REMOVE | remove() sur enfants | Suppression cascade |
| ALL | Tout | À manier avec prudence |
// English-only sample
@OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List lines; orphanRemoval = “remove when unlinked”
// English-only sample @OneToMany(mappedBy = "order", orphanRemoval = true, cascade = CascadeType.ALL) private Listlines;
Si une ligne est retirée de la collection et n’est plus référencée, elle est supprimée au flush. Idéal pour “enfants owned” (agrégat).
Pièges
- Cascade REMOVE sur relation partagée = catastrophe (effets de bord).
- Supprimer un parent avec énorme collection = storm de DELETE (batch à prévoir).
- Les helpers add/remove sont indispensables pour garder le graphe cohérent.
JPQL = entités, pas tables
// English-only sample Listvip = em.createQuery( "select c from Customer c " + "where c.tier = :tier and c.status = :st " + "order by c.name asc", Customer.class) .setParameter("tier", CustomerTier.GOLD) .setParameter("st", "ACTIVE") .getResultList();
Joins
// English-only sample Listorders = em.createQuery( "select o from Order o join o.customer c " + "where c.email = :email", Order.class) .setParameter("email", "a@b.com") .getResultList();
Projection DTO
// English-only sample
public record CustomerSummary(Long id, String name, long orderCount) {}
List rows =
em.createQuery(
"select new com.example.CustomerSummary(c.id, c.name, count(o)) " +
"from Customer c left join c.orders o " +
"group by c.id, c.name",
CustomerSummary.class)
.getResultList(); DTO = moins de data, moins de lazy surprises, plus stable en API.
Pagination
// English-only sample Listpage = em.createQuery("select o from Order o order by o.createdAt desc", Order.class) .setFirstResult(0) .setMaxResults(50) .getResultList();
Si pagination + join fetch collection : préférer deux requêtes (ids page -> fetch graph).
Quand l’utiliser
- Filtres multi-critères construits à runtime.
- Recherche avancée (écran “admin” typique).
- Garder du type-safety (évite concat string).
Alternative : Specifications Spring Data (souvent plus ergonomique).
Exemple minimal
// English-only sample CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQueryq = cb.createQuery(Customer.class); Root c = q.from(Customer.class); List ps = new ArrayList<>(); ps.add(cb.equal(c.get("status"), "ACTIVE")); ps.add(cb.like(cb.lower(c.get("name")), "%ali%")); q.select(c).where(ps.toArray(new Predicate[0])).orderBy(cb.asc(c.get("name"))); List res = em.createQuery(q).setMaxResults(50).getResultList();
Quand SQL natif est légitime
- CTE complexes, window functions, hints DB.
- Optimisations “à la DBA” (plans stables, index-only scans).
- Rapports/BI (agrégations lourdes) → projection DTO.
Exemple : projection simple
// English-only sample List
En pratique : map vers DTO (ou SqlResultSetMapping) pour garder un code propre.
Risques
- Portabilité réduite (dialect).
- Maintenance : SQL long dans le code (à versionner/tester).
- Attention aux injections : toujours bind params, jamais concat.
La frontière transactionnelle : décision d’architecture
- TX trop grande : locks longs, contention, mémoire, timeouts.
- TX trop petite : incohérences, lazy hors scope, répétition I/O.
- Objectif : TX “métier” courte, cohérente, observable.
// English-only sample (Java SE)
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
// work
tx.commit();
} catch (Exception e) {
tx.rollback();
throw e;
} finally {
em.close();
}Isolation & verrous
L’isolation est DB-level. JPA n’invente pas la cohérence : elle s’appuie sur la DB.
| Niveau | Protège contre | Coût |
|---|---|---|
| READ COMMITTED | dirty reads | faible |
| REPEATABLE READ | non-repeatable reads | moyen |
| SERIALIZABLE | phantoms | élevé |
En pratique : lock ciblé + optimistic locking + retries.
Spring (@Transactional) : points d’attention
- Proxy-based : appels internes (self-invocation) ne passent pas toujours par le proxy.
- Propagation : REQUIRED, REQUIRES_NEW, SUPPORTS, etc.
- Read-only : utile pour hints perf (selon provider), mais pas magique.
// English-only sample
@Transactional
public void placeOrder(Long customerId) {
// business work
}@Version : standard “scalable”
// English-only sample @Version private long version;
Chaque UPDATE vérifie la version : si quelqu’un a modifié entre temps → exception (OptimisticLockException). Très bon pour systèmes à faible contention.
Locks pessimistes : quand il faut bloquer
// English-only sample Account a = em.find(Account.class, id, LockModeType.PESSIMISTIC_WRITE);
- Protège fortement, mais peut générer contention et deadlocks.
- À utiliser sur “hot rows” (solde, stock critique) avec TX courte.
Stratégie retry (recommandée)
- Optimistic : retries applicatifs sur collisions.
- Backoff exponentiel + jitter sur contention.
- Mesure : taux de collisions, latence, timeouts.
// English-only pseudo
for (int attempt=1; attempt<=3; attempt++) {
try { doWork(); break; }
catch (OptimisticLockException e) { sleep(backoff(attempt)); }
}Pourquoi flush/clear ?
- Le contexte de persistance grandit (mémoire).
- Dirty checking devient coûteux.
- Le JDBC batching ne sert que si la charge est structurée.
Batch inserts (pattern)
// English-only sample
for (int i = 0; i < items.size(); i++) {
em.persist(items.get(i));
if (i % 100 == 0) {
em.flush();
em.clear();
}
}Config batching (ex provider)
# English-only sample (Hibernate) hibernate.jdbc.batch_size=50 hibernate.order_inserts=true hibernate.order_updates=true
Mass update : attention
Les bulk updates JPQL contournent le contexte (pas de dirty checking). Il faut souvent clear() ensuite pour éviter incohérences en mémoire.
// English-only sample
int n = em.createQuery("update Customer c set c.status='INACTIVE' where c.lastLogin < :d")
.setParameter("d", cutoff)
.executeUpdate();
em.clear();Cache L1 (persistence context)
- Par EntityManager / transaction scope.
- Garantit identité : même ID → même instance.
- Très utile, mais peut grossir (flush/clear).
Cache L2 (second-level)
Partagé entre EntityManagers (selon provider). Très efficace en lecture “reference data”, mais exige une stratégie d’invalidation.
# English-only sample (Hibernate-ish) hibernate.cache.use_second_level_cache=true hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
Query cache : utile mais facile à rendre incohérent. À activer seulement si maîtrisé.
Invalidation : le sujet qui décide
- Données peu volatiles : L2 excellent.
- Données très mutables : L2 peut coûter plus qu’il ne rapporte.
- En cluster : attention à cohérence (replication/invalidation events).
Stratégie recommandée
- Dev : update (toléré) + reset DB simple.
- Pré-prod/prod : validate/none + migrations.
- Index/constraints : via migrations (versionné, relu, testable).
Naming strategy
Fixer une stratégie évite les “surprises” de noms de tables/colonnes entre environnements.
Exécution DBA-friendly
- Générer les scripts DDL (dry-run) pour review.
- Tester migrations sur clone de prod (volumétrie réaliste).
- Automatiser rollback/forward en CI/CD.
# English-only sample flyway migrate flyway info flyway validate
H2 vs Testcontainers
| Option | + | - |
|---|---|---|
| H2 | Très rapide | Différences dialect/behaviors |
| Testcontainers | DB “réelle” | Plus lent |
Pour bugs SQL/fetch/locks : Testcontainers est souvent indispensable.
Tracer ce qui compte
- Nombre de requêtes par endpoint (détecter N+1).
- Temps DB vs temps applicatif.
- Top queries (slow log) + explain plans.
# English-only idea request_id -> app logs -> SQL logs -> db slow logs -> explain analyze
Workflow perf (pro)
- Reproduire (dataset réaliste).
- Capturer SQL réel + binds.
- EXPLAIN ANALYZE + index review.
- Fix fetch strategy / queries / indexes.
- Re-bench + regression tests.
JPA = portable, Provider = performance + options
- Batching avancé, caches, hints, fetch tuning.
- Outils de statistiques/monitoring.
- Extensions (filters, @Where, custom types, etc.).
Conseil
Rester JPA “pur” pour 80%, et assumer les extensions provider uniquement pour le 20% critique.
Checklist prod (provider)
| Point | Impact |
|---|---|
| Batch size | Insert/update massifs |
| Default fetch size | Lecture bulk |
| Second-level cache | Read-heavy |
| Statistics | Diagnostic |
Repository : abstraction efficace
// English-only sample public interface CustomerRepository extends JpaRepository{ }
Ça ne supprime pas la nécessité de comprendre fetch/transactions : ça la rend plus accessible.
Derived queries
// English-only sample ListfindByStatusAndTierOrderByNameAsc(String status, CustomerTier tier);
Super pour CRUD, mais attention : sur du complexe, préfère @Query ou specs.
Specifications + paging
// English-only sample PagefindAll(Specification spec, Pageable pageable);
Très bon pour écrans de recherche avancée, surtout avec filtres optionnels.
Mapping & relations
// English-only quick notes @Entity @Table(name="t") @Id @GeneratedValue @ManyToOne(optional=false) @JoinColumn(name="parent_id") // owning side @OneToMany(mappedBy="parent") // inverse side // Prefer EnumType.STRING @Enumerated(EnumType.STRING) // Optimistic locking @Version long version;
Fetch
// English-only quick notes Default: ManyToOne is EAGER (often change to LAZY if possible) Fix N+1 with: - JOIN FETCH (targeted) - EntityGraph - batch fetching (provider) Avoid join fetch + pagination on collections -> use id page then fetch graph
Queries & transactions
// English-only quick notes JPQL: "select e from Entity e where e.field = :p" Bind params, never concat Pagination: setFirstResult(offset) setMaxResults(limit) Batch writes: flush+clear every N hibernate.jdbc.batch_size=50 Bulk update: executeUpdate() then clear() to avoid stale context
Prod checklist
// English-only checklist - ddl-auto=validate/none - migrations = Flyway/Liquibase - SQL logs off (or sampled) + slow query log on DB - measure query count per request - cache only if invalidation is defined - explain plans for top queries
