Spring Boot et Spring Cloud

Spring Data JPA avec Spring Boot : repositories, requêtes, transactions et migrations

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

Vous saurez persister et interroger des données avec Spring Data JPA dans un service Spring Boot 3 : définir entités et repositories, écrire des requêtes dérivées, JPQL et natives, paginer et projeter les résultats, placer les transactions au bon endroit, éviter les pièges du N+1 et de l'open-in-view, et gérer le schéma avec Flyway plutôt qu'avec ddl-auto.

Ce que vous allez apprendre

  • Configurer une DataSource, JPA et Hibernate par les propriétés Spring Boot, et savoir quelles valeurs par défaut changer
  • Écrire des interfaces de repository avec des requêtes dérivées et des méthodes @Query et @Modifying
  • Paginer, trier et projeter les résultats sans charger des graphes d'entités entiers
  • Placer @Transactional dans la bonne couche et expliquer ce que change une transaction en lecture seule
  • Détecter et corriger les N+1 selects avec des fetch joins et @EntityGraph
  • Gérer les évolutions de schéma avec des migrations Flyway plutôt qu'avec hibernate.ddl-auto

Spring Data JPA supprime l'essentiel du code qu'exigeait autrefois l'accès aux données : une déclaration d'interface remplace une classe DAO, des noms de méthodes remplacent bon nombre de requêtes, et Spring Boot câble la DataSource, Hibernate et le gestionnaire de transactions à partir d'une poignée de propriétés. Ce même confort permet tout aussi facilement de livrer un service qui émet des centaines de requêtes par appel HTTP sans que personne ne s'en aperçoive avant l'arrivée du trafic. Cette page couvre la mécanique et, tout aussi important, les endroits où il faut changer les valeurs par défaut. Elle vise Spring Boot 3 avec Spring Data JPA 3 et Hibernate 6, sur Jakarta Persistence 3.1.

Ce que Boot configure pour vous

Ajouter spring-boot-starter-data-jpa et un driver JDBC apporte un pool de connexions (HikariCP), une EntityManagerFactory adossée à Hibernate, un JpaTransactionManager et le scan des repositories à partir du package de l'application. La configuration minimale se résume à une URL et des identifiants :

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/shop
    username: shop
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 10        # à caler sur le nombre de connexions que la base peut accorder
  jpa:
    open-in-view: false            # voir plus bas
    properties:
      hibernate:
        default_batch_fetch_size: 50
  flyway:
    enabled: true

Deux valeurs par défaut méritent une décision dès le premier jour. spring.jpa.open-in-view vaut true par défaut et garde l'EntityManager ouvert pendant toute la requête web, pour que les vues et les sérialiseurs JSON puissent déclencher des chargements lazy. C'est pratique, et c'est précisément pourquoi Boot journalise un avertissement au démarrage : cela masque les explosions de requêtes et monopolise une connexion à la base pendant l'écriture de la réponse. Désactivez-le et chargez explicitement ce dont vous avez besoin. spring.jpa.hibernate.ddl-auto vaut par défaut create-drop pour les bases embarquées et none sinon ; laissez-le à none et confiez le schéma aux migrations. spring.jpa.show-sql écrit le SQL sur la sortie standard sans passer par le framework de logging ; préférez logging.level.org.hibernate.SQL=DEBUG (et org.hibernate.orm.jdbc.bind=TRACE avec Hibernate 6 pour voir les paramètres liés).

Entités et identifiants

Une entité est une classe mappée sur une table. L'exemple ci-dessous met en avant les choix qui comptent davantage que les annotations elles-mêmes :

@Entity
@Table(name = "orders")
public class Order {

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

    @Version
    private long version;                     // verrouillage optimiste

    @Enumerated(EnumType.STRING)              // jamais ORDINAL : réordonner l'enum corrompt les données
    private OrderStatus status;

    @ManyToOne(fetch = FetchType.LAZY)        // les to-one sont EAGER par défaut ; on surcharge
    @JoinColumn(name = "customer_id")
    private Customer customer;

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

