1. Introduction
Si vous avez déjà regardé productRepository.save(product) en pensant « voilà, j’ai appuyé sur le bouton « sauvegarder », et ensuite Spring s’arrange tout seul avec la base », vous n’êtes pas seul. Spring Data JPA rend effectivement le code plus court et plus agréable : moins de code standard, moins de travail manuel avec EntityManager, moins de "requêtes faites à la main". Le problème commence quand on perçoit cette commodité comme une annulation des lois de la physique : comme si les états des entités, le flush, le dirty checking et les contraintes de la base de données disparaissaient dès qu’on utilise une interface JpaRepository au lieu de EntityManager.
En réalité, le repository n’est pas un nouvel ORM, mais une couche de commodité par-dessus les mécanismes existants d’Hibernate. Il vous aide à ne pas écrire de code redondant, mais il ne change pas la manière dont Hibernate prend ses décisions : quoi charger, quoi mettre à jour, quand exécuter du SQL et ce qui est considéré comme « le même objet » au sein d’une transaction. C’est pourquoi nous allons aujourd’hui examiner les repositories non pas comme une « boîte magique », mais comme une façade soignée devant des mécanismes bien connus.
Pour poser immédiatement le bon cadre, il est utile de retenir une idée simple : le repository cache l’API, mais il ne cache pas la sémantique. La sémantique est toujours déterminée par la transaction, le contexte de persistance et la base de données. Et si vous comprenez cela, JpaRepository devient un super-pouvoir. Sinon, il devient un générateur de SELECT inattendus, de UPDATE surprises et d’étranges « pourquoi ça fonctionne parfois et parfois non ».
2. Chaîne d'appel jusqu’à SQL : Spring Data → Hibernate
Quand le code devient court, le cerveau commence à paresser : « j’ai appelé une méthode, donc quelque chose s’est produit ». Mais Hibernate n’est pas « exécuter du SQL immédiatement », c’est « collecter les modifications dans une unité de travail et les synchroniser selon les règles ». Il est donc utile de tracer la chaîne par laquelle votre bel appel de repository se transforme en SQL dans PostgreSQL. C’est comme regarder le câblage électrique d’une maison : l’ampoule s’allume avec un interrupteur, mais si vous voulez réparer, il faut savoir où se trouve le disjoncteur, le fil, le contact.
Voici un schéma simplifié de ce qui se passe dans Commerce Persistence Lab lorsque vous écrivez du code au niveau du service et du repository :
flowchart TD
A["Méthode de service @Transactional"] --> B["Repository Spring Data (proxy)"]
B --> C["SimpleJpaRepository (implémentation Spring Data)"]
C --> D["EntityManager (API JPA)"]
D --> E["Session Hibernate (moteur ORM réel)"]
E --> F["Pilote JDBC"]
F --> G["PostgreSQL"]
Un détail important : le repository Spring Data est presque toujours un proxy qui délègue l’appel à l’implémentation standard (classiquement SimpleJpaRepository), laquelle travaille via EntityManager. Et EntityManager est en réalité le plus souvent un adaptateur vers la Session Hibernate. Autrement dit, Spring Data est la « poignée de porte », pas le « moteur de la voiture ».
Il en découle une conclusion très pratique : si vous comprenez déjà ce que sont l’état managed, le flush, le dirty checking, les proxies lazy et les effets de bord en masse, alors en passant à Spring Data, vous n’avez pas besoin d’apprendre une nouvelle physique. Vous devez apprendre à voir quelle physique se déclenche pour une méthode donnée du repository. Et c’est exactement ce que nous allons entraîner aujourd’hui.
3. Entités, états et contexte de persistance
Après l’apparition du repository, on a souvent envie de penser : « puisque j’ai productRepository, l’entité est juste un DTO, et le repository décidera tout seul quoi en faire ». C’est une illusion dangereuse. L’entité reste une entité, avec tous ses états, avec la même identity map dans le contexte de persistance, et avec la même règle : une ligne en base de données, dans une transaction, correspond à un objet managed en mémoire.
Commençons par le plus élémentaire. Un repository est une interface que Spring Data sait implémenter pour vous. Il est concis, mais ce n’est pas une « base de données intégrée en Java », c’est juste un contrat pratique.
package com.example.commerce.catalog.repository;
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Spring Data générera lui-même l'implémentation et la connectera via un proxy
}
Cette interface ne rend pas les entités immortelles, n’annule pas @Transactional, ne désactive pas le lazy loading et ne transforme pas Hibernate en « SQL sur clic ». Elle dit simplement : « je veux disposer des opérations CRUD et des opérations de base pour Product ». Ensuite, tout se passe selon les règles de l’ORM.
Regardons un code de service typique. Ici, le repository n’est utilisé que pour obtenir une entité managed. Ensuite, c’est le mécanisme familier qui agit : nous modifions l’objet managed, et Hibernate comprend tout seul quoi faire.
package com.example.commerce.catalog.service;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional // Important : c'est la transaction qui crée l'unité de travail et le contexte de persistance
public void renameProduct(Long id, String newName) {
// Généralement, cela fera un SELECT (ou une entrée dans le cache de premier niveau si déjà chargé dans cette transaction)
Product product = productRepository.findById(id).orElseThrow();
// Nous modifions une entité managed : le dirty checking et le flush feront l’UPDATE eux-mêmes (même sans save())
product.renameTo(newName); // l'entité managed est modifiée
}
}
Remarquez cette « pause dramatique » : nous n’appelons nulle part save(). Et c’est normal, car à l’intérieur de la transaction, product est managed, et le dirty checking associé au cycle de flush fera le travail. Le repository ici ne « sauvegarde » pas ; il « donne accès à une entité managed ». C’est un changement de perspective important, surtout si vous avez l’habitude de penser « j’ai modifié un objet → il faut immédiatement sauvegarder ».
Ici, le repository n’annule pas les états ; il aide à entrer commodément dans le flux où les états opèrent. Par conséquent, quand vous lisez du code avec un repository, vous devez toujours vous demander : dans quel état se trouve l’objet en entrée, et dans quel état sera-t-il en sortie.
4. Frontière transactionnelle et unité de travail
Toute la puissance d’Hibernate ne se manifeste qu’à l’intérieur d’une frontière transactionnelle correcte : c’est là que se trouve le contexte de persistance, l’unité de travail, le droit d’effectuer un flush et les chargements paresseux. Le repository en lui-même ne « crée » pas cette frontière au sens architectural. Certes, les méthodes du repository ont des réglages transactionnels par défaut, mais si vous commencez à penser en termes de transactions au niveau du repository, vous obtiendrez un ensemble de petites opérations non liées, difficiles à prévoir et à déboguer.
Dans ce cours, nous maintenons une discipline : le service définit l'unité de travail, le repository exécute des opérations locales à l’intérieur de cette frontière. Cela devient particulièrement évident quand un même scénario métier implique plusieurs repositories. Par exemple, lors de la validation d’une commande, vous pouvez toucher à PurchaseOrder, InventoryItem, parfois Customer.
Voici une mini-illustration : la transaction englobe plusieurs appels de repository, mais le « sens » de la transaction n’est pas d’avoir appelé les repositories, mais d’avoir effectué l’opération métier dans son ensemble.
package com.example.commerce.orders.service;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.repository.PurchaseOrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderWriteService {
private final ProductRepository productRepository;
private final PurchaseOrderRepository orderRepository;
public OrderWriteService(ProductRepository productRepository, PurchaseOrderRepository orderRepository) {
this.productRepository = productRepository;
this.orderRepository = orderRepository;
}
@Transactional // La transaction « colle » plusieurs repositories en une seule unité de travail
public void addItem(Long orderId, Long productId, int qty) {
// Généralement, cela fera un SELECT : nous avons besoin de l'entité managed de la commande pour modifier ses collections/composition
PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();
// getReferenceById() retourne souvent un proxy sans SELECT (jusqu'au premier accès aux champs)
Product product = productRepository.getReferenceById(productId);
// Nous modifions l'agrégat : le dirty checking s'effectuera au flush/commit, et non « au moment de l'appel »
order.addItem(product, qty);
}
}
Ici, la transaction « colle » le tout en une seule unité de travail. C’est grâce à cela que nous avons un contexte de persistance cohérent, un dirty checking normal et un flush prévisible. Le repository ne doit pas devenir un « lieu où vit la transaction », sinon vous perdrez l’essentiel : la frontière maîtrisée du scénario.
Une pensée importante pour les débutants : @Transactional(readOnly = true) n’est pas « pour que tout aille plus vite », mais une déclaration d’intention. Elle signifie : « dans ce scénario, nous lisons ». Cela aide à garder l’esprit clair : si le scénario est en lecture seule, il ne devrait pas modifier accidentellement des entités managed et créer des UPDATE invisibles lors du commit.
5. Pratique : SQL, coût et lecture des appels
Repository et SQL : dirty checking et cycle de flush
Quand vous passez à Spring Data, un risque apparaît : vous cessez de voir EntityManager, donc vous oubliez que le SQL n’apparaît pas au moment où la méthode est appelée, mais au moment du flush. C’est pourquoi nous vous avons si obstinément fait lire le journal SQL et prononcer le mot « flush » (oui, cela ressemble un peu à une incantation, mais ça marche).
Prenons le même renameProduct(). La logique du débutant est souvent : « j’ai appelé findById() → c’est un SELECT, j’ai modifié le champ → rien, je n’ai pas appelé save, donc pas d’UPDATE ». Et puis soudain, un UPDATE apparaît. On dirait que Spring Data « sauvegarde tout seul ».
En réalité, voici ce qui se passe. Dans la transaction, findById() donne un objet managed (presque toujours via un SELECT). Quand vous appelez product.renameTo(newName), Hibernate enregistre la modification en mémoire. Le SQL UPDATE n’apparaît pas parce que le repository « sauvegarde automatiquement », mais parce que le dirty checking voit la différence entre l’instantané et l’état actuel, et le flush à la fin de la transaction synchronise les modifications avec la base.
Si nous représentions schématiquement la trace SQL, elle ressemblerait à ceci (le journal réel contiendra plus de détails, de paramètres et de noms de colonnes) :
-- SELECT ... FROM product WHERE id = ?
-- UPDATE product SET name = ? WHERE id = ?
Remarquez : entre « j’ai modifié le champ » et « l’UPDATE est parti vers la base », il y a toute une mécanique interne. Le repository ne l’annule pas et ne la remplace pas. Le repository est simplement un point d’accès commode aux opérations de EntityManager.
Et une autre subtilité qui défie souvent l’intuition : le flush peut se produire non seulement au commit, mais aussi « plus tôt », par exemple avant une requête si Hibernate doit assurer la cohérence des résultats. Nous avons déjà abordé cela dans le module sur le flush, donc il suffit ici de retenir : même si vous lisez une méthode du repository comme un « simple SELECT », à l’intérieur de la transaction elle peut déclencher un flush des modifications précédentes – et vous verrez du SQL plus tôt que prévu. Ce n’est pas un « caprice de Spring Data », c’est un comportement d’Hibernate.
Signature simple et opérations coûteuses
Les interfaces des repositories ont l’air trompeusement amicales. findAll() – ça semble inoffensif, presque comme « donne-moi tout ce que tu as ». Mais dans une application réelle, « tout » peut signifier des milliers de lignes, un large SELECT, beaucoup d’associations lazy, et finalement à la fois une charge sur la base, une charge mémoire et un effet étrange : « pourquoi ma liste de produits ralentit ».
Le problème ici n’est pas Spring Data. Le problème est que la signature Java ne montre pas le coût de l’opération. Elle montre seulement la forme de l’appel. Et le coût, c’est le SQL, le plan d’exécution et le volume de données. C’est pourquoi nous conservons l’habitude de regarder la trace SQL et les statistiques, surtout quand une méthode semble « de base et inoffensive ».
Le même principe s’applique aux méthodes qui retournent un proxy/référence. Spring Data propose getReferenceById() : ça ressemble à « obtenir l’entité par son id ». Mais sous le capot, ce n’est pas toujours « obtenir des données », c’est parfois « obtenir une référence qui peut être utilisée comme identité ». Tant que vous ne touchez pas aux champs, il peut n’y avoir aucun SQL. Dès que vous y touchez, il peut y avoir un SELECT. Et c’est très bien… si vous comprenez où se trouve la frontière transactionnelle. Sinon, vous obtiendrez le problème lazy classique, qui semblera encore plus mystique : « j’ai simplement pris l’entité par son id, pourquoi ça a planté ? ».
Enfin, le repository peut fournir des opérations de type bulk (par exemple, une suppression en masse). Elles ressemblent à une « optimisation pratique », mais peuvent désynchroniser le contexte de persistance et la base de données si vous continuez à travailler avec des objets managed déjà chargés. Nous analyserons en détail la suppression et le bulk delete un peu plus tard aujourd’hui ; pour l’instant, il importe simplement de retenir la règle générale : le repository n’est pas une garantie de sémantique sûre. Il est une garantie d’API pratique. La sémantique, vous devez la comprendre vous-même.
Lecture des appels repository : trois questions
Quand vous connaissez déjà le modèle interne d’Hibernate, vous pouvez lire le repository de manière ingénieuse : non pas par le nom de la méthode ou par l’intuition, mais à l’aide de trois questions. C’est comme l’habitude de lire le code des autres non pas avec les yeux du style « beau/pas beau », mais avec les yeux du « quels sont les effets de bord ».
Première question – quel état en entrée. Est-ce que vous passez un nouvel objet ? detached ? ou s’agit-il déjà d’une entité managed que vous avez chargée dans la même transaction ? Deuxième question – quel SQL peut apparaître et quand. Ici, le mot « quand » est important : non seulement « il y aura un SELECT », mais aussi « un UPDATE peut apparaître lors du flush ». Troisième question – quel état en sortie : avez-vous obtenu une entité managed, une référence proxy, ou rien du tout, tout en ayant modifié la base (scénarios bulk/batch) ?
Pour que ce ne soit pas abstrait, résumons les questions dans un petit tableau récapitulatif :
| Question posée | Ce que vous essayez de déterminer | Exemple (au niveau de l’intuition) |
|---|---|---|
| Qu’est-ce qui arrive en entrée ? | transient / managed / detached et qui sera propriétaire des modifications | Nouveau Product vs entité chargée dans la transaction courante |
| Quel SQL peut apparaître ? | SELECT, INSERT, UPDATE, DELETE, et à quel moment (immédiatement ou lors du flush) | findById() fait presque toujours un SELECT ; la modification d’un objet managed produit un UPDATE au flush |
| Qu’y aura-t-il en sortie ? | Objet managed ? Proxy ? Le contexte de persistance est-il synchronisé ? | getReferenceById() retourne souvent un proxy ; une suppression de type bulk peut laisser un contexte de persistance périmé |
Cette approche peut sembler « trop adulte » pour un simple CRUD, mais c’est précisément elle qui supprime la magie. Elle transforme Spring Data de « ça a l’air de marcher » en « je sais pourquoi ça marche comme ça, et je peux le prédire grâce au journal SQL ».
Et là apparaît un autre aspect agréable : quand vous vous posez ces questions, vous cessez de débattre avec vos collègues sur le mode « il me semble que save fait un UPDATE immédiatement ». À la place, vous dites : « regardons le SQL et l’état du contexte de persistance ». Dans le monde d’Hibernate, c’est la façon la plus sereine de communiquer. Presque une thérapie, mais avec des logs.
6. Erreurs typiques lors du travail avec les repositories Spring Data
La quasi-totalité des problèmes ne viennent pas du fait que Spring Data soit « mauvais », mais du fait que le développeur a dans la tête un modèle erroné de causes et d’effets. Le repository a l’air de « j’appelle une méthode → j’obtiens un résultat », et il est très facile d’oublier que dessous opèrent une unité de travail, des états d’entités et le cycle de flush.
Erreur n°1 : percevoir le repository comme une annulation du contexte de persistance.
Parfois, le code est écrit comme si chaque appel au repository était une petite opération indépendante « aller à la base et revenir ». On perd alors la compréhension qu’à l’intérieur d’une transaction, un objet peut être managed, qu’un second findById() peut ne pas faire de SQL grâce au cache de premier niveau, et que la modification d’un objet managed entraînera un UPDATE même sans save(). Le remède est de revenir aux trois questions : état → SQL → état.
Erreur n°2 : confondre « lieu d’appel de la méthode » avec « lieu d’apparition du SQL ».
Les débutants cherchent souvent le UPDATE près du save(), car cela semble logique : « la mise à jour doit être là ». Mais Hibernate envoie le SQL lors du flush, et le flush se produit selon des règles, non selon des émotions. Par conséquent, un UPDATE peut apparaître à la fin de la transaction, avant une requête, et parfois à cause d’un effet de bord à un endroit inattendu. Si vous avez cessé de voir EntityManager, cela ne signifie pas que le flush a disparu.
Erreur n°3 : étaler la transaction sur les repositories et perdre l’unité de travail.
Quand une opération métier se décompose en plusieurs appels de repository sans un @Transactional unique sur le service, le code devient « bavard » : beaucoup d’allers-retours, plus de risques de problème lazy, plus d’imprévisibilité. Dans ce mode, vous obtenez souvent soit des requêtes superflues, soit des problèmes de cohérence, soit un diagnostic très étrange. La transaction doit décrire l’opération métier, et non le « fait d’appeler le repository ».
Erreur n°4 : cesser de lire le journal SQL parce que « maintenant c’est Spring Data ».
C’est particulièrement insidieux : le code est devenu plus joli, et on a l’impression que tout est sous contrôle. Mais l’ORM est justement dangereux parce que « ça a l’air de marcher » jusqu’à ce que ça fasse mal. Dans un projet Hibernate-heavy, la trace SQL n’est pas un « mode débogage », mais une manière normale de penser. Le repository ne dispense pas de l’observabilité ; au contraire, il rend l’observabilité encore plus importante car il y a moins de détails dans le code.
Erreur n°5 : considérer que « toutes les méthodes du repository sont aussi sûres les unes que les autres, ce ne sont que des noms différents ».
En pratique, les méthodes de lecture peuvent retourner une entité complètement chargée ou une référence proxy, les méthodes de suppression peuvent être orientées cycle de vie ou de type bulk, et les méthodes de sauvegarde peuvent avoir des effets de bord différents selon l’état de l’entité. Si l’on ne regarde que la signature Java, on peut facilement choisir la mauvaise sémantique et passer ensuite beaucoup de temps à soigner les symptômes. Le repository est un contrat, mais il faut comprendre ce contrat à travers le modèle Hibernate, et non par intuition du genre « ça semble clair d’après le nom ».
GO TO FULL VERSION