Aller au contenu

Spring Boot — Cours complet

Framework · Java 17 · Maven · PostgreSQL · JDBC · Hibernate · Tests · IntelliJ & Eclipse


Sommaire


1. Introduction à Spring Boot

1.1. Qu’est-ce que Spring et Spring Boot ?

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.

1.2. Les composants de l’écosystème Spring

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

1.3. Ce que Spring Boot apporte concrètement

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

1.4. Versions et compatibilité

Spring Boot Spring Framework Java minimum Jakarta EE
2.7.x 5.3.x Java 8 javax.*
3.0.x 6.0.x Java 17 jakarta.*
3.1.x 6.1.x Java 17 jakarta.*
3.2.x 6.2.x Java 17 jakarta.*

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).


2. Démarrage d’un projet Spring Boot

2.1. Spring Initializr — Le générateur de projets

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

2.2. Créer un projet dans IntelliJ IDEA

  1. FileNewProject
  2. Sélectionner Spring Initializr dans la colonne gauche
  3. Remplir les informations (Group, Artifact, Java 17, Maven)
  4. Choisir les dépendances sur l’écran suivant
  5. Create — IntelliJ télécharge et configure tout

Ou via le menu New ProjectSpring Boot :

2.3. Créer un projet dans Eclipse

  1. FileNewSpring Starter Project (si Spring Tools Suite est installé)
  2. Ou : télécharger le ZIP depuis start.spring.io, puis File → Import → Maven → Existing Maven Projects

Installer Spring Tools 4 dans Eclipse : HelpEclipse Marketplace → rechercher “Spring Tools” → Spring Tools 4Install

2.4. Structure d’un projet Spring Boot

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é)

2.5. Le pom.xml Spring Boot expliqué

<?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>

2.6. La classe principale — @SpringBootApplication

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 :

  1. Spring Boot crée le contexte d’application (conteneur IoC)
  2. Il scanne les classes annotées dans le package
  3. Il lit application.properties et applique la configuration
  4. L’auto-configuration détecte les dépendances présentes et configure automatiquement (détecte PostgreSQL –> configure un DataSource)
  5. Tomcat embarqué démarre sur le port 8080
  6. L’application est prête à recevoir des requêtes

2.7. Lancer l’application

# 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

2.8. TP 1 — Votre première application Spring Boot

Objectif : Créer une application Spring Boot qui répond à des requêtes HTTP.

  1. Allez sur https://start.spring.io et créez un projet avec :

    • Group : fr.formation
    • Artifact : bonjour-app
    • Java 17, Maven
    • Dépendances : Spring Web, Spring Boot DevTools
  2. Importez dans IntelliJ ou Eclipse.

  3. Créez la classe 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");
    }
}
  1. Lancez l’application et testez dans votre navigateur :

    • http://localhost:8080/api/bonjour
    • http://localhost:8080/api/bonjour/Alicia
    • http://localhost:8080/api/version

3. L’injection de dépendances et les Beans

3.1. Comprendre l’IoC et l’injection de dépendances

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é.

3.2. Les annotations de stéréotype

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)

3.3. Les modes d’injection

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;
    }
}

3.4. Lombok — Simplifier les injections

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 !

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(...)

3.5. Le scope des beans

// @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 { }

3.6. TP 2 — Injection de dépendances

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").


4. Spring MVC — Contrôleurs et Routes

4.1. Architecture MVC dans Spring Boot

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

4.2. Les annotations de mapping 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) { ... }
}

4.3. ResponseEntity — Contrôler la réponse HTTP

@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)
}

4.4. Les DTOs — Séparer l’API du modèle

// 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é
}

4.5. Consommer l’API — Exemples avec curl

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

4.6. TP 3 — API REST CRUD complète

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 !


5. Accès aux données avec Spring JDBC

5.1. Pourquoi Spring JDBC avant JPA ?

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 :

5.2. Configuration PostgreSQL

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

5.3. JdbcTemplate — L’outil central de Spring JDBC

JdbcTemplate est la classe principale de Spring JDBC. Elle simplifie énormément le code JDBC brut :

// 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
);

5.4. RowMapper — Mapper les résultats SQL en objets Java

// 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);

5.5. Repository JDBC complet

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);
    }
}