    private Instant createdAt;

    protected Order() {}                      // JPA exige un constructeur sans argument ; gardez-le non public

    // méthodes métier, getters ; pas de setter pour id ni version
}

SEQUENCE avec une taille d'allocation permet à Hibernate d'attribuer les identifiants par lots et active le batching JDBC des insertions ; IDENTITY impose un aller-retour par ligne et désactive le batching des insertions. @Version transforme les mises à jour concurrentes en OptimisticLockException (remontée sous la forme de l'ObjectOptimisticLockingFailureException de Spring) au lieu de mises à jour perdues en silence. Tout @ManyToOne et tout @OneToOne devrait être LAZY : le défaut EAGER que la spécification impose aux associations to-one est de loin la première source de jointures accidentelles.

N'implémentez pas equals/hashCode sur des identifiants de base de données qui restent null tant que l'entité n'est pas sauvegardée ; utilisez plutôt une clé métier ou l'identité fondée sur la classe que décrit la documentation d'Hibernate. Les records ne peuvent pas être des entités (ils sont final et immuables), mais ils conviennent parfaitement aux DTO et aux projections qui sortent de la couche de persistance.

Les repositories

Un repository est une interface ; Spring Data en génère l'implémentation au démarrage :

public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {

    // requête dérivée : analysée à partir du nom de la méthode au démarrage
    List<Order> findByCustomerIdAndStatusOrderByCreatedAtDesc(Long customerId, OrderStatus status);

    Page<Order> findByStatus(OrderStatus status, Pageable pageable);

    boolean existsByCustomerIdAndStatus(Long customerId, OrderStatus status);

    // JPQL avec un fetch join pour éviter le N+1 sur les lignes
    @Query("select o from Order o join fetch o.lines where o.id = :id")
    Optional<Order> findWithLines(@Param("id") Long id);

    // mise à jour en masse : contourne le contexte de persistance, d'où clearAutomatically
    @Modifying(clearAutomatically = true)
    @Query("update Order o set o.status = :status where o.createdAt < :before and o.status = 'NEW'")
    int expireStaleOrders(@Param("status") OrderStatus status, @Param("before") Instant before);

    // SQL natif quand la base fait mieux
    @Query(value = """
            select customer_id, count(*) as orders, sum(total_cents) as revenue_cents
            from orders where created_at >= :since
            group by customer_id order by revenue_cents desc limit :limit
            """, nativeQuery = true)
    List<CustomerRevenue> topCustomers(@Param("since") Instant since, @Param("limit") int limit);
}

JpaRepository étend ListCrudRepository et ListPagingAndSortingRepository (les variantes qui renvoient des List, introduites avec Spring Data 3) et y ajoute des spécificités JPA comme flush() et saveAllAndFlush(). Si un repository ne doit pas exposer deleteAll à toute la base de code, étendez Repository<T, ID> et déclarez uniquement les méthodes voulues.

Les noms des requêtes dérivées sont validés au démarrage, donc une faute de frappe échoue tout de suite, mais les noms longs deviennent illisibles : au-delà de trois conditions, passez à @Query. Les requêtes JPQL sont elles aussi validées au démarrage par rapport au modèle d'entités. Pas les requêtes natives, ce qui est une raison de plus pour les garder rares et couvertes par des tests sur la vraie base.

Specification (fournie par JpaSpecificationExecutor) construit des clauses where dynamiques avec l'API Criteria pour les écrans de recherche aux nombreux filtres facultatifs, et le Query by Example de Spring Data couvre les correspondances d'égalité simples. Au-delà, Querydsl ou du JPQL construit dans un fragment de repository personnalisé reste plus lisible qu'un empilement de specifications.

Pagination, tri et projections

Pageable et Sort passent tels quels du contrôleur au repository ; Page<T> porte le nombre total d'éléments (au prix d'une requête count supplémentaire), tandis que Slice<T> sait seulement s'il existe une page suivante, ce qui coûte moins cher pour un défilement infini.

