Spring Framework est le framework Java d’entreprise le plus utilisé au monde. Il a été créé par Rod Johnson en 2003 pour résoudre la complexité des applications Java EE de l’époque. Son cœur repose sur deux principes fondamentaux : l’Inversion de Contrôle (IoC) et l’Injection de Dépendances (DI).
Spring Boot est né en 2014 pour rendre Spring plus accessible. Il adopte le principe convention over configuration : plutôt que de configurer des dizaines de fichiers XML, Spring Boot prend des décisions intelligentes par défaut. Vous démarrez une application Java complète en quelques minutes.
Avant Spring Boot (2003-2013) : Avec Spring Boot (2014+) : ───────────────────────────── ─────────────────────────── - Fichiers XML de config - application.properties - Déploiement sur Tomcat externe - Tomcat embarqué - WAR complexes - JAR autonome - Des heures de configuration - Prêt en 5 minutes - web.xml obligatoire - Zéro XML
Spring Boot n’est pas un remplacement de Spring — c’est une couche au-dessus qui simplifie la configuration et le démarrage. Tout ce que Spring sait faire, Spring Boot le sait aussi, mais plus simplement.
Spring Framework (le socle) │ ├── Spring Core — IoC Container, Injection de dépendances ├── Spring MVC — Applications web, REST API ├── Spring Data — Accès aux données (JDBC, JPA, MongoDB...) ├── Spring Security — Authentification et autorisation ├── Spring Batch — Traitement par lots └── Spring Cloud — Microservices, cloud natif Spring Boot (la simplification) │ ├── Auto-configuration — devine ce dont vous avez besoin ├── Starters — dépendances pré-packagées ├── Actuator — monitoring de l'application └── CLI — outil ligne de commande
Sans Spring Boot — configuration minimale pour une API REST :
- pom.xml avec 15+ dépendances manuelles - web.xml (descriptor de déploiement) - ApplicationContext.xml (beans Spring) - Dispatcher-servlet.xml (configuration MVC) - Déploiement sur un Tomcat externe = Des heures de configuration avant d'écrire la première ligne de code métier
Avec Spring Boot — même API REST :
@SpringBootApplication public class MonApplication { public static void main(String[] args) { SpringApplication.run(MonApplication.class, args); } } @RestController class BonjourController { @GetMapping("/bonjour") String bonjour() { return "Bonjour les Cobolistes !"; } }
mvn spring-boot:run → Application démarrée en 2 secondes sur http://localhost:8080
Ce cours utilise Spring Boot 3.2.x avec Java 17. Les packages sont jakarta.* (pas javax.*). Si vous voyez du code avec javax.servlet, c’est une version ancienne (Spring Boot 2.x).
jakarta.*
javax.*
javax.servlet
La façon la plus rapide de créer un projet Spring Boot est d’utiliser Spring Initializr : https://start.spring.io
Paramètres à choisir :
Project : Maven Language : Java Spring Boot: 3.2.x Group : fr.formation Artifact : mon-application Name : mon-application Description: Mon premier projet Spring Boot Package : fr.formation.monapplication Packaging : Jar Java : 17 Dependencies (exemples) : ✅ Spring Web — pour faire une API REST ✅ Spring Data JPA — pour JPA / Hibernate ✅ PostgreSQL Driver — driver de base de données ✅ Lombok — pour réduire le code boilerplate ✅ Spring Boot DevTools — rechargement automatique en dev
File
New
Project
Spring Initializr
Create
Ou via le menu New Project → Spring Boot :
New Project
Spring Boot
File → Open
Spring Starter Project
File → Import → Maven → Existing Maven Projects
Installer Spring Tools 4 dans Eclipse : Help → Eclipse Marketplace → rechercher “Spring Tools” → Spring Tools 4 → Install
Help
Eclipse Marketplace
Spring Tools 4
Install
mon-application/ ├── pom.xml Configuration Maven ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── fr/formation/monapplication/ │ │ │ ├── MonApplication.java Classe principale │ │ │ ├── controller/ │ │ │ │ └── ProduitController.java │ │ │ ├── service/ │ │ │ │ └── ProduitService.java │ │ │ ├── repository/ │ │ │ │ └── ProduitRepository.java │ │ │ └── model/ │ │ │ └── Produit.java │ │ └── resources/ │ │ ├── application.properties Configuration │ │ ├── static/ Fichiers statiques (CSS, JS) │ │ └── templates/ Templates Thymeleaf │ └── test/ │ └── java/ │ └── fr/formation/monapplication/ │ └── MonApplicationTests.java └── mvnw Maven Wrapper (exécuter sans Maven installé)
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <!-- Le parent Spring Boot gère les versions de TOUTES les dépendances. Vous n'avez plus à spécifier les versions individuellement. Si spring-boot-starter-parent est en 3.2.3, il sait quelle version de Jackson, Hibernate, JUnit... utiliser — et toutes sont compatibles. --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.3</version> <relativePath/> </parent> <groupId>fr.formation</groupId> <artifactId>mon-application</artifactId> <version>0.0.1-SNAPSHOT</version> <name>mon-application</name> <properties> <java.version>17</java.version> </properties> <dependencies> <!-- Les "starters" sont des méta-dépendances qui regroupent tout ce dont vous avez besoin pour une fonctionnalité. spring-boot-starter-web inclut : Tomcat embarqué, Spring MVC, Jackson (JSON), validation... --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <!-- Pas de <version> — héritée du parent --> </dependency> <!-- Spring Data JPA + Hibernate --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Driver PostgreSQL --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok — génère getters/setters/constructeurs --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Rechargement automatique en développement --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <!-- Tests — inclut JUnit 5, Mockito, AssertJ, Spring Test --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <!-- Plugin Spring Boot — permet de lancer l'app avec mvn spring-boot:run --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>
package fr.formation.monapplication; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * Point d'entrée de l'application Spring Boot. * * @SpringBootApplication est une annotation composite qui regroupe : * * @Configuration — cette classe est une source de beans Spring * @EnableAutoConfiguration — active la configuration automatique de Spring Boot * @ComponentScan — scanne le package courant et ses sous-packages * pour détecter les @Component, @Service, @Repository... */ @SpringBootApplication public class MonApplication { public static void main(String[] args) { // Lance le contexte Spring, configure Tomcat, démarre l'application SpringApplication.run(MonApplication.class, args); } }
Quand vous lancez SpringApplication.run(...), voici ce qui se passe :
SpringApplication.run(...)
application.properties
# Via Maven Wrapper (sans Maven installé) ./mvnw spring-boot:run # Linux/Mac mvnw.cmd spring-boot:run # Windows # Via Maven classique mvn spring-boot:run # Via IntelliJ — bouton vert sur la classe principale # Via Eclipse — Run As → Spring Boot App # Générer un JAR exécutable mvn package java -jar target/mon-application-0.0.1-SNAPSHOT.jar
Objectif : Créer une application Spring Boot qui répond à des requêtes HTTP.
Allez sur https://start.spring.io et créez un projet avec :
fr.formation
bonjour-app
Spring Web
Spring Boot DevTools
Importez dans IntelliJ ou Eclipse.
Créez la classe BonjourController :
BonjourController
package fr.formation.bonjourapp.controller; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api") public class BonjourController { @GetMapping("/bonjour") public String bonjour() { return "Bonjour depuis Spring Boot !"; } @GetMapping("/bonjour/{nom}") public String bonjourPersonne(@PathVariable String nom) { return "Bonjour " + nom + " ! Bienvenue dans Spring Boot."; } @GetMapping("/version") public String version() { return "Spring Boot 3.2 — Java " + System.getProperty("java.version"); } }
Lancez l’application et testez dans votre navigateur :
http://localhost:8080/api/bonjour
http://localhost:8080/api/bonjour/Alicia
http://localhost:8080/api/version
L’Inversion de Contrôle (IoC) est le principe selon lequel ce n’est plus votre code qui crée ses dépendances — c’est le framework qui les injecte.
// SANS IoC — couplage fort, difficile à tester class CommandeService { // On crée NOUS-MÊMES la dépendance private ProduitRepository repo = new ProduitRepositoryImpl(); // Problème : impossible de remplacer par un mock pour tester // Problème : si ProduitRepositoryImpl change de constructeur, tout casse } // AVEC IoC — Spring crée et injecte les dépendances @Service class CommandeService { // Spring NOUS FOURNIT la dépendance — on déclare juste qu'on en a besoin private final ProduitRepository repo; // Spring appelle ce constructeur et passe l'implémentation appropriée public CommandeService(ProduitRepository repo) { this.repo = repo; } }
Imaginez Spring comme un livreur : vous déclarez ce dont vous avez besoin, il le livre. Vous ne savez pas (et n’avez pas à savoir) comment il l’a fabriqué.
Spring reconnaît automatiquement certaines annotations pour créer des beans :
// @Component — annotation générique, "c'est un bean Spring" @Component public class CalculateurTaxe { public double calculer(double montant) { return montant * 0.20; // TVA 20% } } // @Service — pour la couche service (logique métier) // Sémantiquement identique à @Component, mais plus expressif @Service public class CommandeService { // Logique métier ici } // @Repository — pour la couche d'accès aux données // Ajoute la traduction automatique des exceptions de BDD @Repository public class ProduitRepositoryImpl implements ProduitRepository { // Accès à la base de données ici } // @Controller / @RestController — pour la couche web @RestController // = @Controller + @ResponseBody public class ProduitController { // Endpoints HTTP ici } // @Configuration — pour les classes de configuration @Configuration public class AppConfig { @Bean // Déclare un bean manuellement public ObjectMapper objectMapper() { return new ObjectMapper(); } }
La hiérarchie des stéréotypes :
@Component ├── @Service (couche métier) ├── @Repository (couche données) └── @Controller (couche web) └── @RestController (REST = @Controller + @ResponseBody)
Spring Boot supporte trois modes d’injection de dépendances :
@Service public class CommandeService { private final ProduitRepository produitRepo; private final FactureService factureService; // ════════════════════════════════════════════════════════ // MODE 1 — Injection par constructeur (RECOMMANDÉ) // ════════════════════════════════════════════════════════ // Avantages : // - Dépendances obligatoires clairement déclarées // - Fonctionne pour les tests (new CommandeService(mock, mock)) // - Pas d'annotation @Autowired nécessaire (1 seul constructeur) // - Champs final (immutabilité garantie) public CommandeService(ProduitRepository produitRepo, FactureService factureService) { this.produitRepo = produitRepo; this.factureService = factureService; } } // ════════════════════════════════════════════════════════ // MODE 2 — Injection par champ (DÉCONSEILLÉ) // ════════════════════════════════════════════════════════ @Service public class CommandeService { @Autowired // Spring injecte directement dans le champ par réflexion private ProduitRepository produitRepo; // Problème : impossible de tester sans Spring (champ private) // Problème : le champ n'est pas final (peut être réassigné) } // ════════════════════════════════════════════════════════ // MODE 3 — Injection par setter (OPTIONNELLE) // ════════════════════════════════════════════════════════ @Service public class CommandeService { private ProduitRepository produitRepo; @Autowired // Approprié pour les dépendances optionnelles public void setProduitRepo(ProduitRepository repo) { this.produitRepo = repo; } }
Mise en garde lorsque vous utilisez Lombok avec Eclipse, il se peut que l’application lancée depuis Eclipse ne vous retourne pas ce qu’elle devrait en théorie retourner, tout simplement parce que les méthodes getXXX() et setXXX() sont ignorées. Ce qui n’est pas le cas avec IntelliJ. Pour Eclipse, il faut ajouter un plugin depuis le MarkerPlace. Cependant, vous ne pourrez pas le faire depuis le VDI !
getXXX() et setXXX()
Avec Lombok, l’injection par constructeur devient triviale :
import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor // Lombok génère le constructeur avec tous les champs final public class CommandeService { private final ProduitRepository produitRepo; // final = inclus dans le constructeur private final FactureService factureService; // idem private final NotificationService notifService; // idem // Lombok génère automatiquement : // public CommandeService(ProduitRepository produitRepo, // FactureService factureService, // NotificationService notifService) { ... } }
// Toutes les annotations Lombok utiles @Getter // Génère tous les getters @Setter // Génère tous les setters @NoArgsConstructor // Constructeur sans argument @AllArgsConstructor // Constructeur avec tous les arguments @RequiredArgsConstructor // Constructeur avec les champs final @Builder // Pattern Builder @ToString // méthode toString() @EqualsAndHashCode // equals() et hashCode() @Data // = @Getter + @Setter + @NoArgsConstructor + @ToString + @EqualsAndHashCode @Slf4j // Crée un champ log = LoggerFactory.getLogger(...)
// @Scope("singleton") — défaut : UN SEUL bean pour tout l'application @Service @Scope("singleton") // Optionnel — c'est le défaut public class CacheService { } // @Scope("prototype") — NOUVELLE instance à chaque injection @Component @Scope("prototype") public class RequeteContext { } // @RequestScope — une instance par requête HTTP @Component @RequestScope public class PanierUtilisateur { } // @SessionScope — une instance par session HTTP @Component @SessionScope public class SessionUtilisateur { }
Objectif : Comprendre l’injection de dépendances en pratique.
// 1. Créez une interface public interface GreetingService { String saluer(String nom); } // 2. Créez deux implémentations @Service public class FrenchGreetingService implements GreetingService { @Override public String saluer(String nom) { return "Bonjour " + nom + " !"; } } @Service @Primary // Implémentation par défaut si plusieurs existent public class EnglishGreetingService implements GreetingService { @Override public String saluer(String nom) { return "Hello " + nom + "!"; } } // 3. Injectez dans un contrôleur @RestController @RequestMapping("/salut") @RequiredArgsConstructor public class GreetingController { private final GreetingService greetingService; @GetMapping("/{nom}") public String saluer(@PathVariable String nom) { return greetingService.saluer(nom); } }
Testez et observez quelle implémentation est utilisée. Puis essayez avec @Qualifier("frenchGreetingService").
@Qualifier("frenchGreetingService")
Client (navigateur / Postman / curl) │ │ Requête HTTP ▼ DispatcherServlet (front controller — géré par Spring) │ │ Route vers le bon controller ▼ @RestController │ │ Appel du service ▼ @Service (logique métier) │ │ Accès aux données ▼ @Repository / Base de données │ │ Réponse remonte ▼ @RestController → JSON (via Jackson) │ ▼ Client reçoit la réponse HTTP
@RestController @RequestMapping("/api/produits") // Préfixe commun à tous les endpoints public class ProduitController { // GET /api/produits @GetMapping public List<Produit> listerTous() { ... } // GET /api/produits/42 @GetMapping("/{id}") public Produit trouverParId(@PathVariable Long id) { ... } // GET /api/produits?categorie=ELECTRONIQUE&maxPrix=500 @GetMapping("/recherche") public List<Produit> rechercher( @RequestParam String categorie, @RequestParam(required = false, defaultValue = "9999") Double maxPrix) { ... } // POST /api/produits @PostMapping public ResponseEntity<Produit> creer(@RequestBody Produit produit) { ... } // PUT /api/produits/42 @PutMapping("/{id}") public Produit mettreAJour(@PathVariable Long id, @RequestBody Produit produit) { ... } // PATCH /api/produits/42/prix @PatchMapping("/{id}/prix") public Produit mettreAJourPrix(@PathVariable Long id, @RequestBody Double nouveauPrix) { ... } // DELETE /api/produits/42 @DeleteMapping("/{id}") public ResponseEntity<Void> supprimer(@PathVariable Long id) { ... } }
@RestController @RequestMapping("/api/produits") @RequiredArgsConstructor public class ProduitController { private final ProduitService produitService; // ResponseEntity<T> permet de contrôler : // - Le code de statut HTTP (200, 201, 404, etc.) // - Les en-têtes HTTP // - Le corps de la réponse @GetMapping("/{id}") public ResponseEntity<Produit> trouverParId(@PathVariable Long id) { return produitService.trouverParId(id) .map(produit -> ResponseEntity.ok(produit)) // 200 OK .orElse(ResponseEntity.notFound().build()); // 404 Not Found } @PostMapping public ResponseEntity<Produit> creer(@RequestBody Produit produit) { Produit sauvegarde = produitService.sauvegarder(produit); // 201 Created avec l'URL de la ressource créée dans l'en-tête Location URI location = URI.create("/api/produits/" + sauvegarde.getId()); return ResponseEntity.created(location).body(sauvegarde); } @DeleteMapping("/{id}") public ResponseEntity<Void> supprimer(@PathVariable Long id) { if (!produitService.exists(id)) { return ResponseEntity.notFound().build(); // 404 } produitService.supprimer(id); return ResponseEntity.noContent().build(); // 204 No Content } // Codes de statut courants : // 200 OK → ResponseEntity.ok(body) // 201 Created → ResponseEntity.created(uri).body(body) // 204 No Content → ResponseEntity.noContent().build() // 400 Bad Request → ResponseEntity.badRequest().body(message) // 404 Not Found → ResponseEntity.notFound().build() // 500 Server Error → ResponseEntity.internalServerError().body(message) }
// Exposer directement l'entité — dangereux ! // On expose des champs internes (ex: mot de passe haché) // On couple l'API au modèle de données @GetMapping("/{id}") public Utilisateur trouverUtilisateur(@PathVariable Long id) { return utilisateurService.trouver(id); // Expose le mot de passe haché ! } // Utiliser des DTOs (Data Transfer Objects) // Entité (modèle de données) @Entity public class Utilisateur { private Long id; private String email; private String motDePasseHash; // Ne doit jamais être exposé ! private String nom; private LocalDateTime dateCreation; } // DTO de réponse — seulement ce que l'API doit retourner public record UtilisateurReponse( Long id, String email, String nom ) { // Factory method — convertit l'entité en DTO public static UtilisateurReponse depuis(Utilisateur u) { return new UtilisateurReponse(u.getId(), u.getEmail(), u.getNom()); } } // DTO de requête — ce que le client envoie pour créer un utilisateur public record CreerUtilisateurRequete( @NotBlank String email, @NotBlank @Size(min = 8) String motDePasse, @NotBlank String nom ) {} // Dans le contrôleur @PostMapping public ResponseEntity<UtilisateurReponse> creer( @RequestBody @Valid CreerUtilisateurRequete requete) { Utilisateur u = utilisateurService.creer(requete); return ResponseEntity.created(...) .body(UtilisateurReponse.depuis(u)); // ← DTO, pas l'entité }
Lorsque l’on a pas d’outil, on peut utiliser curl pour nos requêtes HTTP en ligne de commande (génralement sous Linux).
# GET — Lister tous les produits curl http://localhost:8080/api/produits # GET — Trouver par ID curl http://localhost:8080/api/produits/1 # GET — Avec paramètres de requête curl "http://localhost:8080/api/produits/recherche?categorie=ELECTRONIQUE&maxPrix=500" # POST — Créer un produit (envoyer du JSON) curl -X POST http://localhost:8080/api/produits \ -H "Content-Type: application/json" \ -d '{"nom":"Laptop","prix":999.99,"stock":10}' # PUT — Mettre à jour curl -X PUT http://localhost:8080/api/produits/1 \ -H "Content-Type: application/json" \ -d '{"nom":"Laptop Pro","prix":1299.99,"stock":5}' # DELETE curl -X DELETE http://localhost:8080/api/produits/1 # Voir les en-têtes de réponse curl -v http://localhost:8080/api/produits/1
Objectif : Créer une API REST complète pour gérer des livres ou autre chose si vous en avez ras-le-bol des livres !
// Modèle (simple, sans base de données pour l'instant) public record Livre( Long id, String titre, String auteur, Double prix, Integer stock ) {} // Service avec stockage en mémoire @Service public class LivreService { private final Map<Long, Livre> livres = new ConcurrentHashMap<>(); private final AtomicLong compteur = new AtomicLong(); public List<Livre> trouverTous() { return new ArrayList<>(livres.values()); } public Optional<Livre> trouverParId(Long id) { return Optional.ofNullable(livres.get(id)); } public Livre sauvegarder(Livre livre) { Long id = compteur.incrementAndGet(); Livre avecId = new Livre(id, livre.titre(), livre.auteur(), livre.prix(), livre.stock()); livres.put(id, avecId); return avecId; } public boolean supprimer(Long id) { return livres.remove(id) != null; } }
Créez le contrôleur REST complet (GET, POST, PUT, DELETE) et testez avec curl, Postman, Swagger ou Bruno !
GET
POST
PUT
DELETE
Avant d’utiliser JPA/Hibernate (qui est une abstraction), il est important de comprendre comment Spring accède directement à la base de données via JDBC. Cela vous donne :
Prérequis : PostgreSQL installé et démarré.
-- Créer la base de données CREATE DATABASE spring_demo WITH ENCODING 'UTF8' LC_COLLATE='fr_FR.UTF-8' LC_CTYPE='fr_FR.UTF-8'; -- Créer un utilisateur dédié (bonne pratique) CREATE USER spring_user WITH PASSWORD 'spring_password'; GRANT ALL PRIVILEGES ON DATABASE spring_demo TO spring_user; -- Se connecter et créer la table \c spring_demo CREATE TABLE produit ( id BIGSERIAL PRIMARY KEY, nom VARCHAR(200) NOT NULL, description TEXT, prix DECIMAL(10, 2) NOT NULL, stock INTEGER NOT NULL DEFAULT 0, categorie VARCHAR(50), actif BOOLEAN NOT NULL DEFAULT TRUE, date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Données de test INSERT INTO produit (nom, description, prix, stock, categorie) VALUES ('Laptop Dell XPS', 'Ordinateur portable 15 pouces', 1299.99, 10, 'ELECTRONIQUE'), ('Souris Logitech MX', 'Souris ergonomique sans fil', 89.99, 25, 'ELECTRONIQUE'), ('T-Shirt coton bio', 'T-shirt blanc 100% bio', 24.90, 50, 'VETEMENT'), ('Clean Code', 'Livre de Robert C. Martin', 35.00, 8, 'LIBRAIRIE');
Dépendances Maven pour Spring JDBC :
<dependencies> <!-- Spring JDBC — JdbcTemplate, NamedParameterJdbcTemplate --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- Driver PostgreSQL --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> </dependencies>
application.properties :
# ════════════════════════════════════════════════════════ # Configuration PostgreSQL # ════════════════════════════════════════════════════════ spring.datasource.url=jdbc:postgresql://localhost:5432/spring_demo spring.datasource.username=spring_user spring.datasource.password=spring_password spring.datasource.driver-class-name=org.postgresql.Driver # Pool de connexions HikariCP (inclus dans Spring Boot) spring.datasource.hikari.maximum-pool-size=10 spring.datasource.hikari.minimum-idle=2 spring.datasource.hikari.connection-timeout=30000 # Afficher le SQL dans les logs (développement seulement) logging.level.org.springframework.jdbc.core=DEBUG
JdbcTemplate est la classe principale de Spring JDBC. Elle simplifie énormément le code JDBC brut :
JdbcTemplate
// JDBC brut — beaucoup de code répétitif et fragile Connection conn = dataSource.getConnection(); try { PreparedStatement ps = conn.prepareStatement("SELECT * FROM produit WHERE id = ?"); ps.setLong(1, id); ResultSet rs = ps.executeQuery(); if (rs.next()) { Produit p = new Produit(); p.setId(rs.getLong("id")); p.setNom(rs.getString("nom")); // ... 10 lignes de plus } } catch (SQLException e) { // gestion d'erreur } finally { // fermeture des ressources } // Spring JdbcTemplate — simple et sûr Produit p = jdbcTemplate.queryForObject( "SELECT * FROM produit WHERE id = ?", produitRowMapper, id );
// Méthode 1 — RowMapper lambda (Java 8+) — pour les classes simples RowMapper<Produit> produitRowMapper = (rs, rowNum) -> { Produit p = new Produit(); p.setId(rs.getLong("id")); p.setNom(rs.getString("nom")); p.setDescription(rs.getString("description")); p.setPrix(rs.getBigDecimal("prix")); p.setStock(rs.getInt("stock")); p.setCategorie(rs.getString("categorie")); p.setActif(rs.getBoolean("actif")); return p; }; // Méthode 2 — Classe RowMapper réutilisable @Component public class ProduitRowMapper implements RowMapper<Produit> { @Override public Produit mapRow(ResultSet rs, int rowNum) throws SQLException { return Produit.builder() .id(rs.getLong("id")) .nom(rs.getString("nom")) .description(rs.getString("description")) .prix(rs.getBigDecimal("prix")) .stock(rs.getInt("stock")) .categorie(rs.getString("categorie")) .actif(rs.getBoolean("actif")) .build(); } } // Méthode 3 — BeanPropertyRowMapper (automatique si noms cohérents) // Mappe automatiquement nom_colonne → nomChamp (snake_case → camelCase) RowMapper<Produit> mapper = new BeanPropertyRowMapper<>(Produit.class);
package fr.formation.demo.repository; import fr.formation.demo.model.Produit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import java.sql.PreparedStatement; import java.sql.Statement; import java.util.List; import java.util.Optional; /** * Repository Spring JDBC pour la table "produit". * Utilise JdbcTemplate — accès direct au SQL. */ @Repository @RequiredArgsConstructor @Slf4j public class ProduitJdbcRepository { private final JdbcTemplate jdbcTemplate; // ── RowMapper réutilisable ─────────────────────────────────────────────── private final RowMapper<Produit> ROW_MAPPER = (rs, rowNum) -> Produit.builder() .id(rs.getLong("id")) .nom(rs.getString("nom")) .description(rs.getString("description")) .prix(rs.getBigDecimal("prix")) .stock(rs.getInt("stock")) .categorie(rs.getString("categorie")) .actif(rs.getBoolean("actif")) .build(); // ── SELECT ─────────────────────────────────────────────────────────────── public List<Produit> findAll() { log.debug("Récupération de tous les produits"); return jdbcTemplate.query( "SELECT * FROM produit WHERE actif = TRUE ORDER BY nom", ROW_MAPPER ); } public Optional<Produit> findById(Long id) { log.debug("Recherche du produit id={}", id); List<Produit> resultats = jdbcTemplate.query( "SELECT * FROM produit WHERE id = ?", ROW_MAPPER, id ); return resultats.isEmpty() ? Optional.empty() : Optional.of(resultats.get(0)); } public List<Produit> findByCategorie(String categorie) { return jdbcTemplate.query( "SELECT * FROM produit WHERE categorie = ? AND actif = TRUE ORDER BY nom", ROW_MAPPER, categorie ); } public List<Produit> findByNomContaining(String terme) { // ILIKE pour recherche insensible à la casse en PostgreSQL return jdbcTemplate.query( "SELECT * FROM produit WHERE nom ILIKE ? AND actif = TRUE", ROW_MAPPER, "%" + terme + "%" ); } public long count() { Long count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM produit WHERE actif = TRUE", Long.class ); return count != null ? count : 0L; } public Double findAveragePrix() { return jdbcTemplate.queryForObject( "SELECT AVG(prix) FROM produit WHERE actif = TRUE", Double.class ); } // ── INSERT ─────────────────────────────────────────────────────────────── public Produit save(Produit produit) { if (produit.getId() == null) { return insert(produit); } else { update(produit); return produit; } } private Produit insert(Produit produit) { // KeyHolder pour récupérer l'ID généré par BIGSERIAL KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(conn -> { PreparedStatement ps = conn.prepareStatement( "INSERT INTO produit (nom, description, prix, stock, categorie, actif) " + "VALUES (?, ?, ?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS ); ps.setString(1, produit.getNom()); ps.setString(2, produit.getDescription()); ps.setBigDecimal(3, produit.getPrix()); ps.setInt(4, produit.getStock() != null ? produit.getStock() : 0); ps.setString(5, produit.getCategorie()); ps.setBoolean(6, produit.isActif()); return ps; }, keyHolder); // Récupérer l'ID généré et l'affecter à l'objet Long idGenere = keyHolder.getKeyAs(Long.class); produit.setId(idGenere); log.info("Produit inséré avec id={}", idGenere); return produit; } private void update(Produit produit) { int rowsAffected = jdbcTemplate.update( "UPDATE produit SET nom=?, description=?, prix=?, stock=?, categorie=?, actif=? " + "WHERE id=?", produit.getNom(), produit.getDescription(), produit.getPrix(), produit.getStock(), produit.getCategorie(), produit.isActif(), produit.getId() ); log.info("Produit id={} mis à jour ({} ligne(s) affectée(s))", produit.getId(), rowsAffected); } // ── DELETE ─────────────────────────────────────────────────────────────── public boolean deleteById(Long id) { // Soft delete — on désactive plutôt que de supprimer int rows = jdbcTemplate.update( "UPDATE produit SET actif = FALSE WHERE id = ?", id); return rows > 0; } public void hardDeleteById(Long id) { jdbcTemplate.update("DELETE FROM produit WHERE id = ?", id); } }
// JdbcTemplate utilise ? pour les paramètres (position) // NamedParameterJdbcTemplate utilise :nom (nommés) — plus lisible @Repository @RequiredArgsConstructor public class ProduitNamedRepository { private final NamedParameterJdbcTemplate namedJdbc; public List<Produit> rechercherAvancee( String categorie, Double prixMin, Double prixMax, int stockMin) { // Construire la requête avec des paramètres nommés String sql = """ SELECT * FROM produit WHERE actif = TRUE AND (:categorie IS NULL OR categorie = :categorie) AND prix >= :prixMin AND prix <= :prixMax AND stock >= :stockMin ORDER BY nom """; // MapSqlParameterSource — type-safe, gère les nulls MapSqlParameterSource params = new MapSqlParameterSource() .addValue("categorie", categorie) .addValue("prixMin", prixMin != null ? prixMin : 0.0) .addValue("prixMax", prixMax != null ? prixMax : Double.MAX_VALUE) .addValue("stockMin", stockMin); return namedJdbc.query(sql, params, ROW_MAPPER); } public int mettreAJourStock(Long id, int nouvelleQuantite) { return namedJdbc.update( "UPDATE produit SET stock = :stock WHERE id = :id", Map.of("stock", nouvelleQuantite, "id", id) ); } }
@Service @RequiredArgsConstructor @Slf4j public class StockService { private final ProduitJdbcRepository produitRepo; private final MouvementJdbcRepository mouvementRepo; /** * @Transactional garantit que les deux opérations réussissent ou échouent ensemble. * Si une exception est lancée, tout est annulé (ROLLBACK automatique). */ @Transactional public void effectuerSortieStock(Long produitId, int quantite, String motif) { // 1. Vérifier le stock Produit produit = produitRepo.findById(produitId) .orElseThrow(() -> new RuntimeException("Produit introuvable : " + produitId)); if (produit.getStock() < quantite) { throw new IllegalStateException( "Stock insuffisant : disponible=" + produit.getStock() + ", demandé=" + quantite); } // 2. Décrémenter le stock produit.setStock(produit.getStock() - quantite); produitRepo.save(produit); // 3. Enregistrer le mouvement // Si cette ligne plante → TOUT est annulé (y compris le step 2) mouvementRepo.save(new Mouvement(produitId, "SORTIE", quantite, motif)); log.info("Sortie de {} unités du produit {} — motif: {}", quantite, produitId, motif); } // @Transactional(readOnly = true) — optimisation pour les lectures @Transactional(readOnly = true) public List<Produit> trouverProduitsCritiques(int seuilStock) { return produitRepo.findAll().stream() .filter(p -> p.getStock() < seuilStock) .toList(); } // Propagation — que faire si on est déjà dans une transaction ? @Transactional(propagation = Propagation.REQUIRES_NEW) public void journaliser(String message) { // Toujours dans une NOUVELLE transaction — jamais annulé même si la tx parente échoue } }
Objectif : Implémenter un accès complet à PostgreSQL avec JdbcTemplate.
spring_demo
produit
Produit
@Data
@Builder
ProduitJdbcRepository
ProduitService
@Transactional
ProduitController
Dans la réalité, les projets utilisent souvent les deux : JPA pour les opérations CRUD courantes, et JDBC (ou @Query natif) pour les requêtes analytiques complexes.
@Query
<!-- pom.xml — Spring Data JPA inclut Hibernate automatiquement --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>
# application.properties — configuration JPA spring.datasource.url=jdbc:postgresql://localhost:5432/spring_demo spring.datasource.username=spring_user spring.datasource.password=spring_password # Dialecte — Spring Boot 3 le détecte souvent automatiquement spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect # Stratégie DDL : # none = ne touche pas au schéma # validate = vérifie que le schéma correspond aux entités # update = crée/modifie les tables manquantes # create = recrée le schéma à chaque démarrage # create-drop = crée et supprime à l'arrêt spring.jpa.hibernate.ddl-auto=update # Afficher le SQL généré par Hibernate (développement) spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # Statistiques Hibernate spring.jpa.properties.hibernate.generate_statistics=false
package fr.formation.demo.model; import jakarta.persistence.*; import lombok.*; import java.math.BigDecimal; import java.time.LocalDateTime; /** * Entité JPA — mappée sur la table "produit" en base de données. */ @Entity @Table(name = "produit", indexes = { @Index(name = "idx_produit_categorie", columnList = "categorie"), @Index(name = "idx_produit_nom", columnList = "nom") }) @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder @ToString(exclude = "commandes") @EqualsAndHashCode(of = "id") public class Produit { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 200) private String nom; @Column(columnDefinition = "TEXT") private String description; @Column(nullable = false, precision = 10, scale = 2) private BigDecimal prix; @Column(nullable = false) @Builder.Default private Integer stock = 0; @Enumerated(EnumType.STRING) // Stocke le nom de l'enum (pas le numéro) @Column(length = 50) private Categorie categorie; @Column(nullable = false) @Builder.Default private boolean actif = true; // Colonnes gérées automatiquement par Hibernate @Column(name = "date_creation", updatable = false) @CreationTimestamp // ou utiliser @Column(insertable=true, updatable=false) private LocalDateTime dateCreation; @Column(name = "date_modification") @UpdateTimestamp private LocalDateTime dateModification; // Version pour l'optimistic locking (évite les conflits concurrents) @Version private Integer version; }
// Enum pour la catégorie public enum Categorie { ELECTRONIQUE, VETEMENT, ALIMENTATION, LIBRAIRIE, AUTRE }
package fr.formation.demo.repository; import fr.formation.demo.model.Categorie; import fr.formation.demo.model.Produit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.math.BigDecimal; import java.util.List; import java.util.Optional; /** * Repository Spring Data JPA. * Spring génère automatiquement l'implémentation à partir de l'interface. */ @Repository public interface ProduitRepository extends JpaRepository<Produit, Long>, JpaSpecificationExecutor<Produit> { // ══════════════════════════════════════════════════════════════════ // MÉTHODES DÉRIVÉES — Spring génère le SQL depuis le nom de méthode // ══════════════════════════════════════════════════════════════════ // findBy<Champ> → WHERE champ = ? List<Produit> findByCategorie(Categorie categorie); // findBy<Champ>And<Champ> → WHERE champ1 = ? AND champ2 = ? List<Produit> findByCategorieAndActifTrue(Categorie categorie); // Containing → LIKE %terme% List<Produit> findByNomContainingIgnoreCaseAndActifTrue(String terme); // OrderBy<Champ>Asc/Desc → ORDER BY List<Produit> findByActifTrueOrderByNomAsc(); // LessThan, GreaterThan, Between → <, >, BETWEEN List<Produit> findByPrixBetween(BigDecimal prixMin, BigDecimal prixMax); List<Produit> findByStockLessThan(int seuil); // Existence et comptage boolean existsByNomIgnoreCase(String nom); long countByCategorie(Categorie categorie); long countByActifTrue(); // ══════════════════════════════════════════════════════════════════ // REQUÊTES JPQL — @Query avec JPQL (langage orienté objet) // ══════════════════════════════════════════════════════════════════ // JPQL utilise les NOMS DE CLASSES Java, pas les noms de tables SQL @Query("SELECT p FROM Produit p WHERE p.prix <= :prixMax AND p.actif = true " + "ORDER BY p.prix ASC") List<Produit> findProductsUnderPrice(@Param("prixMax") BigDecimal prixMax); @Query("SELECT p FROM Produit p WHERE p.stock <= :seuil AND p.actif = true") List<Produit> findProductsWithLowStock(@Param("seuil") int seuil); // JPQL avec agrégation @Query("SELECT AVG(p.prix) FROM Produit p WHERE p.categorie = :cat AND p.actif = true") Double findAveragePrixByCategorie(@Param("cat") Categorie categorie); // ══════════════════════════════════════════════════════════════════ // REQUÊTES SQL NATIVES — @Query(nativeQuery = true) // ══════════════════════════════════════════════════════════════════ // Pour les requêtes SQL spécifiques à PostgreSQL (ILIKE, JSON, etc.) @Query(value = "SELECT * FROM produit WHERE nom ILIKE %:terme% AND actif = TRUE", nativeQuery = true) List<Produit> searchByNomNatif(@Param("terme") String terme); // ══════════════════════════════════════════════════════════════════ // MODIFICATIONS — @Modifying requis pour UPDATE/DELETE // ══════════════════════════════════════════════════════════════════ @Modifying @Transactional @Query("UPDATE Produit p SET p.actif = false WHERE p.id = :id") int desactiverProduit(@Param("id") Long id); @Modifying @Transactional @Query("UPDATE Produit p SET p.stock = p.stock - :quantite WHERE p.id = :id") int decrementerStock(@Param("id") Long id, @Param("quantite") int quantite); }
package fr.formation.demo.service; import fr.formation.demo.exception.ProduitNotFoundException; import fr.formation.demo.model.Categorie; import fr.formation.demo.model.Produit; import fr.formation.demo.repository.ProduitRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; @Service @Transactional // Toutes les méthodes sont transactionnelles par défaut @RequiredArgsConstructor @Slf4j public class ProduitService { private final ProduitRepository produitRepository; // ── Lecture ───────────────────────────────────────────────────────────── @Transactional(readOnly = true) // Optimisation : pas de flush, transactions légères public List<Produit> trouverTous() { return produitRepository.findByActifTrueOrderByNomAsc(); } @Transactional(readOnly = true) public Produit trouverParId(Long id) { return produitRepository.findById(id) .orElseThrow(() -> new ProduitNotFoundException("Produit introuvable : id=" + id)); } @Transactional(readOnly = true) public Page<Produit> trouverAvecPagination(int page, int taille, String triPar) { Pageable pageable = PageRequest.of(page, taille, Sort.by(triPar)); return produitRepository.findAll(pageable); } @Transactional(readOnly = true) public List<Produit> rechercherParNom(String terme) { return produitRepository.findByNomContainingIgnoreCaseAndActifTrue(terme); } @Transactional(readOnly = true) public List<Produit> trouverParCategorie(Categorie categorie) { return produitRepository.findByCategorie(categorie); } // ── Écriture ───────────────────────────────────────────────────────────── public Produit creer(Produit produit) { if (produitRepository.existsByNomIgnoreCase(produit.getNom())) { throw new IllegalArgumentException( "Un produit avec le nom '" + produit.getNom() + "' existe déjà."); } Produit sauvegarde = produitRepository.save(produit); log.info("Produit créé : {} (id={})", sauvegarde.getNom(), sauvegarde.getId()); return sauvegarde; } public Produit mettreAJour(Long id, Produit modifications) { Produit existant = trouverParId(id); existant.setNom(modifications.getNom()); existant.setDescription(modifications.getDescription()); existant.setPrix(modifications.getPrix()); existant.setCategorie(modifications.getCategorie()); // Pas besoin d'appeler save() — le dirty checking d'Hibernate // détecte les changements et génère l'UPDATE automatiquement log.info("Produit mis à jour : id={}", id); return existant; } public void desactiver(Long id) { trouverParId(id); // Vérifie que le produit existe produitRepository.desactiverProduit(id); log.info("Produit désactivé : id={}", id); } public Produit ajouterStock(Long id, int quantite) { Produit produit = trouverParId(id); if (quantite <= 0) throw new IllegalArgumentException("Quantité doit être positive"); produit.setStock(produit.getStock() + quantite); return produitRepository.save(produit); } public Produit retirerStock(Long id, int quantite) { Produit produit = trouverParId(id); if (produit.getStock() < quantite) { throw new IllegalStateException( "Stock insuffisant : disponible=" + produit.getStock() + ", demandé=" + quantite); } produit.setStock(produit.getStock() - quantite); return produitRepository.save(produit); } }
// ── Exemple : Commande contient plusieurs LigneCommande ────────────────────── @Entity @Table(name = "commande") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder @EqualsAndHashCode(of = "id") public class Commande { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String numeroCommande; @Column(nullable = false) private LocalDateTime dateCommande; @Enumerated(EnumType.STRING) private StatutCommande statut; // ✅ @OneToMany — une commande a plusieurs lignes // mappedBy : nom du champ @ManyToOne côté LigneCommande // cascade : les opérations se propagent aux lignes // orphanRemoval : supprime les lignes sans commande @OneToMany( mappedBy = "commande", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY // ← LAZY = chargé à la demande ) @Builder.Default private List<LigneCommande> lignes = new ArrayList<>(); // Méthodes helper pour maintenir la cohérence bidirectionnelle public void ajouterLigne(LigneCommande ligne) { lignes.add(ligne); ligne.setCommande(this); } public BigDecimal calculerTotal() { return lignes.stream() .map(l -> l.getPrixUnitaire().multiply(BigDecimal.valueOf(l.getQuantite()))) .reduce(BigDecimal.ZERO, BigDecimal::add); } } @Entity @Table(name = "ligne_commande") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class LigneCommande { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // ✅ @ManyToOne — plusieurs lignes pour une commande // FetchType.LAZY = la commande n'est chargée que si on y accède @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "commande_id", nullable = false) private Commande commande; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "produit_id", nullable = false) private Produit produit; @Column(nullable = false) private int quantite; @Column(nullable = false, precision = 10, scale = 2) private BigDecimal prixUnitaire; }
// Dans le service @Transactional(readOnly = true) public Page<Produit> trouverAvecPagination(int page, int taille) { // PageRequest.of(page, taille) — page commence à 0 Pageable pageable = PageRequest.of(page, taille, Sort.by(Sort.Order.desc("prix"), Sort.Order.asc("nom"))); return produitRepository.findAll(pageable); } // Dans le contrôleur @GetMapping public ResponseEntity<Map<String, Object>> listerAvecPagination( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int taille) { Page<Produit> pageResultat = produitService.trouverAvecPagination(page, taille); Map<String, Object> reponse = new HashMap<>(); reponse.put("contenu", pageResultat.getContent()); reponse.put("page", pageResultat.getNumber()); reponse.put("taille", pageResultat.getSize()); reponse.put("totalElements", pageResultat.getTotalElements()); reponse.put("totalPages", pageResultat.getTotalPages()); reponse.put("derniere", pageResultat.isLast()); return ResponseEntity.ok(reponse); } // GET /api/produits?page=0&taille=5
Objectif : Migrer le repository JDBC vers Spring Data JPA.
spring-boot-starter-data-jpa
pom.xml
@Entity
@Table
@Id
@Column
ProduitRepository
JpaRepository<Produit, Long>
┌──────────────────────┐ │ Tests E2E (rares) │ ← Testent toute l'appli (lents) │ @SpringBootTest │ /└──────────────────────┘\ / ┌────────────────────────┐ \ / │ Tests Intégration │ \ / │ @DataJpaTest │ \ / │ @WebMvcTest │ \ / └────────────────────────┘ \ / ┌──────────────────────────────────┐ \ / │ Tests Unitaires (nombreux) │ \ / │ JUnit 5 + Mockito (rapides) │ \ /____└──────────────────────────────────┘____\
package fr.formation.demo.service; import fr.formation.demo.exception.ProduitNotFoundException; import fr.formation.demo.model.Categorie; import fr.formation.demo.model.Produit; import fr.formation.demo.repository.ProduitRepository; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; /** * Tests unitaires du ProduitService. * Pas de Spring — rapide et isolé. * Mockito simule le repository. */ @ExtendWith(MockitoExtension.class) @DisplayName("Tests du ProduitService") class ProduitServiceTest { @Mock private ProduitRepository produitRepository; @InjectMocks private ProduitService produitService; // ── Fixtures ───────────────────────────────────────────────────────────── private Produit produitExistant() { return Produit.builder() .id(1L) .nom("Laptop Dell") .prix(new BigDecimal("999.99")) .stock(10) .categorie(Categorie.ELECTRONIQUE) .actif(true) .build(); } // ── Tests trouverParId ──────────────────────────────────────────────────── @Test @DisplayName("trouverParId — ID existant — retourne le produit") void trouverParId_idExistant_retourneProduit() { // GIVEN Produit produit = produitExistant(); when(produitRepository.findById(1L)).thenReturn(Optional.of(produit)); // WHEN Produit resultat = produitService.trouverParId(1L); // THEN assertThat(resultat).isNotNull(); assertThat(resultat.getNom()).isEqualTo("Laptop Dell"); assertThat(resultat.getPrix()).isEqualByComparingTo("999.99"); verify(produitRepository).findById(1L); } @Test @DisplayName("trouverParId — ID inexistant — lève ProduitNotFoundException") void trouverParId_idInexistant_leveException() { // GIVEN when(produitRepository.findById(99L)).thenReturn(Optional.empty()); // WHEN / THEN assertThatThrownBy(() -> produitService.trouverParId(99L)) .isInstanceOf(ProduitNotFoundException.class) .hasMessageContaining("99"); verify(produitRepository).findById(99L); } // ── Tests creer ─────────────────────────────────────────────────────────── @Test @DisplayName("creer — nom unique — produit sauvegardé") void creer_nomUnique_produitSauvegarde() { Produit nouveau = Produit.builder() .nom("Nouveau Produit") .prix(BigDecimal.TEN) .stock(5) .build(); when(produitRepository.existsByNomIgnoreCase("Nouveau Produit")).thenReturn(false); when(produitRepository.save(any(Produit.class))).thenAnswer(inv -> { Produit p = inv.getArgument(0); p.setId(1L); return p; }); Produit resultat = produitService.creer(nouveau); assertThat(resultat.getId()).isNotNull(); verify(produitRepository).existsByNomIgnoreCase("Nouveau Produit"); verify(produitRepository).save(nouveau); } @Test @DisplayName("creer — nom existant — lève IllegalArgumentException") void creer_nomExistant_leveException() { Produit nouveau = Produit.builder().nom("Laptop Dell").build(); when(produitRepository.existsByNomIgnoreCase("Laptop Dell")).thenReturn(true); assertThatThrownBy(() -> produitService.creer(nouveau)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Laptop Dell"); verify(produitRepository, never()).save(any()); } // ── Tests retirerStock ──────────────────────────────────────────────────── @Test @DisplayName("retirerStock — stock suffisant — stock décrémenté") void retirerStock_stockSuffisant_stockDecremente() { Produit produit = produitExistant(); // stock = 10 when(produitRepository.findById(1L)).thenReturn(Optional.of(produit)); when(produitRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); Produit resultat = produitService.retirerStock(1L, 3); assertThat(resultat.getStock()).isEqualTo(7); verify(produitRepository).save(argThat(p -> p.getStock() == 7)); } @Test @DisplayName("retirerStock — stock insuffisant — lève IllegalStateException") void retirerStock_stockInsuffisant_leveException() { Produit produit = produitExistant(); // stock = 10 when(produitRepository.findById(1L)).thenReturn(Optional.of(produit)); assertThatThrownBy(() -> produitService.retirerStock(1L, 15)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("insuffisant"); verify(produitRepository, never()).save(any()); } // ── Tests paramétrés ────────────────────────────────────────────────────── @ParameterizedTest @DisplayName("retirerStock — quantités invalides — lève exception") @CsvSource({ "0", "-1", "-100" }) void retirerStock_quantiteInvalide_leveException(int quantiteInvalide) { Produit produit = produitExistant(); when(produitRepository.findById(1L)).thenReturn(Optional.of(produit)); assertThatThrownBy(() -> produitService.retirerStock(1L, quantiteInvalide)) .isInstanceOf(IllegalArgumentException.class); } }
package fr.formation.demo.repository; import fr.formation.demo.model.Categorie; import fr.formation.demo.model.Produit; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.test.context.ActiveProfiles; import java.math.BigDecimal; import java.util.List; import static org.assertj.core.api.Assertions.*; /** * Tests d'intégration du repository JPA. * @DataJpaTest : * - Configure H2 en mémoire automatiquement * - Charge uniquement la couche JPA (pas les contrôleurs, services...) * - Chaque test est dans une transaction rollbackée */ @DataJpaTest @DisplayName("Tests du ProduitRepository") class ProduitRepositoryTest { @Autowired private ProduitRepository produitRepository; @BeforeEach void setUp() { produitRepository.deleteAll(); produitRepository.saveAll(List.of( Produit.builder().nom("Laptop Dell").prix(new BigDecimal("999.99")) .stock(10).categorie(Categorie.ELECTRONIQUE).actif(true).build(), Produit.builder().nom("Souris Logitech").prix(new BigDecimal("89.99")) .stock(0).categorie(Categorie.ELECTRONIQUE).actif(true).build(), Produit.builder().nom("T-Shirt Bio").prix(new BigDecimal("24.90")) .stock(50).categorie(Categorie.VETEMENT).actif(true).build(), Produit.builder().nom("Clean Code").prix(new BigDecimal("35.00")) .stock(8).categorie(Categorie.LIBRAIRIE).actif(false).build() )); } @Test @DisplayName("findByActifTrueOrderByNomAsc — retourne les actifs triés") void findByActifTrue_retourneProduitsActifsTries() { List<Produit> actifs = produitRepository.findByActifTrueOrderByNomAsc(); assertThat(actifs).hasSize(3); assertThat(actifs).extracting(Produit::getNom) .containsExactly("Laptop Dell", "Souris Logitech", "T-Shirt Bio"); assertThat(actifs).allMatch(Produit::isActif); } @Test @DisplayName("findByCategorie — filtre par catégorie") void findByCategorie_electronique_retourneSeulementElectronique() { List<Produit> electroniques = produitRepository.findByCategorie(Categorie.ELECTRONIQUE); assertThat(electroniques).hasSize(2); assertThat(electroniques).allMatch(p -> p.getCategorie() == Categorie.ELECTRONIQUE); } @Test @DisplayName("findByNomContaining — recherche insensible casse") void findByNomContaining_rechercheInsensibleCasse() { List<Produit> resultats = produitRepository.findByNomContainingIgnoreCaseAndActifTrue("laptop"); assertThat(resultats).hasSize(1); assertThat(resultats.get(0).getNom()).isEqualTo("Laptop Dell"); } @Test @DisplayName("findByStockLessThan — trouve les produits à stock faible") void findByStockLessThan_seuilDix_retourneProduitsCritiques() { List<Produit> critiqueS = produitRepository.findByStockLessThan(5); assertThat(critiqueS).hasSize(1); assertThat(critiqueS.get(0).getNom()).isEqualTo("Souris Logitech"); } @Test @DisplayName("existsByNomIgnoreCase — détecte les doublons") void existsByNom_nomExistant_retourneTrue() { assertThat(produitRepository.existsByNomIgnoreCase("laptop dell")).isTrue(); assertThat(produitRepository.existsByNomIgnoreCase("Produit Inconnu")).isFalse(); } @Test @DisplayName("save — persiste et retourne avec ID") void save_nouvauProduit_retourneAvecId() { Produit nouveau = Produit.builder() .nom("Nouveau Test") .prix(BigDecimal.TEN) .stock(5) .actif(true) .build(); Produit sauvegarde = produitRepository.save(nouveau); assertThat(sauvegarde.getId()).isNotNull(); assertThat(produitRepository.findById(sauvegarde.getId())).isPresent(); } }
package fr.formation.demo.controller; import com.fasterxml.jackson.databind.ObjectMapper; import fr.formation.demo.exception.ProduitNotFoundException; import fr.formation.demo.model.Categorie; import fr.formation.demo.model.Produit; import fr.formation.demo.service.ProduitService; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.math.BigDecimal; import java.util.List; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.hamcrest.Matchers.*; /** * Tests du contrôleur REST. * @WebMvcTest : * - Charge uniquement la couche MVC (contrôleur) * - Pas de vraie base de données * - MockMvc simule les requêtes HTTP * - @MockBean remplace le service par un mock Mockito */ @WebMvcTest(ProduitController.class) @DisplayName("Tests du ProduitController") class ProduitControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; // Pour sérialiser/désérialiser JSON @MockBean private ProduitService produitService; private Produit produitExemple() { return Produit.builder() .id(1L) .nom("Laptop Dell") .prix(new BigDecimal("999.99")) .stock(10) .categorie(Categorie.ELECTRONIQUE) .actif(true) .build(); } // ── Tests GET ───────────────────────────────────────────────────────────── @Test @DisplayName("GET /api/produits — retourne liste avec statut 200") void getAll_retourneListeEt200() throws Exception { when(produitService.trouverTous()) .thenReturn(List.of(produitExemple())); mockMvc.perform(get("/api/produits")) .andExpect(status().isOk()) // 200 OK .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].nom", is("Laptop Dell"))) .andExpect(jsonPath("$[0].prix", is(999.99))); } @Test @DisplayName("GET /api/produits/1 — produit existant — retourne 200") void getById_produitExistant_retourne200() throws Exception { when(produitService.trouverParId(1L)).thenReturn(produitExemple()); mockMvc.perform(get("/api/produits/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id", is(1))) .andExpect(jsonPath("$.nom", is("Laptop Dell"))); } @Test @DisplayName("GET /api/produits/99 — produit inexistant — retourne 404") void getById_produitInexistant_retourne404() throws Exception { when(produitService.trouverParId(99L)) .thenThrow(new ProduitNotFoundException("Produit introuvable : id=99")); mockMvc.perform(get("/api/produits/99")) .andExpect(status().isNotFound()); } // ── Tests POST ──────────────────────────────────────────────────────────── @Test @DisplayName("POST /api/produits — données valides — retourne 201") void create_donneesValides_retourne201AvecProduit() throws Exception { Produit input = Produit.builder() .nom("Nouveau Produit") .prix(BigDecimal.TEN) .stock(5) .build(); Produit output = Produit.builder() .id(42L) .nom("Nouveau Produit") .prix(BigDecimal.TEN) .stock(5) .build(); when(produitService.creer(any(Produit.class))).thenReturn(output); mockMvc.perform(post("/api/produits") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(input))) .andExpect(status().isCreated()) // 201 .andExpect(jsonPath("$.id", is(42))) .andExpect(jsonPath("$.nom", is("Nouveau Produit"))); } // ── Tests DELETE ────────────────────────────────────────────────────────── @Test @DisplayName("DELETE /api/produits/1 — produit existant — retourne 204") void delete_produitExistant_retourne204() throws Exception { doNothing().when(produitService).desactiver(1L); mockMvc.perform(delete("/api/produits/1")) .andExpect(status().isNoContent()); // 204 } }
package fr.formation.demo; import fr.formation.demo.model.Produit; import fr.formation.demo.repository.ProduitRepository; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.*; import org.springframework.test.context.ActiveProfiles; import java.math.BigDecimal; import static org.assertj.core.api.Assertions.*; /** * Tests d'intégration complets — démarrent tout le contexte Spring. * Utilisent une base H2 en mémoire (profil "test"). * Plus lents mais testent toute la pile. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") // Utilise application-test.properties @DisplayName("Tests d'intégration ProduitController") class ProduitIntegrationTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Autowired private ProduitRepository produitRepository; private String baseUrl() { return "http://localhost:" + port + "/api/produits"; } @BeforeEach void setUp() { produitRepository.deleteAll(); } @Test @DisplayName("Cycle complet : créer → récupérer → supprimer") void cycleCrud_complet_retourneResultatsCorrects() { // Créer Produit nouveau = Produit.builder() .nom("Test Integration") .prix(new BigDecimal("99.99")) .stock(5) .actif(true) .build(); ResponseEntity<Produit> reponseCreation = restTemplate.postForEntity(baseUrl(), nouveau, Produit.class); assertThat(reponseCreation.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(reponseCreation.getBody()).isNotNull(); Long id = reponseCreation.getBody().getId(); assertThat(id).isNotNull(); // Récupérer ResponseEntity<Produit> reponseGet = restTemplate.getForEntity(baseUrl() + "/" + id, Produit.class); assertThat(reponseGet.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(reponseGet.getBody().getNom()).isEqualTo("Test Integration"); } }
Spring Boot supporte 2 formats de configuration :
# application.properties — format clé=valeur server.port=8080 spring.datasource.url=jdbc:postgresql://localhost:5432/mydb spring.jpa.show-sql=true app.nom=Mon Application app.version=1.0.0
# application.yml — format YAML (plus lisible pour les configurations complexes) server: port: 8080 spring: datasource: url: jdbc:postgresql://localhost:5432/mydb jpa: show-sql: true app: nom: Mon Application version: 1.0.0
Les 2 formats sont équivalents. Choisissez selon votre préférence. application.yml est souvent préféré pour sa lisibilité avec des configurations imbriquées. Ce cours utilise .properties.
application.yml
.properties
Les profils permettent d’avoir des configurations différentes selon l’environnement :
src/main/resources/ ├── application.properties ← Configuration commune ├── application-dev.properties ← Profil développement ├── application-test.properties ← Profil test └── application-prod.properties ← Profil production
# application.properties — commun à tous les profils app.nom=Mon Application spring.application.name=mon-application # Activer un profil (peut être surchargé au démarrage) spring.profiles.active=dev
# application-dev.properties spring.datasource.url=jdbc:postgresql://localhost:5432/spring_demo_dev spring.datasource.username=dev_user spring.datasource.password=dev_password spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true logging.level.fr.formation=DEBUG
# application-test.properties # H2 en mémoire pour les tests — rapide et sans configuration spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false
# application-prod.properties spring.datasource.url=${DB_URL} ← Variables d'environnement spring.datasource.username=${DB_USER} spring.datasource.password=${DB_PASS} spring.jpa.hibernate.ddl-auto=validate ← Ne jamais modifier le schéma ! spring.jpa.show-sql=false logging.level.root=WARN
Activer un profil au démarrage :
# Via la ligne de commande java -jar app.jar --spring.profiles.active=prod # Via variable d'environnement export SPRING_PROFILES_ACTIVE=prod java -jar app.jar # Via Maven mvn spring-boot:run -Dspring-boot.run.profiles=dev
# application.properties app.catalogue.pagination.taille-defaut=10 app.catalogue.pagination.taille-max=100 app.catalogue.stock.seuil-alerte=5 app.catalogue.stock.seuil-critique=1 app.notifications.email-admin=admin@formation.fr app.notifications.activer=true
package fr.formation.demo.config; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; /** * Mappe automatiquement les propriétés app.catalogue.* vers cette classe. * Type-safe — les propriétés sont validées au démarrage. */ @Component @ConfigurationProperties(prefix = "app.catalogue") @Getter @Setter public class CatalogueProperties { private Pagination pagination = new Pagination(); private Stock stock = new Stock(); @Getter @Setter public static class Pagination { private int tailleDefaut = 10; private int tailleMax = 100; } @Getter @Setter public static class Stock { private int seuilAlerte = 5; private int seuilCritique = 1; } } // Utilisation dans un service @Service @RequiredArgsConstructor public class CatalogueService { private final CatalogueProperties props; private final ProduitRepository produitRepo; public Page<Produit> listerPage(int page, Integer taille) { int tailleEffective = taille != null ? Math.min(taille, props.getPagination().getTailleMax()) : props.getPagination().getTailleDefaut(); return produitRepo.findAll(PageRequest.of(page, tailleEffective)); } public List<Produit> trouverEnAlerte() { return produitRepo.findByStockLessThan(props.getStock().getSeuilAlerte()); } }
@RestController public class InfoController { @Value("${app.nom}") private String appNom; @Value("${app.version:1.0.0}") // valeur par défaut si absent private String appVersion; @Value("${server.port:8080}") private int serverPort; @GetMapping("/info") public Map<String, Object> info() { return Map.of( "application", appNom, "version", appVersion, "port", serverPort, "java", System.getProperty("java.version") ); } }
<!-- pom.xml — inclus dans spring-boot-starter-web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
// DTO de requête avec validations public record CreerProduitRequete( @NotBlank(message = "Le nom est obligatoire") @Size(min = 2, max = 200, message = "Le nom doit faire entre 2 et 200 caractères") String nom, @Size(max = 1000, message = "La description ne peut pas dépasser 1000 caractères") String description, @NotNull(message = "Le prix est obligatoire") @DecimalMin(value = "0.01", message = "Le prix doit être supérieur à 0") @DecimalMax(value = "99999.99", message = "Le prix ne peut pas dépasser 99999.99") BigDecimal prix, @Min(value = 0, message = "Le stock ne peut pas être négatif") int stock, @NotNull(message = "La catégorie est obligatoire") Categorie categorie ) {} // Dans le contrôleur — @Valid déclenche la validation @PostMapping public ResponseEntity<Produit> creer( @Valid @RequestBody CreerProduitRequete requete) { // Si la validation échoue, une MethodArgumentNotValidException est lancée // Spring retourne automatiquement un 400 Bad Request Produit produit = Produit.builder() .nom(requete.nom()) .prix(requete.prix()) .stock(requete.stock()) .build(); return ResponseEntity.status(201).body(produitService.creer(produit)); }
package fr.formation.demo.exception; import lombok.extern.slf4j.Slf4j; import org.springframework.http.*; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; import java.util.*; /** * Gestionnaire global des exceptions HTTP. * Intercepte les exceptions et retourne des réponses JSON cohérentes. */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { // Structure de réponse d'erreur standardisée record ErreurReponse( LocalDateTime timestamp, int statut, String erreur, String message, String chemin ) {} // ── Ressource introuvable → 404 ─────────────────────────────────────────── @ExceptionHandler(ProduitNotFoundException.class) public ResponseEntity<ErreurReponse> handleNotFound( ProduitNotFoundException ex, jakarta.servlet.http.HttpServletRequest request) { log.warn("Ressource introuvable : {}", ex.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErreurReponse( LocalDateTime.now(), 404, "Not Found", ex.getMessage(), request.getRequestURI())); } // ── Argument invalide → 400 ─────────────────────────────────────────────── @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<ErreurReponse> handleBadArgument( IllegalArgumentException ex, jakarta.servlet.http.HttpServletRequest request) { return ResponseEntity.badRequest() .body(new ErreurReponse( LocalDateTime.now(), 400, "Bad Request", ex.getMessage(), request.getRequestURI())); } // ── État invalide → 409 Conflict ────────────────────────────────────────── @ExceptionHandler(IllegalStateException.class) public ResponseEntity<ErreurReponse> handleConflict( IllegalStateException ex, jakarta.servlet.http.HttpServletRequest request) { return ResponseEntity.status(HttpStatus.CONFLICT) .body(new ErreurReponse( LocalDateTime.now(), 409, "Conflict", ex.getMessage(), request.getRequestURI())); } // ── Erreurs de validation (@Valid) → 400 avec détails ──────────────────── @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidation( MethodArgumentNotValidException ex) { Map<String, String> champsEnErreur = new HashMap<>(); ex.getBindingResult().getAllErrors().forEach(error -> { String champ = ((FieldError) error).getField(); String message = error.getDefaultMessage(); champsEnErreur.put(champ, message); }); Map<String, Object> reponse = new LinkedHashMap<>(); reponse.put("timestamp", LocalDateTime.now()); reponse.put("statut", 400); reponse.put("erreur", "Validation Failed"); reponse.put("champs", champsEnErreur); return ResponseEntity.badRequest().body(reponse); } // ── Erreur serveur générique → 500 ─────────────────────────────────────── @ExceptionHandler(Exception.class) public ResponseEntity<ErreurReponse> handleGeneral( Exception ex, jakarta.servlet.http.HttpServletRequest request) { log.error("Erreur inattendue sur {} : {}", request.getRequestURI(), ex.getMessage(), ex); return ResponseEntity.internalServerError() .body(new ErreurReponse( LocalDateTime.now(), 500, "Internal Server Error", "Une erreur inattendue s'est produite.", request.getRequestURI())); } }
// Exception métier personnalisée public class ProduitNotFoundException extends RuntimeException { public ProduitNotFoundException(String message) { super(message); } public ProduitNotFoundException(Long id) { super("Produit introuvable : id=" + id); } }
Vous allez construire RestaurantAPI : une API REST Spring Boot complète pour gérer un restaurant. L’application gérera les plats, les menus, les commandes et les tables.
Deux versions à implémenter :
-- Script SQL à exécuter dans PostgreSQL CREATE DATABASE restaurant_db WITH ENCODING 'UTF8'; \c restaurant_db -- Table des catégories de plats CREATE TABLE categorie_plat ( id BIGSERIAL PRIMARY KEY, libelle VARCHAR(100) NOT NULL UNIQUE ); -- Table des plats CREATE TABLE plat ( id BIGSERIAL PRIMARY KEY, nom VARCHAR(200) NOT NULL, description TEXT, prix DECIMAL(8, 2) NOT NULL CHECK (prix > 0), calories INTEGER, vegetarien BOOLEAN NOT NULL DEFAULT FALSE, disponible BOOLEAN NOT NULL DEFAULT TRUE, categorie_id BIGINT NOT NULL REFERENCES categorie_plat(id), date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Table des menus CREATE TABLE menu ( id BIGSERIAL PRIMARY KEY, nom VARCHAR(200) NOT NULL, description TEXT, prix_fixe DECIMAL(8, 2), actif BOOLEAN NOT NULL DEFAULT TRUE, date_menu DATE ); -- Table de liaison menu ↔ plat CREATE TABLE menu_plat ( menu_id BIGINT NOT NULL REFERENCES menu(id) ON DELETE CASCADE, plat_id BIGINT NOT NULL REFERENCES plat(id), PRIMARY KEY (menu_id, plat_id) ); -- Table des tables du restaurant CREATE TABLE table_restaurant ( id BIGSERIAL PRIMARY KEY, numero INTEGER NOT NULL UNIQUE, capacite INTEGER NOT NULL CHECK (capacite > 0), statut VARCHAR(20) NOT NULL DEFAULT 'LIBRE' CHECK (statut IN ('LIBRE', 'OCCUPEE', 'RESERVEE')) ); -- Table des commandes CREATE TABLE commande ( id BIGSERIAL PRIMARY KEY, table_id BIGINT REFERENCES table_restaurant(id), statut VARCHAR(20) NOT NULL DEFAULT 'EN_COURS' CHECK (statut IN ('EN_COURS', 'SERVIE', 'PAYEE', 'ANNULEE')), date_commande TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, montant_total DECIMAL(10, 2), nombre_couverts INTEGER NOT NULL DEFAULT 1, notes TEXT ); -- Lignes de commande CREATE TABLE ligne_commande ( id BIGSERIAL PRIMARY KEY, commande_id BIGINT NOT NULL REFERENCES commande(id) ON DELETE CASCADE, plat_id BIGINT NOT NULL REFERENCES plat(id), quantite INTEGER NOT NULL DEFAULT 1 CHECK (quantite > 0), prix_unitaire DECIMAL(8, 2) NOT NULL, notes TEXT ); -- Données initiales INSERT INTO categorie_plat (libelle) VALUES ('Entrées'), ('Plats principaux'), ('Desserts'), ('Boissons'), ('Fromages'); INSERT INTO plat (nom, description, prix, calories, vegetarien, categorie_id) VALUES ('Salade César', 'Laitue romaine, croûtons, parmesan', 8.50, 320, TRUE, 1), ('Soupe à l''oignon', 'Gratinée au gruyère', 7.00, 280, TRUE, 1), ('Steak Frites', 'Entrecôte 250g, frites maison', 18.90, 850, FALSE, 2), ('Saumon Grillé', 'Saumon atlantique, légumes de saison', 22.50, 520, FALSE, 2), ('Risotto Truffe', 'Riz arborio, truffe noire', 24.00, 580, TRUE, 2), ('Fondant Chocolat', 'Cœur coulant, glace vanille', 7.50, 480, TRUE, 3), ('Crème Brûlée', 'Recette traditionnelle', 6.50, 380, TRUE, 3), ('Eau Minérale 75cl', 'Plate ou gazeuse', 3.00, 0, TRUE, 4), ('Vin Rouge 25cl', 'Sélection du sommelier', 6.50, 210, TRUE, 4); INSERT INTO table_restaurant (numero, capacite, statut) VALUES (1, 2, 'LIBRE'), (2, 4, 'LIBRE'), (3, 4, 'OCCUPEE'), (4, 6, 'LIBRE'), (5, 8, 'LIBRE'), (6, 2, 'RESERVEE');
restaurant-api/ ├── pom.xml └── src/ ├── main/ │ ├── java/fr/formation/restaurant/ │ │ ├── RestaurantApplication.java │ │ ├── config/ │ │ │ └── DatabaseConfig.java │ │ ├── controller/ │ │ │ ├── PlatController.java │ │ │ ├── MenuController.java │ │ │ ├── CommandeController.java │ │ │ └── TableController.java │ │ ├── service/ │ │ │ ├── PlatService.java │ │ │ ├── MenuService.java │ │ │ └── CommandeService.java │ │ ├── repository/ │ │ │ ├── jdbc/ ← Version 1 : JDBC │ │ │ │ ├── PlatJdbcRepository.java │ │ │ │ └── CommandeJdbcRepository.java │ │ │ └── jpa/ ← Version 2 : JPA │ │ │ ├── PlatJpaRepository.java │ │ │ └── CommandeJpaRepository.java │ │ ├── model/ │ │ │ ├── Plat.java │ │ │ ├── Menu.java │ │ │ ├── Commande.java │ │ │ ├── LigneCommande.java │ │ │ └── TableRestaurant.java │ │ ├── dto/ │ │ │ ├── CreerPlatRequete.java │ │ │ ├── PlatReponse.java │ │ │ └── PasserCommandeRequete.java │ │ └── exception/ │ │ ├── PlatNotFoundException.java │ │ └── GlobalExceptionHandler.java │ └── resources/ │ ├── application.properties │ ├── application-dev.properties │ └── application-test.properties └── test/ └── java/fr/formation/restaurant/ ├── service/ │ ├── PlatServiceTest.java │ └── CommandeServiceTest.java ├── repository/ │ └── PlatRepositoryTest.java └── controller/ └── PlatControllerTest.java
API Restaurant — Endpoints complets PLATS GET /api/plats Lister tous les plats disponibles GET /api/plats/{id} Détail d'un plat GET /api/plats?categorie=ENTREES Filtrer par catégorie GET /api/plats?vegetarien=true Plats végétariens GET /api/plats/recherche?q=saumon Recherche par nom POST /api/plats Créer un plat (admin) PUT /api/plats/{id} Modifier un plat (admin) DELETE /api/plats/{id} Désactiver un plat (admin) MENUS GET /api/menus Lister les menus actifs GET /api/menus/{id} Détail d'un menu avec ses plats GET /api/menus/du-jour Menu du jour POST /api/menus Créer un menu POST /api/menus/{id}/plats/{platId} Ajouter un plat à un menu TABLES GET /api/tables État de toutes les tables GET /api/tables/libres Tables disponibles PUT /api/tables/{id}/statut Changer le statut COMMANDES GET /api/commandes Lister les commandes en cours GET /api/commandes/{id} Détail d'une commande POST /api/commandes Passer une nouvelle commande POST /api/commandes/{id}/lignes Ajouter un plat à la commande PUT /api/commandes/{id}/statut Changer le statut (servir, payer...) GET /api/commandes/{id}/addition Calculer l'addition
Mission 1 — Mise en place et Version JDBC
Spring JDBC
PostgreSQL Driver
Lombok
Validation
Plat
PlatJdbcRepository
PlatService
PlatController
Mission 2 — Version Spring Data JPA
PlatJpaRepository
JpaRepository
CommandeJpaRepository
@OneToMany
@ManyToOne
CommandeService
passerCommande(tableId, nombreCouverts)
ajouterPlat(commandeId, platId, quantite)
calculerAddition(commandeId)
changerStatut(commandeId, statut)
CommandeController
Mission 3 — Tests
PlatServiceTest — tests unitaires avec Mockito (10 tests minimum) :
PlatServiceTest
creerPlat_nomUnique_platSauvegarde()
creerPlat_nomExistant_leveException()
trouverParId_idInexistant_levePlatNotFoundException()
PlatRepositoryTest — tests @DataJpaTest avec H2 (6 tests minimum) :
PlatRepositoryTest
@DataJpaTest
PlatControllerTest — tests @WebMvcTest (6 tests minimum) :
PlatControllerTest
@WebMvcTest
Mission Bonus — Fonctionnalités avancées
Ajoutez ces fonctionnalités :
GET /api/commandes/{id}/addition
GET /api/statistiques/plats-populaires
disponible=true
# Créer et lancer mvn spring-boot:run # Démarrer en développement mvn spring-boot:run -Dspring-boot.run.profiles=dev mvn clean package # Construire le JAR java -jar target/app.jar # Exécuter le JAR java -jar target/app.jar --server.port=9090 # Surcharger la config # Tests mvn test # Lancer tous les tests mvn test -Dtest=PlatServiceTest # Lancer un test précis mvn verify # Tests + rapport qualité # Dépendances mvn dependency:tree # Arbre des dépendances mvn dependency:analyze # Dépendances inutilisées
@SpringBootApplication
@RestController
@GetMapping
@PostMapping
@PathVariable
@RequestParam
@RequestBody
@Valid
@Service
@Repository
@GeneratedValue
@ControllerAdvice
@Value
@ConfigurationProperties
Configuration ☐ application.properties configuré (datasource, jpa, logging) ☐ Profils dev / test / prod séparés ☐ Aucun mot de passe en dur dans le code source ☐ .gitignore inclut application-prod.properties Structure ☐ Controller → Service → Repository (jamais de saut de couche) ☐ @RestController injecte uniquement des services (pas de repo directement) ☐ @Service injecte des repositories et d'autres services ☐ DTOs pour les requêtes et réponses (pas d'entités directement) ☐ Exceptions métier personnalisées + @ControllerAdvice JPA ☐ @Transactional(readOnly=true) sur les méthodes de lecture ☐ FetchType.LAZY sur les collections ☐ equals/hashCode basés sur l'ID ☐ Pas de @Entity dans les controllers ou services Tests ☐ Tests unitaires du service avec Mockito (@ExtendWith) ☐ Tests du repository avec @DataJpaTest (H2) ☐ Tests du controller avec @WebMvcTest (MockMvc) ☐ Profil "test" utilise H2 (pas PostgreSQL)
Auteur : Philippe Bouget — Spring Boot 3.2 · Java 17 · Maven · PostgreSQL