📌 Article principal de la série : Java Enterprise : Spring Boot, Jakarta EE et microservices modernes
Ce tutoriel fait partie de la série Java Enterprise. Le tutoriel Créer sa première application Spring Boot pas à pas est le prérequis direct.
Le tutoriel précédent posait les bases de Spring Boot avec un controller REST utilisant des données statiques en mémoire. Dans un projet réel, les données doivent être persistées dans une base de données et survivre aux redémarrages. Ce tutoriel construit une API REST complète avec persistance en base de données en utilisant Spring Data JPA et Hibernate. On part d’un projet Spring Boot existant pour ajouter une entité Produit, un repository Spring Data JPA, un service avec logique métier, et un controller avec gestion propre des erreurs — le tout testé de bout en bout.
Prérequis
- Java 21 LTS et Maven 3.9 installés (voir Créer sa première application Spring Boot pas à pas)
- Projet Spring Boot 3.5.x fonctionnel avec
spring-boot-starter-web - Bases Java : classes, interfaces, annotations, génériques
- Niveau : débutant à intermédiaire
- Temps estimé : 60 à 90 minutes
Étape 1 — Ajouter les dépendances Spring Data JPA et H2
Spring Data JPA est une couche d’abstraction sur JPA (Jakarta Persistence API), l’API de persistence standard Java, avec Hibernate comme implémentation ORM sous-jacente par défaut. H2 est une base de données relationnelle embarquée, écrite en Java, idéale pour le développement et les tests — elle s’initialise en mémoire ou sur fichier, sans installation séparée. En production, on la remplace par PostgreSQL ou MySQL en changeant simplement la configuration.
Ouvrez le fichier pom.xml et ajoutez les deux dépendances dans la section <dependencies> :
<!-- Spring Data JPA + Hibernate (ORM) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Base de données H2 embarquée (scope test/développement) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation des données (Bean Validation / Hibernate Validator) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
La dépendance spring-boot-starter-data-jpa tire automatiquement Spring Data JPA, Hibernate Core, les transactions Spring et le pool de connexions HikariCP. Le scope runtime sur H2 signifie qu’elle est présente à l’exécution mais pas dans le classpath de compilation — bonne pratique pour les dépendances de base de données. Après avoir modifié le pom.xml, lancez mvn dependency:resolve pour télécharger les nouvelles dépendances sans avoir à relancer le projet.
Étape 2 — Configurer la source de données dans application.properties
Spring Boot auto-configure H2 si la dépendance est présente, mais il est recommandé de définir explicitement la configuration pour éviter les surprises. Ouvrez src/main/resources/application.properties et ajoutez :
# Base de données H2 en mémoire (données perdues au redémarrage)
spring.datasource.url=jdbc:h2:mem:produits_db;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# Dialect Hibernate pour H2
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# Créer/mettre à jour le schéma automatiquement depuis les entités JPA
spring.jpa.hibernate.ddl-auto=create-drop
# Afficher les requêtes SQL générées dans les logs (développement uniquement)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Console web H2 (développement uniquement)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
La propriété ddl-auto=create-drop demande à Hibernate de créer automatiquement le schéma de la base de données au démarrage (en se basant sur les entités JPA) et de le détruire à l’arrêt. C’est parfait pour le développement et les tests. En production avec PostgreSQL, cette valeur doit être validate (Hibernate vérifie que le schéma correspond aux entités mais ne le modifie pas) ou désactivée, le schéma étant géré par des outils de migration comme Flyway ou Liquibase. La console H2 accessible à http://localhost:8080/h2-console permet de visualiser et requêter la base de données directement dans un navigateur — pratique pour déboguer.
Étape 3 — Créer l’entité JPA
Une entité JPA est une classe Java annotée @Entity dont les instances sont persistées dans une table de la base de données. Chaque instance correspond à une ligne, chaque champ annoté correspond à une colonne. Créez le sous-package model et la classe Produit :
mkdir -p src/main/java/io/itskillscenter/demo/model
// src/main/java/io/itskillscenter/demo/model/Produit.java
package io.itskillscenter.demo.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "produits")
public class Produit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Le nom ne peut pas être vide")
@Size(max = 200, message = "Le nom ne doit pas dépasser 200 caractères")
@Column(nullable = false)
private String nom;
@NotNull(message = "Le prix est obligatoire")
@Positive(message = "Le prix doit être positif")
@Column(nullable = false)
private Double prix;
@NotNull(message = "Le stock est obligatoire")
@PositiveOrZero(message = "Le stock ne peut pas être négatif")
@Column(nullable = false)
private Integer stock;
@Column(name = "cree_le", updatable = false)
private LocalDateTime creeLe;
@PrePersist
protected void onCreate() {
this.creeLe = LocalDateTime.now();
}
// Constructeurs
public Produit() {}
public Produit(String nom, Double prix, Integer stock) {
this.nom = nom;
this.prix = prix;
this.stock = stock;
}
// Getters et Setters
public Long getId() { return id; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public Double getPrix() { return prix; }
public void setPrix(Double prix) { this.prix = prix; }
public Integer getStock() { return stock; }
public void setStock(Integer stock) { this.stock = stock; }
public LocalDateTime getCreeLe() { return creeLe; }
}
Décortiquons les annotations clés. @Entity marque la classe comme entité JPA persistable. @Table(name = "produits") mappe explicitement sur la table SQL produits — sans cette annotation, Hibernate utilise le nom de la classe en minuscules. @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) configure la clé primaire auto-incrémentée par la base de données (équivalent de SERIAL PostgreSQL ou AUTO_INCREMENT MySQL). @NotBlank, @Positive et @PositiveOrZero sont des contraintes Bean Validation qui seront vérifiées automatiquement par Spring. Le callback @PrePersist sur onCreate() est exécuté automatiquement par Hibernate juste avant l’insertion — permettant de renseigner creeLe sans logique dans le service.
Étape 4 — Créer le repository Spring Data JPA
Un repository Spring Data JPA est une simple interface qui étend JpaRepository. Spring Data génère automatiquement l’implémentation complète à l’exécution, sans une seule ligne de SQL à écrire pour les opérations CRUD standard.
mkdir -p src/main/java/io/itskillscenter/demo/repository
// src/main/java/io/itskillscenter/demo/repository/ProduitRepository.java
package io.itskillscenter.demo.repository;
import io.itskillscenter.demo.model.Produit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ProduitRepository extends JpaRepository<Produit, Long> {
// Méthode dérivée du nom — Spring génère : SELECT * FROM produits WHERE nom ILIKE ?
List<Produit> findByNomContainingIgnoreCase(String nom);
// Produits dont le stock est en dessous d'un seuil
List<Produit> findByStockLessThan(int seuil);
// Requête JPQL personnalisée
@Query("SELECT p FROM Produit p WHERE p.prix BETWEEN :min AND :max ORDER BY p.prix ASC")
List<Produit> findByPrixBetween(double min, double max);
}
L’interface hérite de JpaRepository<Produit, Long> — le premier paramètre est le type de l’entité, le second est le type de la clé primaire. Cette interface héritée fournit instantanément : save(), findById(), findAll(), deleteById(), count(), existsById() et bien d’autres. Les méthodes dérivées comme findByNomContainingIgnoreCase sont analysées par Spring Data qui génère la requête SQL correspondante à partir du nom de la méthode. La méthode findByPrixBetween illustre une requête JPQL (Java Persistence Query Language) personnalisée — syntaxe orientée objet qui opère sur les entités Java plutôt que sur les tables SQL directement.
Étape 5 — Créer le service métier
La couche service isole la logique métier du controller. Elle orchestre les appels au repository, applique les règles métier, et gère les exceptions. Dans Spring, les services sont annotés @Service et injectés dans les controllers par le conteneur IoC.
mkdir -p src/main/java/io/itskillscenter/demo/service
// src/main/java/io/itskillscenter/demo/service/ProduitService.java
package io.itskillscenter.demo.service;
import io.itskillscenter.demo.model.Produit;
import io.itskillscenter.demo.repository.ProduitRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true) // Toutes les méthodes sont en lecture seule par défaut
public class ProduitService {
private final ProduitRepository produitRepository;
// Injection par constructeur (recommandé — immutable, testable)
public ProduitService(ProduitRepository produitRepository) {
this.produitRepository = produitRepository;
}
public List<Produit> listerTous() {
return produitRepository.findAll();
}
public Produit trouverParId(Long id) {
return produitRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Produit introuvable : id=" + id));
}
@Transactional // Override : lecture-écriture pour les mutations
public Produit creer(Produit produit) {
return produitRepository.save(produit);
}
@Transactional
public Produit modifier(Long id, Produit modifications) {
Produit existant = trouverParId(id);
if (modifications.getNom() != null) existant.setNom(modifications.getNom());
if (modifications.getPrix() != null) existant.setPrix(modifications.getPrix());
if (modifications.getStock() != null) existant.setStock(modifications.getStock());
// Pas besoin d'appeler save() : Hibernate détecte les modifications automatiquement
// grâce au mécanisme de "dirty checking" dans le contexte transactionnel
return existant;
}
@Transactional
public void supprimer(Long id) {
if (!produitRepository.existsById(id)) {
throw new EntityNotFoundException("Produit introuvable : id=" + id);
}
produitRepository.deleteById(id);
}
public List<Produit> rechercherParNom(String nom) {
return produitRepository.findByNomContainingIgnoreCase(nom);
}
}
L’annotation @Transactional(readOnly = true) au niveau de la classe optimise toutes les méthodes de lecture (Hibernate désactive le dirty checking, améliore les performances). Les méthodes de mutation (creer, modifier, supprimer) ont leur propre @Transactional qui surcharge la classe pour permettre les écritures. Le commentaire sur modifier explique le mécanisme de « dirty checking » d’Hibernate : dans un contexte transactionnel, Hibernate surveille automatiquement les modifications apportées aux entités chargées et génère les UPDATE SQL nécessaires au commit — sans appel explicite à save(). L’injection par constructeur (plutôt que @Autowired sur le champ) rend la dépendance explicite, le service immutable et plus facile à tester.
Étape 6 — Créer le controller REST avec gestion des erreurs
Le controller orchestre les requêtes HTTP entrantes, délègue au service, et retourne des réponses HTTP appropriées. Un @ControllerAdvice global centralise la gestion des exceptions pour éviter la duplication de code de traitement d’erreur dans chaque controller.
// src/main/java/io/itskillscenter/demo/controller/ProduitController.java
package io.itskillscenter.demo.controller;
import io.itskillscenter.demo.model.Produit;
import io.itskillscenter.demo.service.ProduitService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/produits")
public class ProduitController {
private final ProduitService produitService;
public ProduitController(ProduitService produitService) {
this.produitService = produitService;
}
@GetMapping
public List<Produit> listerTous() {
return produitService.listerTous();
}
@GetMapping("/{id}")
public Produit obtenirParId(@PathVariable Long id) {
return produitService.trouverParId(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Produit creer(@Valid @RequestBody Produit produit) {
return produitService.creer(produit);
}
@PutMapping("/{id}")
public Produit modifier(@PathVariable Long id, @RequestBody Produit modifications) {
return produitService.modifier(id, modifications);
}
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> supprimer(@PathVariable Long id) {
produitService.supprimer(id);
return ResponseEntity.ok(Map.of("message", "Produit #" + id + " supprimé"));
}
@GetMapping("/recherche")
public List<Produit> rechercher(@RequestParam String nom) {
return produitService.rechercherParNom(nom);
}
}
L’annotation @Valid sur le paramètre @RequestBody de la méthode creer déclenche la validation Bean Validation sur l’entité reçue — si un champ ne respecte pas les contraintes (@NotBlank, @Positive, etc.), Spring retourne automatiquement une réponse 400 avec le détail des violations. @ResponseStatus(HttpStatus.CREATED) retourne HTTP 201 au lieu du 200 par défaut pour les créations. ResponseEntity<T> sur supprimer permet de contrôler précisément le statut et le corps de la réponse. Ajoutez maintenant le gestionnaire d’exceptions global :
// src/main/java/io/itskillscenter/demo/controller/GlobalExceptionHandler.java
package io.itskillscenter.demo.controller;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, String> handleNotFound(EntityNotFoundException ex) {
return Map.of("erreur", ex.getMessage(), "statut", "404");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> erreurs = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String champ = ((FieldError) error).getField();
String message = error.getDefaultMessage();
erreurs.put(champ, message);
});
return Map.of("erreurs", erreurs, "statut", "400");
}
}
Le @RestControllerAdvice crée un gestionnaire d’exceptions global qui intercepte les exceptions levées dans n’importe quel controller. EntityNotFoundException (levée par le service quand une entité n’est pas trouvée) est traduite en réponse HTTP 404 avec un message JSON structuré. MethodArgumentNotValidException (levée automatiquement par Spring quand la validation @Valid échoue) est traduite en une map des champs invalides avec leurs messages d’erreur. Cette approche centralise toute la gestion d’erreurs en un seul endroit, éliminant la duplication de blocs try/catch dans les controllers.
Étape 7 — Pré-charger des données au démarrage
Pour tester l’API sans avoir à créer des données manuellement à chaque redémarrage, Spring Boot permet d’initialiser la base au démarrage via un composant CommandLineRunner :
// Dans DemoSpringApplication.java — ajouter la méthode suivante
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import io.itskillscenter.demo.model.Produit;
import io.itskillscenter.demo.repository.ProduitRepository;
// Dans la classe DemoSpringApplication :
@Bean
CommandLineRunner initData(ProduitRepository repo) {
return args -> {
repo.save(new Produit("Laptop ThinkPad X1", 850000.0, 10));
repo.save(new Produit("Souris USB ergonomique", 15000.0, 50));
repo.save(new Produit("Clé USB 64GB", 8000.0, 200));
repo.save(new Produit("Casque Bluetooth", 35000.0, 15));
System.out.println("Base de données initialisée avec " + repo.count() + " produits.");
};
}
Le bean CommandLineRunner est exécuté automatiquement par Spring Boot après l’initialisation complète du contexte applicatif et de la base de données. Le lambda reçoit les arguments de la ligne de commande (non utilisés ici). Les 4 appels à repo.save() persistent les produits initiaux dans H2 — disponibles immédiatement via l’API. La ligne System.out.println confirme dans les logs que l’initialisation s’est bien passée.
Étape 8 — Lancer et tester l’API complète
Démarrez l’application et testez l’ensemble des endpoints avec curl, en observant la persistance des données :
mvn spring-boot:run
# Dans un autre terminal :
BASE="http://localhost:8080/api/produits"
echo "--- Lister tous les produits ---"
curl -s $BASE | python3 -m json.tool
echo "--- Créer un produit ---"
curl -s -X POST $BASE \
-H "Content-Type: application/json" \
-d '{"nom":"Moniteur 24 pouces","prix":180000,"stock":8}' \
| python3 -m json.tool
echo "--- Rechercher par nom ---"
curl -s "$BASE/recherche?nom=laptop" | python3 -m json.tool
echo "--- Tester la validation (prix négatif) ---"
curl -s -X POST $BASE \
-H "Content-Type: application/json" \
-d '{"nom":"Test","prix":-500,"stock":1}' \
| python3 -m json.tool
echo "--- Supprimer produit #3 ---"
curl -s -X DELETE $BASE/3 | python3 -m json.tool
echo "--- Tester le 404 ---"
curl -s $BASE/999 | python3 -m json.tool
La commande de listing doit retourner les 4 produits préchargés. La création retourne le nouveau produit avec son id généré (5) et son creeLe renseigné. La recherche par nom « laptop » retourne uniquement « Laptop ThinkPad X1 » (recherche insensible à la casse). Le test de validation retourne une réponse 400 avec {"erreurs": {"prix": "Le prix doit être positif"}, "statut": "400"}. La suppression retourne un message de confirmation. Le 404 retourne {"erreur": "Produit introuvable : id=999", "statut": "404"}. Si vous ouvrez http://localhost:8080/h2-console dans un navigateur et vous connectez avec l’URL JDBC jdbc:h2:mem:produits_db, vous pouvez voir directement la table PRODUITS et exécuter des requêtes SQL pour valider l’état de la base.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
javax.persistence vs jakarta.persistence |
Ancien code Spring Boot 2.x copié | Spring Boot 3.x utilise exclusivement jakarta.* — remplacer tous les imports |
Entité non trouvée, @Valid silencieux |
Dépendance spring-boot-starter-validation manquante |
Ajouter la dépendance dans pom.xml |
LazyInitializationException en production |
Accès à une relation lazy hors contexte transactionnel | Utiliser @Transactional dans le service ou charger eagerly avec JOIN FETCH |
H2 console inaccessible malgré spring.h2.console.enabled=true |
Spring Security bloque l’accès (si ajouté) | Autoriser /h2-console/** dans la config Security en dev |
| Boucle infinie JSON lors de relations bidirectionnelles | Jackson sérialise récursivement les relations | Utiliser @JsonIgnore ou des DTOs distincts pour les réponses |
Tutoriels frères
- Créer sa première application Spring Boot pas à pas — Prérequis : installation Java/Maven, premier controller REST statique
Pour aller plus loin
- 🔝 Retour à l’article principal : Java Enterprise : Spring Boot, Jakarta EE et microservices modernes
- Documentation Spring Data JPA
- Documentation Hibernate ORM 6.6
- Spécification Jakarta Persistence 3.2
- Flyway — migrations de schéma SQL en production
FAQ
- Comment passer de H2 à PostgreSQL en production ?
- Remplacez la dépendance H2 par le driver PostgreSQL (
org.postgresql:postgresql), mettez à jourspring.datasource.url(jdbc:postgresql://host:5432/dbname), changezspring.datasource.usernameetspring.datasource.password, et passezddl-autoàvalidateen production. Utilisez des profils Spring (application-prod.properties) pour séparer les configurations dev et prod. - Quelle est la différence entre
save()etsaveAndFlush()? save()persiste l’entité dans le contexte de persistence (mémoire) et génère le SQL au commit de la transaction.saveAndFlush()force le flush immédiat du SQL vers la base de données dans la transaction courante. Utile quand vous avez besoin de voir les changements immédiatement (tests de contraintes DB, requêtes natives dans la même transaction). En conditions normales,save()est préférable.- Doit-on utiliser des DTOs ou exposer directement les entités JPA ?
- Il est fortement recommandé d’utiliser des DTOs (Data Transfer Objects) distincts pour les entrées et sorties de l’API. Exposer directement les entités JPA crée un couplage fort entre la couche persistance et la couche API, expose des détails internes (relations lazy, annotations Hibernate), et peut provoquer des boucles de sérialisation JSON. Des bibliothèques comme MapStruct automatisent la conversion entité ↔ DTO avec un minimum de boilerplate.