Charger des entités complètes pour renvoyer trois champs est du gaspillage. Les projections par interface permettent à la requête de ne sélectionner que ce qui est déclaré :

public interface OrderSummary {
    Long getId();
    OrderStatus getStatus();
    Instant getCreatedAt();
    CustomerName getCustomer();          // projection imbriquée

    interface CustomerName { String getName(); }
}

Page<OrderSummary> findByStatus(OrderStatus status, Pageable pageable);

Les projections par classe (DTO) reposent sur un record et une expression de constructeur ; ce sont les plus efficaces, car Hibernate sélectionne exactement les arguments du constructeur :

public record CustomerRevenue(Long customerId, long orders, long revenueCents) {}

@Query("select new com.example.shop.report.CustomerRevenue(o.customer.id, count(o), sum(o.totalCents)) "
     + "from Order o where o.createdAt >= :since group by o.customer.id")
List<CustomerRevenue> revenueSince(@Param("since") Instant since);

Transactions et contexte de persistance

Le SimpleJpaRepository de Spring Data est annoté @Transactional(readOnly = true) au niveau de la classe, avec des redéfinitions en lecture-écriture sur les méthodes qui modifient des données : chaque appel de repository est donc transactionnel à lui seul. Une opération métier qui enchaîne plusieurs appels a besoin de sa propre frontière transactionnelle, et c'est dans la couche service qu'elle doit se trouver :

@Service
public class OrderService {

    private final OrderRepository orders;
    private final InventoryClient inventory;

    @Transactional
    public Order ship(long orderId) {
        Order order = orders.findWithLines(orderId).orElseThrow(() -> new OrderNotFoundException(orderId));
        order.markShipped();          // le dirty checking écrit ce changement au commit ; pas besoin de save()
        inventory.release(order);     // appel distant dans une transaction : gardez-le court, ou déplacez-le après le commit
        return order;
    }

    @Transactional(readOnly = true)
    public Page<OrderSummary> search(OrderStatus status, Pageable pageable) {
        return orders.findByStatus(status, pageable);
    }
}

Dans une transaction, le contexte de persistance suit chaque entité chargée ; les modifications sont détectées au flush et écrites sous forme d'UPDATE, si bien qu'un save() sur une entité déjà managée est superflu. readOnly = true passe le flush mode d'Hibernate à MANUAL, évite les snapshots du dirty checking et permet à certains drivers et routeurs d'envoyer la requête vers un réplica ; utilisez-le sur tous les chemins de lecture.

Quelques règles qui font gagner du temps de débogage. @Transactional ne fonctionne que sur des méthodes publiques appelées à travers le proxy Spring : une auto-invocation au sein de la même classe n'est donc pas transactionnelle. Les exceptions runtime provoquent un rollback par défaut, les exceptions checked non (rollbackFor change ce comportement). Enfin, une méthode @Transactional qui intercepte l'exception d'un appel de repository imbriqué subit quand même un rollback, car l'échec interne a marqué la transaction rollback-only. Le guide Spring Framework consacré à JDBC et aux transactions traite en détail la propagation et l'isolation.

Le problème du N+1

Chargez vingt commandes, puis appelez order.getLines() sur chacune : une requête pour les commandes, plus vingt pour les lignes. Spring Data et Hibernate ne peuvent pas deviner à l'avance quelles associations l'appelant va toucher ; la solution consiste donc à le leur dire :

  • Fetch join en JPQL : select o from Order o join fetch o.lines where .... Charger deux collections dans une même requête multiplie les lignes (un produit cartésien) ; Hibernate 6 supprime les doublons, mais le nombre de lignes transférées augmente quand même : chargez une seule collection par requête.
  • @EntityGraph sur une méthode de repository : @EntityGraph(attributePaths = {"lines", "customer"}) s'applique aux requêtes dérivées et à findById.
  • Batch fetching avec hibernate.default_batch_fetch_size : les collections lazy des entités déjà chargées sont initialisées par lots grâce à une requête in (...) au lieu d'une requête chacune. C'est l'amélioration globale la moins coûteuse, et elle fonctionne partout.
  • Projections qui ne chargent jamais la collection.