5.6. NamedParameterJdbcTemplate — Paramètres nommés

// 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)
        );
    }
}

5.7. Transactions avec Spring JDBC

@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
    }
}

5.8. TP 4 — Repository JDBC complet

Objectif : Implémenter un accès complet à PostgreSQL avec JdbcTemplate.

  1. Créez la base spring_demo dans PostgreSQL avec la table produit.
  2. Configurez application.properties pour PostgreSQL.
  3. Créez l’entité Produit avec Lombok (@Data, @Builder).
  4. Implémentez ProduitJdbcRepository avec findAll, findById, findByCategorie, save, deleteById.
  5. Créez ProduitService avec @Transactional.
  6. Créez ProduitController avec les endpoints GET et POST.
  7. Testez avec curl ou Postman.

6. Accès aux données avec Spring Data JPA / Hibernate

6.1. JPA / Hibernate vs Spring JDBC — Quand utiliser quoi ?

Critère Spring JDBC Spring Data JPA
Contrôle du SQL Total Partiel (généré par Hibernate)
Productivité Moyenne Haute
Performances Maximales Bonnes (avec optimisations)
Complexité Simple Modérée
Idéal pour SQL complexe, procédures CRUD standard, relations

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.

6.2. Configuration Spring Data JPA

<!-- 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

6.3. Les entités JPA

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
}

6.4. JpaRepository — CRUD gratuit

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);
}

6.5. Service avec Spring Data JPA

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);
    }
}

6.6. Relations JPA — OneToMany et ManyToOne

// ── 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;
}

6.7. Pagination et tri

// 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

6.8. TP 5 — Migration JDBC → JPA

Objectif : Migrer le repository JDBC vers Spring Data JPA.

  1. Ajoutez la dépendance spring-boot-starter-data-jpa dans le pom.xml.
  2. Annotez l’entité Produit avec @Entity, @Table, @Id, @Column
  3. Créez ProduitRepository extends JpaRepository<Produit, Long>.
  4. Ajoutez les méthodes dérivées nécessaires.
  5. Mettez à jour ProduitService pour utiliser le nouveau repository.
  6. Le contrôleur ne change pas ! ✅
  7. Vérifiez que les mêmes requêtes curl fonctionnent.

7. Tests avec Spring Boot

7.1. La pyramide des tests

           ┌──────────────────────┐
           │   Tests E2E (rares)  │  ← Testent toute l'appli (lents)
           │   @SpringBootTest    │
          /└──────────────────────┘\
         / ┌────────────────────────┐ \
        /  │  Tests Intégration     │  \
       /   │  @DataJpaTest          │   \
      /    │  @WebMvcTest           │    \
     /     └────────────────────────┘     \
    / ┌──────────────────────────────────┐ \
   /  │     Tests Unitaires (nombreux)   │  \
  /   │     JUnit 5 + Mockito (rapides)  │   \
 /____└──────────────────────────────────┘____\

7.2. Tests unitaires — JUnit 5 + Mockito

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);
    }
}

7.3. Tests de repository — @DataJpaTest

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();
    }
}

7.4. Tests du contrôleur — @WebMvcTest

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
    }
}

7.5. Tests d’intégration complets — @SpringBootTest

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");
    }
}

8. Configuration et Profils

8.1. application.properties vs application.yml

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.

8.2. Profils Spring — dev, test, prod

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

8.3. @ConfigurationProperties — Propriétés typées

# 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());
    }
}

8.4. @Value — Injection de propriétés simples

@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")
        );
    }
}

9. Gestion des erreurs et validation

9.1. Validation des données — Bean Validation

<!-- 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));
}

9.2. @ControllerAdvice — Gestion globale des erreurs

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);
    }
}

10. TP Final — Application de Restauration

10.1. Présentation du projet

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 :

10.2. Modèle de données

-- 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');

10.3. Architecture du projet

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

10.4. Endpoints de l’API

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

10.5. Missions du TP Final