Détectez le problème avant vos utilisateurs : journalisez le SQL dans les tests @DataJpaTest et comptez les requêtes, ou activez les statistiques d'Hibernate (spring.jpa.properties.hibernate.generate_statistics=true) dans un environnement de préproduction et surveillez le nombre de requêtes par appel HTTP.

Migrations de schéma avec Flyway

Boot configure automatiquement Flyway quand flyway-core est sur le classpath avec le module propre à la base de données (flyway-database-postgresql pour PostgreSQL depuis Flyway 10). Les migrations sont des fichiers SQL placés dans src/main/resources/db/migration et nommés par version :

-- V3__add_order_version_and_status_index.sql
alter table orders add column version bigint not null default 0;
create index orders_status_created_at_idx on orders (status, created_at desc);

Flyway exécute les migrations en attente au démarrage, enregistre chacune dans flyway_schema_history et refuse de démarrer si un fichier déjà appliqué a été modifié (son checksum a changé). Ce comportement est voulu : les migrations forment un historique immuable, et une migration erronée se corrige par une nouvelle migration. Laissez le ddl-auto d'Hibernate à none dans tous les environnements qui utilisent des migrations ; les deux mécanismes ne doivent jamais se partager le schéma. Définir spring.jpa.hibernate.ddl-auto=validate dans les tests constitue un filet de sécurité utile : Hibernate vérifie que le schéma migré correspond aux mappings des entités et fait échouer le build s'ils divergent.

Liquibase est l'alternative équivalente, avec des changelogs XML, YAML ou SQL et un modèle de rollback plus riche ; Boot le configure automatiquement de la même manière.

Erreurs fréquentes

Se fier aux EAGER par défaut sur les associations to-one. Chaque findById entraîne avec lui le client, l'adresse du client et tout ce qui y est rattaché. Indiquez explicitement fetch = FetchType.LAZY sur chaque @ManyToOne et chaque @OneToOne.

Utiliser la génération IDENTITY et s'étonner de la lenteur des insertions par lots. Passez à SEQUENCE avec allocationSize, et définissez spring.jpa.properties.hibernate.jdbc.batch_size=50 et hibernate.order_inserts=true.

Appeler save() dans une boucle pour une mise à jour en masse. Utilisez un update JPQL @Modifying ou une instruction native ; mille entités managées et mille UPDATE, ce n'est pas un traitement batch.

Intercepter une exception dans une transaction et poursuivre. La transaction est déjà rollback-only ; le commit échouera avec une UnexpectedRollbackException. Gérez les échecs en dehors de la méthode transactionnelle, ou utilisez REQUIRES_NEW en connaissance de cause.

Stocker les enums sous forme d'ordinaux. Insérer une nouvelle constante au milieu de l'enum remappe en silence toutes les lignes stockées. Toujours EnumType.STRING.

Tester sur H2 en mode PostgreSQL et déployer sur PostgreSQL. La gestion des types, les séquences, on conflict et le JSON diffèrent. Utilisez Testcontainers avec le vrai moteur pour les tests de repositories, comme le montre le guide sur les tests.

Bonnes pratiques

  • Gardez les entités dans la couche de persistance et mappez-les vers des records à la frontière du service.
  • Une frontière @Transactional par cas d'utilisation, dans le service ; readOnly = true sur tous les chemins de lecture.
  • Rendez toutes les associations lazy, puis chargez délibérément avec des fetch joins, des entity graphs ou du batch fetching.
  • Préférez les projections pour les listes et les rapports ; ne chargez des agrégats complets que pour les modifier.
  • Confiez le schéma à Flyway ou Liquibase ; validez les mappings par rapport à lui en CI.
  • Dimensionnez le pool HikariCP d'après la capacité de la base de données, pas d'après le nombre de threads de l'application, et surveillez les temps d'attente du pool grâce aux métriques Actuator.

Points clés à retenir

  • Boot configure HikariCP, Hibernate et les transactions à partir de quelques propriétés ; désactivez l'open-in-view et laissez ddl-auto à none en dehors des bases jetables.
  • Les repositories sont des interfaces : requêtes dérivées pour les cas simples, JPQL via @Query pour le reste, SQL natif uniquement quand la base offre ce que JPQL ne sait pas faire.
  • Les transactions se placent sur les méthodes de service ; les méthodes de repository sont déjà transactionnelles individuellement, et le dirty checking écrit les modifications sans appel explicite à save().
  • Les N+1 selects viennent d'associations lazy touchées en dehors d'un plan de chargement ; corrigez-les avec des fetch joins, @EntityGraph, le batch fetching ou des projections.
  • Utilisez par défaut des identifiants SEQUENCE, @Version pour le verrouillage optimiste, EnumType.STRING et des associations to-one lazy.
  • Des migrations Flyway versionnées, validées par rapport au modèle d'entités dans les tests, maintiennent le schéma et le code en phase.

Sources et lectures complémentaires

Questions fréquentes

Faut-il @Transactional sur les appels de repository ?
Pas pour un appel isolé : l'implémentation des repositories de Spring Data exécute déjà chaque méthode dans une transaction (en lecture seule pour les requêtes). Il faut en revanche @Transactional sur une méthode de service qui enchaîne plusieurs appels de repository devant réussir ou échouer ensemble, ou qui modifie une entité en comptant sur le dirty checking pour écrire le changement au commit.
Pourquoi mon association lazy lève-t-elle une LazyInitializationException ?
L'entité a été chargée dans une transaction, la transaction s'est terminée en fermant le contexte de persistance, puis du code a touché une collection ou un proxy non initialisé. Trois solutions : charger l'association dans la requête (join fetch ou @EntityGraph), mapper vers un DTO à l'intérieur de la transaction, ou élargir la transaction pour couvrir l'accès. Activer l'open-in-view masque le symptôme, pas les requêtes supplémentaires.
spring.jpa.hibernate.ddl-auto=update est-il sûr en production ?
Non. Il sait seulement ajouter, jamais renommer ni supprimer proprement, il devine les types de colonnes, il exécute au démarrage du DDL que personne n'a relu avec les droits de l'application, et il ne laisse aucun historique permettant de revenir en arrière. Réservez-le au mieux aux bases locales jetables, et gérez les vrais schémas avec des migrations versionnées Flyway ou Liquibase.
Quand utiliser une requête native plutôt que du JPQL ?
Quand vous avez besoin de quelque chose que JPQL ne sait pas exprimer ou que la base fait bien mieux : fonctions de fenêtrage, CTE récursives, opérateurs JSON propres à un éditeur, ou jointure optimisée à la main. Les requêtes natives perdent la portabilité et la validation au démarrage par rapport au modèle d'entités : gardez-en peu, testez-les sur le vrai moteur de base de données et mappez les résultats vers une projection ou un DTO.
SpringInté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 lectureSpringSpring JDBC et les transactions déclarativesJdbcTemplate et JdbcClient pour du SQL sans boilerplate, traduction en DataAccessException et @Transactional en détail : propagation, isolation, rollback.Intermédiaire8 min de lectureJakarta EEJakarta Persistence (JPA 3.1) : entités, mapping et contexte de persistanceComment JPA mappe les classes sur les tables : unité de persistance, EntityManager, états, identifiants, relations, chargement, cascades, héritage et verrous.Intermédiaire11 min de lectureJakarta EEJPQL, 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 lectureTester Spring Boot 3 : slices, MockMvc, Testcontainers et cache de contextesUne stratégie de test pour Spring Boot 3 : tests unitaires sans conteneur, slices @WebMvcTest et @DataJpaTest, @SpringBootTest, Testcontainers et suite rapide.Intermédiaire8 min de lectureDonnéesIndex et plans d'exécution : faire travailler l'optimiseur pour vousB-trees, ordre des colonnes d'un index composite, index couvrants et partiels, statistiques, lecture d'EXPLAIN ANALYZE et correction d'un mauvais plan.Avancé11 min de lecture