Mission 1 — Mise en place et Version JDBC

  1. Créez le projet Spring Boot depuis start.spring.io avec les dépendances : Spring Web, Spring JDBC, PostgreSQL Driver, Lombok, Validation.
  2. Exécutez le script SQL pour créer la base et insérer les données.
  3. Configurez application.properties pour PostgreSQL.
  4. Créez l’entité Plat avec Lombok (@Data, @Builder).
  5. Implémentez PlatJdbcRepository avec toutes les opérations CRUD.
  6. Implémentez PlatService avec la logique métier et @Transactional.
  7. Implémentez PlatController avec tous les endpoints GET et POST.
  8. Testez avec curl ou Postman.

Mission 2 — Version Spring Data JPA

  1. Ajoutez la dépendance spring-boot-starter-data-jpa (garder aussi JDBC).
  2. Annotez l’entité Plat avec @Entity, @Table, @Column
  3. Créez PlatJpaRepository extends JpaRepository.
  4. Créez CommandeJpaRepository avec les relations @OneToMany / @ManyToOne.
  5. Créez CommandeService pour gérer les commandes :
    • passerCommande(tableId, nombreCouverts) — crée une commande
    • ajouterPlat(commandeId, platId, quantite) — ajoute un plat
    • calculerAddition(commandeId) — retourne le total
    • changerStatut(commandeId, statut) — met à jour le statut
  6. Créez CommandeController avec les endpoints de commande.

Mission 3 — Tests

  1. PlatServiceTest — tests unitaires avec Mockito (10 tests minimum) :

    • creerPlat_nomUnique_platSauvegarde()
    • creerPlat_nomExistant_leveException()
    • trouverParId_idInexistant_levePlatNotFoundException()
    • Tests de filtrage par catégorie
    • Tests de recherche
  2. PlatRepositoryTest — tests @DataJpaTest avec H2 (6 tests minimum) :

    • Vérifier findByDisponibleTrue
    • Vérifier la recherche par nom
    • Vérifier la création et persistence
  3. PlatControllerTest — tests @WebMvcTest (6 tests minimum) :

    • GET tous les plats → 200
    • GET plat existant → 200 avec données
    • GET plat inexistant → 404
    • POST plat valide → 201
    • POST plat invalide (validation) → 400

Mission Bonus — Fonctionnalités avancées

Ajoutez ces fonctionnalités :

  1. Endpoint GET /api/commandes/{id}/addition qui retourne le détail et le total.
  2. Endpoint GET /api/statistiques/plats-populaires qui retourne les plats les plus commandés.
  3. Validation que les plats ajoutés à une commande sont bien disponible=true.
  4. Notification console (log) quand une commande est payée.

Annexe — Aide-mémoire et ressources

Commandes essentielles

# 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

Annotations Spring Boot essentielles

Annotation Couche Description
@SpringBootApplication Application Point d’entrée
@RestController Web Controller REST
@GetMapping, @PostMapping Web Mapping HTTP
@PathVariable Web Variable d’URL
@RequestParam Web Paramètre de requête
@RequestBody Web Corps de la requête
@Valid Web Déclenche la validation
@Service Service Bean de service
@Transactional Service Gestion des transactions
@Repository Données Bean de repository
@Entity JPA Entité persistée
@Table JPA Nom de la table
@Id JPA Clé primaire
@GeneratedValue JPA Stratégie de génération
@Column JPA Mapping de colonne
@OneToMany JPA Relation 1-N
@ManyToOne JPA Relation N-1
@ControllerAdvice Web Gestion globale erreurs
@Value Config Injection de propriété
@ConfigurationProperties Config Propriétés typées

Codes de statut HTTP courants

Code Signification Quand l’utiliser
200 OK Succès GET, PUT réussis
201 Created Créé POST réussi
204 No Content Succès sans contenu DELETE réussi
400 Bad Request Requête invalide Données incorrectes
404 Not Found Introuvable Ressource absente
409 Conflict Conflit Doublon, état invalide
422 Unprocessable Validation échouée @Valid échoue
500 Server Error Erreur serveur Exception non gérée

Ressources

Ressource URL
Documentation officielle Spring Boot https://docs.spring.io/spring-boot/docs/current/reference
Spring Initializr https://start.spring.io
Spring Data JPA https://docs.spring.io/spring-data/jpa/reference
Baeldung (tutoriels) https://www.baeldung.com/spring-boot
PostgreSQL https://www.postgresql.org/docs

Checklist projet Spring Boot

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