Aller au contenu

DTO, Validation & Gestion des Erreurs avec Spring Boot

Thymeleaf · JPA/Hibernate · PostgreSQL · Java 17 · IntelliJ & Eclipse


Sommaire


1. Introduction — Pourquoi les DTO ?

1.1. Le problème de l’exposition directe des entités

Imaginez une entité JPA Utilisateur avec des champs sensibles…

@Entity
public class Utilisateur {
    @Id @GeneratedValue
    private Long id;
    private String nom;
    private String email;
    private String motDePasseHash;   // ne JAMAIS exposer !
    private String tokenResetMdp;    // ne JAMAIS exposer !
    private LocalDateTime dateCreation;
    private boolean admin;            // Ne pas exposer côté client
    @OneToMany(mappedBy = "utilisateur")
    private List<Commande> commandes; // peut déclencher un "LazyInitializationException" !
}

Si vous exposez cette entité directement dans l’API ou le formulaire Thymeleaf :

// dangereux et fragile
@GetMapping("/profil")
public String profil(Model model) {
    model.addAttribute("user", utilisateurService.trouverCourant());
    // Jackson sérialise TOUT — motDePasseHash est exposé au client !
    // Hibernate peut lever LazyInitializationException sur les collections
    return "profil";
}

Les problèmes sont nombreux :

1.2. La solution : les DTO (Data Transfer Objects)

Un DTO est un objet simple dont le seul rôle est de transporter des données entre les couches de l’application. Il ne contient pas de logique métier.

Client (navigateur/Thymeleaf)
    │
    │ FormDTO (données du formulaire)
    ▼
Controller
    │
    │ mapper FormDTO → Entité
    ▼
Service  ←→  Entité JPA  ←→  Base de données
    │
    │ mapper Entité → ReponseDTO
    ▼
Controller
    │
    │ ReponseDTO (données affichées)
    ▼
Client (navigateur/Thymeleaf)

Les types de DTO courants :

Type Description Exemple
Request DTO Données envoyées par le client (formulaire, API) CreerUtilisateurForm
Response DTO Données retournées au client UtilisateurReponse
Command Action métier spécifique ChangerMotDePasseCommande
Projection Vue partielle d’une entité UtilisateurResume

1.3. Bénéfices des DTO

✅ Sécurité    — Contrôle précis de ce qui est exposé
✅ Découplage  — Le modèle peut évoluer sans casser l'API
✅ Validation  — Les contraintes sont sur le DTO, pas l'entité
✅ Testabilité — Plus facile à tester (POJO simples)
✅ Flexibilité — Un DTO peut combiner des données de plusieurs entités
✅ Performance — On ne charge que les données nécessaires

2. Les DTO en pratique

2.1. Structure d’un projet avec DTO

fr.formation.monapp/
├── model/                  ← Entités JPA (couche persistance)
│   └── Utilisateur.java
├── dto/                    ← DTOs (couche transport)
│   ├── request/
│   │   ├── CreerUtilisateurForm.java
│   │   └── ChangerMotDePasseForm.java
│   └── response/
│       ├── UtilisateurReponse.java
│       └── UtilisateurResume.java
├── mapper/                 ← Conversion entité ↔ DTO
│   └── UtilisateurMapper.java
├── repository/
│   └── UtilisateurRepository.java
├── service/
│   └── UtilisateurService.java
└── controller/
    └── UtilisateurController.java

2.2. Créer des DTO — Records Java 17

Java 17 offre les records, parfaits et pratique pour les DTO immuables…

// dto/response/UtilisateurReponse.java
package fr.formation.monapp.dto.response;

import java.time.LocalDateTime;

/**
 * DTO de réponse — ce que l'API retourne au client.
 * Record Java 17 : immutable, equals/hashCode/toString générés automatiquement.
 * JAMAIS de motDePasseHash ici !
 */
public record UtilisateurReponse(
    Long            id,
    String          nom,
    String          prenom,
    String          email,
    String          role,
    LocalDateTime   dateCreation,
    int             nombreCommandes
) {}
// dto/request/CreerUtilisateurForm.java
package fr.formation.monapp.dto.request;

import jakarta.validation.constraints.*;

/**
 * DTO de requête — données du formulaire de création.
 * Contient les contraintes de validation.
 */
public record CreerUtilisateurForm(

    @NotBlank(message = "Le prénom est obligatoire")
    @Size(min = 2, max = 100)
    String prenom,

    @NotBlank(message = "Le nom est obligatoire")
    @Size(min = 2, max = 100)
    String nom,

    @NotBlank(message = "L'email est obligatoire")
    @Email(message = "L'email n'est pas valide")
    String email,

    @NotBlank(message = "Le mot de passe est obligatoire")
    @Size(min = 8, message = "Le mot de passe doit faire au moins 8 caractères")
    @Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
        message = "Le mot de passe doit contenir une majuscule, une minuscule et un chiffre"
    )
    String motDePasse,

    @NotBlank(message = "La confirmation est obligatoire")
    String confirmationMotDePasse

) {}

2.3. DTO avec des classes (quand les records ne suffisent pas)

Les records sont immuables. Pour les formulaires Thymeleaf (liaison bidirectionnelle), utilisez des classes :

// dto/request/ProduitForm.java
package fr.formation.monapp.dto.request;

import jakarta.validation.constraints.*;
import lombok.*;
import java.math.BigDecimal;

/**
 * DTO de formulaire avec Lombok.
 * Utilise une classe (pas un record) car Thymeleaf
 * a besoin de setters pour la liaison de formulaire.
 */
@Data                  // Lombok : getters + setters + equals + hashCode + toString
@NoArgsConstructor     // Obligatoire pour la liaison Thymeleaf
@AllArgsConstructor
@Builder
public class ProduitForm {

    private Long id;  // null pour la création, non null pour la modification

    @NotBlank(message = "Le nom est obligatoire")
    @Size(min = 2, max = 200, message = "Le nom doit faire entre 2 et 200 caractères")
    private String nom;

    @Size(max = 1000, message = "La description ne peut pas dépasser 1000 caractères")
    private 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 99 999,99 €")
    private BigDecimal prix;

    @Min(value = 0, message = "Le stock ne peut pas être négatif")
    @Max(value = 99999, message = "Le stock ne peut pas dépasser 99 999")
    private Integer stock;

    @NotNull(message = "La catégorie est obligatoire")
    private Long categorieId;

    private boolean actif = true;
}

2.4. Mapper manuellement — Sans bibliothèque

// mapper/ProduitMapper.java
package fr.formation.monapp.mapper;

import fr.formation.monapp.dto.request.ProduitForm;
import fr.formation.monapp.dto.response.ProduitReponse;
import fr.formation.monapp.model.Produit;
import org.springframework.stereotype.Component;

/**
 * Mapper manuel — conversion entre entités et DTOs.
 * Simple, transparent, aucune magie.
 */
@Component
public class ProduitMapper {

    // ── Entité → DTO de réponse ──────────────────────────────────────────────
    public ProduitReponse versReponse(Produit produit) {
        return new ProduitReponse(
            produit.getId(),
            produit.getNom(),
            produit.getDescription(),
            produit.getPrix(),
            produit.getStock(),
            produit.isActif(),
            produit.getCategorie() != null
                ? produit.getCategorie().getLibelle()
                : null,
            produit.getDateCreation()
        );
    }

    // ── DTO de formulaire → Entité (pour création) ───────────────────────────
    public Produit versEntite(ProduitForm form) {
        Produit produit = new Produit();
        produit.setNom(form.getNom());
        produit.setDescription(form.getDescription());
        produit.setPrix(form.getPrix());
        produit.setStock(form.getStock() != null ? form.getStock() : 0);
        produit.setActif(form.isActif());
        return produit;
        // Note : categorieId sera résolu par le service
    }

    // ── DTO de formulaire → Entité existante (pour mise à jour) ──────────────
    public void mettreAJourEntite(ProduitForm form, Produit produit) {
        produit.setNom(form.getNom());
        produit.setDescription(form.getDescription());
        produit.setPrix(form.getPrix());
        produit.setStock(form.getStock() != null ? form.getStock() : produit.getStock());
        produit.setActif(form.isActif());
    }

    // ── Entité → DTO de formulaire (pour pré-remplir le formulaire d'édition) ─
    public ProduitForm versFormulaire(Produit produit) {
        ProduitForm form = new ProduitForm();
        form.setId(produit.getId());
        form.setNom(produit.getNom());
        form.setDescription(produit.getDescription());
        form.setPrix(produit.getPrix());
        form.setStock(produit.getStock());
        form.setActif(produit.isActif());
        if (produit.getCategorie() != null) {
            form.setCategorieId(produit.getCategorie().getId());
        }
        return form;
    }

    // ── Liste d'entités → Liste de DTOs ──────────────────────────────────────
    public java.util.List<ProduitReponse> versListeReponse(
            java.util.List<Produit> produits) {
        return produits.stream()
            .map(this::versReponse)
            .toList();
    }
}

2.5. DTO de réponse avec record

// dto/response/ProduitReponse.java
package fr.formation.monapp.dto.response;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public record ProduitReponse(
    Long          id,
    String        nom,
    String        description,
    BigDecimal    prix,
    int           stock,
    boolean       actif,
    String        categorie,
    LocalDateTime dateCreation
) {
    // Méthode factory statique — utile pour la lisibilité
    public static ProduitReponse vide() {
        return new ProduitReponse(null, "", "", BigDecimal.ZERO,
            0, false, null, null);
    }
}

2.6. TP 1 — Créer les DTOs d’une application de blog

Objectif : Créer les DTOs pour une application de blog simple.

Entités : Article (id, titre, contenu, auteur, datePublication, statut, nombreVues) et Commentaire (id, article, auteur, texte, date).

  1. Créez ArticleForm (pour création/édition) avec Lombok.
  2. Créez ArticleReponse (record Java 17) avec : id, titre, résumé (50 premiers caractères du contenu), auteur, date, nombreCommentaires.
  3. Créez CommentaireForm (pour poster un commentaire).
  4. Créez ArticleMapper avec les méthodes versReponse, versEntite, versFormulaire.
  5. Écrivez un test unitaire pour chaque méthode du mapper.

3. MapStruct — Mapping automatique

3.1. Qu’est-ce que MapStruct ?

MapStruct est une bibliothèque qui génère automatiquement le code de mapping entre entités et DTOs à la compilation. C’est plus sûr et plus performant que les solutions basées sur la réflexion (ModelMapper, etc.).

<!-- pom.xml -->
<properties>
    <mapstruct.version>1.5.5.Final</mapstruct.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${mapstruct.version}</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version>
            <configuration>
                <source>17</source>
                <target>17</target>
                <annotationProcessorPaths>
                    <!-- MapStruct doit être avant Lombok -->
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${mapstruct.version}</version>
                    </path>
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                        <version>${lombok.version}</version>
                    </path>
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok-mapstruct-binding</artifactId>
                        <version>0.2.0</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

3.2. Mapper simple avec MapStruct

// mapper/ProduitMapStructMapper.java
package fr.formation.monapp.mapper;

import fr.formation.monapp.dto.request.ProduitForm;
import fr.formation.monapp.dto.response.ProduitReponse;
import fr.formation.monapp.model.Produit;
import org.mapstruct.*;

/**
 * Mapper MapStruct — le code d'implémentation est généré automatiquement
 * à la compilation dans target/generated-sources/
 */
@Mapper(
    componentModel = "spring",  // Spring gère l'instance (@Component généré)
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface ProduitMapStructMapper {

    // ── Entité → DTO réponse ──────────────────────────────────────────────────
    // Quand les noms correspondent : mappage automatique
    // Quand les noms diffèrent : @Mapping source → target

    @Mapping(source = "categorie.libelle", target = "categorie")
    ProduitReponse versReponse(Produit produit);

    // ── DTO formulaire → Entité ───────────────────────────────────────────────
    @Mapping(target = "id",           ignore = true)  // L'ID vient de la BDD
    @Mapping(target = "dateCreation", ignore = true)  // Géré par @CreationTimestamp
    @Mapping(target = "categorie",    ignore = true)  // Résolu par le service
    Produit versEntite(ProduitForm form);

    // ── Mise à jour d'une entité existante ────────────────────────────────────
    @Mapping(target = "id",           ignore = true)
    @Mapping(target = "dateCreation", ignore = true)
    @Mapping(target = "categorie",    ignore = true)
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    void mettreAJourEntite(ProduitForm form, @MappingTarget Produit produit);

    // ── Entité → DTO formulaire (pré-remplissage) ─────────────────────────────
    @Mapping(source = "categorie.id", target = "categorieId")
    ProduitForm versFormulaire(Produit produit);
}

3.3. Mappings avancés

@Mapper(componentModel = "spring", uses = {CategorieMapper.class})
public interface CommandeMapper {

    // Mapping avec transformation
    @Mapping(source = "client.nom",    target = "nomClient")
    @Mapping(source = "client.email",  target = "emailClient")
    @Mapping(source = "lignes",        target = "lignesCommande")
    @Mapping(target = "montantTotal",  expression = "java(commande.calculerTotal())")
    @Mapping(target = "nombreArticles",expression = "java(commande.getLignes().size())")
    CommandeReponse versReponse(Commande commande);

    // Mapping d'une collection
    java.util.List<CommandeReponse> versListeReponse(java.util.List<Commande> commandes);

    // Mapper une sous-entité
    @Mapping(source = "produit.nom",   target = "nomProduit")
    @Mapping(source = "produit.prix",  target = "prixUnitaire")
    LigneCommandeReponse versReponse(LigneCommande ligne);
}

4. Bean Validation — Valider les données

4.1. Les annotations de validation essentielles

<!-- Inclus dans spring-boot-starter-web, mais explicite avec : -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;

public class ExemplesValidation {

    // ── Chaînes de caractères ─────────────────────────────────────────────────
    @NotNull(message = "Ne peut pas être null")
    private String champ1;

    @NotBlank(message = "Ne peut pas être vide ou que des espaces")
    private String champ2;

    @NotEmpty(message = "Ne peut pas être vide (null ou chaîne vide)")
    private String champ3;

    @Size(min = 2, max = 100, message = "Entre 2 et 100 caractères")
    private String champ4;

    @Pattern(regexp = "^[A-Z]{2}\\d{4}$", message = "Format : 2 lettres + 4 chiffres")
    private String codeRef;

    @Email(message = "Email invalide")
    private String email;

    @URL(message = "URL invalide")   // nécessite hibernate-validator
    private String siteWeb;

    // ── Nombres ───────────────────────────────────────────────────────────────
    @Min(value = 0,   message = "Doit être >= 0")
    @Max(value = 999, message = "Doit être <= 999")
    private Integer quantite;

    @Positive(message = "Doit être strictement positif")
    private Integer nombre;

    @PositiveOrZero(message = "Doit être positif ou zéro")
    private Integer quantiteMin;

    @Negative(message = "Doit être négatif")
    private Integer rabais;

    @DecimalMin(value = "0.01", inclusive = true,  message = "Prix > 0.01")
    @DecimalMax(value = "9999.99", inclusive = true, message = "Prix <= 9999.99")
    @Digits(integer = 6, fraction = 2, message = "Max 6 entiers et 2 décimales")
    private BigDecimal prix;

    // ── Dates ─────────────────────────────────────────────────────────────────
    @Past(message = "Doit être dans le passé")
    private LocalDate dateNaissance;

    @PastOrPresent(message = "Doit être aujourd'hui ou dans le passé")
    private LocalDate dateDebut;

    @Future(message = "Doit être dans le futur")
    private LocalDate dateExpiration;

    @FutureOrPresent(message = "Doit être aujourd'hui ou dans le futur")
    private LocalDate dateEcheance;

    // ── Booléens ──────────────────────────────────────────────────────────────
    @AssertTrue(message = "Doit être vrai (CGU acceptées)")
    private Boolean cguAcceptees;

    @AssertFalse(message = "Doit être faux")
    private Boolean compteBloque;

    // ── Collections ───────────────────────────────────────────────────────────
    @NotEmpty(message = "La liste ne peut pas être vide")
    @Size(min = 1, max = 10, message = "Entre 1 et 10 éléments")
    private java.util.List<String> tags;
}

4.2. Validation personnalisée — @Constraint

// 1. Créer l'annotation de contrainte
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MotsDePasseCorrespondantsValidator.class)
@Documented
public @interface MotsDePasseCorrespondants {
    String message() default "Les mots de passe ne correspondent pas";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String premierChamp()  default "motDePasse";
    String secondChamp()   default "confirmationMotDePasse";
}

// 2. Créer le validateur
public class MotsDePasseCorrespondantsValidator
        implements ConstraintValidator<MotsDePasseCorrespondants, Object> {

    private String premierChamp;
    private String secondChamp;

    @Override
    public void initialize(MotsDePasseCorrespondants annotation) {
        this.premierChamp = annotation.premierChamp();
        this.secondChamp  = annotation.secondChamp();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        try {
            var getter1 = value.getClass()
                .getMethod("get" + capitaliser(premierChamp));
            var getter2 = value.getClass()
                .getMethod("get" + capitaliser(secondChamp));
            Object val1 = getter1.invoke(value);
            Object val2 = getter2.invoke(value);

            boolean valide = (val1 == null && val2 == null)
                || (val1 != null && val1.equals(val2));

            if (!valide) {
                // Attacher l'erreur au champ secondChamp
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(
                    context.getDefaultConstraintMessageTemplate())
                    .addPropertyNode(secondChamp)
                    .addConstraintViolation();
            }
            return valide;
        } catch (Exception e) {
            return false;
        }
    }

    private String capitaliser(String s) {
        return Character.toUpperCase(s.charAt(0)) + s.substring(1);
    }
}

// 3. Utiliser l'annotation sur le DTO
@MotsDePasseCorrespondants(
    premierChamp = "motDePasse",
    secondChamp  = "confirmation",
    message      = "La confirmation ne correspond pas au mot de passe"
)
@Data @NoArgsConstructor
public class InscriptionForm {
    @NotBlank @Size(min = 8)
    private String motDePasse;
    @NotBlank
    private String confirmation;
    @NotBlank @Email
    private String email;
}

4.3. Groupes de validation

// Définir des groupes
public interface GroupeCreation {}
public interface GroupeModification {}

// Utiliser les groupes
@Data @NoArgsConstructor
public class ProduitForm {

    // Présent seulement pour la modification
    @NotNull(message = "L'ID est requis pour la modification",
             groups = GroupeModification.class)
    private Long id;

    // Requis pour la création et la modification
    @NotBlank(groups = {GroupeCreation.class, GroupeModification.class})
    private String nom;

    // Requis seulement pour la création
    @NotNull(groups = GroupeCreation.class)
    private Long categorieId;
}

// Dans le contrôleur
@PostMapping("/produits")
public String creer(
        @Validated(GroupeCreation.class) // ← Utiliser @Validated, pas @Valid
        @ModelAttribute ProduitForm form,
        BindingResult result) {
    if (result.hasErrors()) return "produit/formulaire";
    // ...
}

@PutMapping("/produits/{id}")
public String modifier(
        @PathVariable Long id,
        @Validated(GroupeModification.class)
        @ModelAttribute ProduitForm form,
        BindingResult result) {
    // ...
}

4.4. Validation en cascade — @Valid

@Data @NoArgsConstructor
public class CommandeForm {

    @NotNull
    private Long clientId;

    @NotEmpty(message = "La commande doit contenir au moins un article")
    @Valid  // ← Déclenche la validation des éléments de la liste
    private List<LigneCommandeForm> lignes;
}

@Data @NoArgsConstructor
public class LigneCommandeForm {

    @NotNull(message = "Le produit est obligatoire")
    private Long produitId;

    @Min(value = 1, message = "La quantité doit être au moins 1")
    @Max(value = 100)
    private int quantite;
}

5. Validation dans Spring MVC et Thymeleaf

5.1. @Valid / @Validated dans les contrôleurs

@Controller
@RequestMapping("/produits")
@RequiredArgsConstructor
public class ProduitController {

    private final ProduitService  produitService;
    private final ProduitMapper   produitMapper;
    private final CategorieService categorieService;

    // ── Afficher le formulaire de création ────────────────────────────────────
    @GetMapping("/nouveau")
    public String afficherFormulaire(Model model) {
        model.addAttribute("produitForm", new ProduitForm()); // Formulaire vide
        model.addAttribute("categories", categorieService.trouverToutes());
        return "produit/formulaire";
    }

    // ── Traiter la soumission du formulaire ───────────────────────────────────
    @PostMapping("/nouveau")
    public String creer(
            @Valid @ModelAttribute("produitForm") ProduitForm form,
            // BindingResult DOIT être juste après le DTO annoté
            BindingResult bindingResult,
            Model model,
            RedirectAttributes redirectAttributes) {

        // ── Étape 1 : vérifier les erreurs de validation automatiques ─────────
        if (bindingResult.hasErrors()) {
            // Repopuler les données nécessaires au formulaire
            model.addAttribute("categories", categorieService.trouverToutes());
            // Retourner le formulaire avec les erreurs
            return "produit/formulaire";
        }

        // ── Étape 2 : vérification métier ─────────────────────────────────────
        if (produitService.existeParNom(form.getNom())) {
            // Ajouter une erreur sur un champ spécifique
            bindingResult.rejectValue(
                "nom",                           // Champ concerné
                "erreur.produit.nom.doublon",    // Code d'erreur (pour i18n)
                "Un produit avec ce nom existe déjà."  // Message par défaut
            );
            model.addAttribute("categories", categorieService.trouverToutes());
            return "produit/formulaire";
        }

        // ── Étape 3 : créer le produit ────────────────────────────────────────
        try {
            produitService.creer(form);
            // Message flash — affiché une seule fois après la redirection
            redirectAttributes.addFlashAttribute("succes",
                "Produit '" + form.getNom() + "' créé avec succès !");
            return "redirect:/produits";
        } catch (Exception e) {
            // Erreur globale (pas liée à un champ)
            bindingResult.reject("erreur.globale",
                "Erreur lors de la création : " + e.getMessage());
            model.addAttribute("categories", categorieService.trouverToutes());
            return "produit/formulaire";
        }
    }

    // ── Afficher le formulaire d'édition ──────────────────────────────────────
    @GetMapping("/{id}/modifier")
    public String afficherModification(@PathVariable Long id, Model model) {
        Produit produit = produitService.trouverParId(id);
        model.addAttribute("produitForm",  produitMapper.versFormulaire(produit));
        model.addAttribute("categories",   categorieService.trouverToutes());
        return "produit/formulaire";
    }

    // ── Traiter la modification ───────────────────────────────────────────────
    @PostMapping("/{id}/modifier")
    public String modifier(
            @PathVariable Long id,
            @Valid @ModelAttribute("produitForm") ProduitForm form,
            BindingResult bindingResult,
            Model model,
            RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) {
            model.addAttribute("categories", categorieService.trouverToutes());
            return "produit/formulaire";
        }
        form.setId(id);
        produitService.mettreAJour(form);
        redirectAttributes.addFlashAttribute("succes", "Produit modifié avec succès !");
        return "redirect:/produits";
    }
}

5.2. Thymeleaf — Afficher les erreurs de validation

<!-- templates/produit/formulaire.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      th:lang="fr">
<head>
    <meta charset="UTF-8">
    <title th:text="${produitForm.id == null} ? 'Nouveau produit' : 'Modifier le produit'">
        Formulaire produit
    </title>
    <!-- Bootstrap 5 pour le style -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
          rel="stylesheet">
</head>
<body>
<div class="container mt-4">
    <h2 th:text="${produitForm.id == null} ? 'Créer un produit' : 'Modifier le produit'">
        Formulaire
    </h2>

    <!-- ── Message de succès (flash attribute) ── -->
    <div th:if="${succes}" class="alert alert-success alert-dismissible fade show">
        <span th:text="${succes}">Succès</span>
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    </div>

    <!-- ── Erreurs globales (non liées à un champ) ── -->
    <div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
        <ul class="mb-0">
            <li th:each="err : ${#fields.globalErrors()}" th:text="${err}">Erreur</li>
        </ul>
    </div>

    <!-- ── Formulaire lié au DTO ProduitForm ── -->
    <!--
        th:action  = URL de soumission
        th:object  = le DTO lié au formulaire (doit correspondre au Model)
        th:method  = méthode HTTP
    -->
    <form th:action="${produitForm.id == null}
                     ? @{/produits/nouveau}
                     : @{/produits/{id}/modifier(id=${produitForm.id})}"
          th:object="${produitForm}"
          th:method="post"
          novalidate>

        <!-- Token CSRF — ajouté automatiquement par Spring Security si présent -->

        <!-- ── Champ Nom ── -->
        <div class="mb-3">
            <label for="nom" class="form-label">Nom <span class="text-danger">*</span></label>
            <!--
                th:field="*{nom}" génère :
                - id="nom"
                - name="nom"
                - value="${produitForm.nom}"
                ET la classe 'is-invalid' si erreur (avec th:errorclass)
            -->
            <input type="text"
                   class="form-control"
                   id="nom"
                   th:field="*{nom}"
                   th:errorclass="is-invalid"
                   placeholder="Nom du produit">
            <!-- th:errors="*{nom}" affiche les messages d'erreur du champ 'nom' -->
            <div th:if="${#fields.hasErrors('nom')}"
                 th:errors="*{nom}"
                 class="invalid-feedback">
                Erreur nom
            </div>
        </div>

        <!-- ── Champ Description ── -->
        <div class="mb-3">
            <label for="description" class="form-label">Description</label>
            <textarea class="form-control"
                      id="description"
                      th:field="*{description}"
                      th:errorclass="is-invalid"
                      rows="3"
                      placeholder="Description optionnelle"></textarea>
            <div th:if="${#fields.hasErrors('description')}"
                 th:errors="*{description}"
                 class="invalid-feedback">
                Erreur description
            </div>
        </div>

        <!-- ── Champ Prix ── -->
        <div class="mb-3">
            <label for="prix" class="form-label">Prix (€) <span class="text-danger">*</span></label>
            <input type="number"
                   step="0.01"
                   min="0.01"
                   class="form-control"
                   id="prix"
                   th:field="*{prix}"
                   th:errorclass="is-invalid"
                   placeholder="0.00">
            <div th:if="${#fields.hasErrors('prix')}"
                 th:errors="*{prix}"
                 class="invalid-feedback">
                Erreur prix
            </div>
        </div>

        <!-- ── Champ Stock ── -->
        <div class="mb-3">
            <label for="stock" class="form-label">Stock</label>
            <input type="number"
                   min="0"
                   class="form-control"
                   id="stock"
                   th:field="*{stock}"
                   th:errorclass="is-invalid">
            <div th:if="${#fields.hasErrors('stock')}"
                 th:errors="*{stock}"
                 class="invalid-feedback">
                Erreur stock
            </div>
        </div>

        <!-- ── Champ Catégorie (liste déroulante) ── -->
        <div class="mb-3">
            <label for="categorieId" class="form-label">
                Catégorie <span class="text-danger">*</span>
            </label>
            <select class="form-select"
                    id="categorieId"
                    th:field="*{categorieId}"
                    th:errorclass="is-invalid">
                <option value="">-- Choisir une catégorie --</option>
                <!--
                    th:each    = itérer sur la liste
                    th:value   = valeur de l'option
                    th:text    = texte affiché
                    th:selected est géré automatiquement par th:field
                -->
                <option th:each="cat : ${categories}"
                        th:value="${cat.id}"
                        th:text="${cat.libelle}">
                    Catégorie
                </option>
            </select>
            <div th:if="${#fields.hasErrors('categorieId')}"
                 th:errors="*{categorieId}"
                 class="invalid-feedback">
                Erreur catégorie
            </div>
        </div>

        <!-- ── Case à cocher Actif ── -->
        <div class="mb-3 form-check">
            <input type="checkbox"
                   class="form-check-input"
                   id="actif"
                   th:field="*{actif}">
            <label class="form-check-label" for="actif">Produit actif</label>
        </div>

        <!-- ── Boutons ── -->
        <div class="d-flex gap-2">
            <button type="submit" class="btn btn-primary">
                <span th:text="${produitForm.id == null} ? '✅ Créer' : '💾 Enregistrer'">
                    Soumettre
                </span>
            </button>
            <a th:href="@{/produits}" class="btn btn-secondary">Annuler</a>
        </div>
    </form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

5.3. Internationalisation des messages de validation

# src/main/resources/messages.properties (ou ValidationMessages.properties)

# Contraintes standard
jakarta.validation.constraints.NotBlank.message=Ce champ est obligatoire
jakarta.validation.constraints.NotNull.message=Ce champ est obligatoire
jakarta.validation.constraints.Size.message=Doit faire entre {min} et {max} caractères
jakarta.validation.constraints.Email.message=Adresse email invalide
jakarta.validation.constraints.Min.message=Doit être au moins {value}
jakarta.validation.constraints.Max.message=Ne peut pas dépasser {value}
jakarta.validation.constraints.DecimalMin.message=Doit être supérieur à {value}
jakarta.validation.constraints.Pattern.message=Format invalide

# Messages spécifiques à l'application
erreur.produit.nom.doublon=Un produit avec ce nom existe déjà dans le catalogue
erreur.produit.stock.negatif=Le stock ne peut pas être négatif
erreur.utilisateur.email.doublon=Cette adresse email est déjà utilisée
erreur.global=Une erreur inattendue s'est produite. Veuillez réessayer.
// Configuration pour utiliser messages.properties
@Configuration
public class ValidationConfig {

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource source =
            new ReloadableResourceBundleMessageSource();
        source.setBasenames("classpath:messages");
        source.setDefaultEncoding("UTF-8");
        source.setCacheSeconds(3600);
        return source;
    }

    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource);
        return bean;
    }
}

6. Gestion globale des erreurs

6.1. @ControllerAdvice — Intercepter toutes les exceptions

Notions complémentaires sur le @ControllerAdvice à consulter.

// exception/GlobalExceptionHandler.java
package fr.formation.monapp.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

/**
 * Gestionnaire global d'exceptions pour les vues Thymeleaf.
 * Intercepte toutes les exceptions non gérées dans les contrôleurs.
 */
@ControllerAdvice  // Pour les contrôleurs MVC retournant des vues
@Slf4j
public class GlobalExceptionHandler {

    // ── Ressource introuvable → page 404 ─────────────────────────────────────
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handleNotFound(ResourceNotFoundException ex, Model model) {
        log.warn("Ressource introuvable : {}", ex.getMessage());
        model.addAttribute("titre",   "Ressource introuvable");
        model.addAttribute("message", ex.getMessage());
        model.addAttribute("statut",  404);
        return "error/404";  // Vue Thymeleaf templates/error/404.html
    }

    // ── Accès refusé → page 403 ───────────────────────────────────────────────
    @ExceptionHandler(AccesRefuseException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public String handleForbidden(AccesRefuseException ex, Model model) {
        log.warn("Accès refusé : {}", ex.getMessage());
        model.addAttribute("titre",   "Accès refusé");
        model.addAttribute("message", ex.getMessage());
        model.addAttribute("statut",  403);
        return "error/403";
    }

    // ── Argument invalide → page d'erreur avec message ────────────────────────
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String handleBadRequest(IllegalArgumentException ex, Model model) {
        log.warn("Argument invalide : {}", ex.getMessage());
        model.addAttribute("titre",   "Données invalides");
        model.addAttribute("message", ex.getMessage());
        model.addAttribute("statut",  400);
        return "error/erreur";
    }

    // ── Erreur générique → page 500 ───────────────────────────────────────────
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleGeneral(Exception ex, Model model) {
        log.error("Erreur inattendue : {}", ex.getMessage(), ex);
        model.addAttribute("titre",   "Erreur serveur");
        model.addAttribute("message",
            "Une erreur inattendue s'est produite. Veuillez réessayer.");
        model.addAttribute("statut",  500);
        return "error/500";
    }
}

6.2. Exceptions métier personnalisées

// exception/ResourceNotFoundException.java
public class ResourceNotFoundException extends RuntimeException {

    private final String     ressource;
    private final Object     identifiant;

    public ResourceNotFoundException(String ressource, Object identifiant) {
        super(ressource + " introuvable avec l'identifiant : " + identifiant);
        this.ressource    = ressource;
        this.identifiant  = identifiant;
    }

    public ResourceNotFoundException(String message) {
        super(message);
        this.ressource   = null;
        this.identifiant = null;
    }

    public String  getRessource()    { return ressource; }
    public Object  getIdentifiant()  { return identifiant; }
}
// exception/ValidationMetierException.java
/**
 * Exception pour les erreurs de validation métier
 * (non couvertes par Bean Validation).
 */
public class ValidationMetierException extends RuntimeException {

    private final String champ;
    private final String codeErreur;

    public ValidationMetierException(String champ, String codeErreur, String message) {
        super(message);
        this.champ      = champ;
        this.codeErreur = codeErreur;
    }

    /** Injecter dans un BindingResult */
    public void injecterDans(BindingResult result) {
        result.rejectValue(champ, codeErreur, getMessage());
    }

    public String getChamp()      { return champ; }
    public String getCodeErreur() { return codeErreur; }
}
// exception/AccesRefuseException.java
public class AccesRefuseException extends RuntimeException {
    public AccesRefuseException(String message) {
        super(message);
    }
}

6.3. Pages d’erreur Thymeleaf

<!-- templates/error/404.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Ressource introuvable — 404</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
          rel="stylesheet">
</head>
<body class="bg-light">
<div class="container text-center mt-5">
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="display-1 text-muted">🔍</div>
            <h1 class="display-4 fw-bold text-danger">404</h1>
            <h2 th:text="${titre}">Ressource introuvable</h2>
            <p class="lead text-muted" th:text="${message}">
                La ressource demandée n'existe pas.
            </p>
            <a href="/" class="btn btn-primary">
                🏠 Retour à l'accueil
            </a>
        </div>
    </div>
</div>
</body>
</html>
<!-- templates/error/erreur.html — page d'erreur générique -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title th:text="${titre}">Erreur</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container mt-5">
    <div class="alert alert-danger" role="alert">
        <h4 class="alert-heading">
            <span th:text="${statut}">Erreur</span><span th:text="${titre}">Titre</span>
        </h4>
        <hr>
        <p class="mb-0" th:text="${message}">Message d'erreur</p>
    </div>
    <a href="/" class="btn btn-outline-primary">Retour à l'accueil</a>
</div>
</body>
</html>

6.4. Gestion des erreurs Spring Boot — ErrorController

Spring Boot fournit une page d’erreur par défaut sur /error. Pour la personnaliser :

// config/CustomErrorController.java
@Controller
public class CustomErrorController implements ErrorController {

    @Autowired
    private ErrorAttributes errorAttributes;

    @RequestMapping("/error")
    public String handleError(HttpServletRequest request, Model model) {
        WebRequest webRequest = new ServletWebRequest(request);
        Map<String, Object> attrs = errorAttributes.getErrorAttributes(
            webRequest, ErrorAttributeOptions.defaults());

        int statut = (int) attrs.getOrDefault("status", 500);
        model.addAttribute("statut",  statut);
        model.addAttribute("titre",   attrs.get("error"));
        model.addAttribute("message", attrs.get("message"));

        return switch (statut) {
            case 404 -> "error/404";
            case 403 -> "error/403";
            case 500 -> "error/500";
            default  -> "error/erreur";
        };
    }
}

6.5. @RestControllerAdvice — Pour les API REST

// Si votre application expose aussi des endpoints REST (API JSON)
@RestControllerAdvice  // = @ControllerAdvice + @ResponseBody
@Slf4j
public class RestGlobalExceptionHandler {

    record ErreurReponse(
        LocalDateTime timestamp,
        int statut, String erreur,
        String message, String chemin
    ) {}

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErreurReponse handleNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest req) {
        return new ErreurReponse(LocalDateTime.now(), 404,
            "Not Found", ex.getMessage(), req.getRequestURI());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> champs = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
            .forEach(e -> champs.put(e.getField(), e.getDefaultMessage()));
        return Map.of(
            "timestamp", LocalDateTime.now(),
            "statut",    400,
            "erreur",    "Validation Failed",
            "champs",    champs);
    }
}

7. JPA — Hibernate — Tous les cas de mapping

7.1. @Entity et configuration de base

Voici d’autres paramètres que l’on peut préciser pour la création de la table qui va correspondre à l’entité Produit.

@Entity
@Table(
    name = "produit",
    schema = "catalogue",  // Schéma PostgreSQL
    indexes = {
        @Index(name = "idx_produit_nom",       columnList = "nom"),
        @Index(name = "idx_produit_categorie", columnList = "categorie_id"),
        @Index(name = "idx_produit_actif",     columnList = "actif, prix")
    },
    uniqueConstraints = {
        @UniqueConstraint(name = "uc_produit_sku", columnNames = {"sku"})
    }
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = {"categorie", "commandes"})
@EqualsAndHashCode(of = "id")
public class Produit {

    // ── Clé primaire ──────────────────────────────────────────────────────────
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    // IDENTITY   = SERIAL / BIGSERIAL PostgreSQL (pour MySQL c'est différent)
    // SEQUENCE   = CREATE SEQUENCE PostgreSQL
    // TABLE      = Table de séquences (portable mais lent)
    // UUID       = Type UUID (de plus en plus utilisé)
    private Long id;

    // ── Colonnes simples ──────────────────────────────────────────────────────
    @Column(
        nullable = false,
        length   = 200,
        name     = "nom"       // Optionnel si même nom
    )
    private String nom;

    @Column(columnDefinition = "TEXT")   // Type SQL explicite
    private String description;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal prix;

    @Column(nullable = false, columnDefinition = "INTEGER DEFAULT 0")
    private int stock = 0;

    // ── Champ unique ──────────────────────────────────────────────────────────
    @Column(unique = true, length = 50)
    private String sku;  // Stock Keeping Unit

    // ── Enum stockée comme chaîne ─────────────────────────────────────────────
    @Enumerated(EnumType.STRING)  // STRING = "ELECTRONIQUE" / ORDINAL = 0 (déconseillé)
    @Column(nullable = false, length = 50)
    private Categorie categorie;

    // ── Booléen ───────────────────────────────────────────────────────────────
    @Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT TRUE")
    private boolean actif = true;

    // ── Dates gérées automatiquement ─────────────────────────────────────────
    @CreationTimestamp                           // Hibernate remplit à la création
    @Column(name = "date_creation", updatable = false)
    private LocalDateTime dateCreation;

    @UpdateTimestamp                             // Hibernate remplit à chaque update
    @Column(name = "date_modification")
    private LocalDateTime dateModification;

    // ── Champ calculé — non persisté ─────────────────────────────────────────
    @Transient
    private String libellePrixFormate;          // Calculé en mémoire, jamais en BDD

    // ── Version pour Optimistic Locking ──────────────────────────────────────
    @Version
    private Integer version;                    // Hibernate incrémente à chaque UPDATE

    // ── Large Object ─────────────────────────────────────────────────────────
    @Lob
    @Column(name = "image_donnees")
    private byte[] imageDonnees;                // BYTEA dans PostgreSQL

    // ── Type tableau PostgreSQL ───────────────────────────────────────────────
    // Nécessite hibernate-types : @Type(PostgreSQLArrayType.class)
    @Column(columnDefinition = "TEXT[]")
    private String[] tags;
}

7.2. Toutes les relations — Exemples complets

@ManyToOne — La relation la plus courante

// Commande → Client (N commandes pour 1 client)
@Entity
@Table(name = "commande")
public class Commande {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // ✅ @ManyToOne — côté propriétaire (clé étrangère en base)
    @ManyToOne(
        fetch = FetchType.LAZY,     // ← TOUJOURS LAZY pour éviter les N+1
        optional = false            // NOT NULL en SQL
    )
    @JoinColumn(
        name                 = "client_id",    // Nom de la colonne FK
        nullable             = false,
        foreignKey           = @ForeignKey(name = "fk_commande_client")
    )
    private Client client;

    @Column(nullable = false)
    private LocalDateTime dateCommande = LocalDateTime.now();

    @Enumerated(EnumType.STRING)
    private StatutCommande statut = StatutCommande.EN_COURS;
}

@OneToMany — Collection, côté inverse

@Entity
@Table(name = "client")
public class Client {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String nom;
    private String email;

    // ✅ @OneToMany — côté inverse (mappedBy = nom du champ @ManyToOne)
    @OneToMany(
        mappedBy      = "client",           // ← Champ @ManyToOne dans Commande
        cascade       = CascadeType.ALL,    // Les opérations se propagent
        orphanRemoval = true,               // Supprime les commandes sans client
        fetch         = FetchType.LAZY      // ← TOUJOURS LAZY pour les collections
    )
    private List<Commande> commandes = new ArrayList<>();

    // ── Méthodes helper — maintenir la cohérence bidirectionnelle ─────────────
    public void ajouterCommande(Commande commande) {
        commandes.add(commande);
        commande.setClient(this);     // ← Côté propriétaire DOIT être mis à jour
    }

    public void retirerCommande(Commande commande) {
        commandes.remove(commande);
        commande.setClient(null);
    }
}

@OneToOne — Relation un à un

@Entity
@Table(name = "utilisateur")
public class Utilisateur {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    // @OneToOne — côté propriétaire
    @OneToOne(
        cascade       = CascadeType.ALL,
        orphanRemoval = true,
        fetch         = FetchType.LAZY
    )
    @JoinColumn(
        name       = "profil_id",
        unique     = true,
        foreignKey = @ForeignKey(name = "fk_utilisateur_profil")
    )
    private ProfilUtilisateur profil;
}

@Entity
@Table(name = "profil_utilisateur")
public class ProfilUtilisateur {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String bio;
    private String avatar;
    private String siteWeb;

    // ✅ @OneToOne — côté inverse
    @OneToOne(mappedBy = "profil")
    private Utilisateur utilisateur;
}

@ManyToMany — Relation plusieurs à plusieurs

@Entity
@Table(name = "article")
public class Article {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String titre;

    // @ManyToMany — côté propriétaire
    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(
        name               = "article_tag",      // Table de jointure
        joinColumns        = @JoinColumn(name = "article_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id"),
        // Nommer les clés étrangères est une bonne pratique
        foreignKey        = @ForeignKey(name = "fk_article_tag_article"),
        inverseForeignKey = @ForeignKey(name = "fk_article_tag_tag")
    )
    private Set<Tag> tags = new HashSet<>();

    // Méthodes helper
    public void ajouterTag(Tag tag)    { tags.add(tag);    tag.getArticles().add(this); }
    public void retirerTag(Tag tag)    { tags.remove(tag); tag.getArticles().remove(this); }
}

@Entity
@Table(name = "tag")
public class Tag {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false, length = 50)
    private String nom;

    // @ManyToMany — côté inverse
    @ManyToMany(mappedBy = "tags")
    private Set<Article> articles = new HashSet<>();
}

@ManyToMany avec attributs sur la relation

// Quand la table de jointure a ses propres colonnes,
// créer une entité intermédiaire

@Entity
@Table(name = "inscription")
public class Inscription {

    // Clé composite — entité dédiée
    @EmbeddedId
    private InscriptionId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("etudiantId")
    @JoinColumn(name = "etudiant_id")
    private Etudiant etudiant;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("coursId")
    @JoinColumn(name = "cours_id")
    private Cours cours;

    // Attributs propres à la relation
    private LocalDate dateInscription = LocalDate.now();
    private String    statut          = "ACTIF";
    private Double    note;
}

@Embeddable
public class InscriptionId implements java.io.Serializable {
    private Long etudiantId;
    private Long coursId;
    // equals() et hashCode() OBLIGATOIRES
    @Override
    public boolean equals(Object o) { /* ... */ }
    @Override
    public int hashCode() { /* ... */ }
}

7.3. Héritage — Stratégies JPA

// ── SINGLE_TABLE : une seule table pour toute la hiérarchie ──────────────────
// Performances — pas de jointure
// Colonnes NULL pour les champs des sous-classes
@Entity
@Table(name = "vehicule")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type_vehicule", discriminatorType = DiscriminatorType.STRING)
public abstract class Vehicule {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String marque;
    private int annee;
}

@Entity
@DiscriminatorValue("VOITURE")
public class Voiture extends Vehicule {
    private int nombrePortes;
    private String typeBoite;  // "MANUELLE" ou "AUTOMATIQUE"
}

@Entity
@DiscriminatorValue("MOTO")
public class Moto extends Vehicule {
    private String typeMoto;   // "SPORTIVE", "TRAIL", "CUSTOM"
    private boolean sidecar;
}

// ── JOINED : une table par classe avec jointure ────────────────────────────
// Schéma normalisé
// Jointures à chaque requête
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Paiement {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private BigDecimal montant;
    private LocalDateTime date;
}

@Entity
@Table(name = "paiement_carte")
public class PaiementCarte extends Paiement {
    private String numeroCarte;   // Haché bien sûr !
    private String typeCarte;
}

@Entity
@Table(name = "paiement_virement")
public class PaiementVirement extends Paiement {
    private String iban;
    private String reference;
}

7.4. Requêtes avancées

@Repository
public interface ProduitRepository extends JpaRepository<Produit, Long>,
                                            JpaSpecificationExecutor<Produit> {

    // ── Méthodes dérivées ────────────────────────────────────────────────────
    List<Produit> findByActifTrueOrderByNomAsc();
    List<Produit> findByCategorieAndActifTrue(Categorie categorie);
    Optional<Produit> findBySkuIgnoreCase(String sku);
    boolean existsByNomIgnoreCase(String nom);
    long countByActifTrue();

    // ── @Query JPQL ───────────────────────────────────────────────────────────
    // JPQL = Java Persistence Query Language — basé sur les entités, pas les tables
    @Query("""
        SELECT p FROM Produit p
        WHERE p.actif = true
          AND p.prix BETWEEN :prixMin AND :prixMax
          AND (:categorie IS NULL OR p.categorie = :categorie)
        ORDER BY p.prix ASC
        """)
    List<Produit> rechercherAvance(
        @Param("prixMin")    BigDecimal prixMin,
        @Param("prixMax")    BigDecimal prixMax,
        @Param("categorie")  Categorie categorie
    );

    // JOIN FETCH — évite le problème N+1 (charge les associations en une requête)
    @Query("""
        SELECT DISTINCT c FROM Commande c
        LEFT JOIN FETCH c.lignes l
        LEFT JOIN FETCH l.produit
        WHERE c.client.id = :clientId
        ORDER BY c.dateCommande DESC
        """)
    List<Commande> findCommandesAvecLignes(@Param("clientId") Long clientId);

    // ── @Query SQL natif ─────────────────────────────────────────────────────
    @Query(value = """
        SELECT p.*, cp.libelle AS categorie_libelle,
               COUNT(lc.id) AS nombre_ventes
        FROM produit p
        LEFT JOIN categorie_plat cp ON p.categorie_id = cp.id
        LEFT JOIN ligne_commande lc ON lc.produit_id = p.id
        WHERE p.actif = TRUE
        GROUP BY p.id, cp.libelle
        ORDER BY nombre_ventes DESC
        LIMIT :limite
        """,
        countQuery = "SELECT COUNT(*) FROM produit WHERE actif = TRUE",
        nativeQuery = true)
    List<Object[]> trouverLesPlusVendus(@Param("limite") int limite);

    // ── Projection interface — ne charger que certains champs ─────────────────
    interface ProduitResume {
        Long       getId();
        String     getNom();
        BigDecimal getPrix();
        boolean    isActif();
    }

    List<ProduitResume> findByActifTrue(Sort sort);

    // ── Pagination ────────────────────────────────────────────────────────────
    Page<Produit> findByActifTrue(Pageable pageable);

    // ── Mise à jour en masse ──────────────────────────────────────────────────
    @Modifying
    @Transactional
    @Query("UPDATE Produit p SET p.actif = false WHERE p.stock = 0")
    int desactiverProduitsSansStock();
}

7.5. Specification — Filtres dynamiques

// specification/ProduitSpecification.java
public class ProduitSpecification {

    private ProduitSpecification() {}

    public static Specification<Produit> estActif() {
        return (root, q, cb) -> cb.isTrue(root.get("actif"));
    }

    public static Specification<Produit> avecCategorie(Categorie cat) {
        return (root, q, cb) ->
            cat == null ? cb.conjunction() :
            cb.equal(root.get("categorie"), cat);
    }

    public static Specification<Produit> prixEntre(BigDecimal min, BigDecimal max) {
        return (root, q, cb) -> {
            if (min == null && max == null) return cb.conjunction();
            if (min == null) return cb.lessThanOrEqualTo(root.get("prix"), max);
            if (max == null) return cb.greaterThanOrEqualTo(root.get("prix"), min);
            return cb.between(root.get("prix"), min, max);
        };
    }

    public static Specification<Produit> nomContient(String terme) {
        return (root, q, cb) ->
            terme == null || terme.isBlank() ? cb.conjunction() :
            cb.like(cb.lower(root.get("nom")),
                    "%" + terme.toLowerCase().trim() + "%");
    }

    public static Specification<Produit> stockInsuffisant(int seuil) {
        return (root, q, cb) ->
            cb.lessThanOrEqualTo(root.get("stock"), seuil);
    }
}

// Utilisation dans le service
@Transactional(readOnly = true)
public Page<Produit> rechercherAvance(RechercheForm form, Pageable pageable) {
    Specification<Produit> spec = Specification
        .where(ProduitSpecification.estActif())
        .and(ProduitSpecification.nomContient(form.getNom()))
        .and(ProduitSpecification.avecCategorie(form.getCategorie()))
        .and(ProduitSpecification.prixEntre(form.getPrixMin(), form.getPrixMax()));

    return produitRepository.findAll(spec, pageable);
}

8. Thymeleaf — Templates et formulaires

8.1. Layout et fragments

<!-- templates/layout/base.html — Layout principal -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- Titre dynamique -->
    <title th:text="${pageTitle != null} ? ${pageTitle} + ' — MonApp' : 'MonApp'">
        MonApp
    </title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
          rel="stylesheet">
    <link th:href="@{/css/style.css}" rel="stylesheet">
    <!-- Fragment pour des styles supplémentaires -->
    <th:block layout:fragment="styles"></th:block>
</head>
<body>
    <!-- Navigation -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" th:href="@{/}">MonApp</a>
            <div class="navbar-nav ms-auto">
                <a class="nav-link" th:href="@{/produits}">Produits</a>
                <a class="nav-link" th:href="@{/commandes}">Commandes</a>
            </div>
        </div>
    </nav>

    <!-- Contenu principal — défini par les pages enfants -->
    <main class="container mt-4">
        <!-- Messages flash -->
        <div th:if="${succes}"
             class="alert alert-success alert-dismissible fade show">
            <span th:text="${succes}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
        <div th:if="${erreur}"
             class="alert alert-danger alert-dismissible fade show">
            <span th:text="${erreur}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>

        <!-- Zone de contenu remplacée par les pages enfants -->
        <th:block layout:fragment="contenu">
            Contenu par défaut
        </th:block>
    </main>

    <footer class="bg-dark text-light mt-5 py-3">
        <div class="container text-center">
            <small>© 2024 MonApp — Tous droits réservés</small>
        </div>
    </footer>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <th:block layout:fragment="scripts"></th:block>
</body>
</html>
<!-- templates/produit/liste.html — Page utilisant le layout -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/base}">  <!-- Utiliser le layout base -->
<head>
    <title>Liste des produits</title>
</head>
<body>

<!-- ── Fragment : contenu de la page ─── -->
<div layout:fragment="contenu">

    <div class="d-flex justify-content-between align-items-center mb-4">
        <h1>📦 Produits</h1>
        <a th:href="@{/produits/nouveau}" class="btn btn-primary">
            ➕ Nouveau produit
        </a>
    </div>

    <!-- Tableau des produits -->
    <div class="table-responsive">
        <table class="table table-striped table-hover">
            <thead class="table-dark">
                <tr>
                    <th>ID</th>
                    <th>Nom</th>
                    <th>Catégorie</th>
                    <th>Prix</th>
                    <th>Stock</th>
                    <th>Statut</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
                <!-- th:each = boucle -->
                <tr th:each="produit : ${produits}">
                    <td th:text="${produit.id}">1</td>
                    <td th:text="${produit.nom}">Nom</td>
                    <td>
                        <!-- th:text avec formatage conditionnel -->
                        <span class="badge bg-secondary"
                              th:text="${produit.categorie}">
                            Catégorie
                        </span>
                    </td>
                    <td>
                        <!-- Formatage des nombres -->
                        <span th:text="${#numbers.formatDecimal(produit.prix, 1, 2)} + ' €'">
                            0.00 €
                        </span>
                    </td>
                    <td>
                        <!-- Alerte si stock faible -->
                        <span th:classappend="${produit.stock < 5} ? 'text-danger fw-bold' : ''"
                              th:text="${produit.stock}">
                            0
                        </span>
                    </td>
                    <td>
                        <!-- Affichage conditionnel -->
                        <span th:if="${produit.actif}"
                              class="badge bg-success">Actif</span>
                        <span th:unless="${produit.actif}"
                              class="badge bg-danger">Inactif</span>
                    </td>
                    <td>
                        <!-- Liens avec paramètres -->
                        <a th:href="@{/produits/{id}/modifier(id=${produit.id})}"
                           class="btn btn-sm btn-warning">✏️</a>
                        <a th:href="@{/produits/{id}(id=${produit.id})}"
                           class="btn btn-sm btn-info">👁️</a>
                        <form th:action="@{/produits/{id}/supprimer(id=${produit.id})}"
                              method="post" style="display:inline"
                              th:onsubmit="|return confirm('Supprimer ${produit.nom} ?')|">
                            <button type="submit" class="btn btn-sm btn-danger">🗑️</button>
                        </form>
                    </td>
                </tr>
                <!-- Cas liste vide -->
                <tr th:if="${#lists.isEmpty(produits)}">
                    <td colspan="7" class="text-center text-muted py-4">
                        Aucun produit trouvé
                    </td>
                </tr>
            </tbody>
        </table>
    </div>

    <!-- Pagination -->
    <nav th:if="${page.totalPages > 1}">
        <ul class="pagination justify-content-center">
            <li class="page-item" th:classappend="${page.first} ? 'disabled'">
                <a class="page-link"
                   th:href="@{/produits(page=${page.number - 1})}">
                    ‹ Précédent
                </a>
            </li>
            <li class="page-item"
                th:each="i : ${#numbers.sequence(0, page.totalPages - 1)}"
                th:classappend="${i == page.number} ? 'active'">
                <a class="page-link"
                   th:href="@{/produits(page=${i})}"
                   th:text="${i + 1}">1</a>
            </li>
            <li class="page-item" th:classappend="${page.last} ? 'disabled'">
                <a class="page-link"
                   th:href="@{/produits(page=${page.number + 1})}">
                    Suivant ›
                </a>
            </li>
        </ul>
        <p class="text-center text-muted">
            Page <span th:text="${page.number + 1}"></span>
            sur <span th:text="${page.totalPages}"></span>
            (<span th:text="${page.totalElements}"></span> produits)
        </p>
    </nav>

</div>

</body>
</html>

8.2. Expressions Thymeleaf essentielles

<!-- Expressions de variable -->
<p th:text="${variable}">Texte par défaut</p>
<p th:utext="${htmlVariable}">HTML non échappé (attention XSS !)</p>

<!-- Expressions de sélection (dans un th:object) -->
<p th:text="*{champ}">Valeur du champ</p>

<!-- Expressions de message (internationalisation) -->
<p th:text="#{cle.message}">Message traduit</p>

<!-- Expressions d'URL -->
<a th:href="@{/produits}">Lien absolu</a>
<a th:href="@{/produits/{id}(id=${produit.id})}">Lien avec variable</a>
<a th:href="@{/produits(page=0, tri='nom')}">Lien avec paramètres</a>

<!-- Expressions de fragment -->
<div th:insert="~{fragments/nav :: navigation}">Fragment inséré</div>
<div th:replace="~{fragments/nav :: navigation}">Fragment remplaçant</div>

<!-- Opérateurs conditionnels -->
<p th:text="${user != null} ? ${user.nom} : 'Anonyme'">Nom</p>
<p th:text="${user?.nom ?: 'Anonyme'}">Nom avec Elvis</p>

<!-- Itération avec statut -->
<tr th:each="item, stat : ${liste}"
    th:classappend="${stat.odd} ? 'table-warning' : ''">
    <td th:text="${stat.index + 1}">1</td>    <!-- Index 0-based -->
    <td th:text="${stat.count}">1</td>          <!-- Count 1-based -->
    <td th:text="${stat.first} ? 'Premier' : ''">Premier</td>
    <td th:text="${stat.last} ? 'Dernier' : ''">Dernier</td>
    <td th:text="${stat.size}">Total</td>
    <td th:text="${item.nom}">Nom</td>
</tr>

<!-- Formatage des dates -->
<td th:text="${#temporals.format(produit.dateCreation, 'dd/MM/yyyy HH:mm')}">
    01/01/2024 10:00
</td>

<!-- Formatage des nombres -->
<td th:text="${#numbers.formatDecimal(produit.prix, 1, 'COMMA', 2, 'POINT')}">
    1,299.99
</td>

<!-- Opérations sur les chaînes -->
<p th:text="${#strings.abbreviate(article.contenu, 100)}">Résumé...</p>
<p th:text="${#strings.toUpperCase(produit.nom)}">NOM</p>

9. Tests — DTO, validation, contrôleurs

9.1. Tester les mappers

@ExtendWith(MockitoExtension.class)
@DisplayName("Tests du ProduitMapper")
class ProduitMapperTest {

    private final ProduitMapper mapper = new ProduitMapper();

    @Test
    @DisplayName("versReponse — entité complète — tous les champs mappés")
    void versReponse_entiteComplete_tousLesChampsMapes() {
        Categorie categorie = new Categorie(1L, "Électronique");
        Produit produit = Produit.builder()
            .id(42L).nom("Laptop").description("PC portable")
            .prix(new BigDecimal("999.99")).stock(10)
            .categorie(categorie).actif(true)
            .dateCreation(LocalDateTime.of(2024, 1, 15, 10, 0))
            .build();

        ProduitReponse reponse = mapper.versReponse(produit);

        assertThat(reponse.id()).isEqualTo(42L);
        assertThat(reponse.nom()).isEqualTo("Laptop");
        assertThat(reponse.prix()).isEqualByComparingTo("999.99");
        assertThat(reponse.categorie()).isEqualTo("Électronique");
        assertThat(reponse.actif()).isTrue();
    }

    @Test
    @DisplayName("versEntite — form valide — entité sans ID")
    void versEntite_formValide_entiteSansId() {
        ProduitForm form = new ProduitForm();
        form.setNom("Nouveau Produit");
        form.setPrix(new BigDecimal("29.99"));
        form.setStock(5);
        form.setCategorieId(2L);

        Produit entite = mapper.versEntite(form);

        assertThat(entite.getId()).isNull();     // ID doit être null
        assertThat(entite.getNom()).isEqualTo("Nouveau Produit");
        assertThat(entite.getPrix()).isEqualByComparingTo("29.99");
    }

    @Test
    @DisplayName("mettreAJourEntite — modification partielle — champs modifiés")
    void mettreAJourEntite_modificationPartielle_champsModifies() {
        Produit existant = Produit.builder()
            .id(1L).nom("Ancien Nom").prix(BigDecimal.TEN).stock(5).build();
        ProduitForm form = new ProduitForm();
        form.setNom("Nouveau Nom");
        form.setPrix(new BigDecimal("15.00"));
        form.setStock(8);

        mapper.mettreAJourEntite(form, existant);

        assertThat(existant.getId()).isEqualTo(1L);  // ID préservé
        assertThat(existant.getNom()).isEqualTo("Nouveau Nom");
        assertThat(existant.getPrix()).isEqualByComparingTo("15.00");
        assertThat(existant.getStock()).isEqualTo(8);
    }
}

9.2. Tester la validation

@DisplayName("Tests de validation des DTOs")
class ValidationDtoTest {

    private static ValidatorFactory factory;
    private static Validator        validator;

    @BeforeAll
    static void setUp() {
        factory   = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @AfterAll
    static void tearDown() { factory.close(); }

    @Test
    @DisplayName("ProduitForm valide — aucune erreur de validation")
    void produitForm_valide_aucuneErreur() {
        ProduitForm form = new ProduitForm();
        form.setNom("Laptop Dell");
        form.setPrix(new BigDecimal("999.99"));
        form.setStock(5);
        form.setCategorieId(1L);

        Set<ConstraintViolation<ProduitForm>> violations = validator.validate(form);
        assertThat(violations).isEmpty();
    }

    @Test
    @DisplayName("ProduitForm — nom vide — erreur sur le champ nom")
    void produitForm_nomVide_erreurSurNom() {
        ProduitForm form = new ProduitForm();
        form.setNom(""); // ← Invalide
        form.setPrix(new BigDecimal("10.00"));
        form.setCategorieId(1L);

        Set<ConstraintViolation<ProduitForm>> violations = validator.validate(form);

        assertThat(violations).hasSize(1);
        assertThat(violations.iterator().next().getPropertyPath().toString())
            .isEqualTo("nom");
    }

    @ParameterizedTest
    @CsvSource({
        "'-1.00', 'Le prix doit être supérieur à 0'",
        "'0.00',  'Le prix doit être supérieur à 0'",
        "'100000.00', 'Le prix ne peut pas dépasser'"
    })
    @DisplayName("ProduitForm — prix invalide — erreur de validation")
    void produitForm_prixInvalide_erreurValidation(String prix, String messageAttendu) {
        ProduitForm form = new ProduitForm();
        form.setNom("Test");
        form.setPrix(new BigDecimal(prix));
        form.setCategorieId(1L);

        Set<ConstraintViolation<ProduitForm>> violations = validator.validate(form);

        assertThat(violations).isNotEmpty();
        assertThat(violations.stream()
            .anyMatch(v -> v.getMessage().contains(messageAttendu.replace("'", ""))
                        || v.getPropertyPath().toString().equals("prix")))
            .isTrue();
    }

    @Test
    @DisplayName("InscriptionForm — mots de passe non correspondants — erreur")
    void inscriptionForm_motPasseNonCorrespondants_erreur() {
        InscriptionForm form = new InscriptionForm();
        form.setEmail("test@test.fr");
        form.setMotDePasse("Password1");
        form.setConfirmation("AutrePassword1");

        Set<ConstraintViolation<InscriptionForm>> violations = validator.validate(form);

        assertThat(violations).isNotEmpty();
        assertThat(violations.stream()
            .anyMatch(v -> v.getMessage().contains("correspondent")))
            .isTrue();
    }
}

9.3. Tests du contrôleur — @WebMvcTest

@WebMvcTest(ProduitController.class)
@DisplayName("Tests du ProduitController")
class ProduitControllerTest {

    @Autowired private MockMvc     mockMvc;
    @MockBean  private ProduitService  produitService;
    @MockBean  private ProduitMapper   produitMapper;
    @MockBean  private CategorieService categorieService;

    @Autowired private ObjectMapper objectMapper;

    @Test
    @DisplayName("GET /produits — liste vide — affiche le template")
    void listeVide_afficheTemplate() throws Exception {
        when(produitService.trouverTous()).thenReturn(List.of());
        when(categorieService.trouverToutes()).thenReturn(List.of());

        mockMvc.perform(get("/produits"))
            .andExpect(status().isOk())
            .andExpect(view().name("produit/liste"))
            .andExpect(model().attributeExists("produits"))
            .andExpect(model().attribute("produits", hasSize(0)));
    }

    @Test
    @DisplayName("POST /produits/nouveau — form valide — redirection")
    void postNouveau_formValide_redirige() throws Exception {
        when(produitService.existeParNom(any())).thenReturn(false);
        when(produitService.creer(any())).thenReturn(
            Produit.builder().id(1L).nom("Test").build());

        mockMvc.perform(post("/produits/nouveau")
                .param("nom",         "Test Produit")
                .param("prix",        "29.99")
                .param("stock",       "5")
                .param("categorieId", "1")
                .with(csrf()))  // Spring Security CSRF token
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/produits"))
            .andExpect(flash().attribute("succes", containsString("créé")));
    }

    @Test
    @DisplayName("POST /produits/nouveau — nom vide — retourne formulaire avec erreurs")
    void postNouveau_nomVide_retourneFormulaireAvecErreurs() throws Exception {
        when(categorieService.trouverToutes()).thenReturn(List.of());

        mockMvc.perform(post("/produits/nouveau")
                .param("nom",  "")       // ← Invalide
                .param("prix", "29.99")
                .with(csrf()))
            .andExpect(status().isOk())
            .andExpect(view().name("produit/formulaire"))
            .andExpect(model().hasErrors())
            .andExpect(model().attributeHasFieldErrors("produitForm", "nom"));
    }

    @Test
    @DisplayName("GET /produits/99 — inexistant — redirige vers 404")
    void getProduitInexistant_retourne404() throws Exception {
        when(produitService.trouverParId(99L))
            .thenThrow(new ResourceNotFoundException("Produit", 99L));

        mockMvc.perform(get("/produits/99"))
            .andExpect(status().isNotFound())
            .andExpect(view().name("error/404"));
    }
}

10. TP Final — Catalogue de modèles IA

10.1. Présentation du projet

Vous allez construire AIModelHub : une application web Spring Boot complète permettant de cataloguer, noter et commenter des modèles d’intelligence artificielle (comme GPT-4, Llama 3, Mistral, etc.).

L’application sera développée avec :

10.2. Modèle de données

-- Script SQL à exécuter dans PostgreSQL

CREATE DATABASE ai_model_hub WITH ENCODING 'UTF8';
\c ai_model_hub

-- Fournisseurs (OpenAI, Meta, Mistral AI, Google...)
CREATE TABLE fournisseur (
    id          BIGSERIAL PRIMARY KEY,
    nom         VARCHAR(150) NOT NULL UNIQUE,
    description TEXT,
    site_web    VARCHAR(255),
    pays        VARCHAR(100),
    date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Modèles IA
CREATE TABLE modele_ia (
    id              BIGSERIAL PRIMARY KEY,
    nom             VARCHAR(200) NOT NULL,
    version         VARCHAR(50),
    description     TEXT,
    type_modele     VARCHAR(50) NOT NULL,
    -- 'TEXTE', 'IMAGE', 'AUDIO', 'MULTIMODAL', 'CODE', 'EMBEDDING'
    statut          VARCHAR(20) NOT NULL DEFAULT 'ACTIF',
    -- 'ACTIF', 'DEPRECATED', 'EXPERIMENTAL'
    parametres      BIGINT,       -- Nombre de paramètres (ex: 70000000000 = 70B)
    contexte_max    INTEGER,      -- Taille max du contexte en tokens
    open_source     BOOLEAN NOT NULL DEFAULT FALSE,
    gratuit         BOOLEAN NOT NULL DEFAULT FALSE,
    fournisseur_id  BIGINT NOT NULL REFERENCES fournisseur(id),
    date_sortie     DATE,
    date_creation   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    date_modif      TIMESTAMP
);

-- Tags pour les modèles
CREATE TABLE tag (
    id  BIGSERIAL PRIMARY KEY,
    nom VARCHAR(50) NOT NULL UNIQUE
);

-- Relation modele_ia ↔ tag (ManyToMany)
CREATE TABLE modele_tag (
    modele_id BIGINT NOT NULL REFERENCES modele_ia(id) ON DELETE CASCADE,
    tag_id    BIGINT NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
    PRIMARY KEY (modele_id, tag_id)
);

-- Évaluations
CREATE TABLE evaluation (
    id              BIGSERIAL PRIMARY KEY,
    modele_id       BIGINT NOT NULL REFERENCES modele_ia(id) ON DELETE CASCADE,
    evaluateur      VARCHAR(100) NOT NULL,
    note            INTEGER NOT NULL CHECK (note BETWEEN 1 AND 5),
    commentaire     TEXT,
    cas_utilisation VARCHAR(200),
    date_evaluation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Données initiales
INSERT INTO fournisseur (nom, description, site_web, pays) VALUES
    ('OpenAI',     'Créateur de GPT et DALL-E',          'https://openai.com',     'États-Unis'),
    ('Meta AI',    'Division IA de Meta (Facebook)',      'https://ai.meta.com',    'États-Unis'),
    ('Mistral AI', 'Startup française spécialisée en LLM','https://mistral.ai',     'France'),
    ('Google',     'DeepMind et Google Brain',            'https://deepmind.google','États-Unis'),
    ('Anthropic',  'Créateur de Claude',                  'https://anthropic.com',  'États-Unis');

INSERT INTO tag (nom) VALUES
    ('NLP'),('Vision'),('Code'),('Multimodal'),('Open Source'),
    ('RLHF'),('RAG'),('Fine-tunable'),('API disponible'),('Temps réel');

10.3. Modèles JPA à créer

// ── Fournisseur ──────────────────────────────────────────────────────────────
@Entity
@Table(name = "fournisseur")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@EqualsAndHashCode(of = "id")
public class Fournisseur {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 150, unique = true)
    private String nom;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Column(name = "site_web", length = 255)
    private String siteWeb;

    @Column(length = 100)
    private String pays;

    @CreationTimestamp
    @Column(name = "date_creation", updatable = false)
    private LocalDateTime dateCreation;

    // Relation inverse — un fournisseur a plusieurs modèles
    @OneToMany(mappedBy = "fournisseur", fetch = FetchType.LAZY)
    private List<ModeleIa> modeles = new ArrayList<>();
}

// ── ModeleIa ─────────────────────────────────────────────────────────────────
@Entity
@Table(name = "modele_ia")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@EqualsAndHashCode(of = "id")
@ToString(exclude = {"fournisseur", "tags", "evaluations"})
public class ModeleIa {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String nom;

    @Column(length = 50)
    private String version;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(name = "type_modele", nullable = false, length = 50)
    private TypeModele typeModele;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    @Builder.Default
    private StatutModele statut = StatutModele.ACTIF;

    @Column
    private Long parametres;  // Nombre de paramètres

    @Column(name = "contexte_max")
    private Integer contexteMax;  // Tokens max

    @Column(name = "open_source", nullable = false)
    @Builder.Default
    private boolean openSource = false;

    @Column(nullable = false)
    @Builder.Default
    private boolean gratuit = false;

    // @ManyToOne — vers le fournisseur
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "fournisseur_id", nullable = false,
                foreignKey = @ForeignKey(name = "fk_modele_fournisseur"))
    private Fournisseur fournisseur;

    @Column(name = "date_sortie")
    private LocalDate dateSortie;

    @CreationTimestamp
    @Column(name = "date_creation", updatable = false)
    private LocalDateTime dateCreation;

    @UpdateTimestamp
    @Column(name = "date_modif")
    private LocalDateTime dateModif;

    // @ManyToMany — avec les tags
    @ManyToMany
    @JoinTable(
        name               = "modele_tag",
        joinColumns        = @JoinColumn(name = "modele_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    @Builder.Default
    private Set<Tag> tags = new HashSet<>();

    // @OneToMany — évaluations
    @OneToMany(mappedBy = "modele", cascade = CascadeType.ALL,
               orphanRemoval = true, fetch = FetchType.LAZY)
    @Builder.Default
    private List<Evaluation> evaluations = new ArrayList<>();

    // Méthode calculée (non persistée)
    @Transient
    public Double getNoteMoyenne() {
        if (evaluations == null || evaluations.isEmpty()) return null;
        return evaluations.stream()
            .mapToInt(Evaluation::getNote)
            .average().orElse(0.0);
    }
}

10.4. DTOs à créer

// ── DTO de formulaire ─────────────────────────────────────────────────────────

// ModeleForm.java — pour créer ou modifier un modèle
@Data @NoArgsConstructor @AllArgsConstructor @Builder
public class ModeleForm {

    private Long id;  // null = création, non null = modification

    @NotBlank(message = "Le nom est obligatoire")
    @Size(min = 2, max = 200)
    private String nom;

    @Size(max = 50)
    private String version;

    @NotBlank(message = "La description est obligatoire")
    @Size(min = 10, message = "Description trop courte (minimum 10 caractères)")
    private String description;

    @NotNull(message = "Le type de modèle est obligatoire")
    private TypeModele typeModele;

    private StatutModele statut = StatutModele.ACTIF;

    @Min(value = 0)
    private Long parametres;

    @Min(value = 0)
    private Integer contexteMax;

    private boolean openSource;
    private boolean gratuit;

    @NotNull(message = "Le fournisseur est obligatoire")
    private Long fournisseurId;

    private LocalDate dateSortie;

    private Set<Long> tagIds = new HashSet<>();  // IDs des tags sélectionnés
}

// EvaluationForm.java — pour soumettre une évaluation
@Data @NoArgsConstructor @AllArgsConstructor @Builder
public class EvaluationForm {

    @NotBlank(message = "Votre nom est obligatoire")
    @Size(min = 2, max = 100)
    private String evaluateur;

    @NotNull(message = "La note est obligatoire")
    @Min(value = 1, message = "La note minimum est 1")
    @Max(value = 5, message = "La note maximum est 5")
    private Integer note;

    @Size(max = 2000, message = "Le commentaire ne peut pas dépasser 2000 caractères")
    private String commentaire;

    @Size(max = 200)
    private String casUtilisation;
}

// ── DTO de réponse ────────────────────────────────────────────────────────────

// ModeleReponse.java
public record ModeleReponse(
    Long          id,
    String        nom,
    String        version,
    String        description,
    TypeModele    typeModele,
    StatutModele  statut,
    Long          parametres,
    Integer       contexteMax,
    boolean       openSource,
    boolean       gratuit,
    String        fournisseurNom,
    String        fournisseurPays,
    LocalDate     dateSortie,
    Set<String>   tags,
    Double        noteMoyenne,
    int           nombreEvaluations
) {}

// ModeleResume.java — version légère pour la liste
public record ModeleResume(
    Long         id,
    String       nom,
    String       version,
    TypeModele   typeModele,
    String       fournisseurNom,
    boolean      openSource,
    boolean      gratuit,
    Double       noteMoyenne,
    int          nombreEvaluations,
    StatutModele statut
) {}

10.5. Missions du TP Final

Mission 1 — Mise en place

  1. Créez le projet Spring Boot depuis start.spring.io avec : Spring Web, Spring Data JPA, PostgreSQL Driver, Thymeleaf, Lombok, Validation, Thymeleaf Layout Dialect.
  2. Exécutez le script SQL.
  3. Configurez application.properties pour PostgreSQL.
  4. Créez toutes les entités JPA (Fournisseur, ModeleIa, Tag, Evaluation) avec les annotations complètes.
  5. Vérifiez que l’application démarre sans erreur (spring.jpa.hibernate.ddl-auto=validate).

Mission 2 — DTO et Mappers

  1. Créez ModeleForm, EvaluationForm avec toutes les validations.
  2. Créez ModeleReponse (record), ModeleResume (record).
  3. Créez ModeleMapper avec les méthodes : versReponse, versResume, versEntite, mettreAJourEntite, versFormulaire.
  4. Créez EvaluationMapper.
  5. Testez tous les mappers (tests unitaires).

Mission 3 — CRUD des modèles

Implémentez le CRUD complet pour les modèles IA :

URL Action
GET /modeles Liste paginée des modèles avec filtres
GET /modeles/{id} Détail d’un modèle avec ses évaluations
GET /modeles/nouveau Formulaire de création
POST /modeles/nouveau Traiter la création
GET /modeles/{id}/modifier Formulaire de modification
POST /modeles/{id}/modifier Traiter la modification
POST /modeles/{id}/supprimer Désactiver un modèle

Mission 4 — Évaluations

  1. Sur la page de détail d’un modèle, ajouter un formulaire d’évaluation (note + commentaire).
  2. Implémenter POST /modeles/{id}/evaluer pour soumettre une évaluation.
  3. Afficher la note moyenne et les évaluations sur la page de détail.
  4. Ajouter une validation : un même évaluateur ne peut évaluer qu’une fois le même modèle.

Mission 5 — Gestion des erreurs

  1. Créer ResourceNotFoundException et ValidationMetierException.
  2. Créer GlobalExceptionHandler avec tous les cas traités.
  3. Créer les pages Thymeleaf : error/404.html, error/500.html, error/erreur.html.
  4. Tester : accéder à /modeles/9999 doit afficher la page 404.

Mission 6 — Tests

  1. ModeleMapperTest — 5 tests du mapper.
  2. ModeleFormValidationTest — 6 tests de validation (cas valide + cas invalides).
  3. ModeleControllerTest avec @WebMvcTest — 6 tests du contrôleur.

Bonus : Ajouter un moteur de recherche avec filtres multiples (type, fournisseur, open source, gratuit) et affichage des résultats paginés.


Annexe — Aide-mémoire et ressources

Récapitulatif des annotations

Annotation Couche Description
@Valid Controller Déclenche la validation Bean Validation
@Validated Controller Comme @Valid + support des groupes de validation
@ModelAttribute Controller Lie le formulaire HTML au DTO
@ControllerAdvice Exception Intercepte les exceptions de tous les contrôleurs
@ExceptionHandler Exception Gère un type d’exception spécifique
@ResponseStatus Exception Définit le code HTTP de la réponse
@NotNull DTO Ne peut pas être null
@NotBlank DTO Chaîne non null, non vide, non blancs
@Size DTO Taille de chaîne ou collection
@Email DTO Format email valide
@Min / @Max DTO Valeur numérique min/max
@DecimalMin/Max DTO Valeur décimale min/max
@Pattern DTO Expression régulière
@Mapper MapStruct Interface de mapping
@Mapping MapStruct Mapping d’un champ spécifique
@MappingTarget MapStruct Entité existante à mettre à jour

Annotations Thymeleaf essentielles

Attribut Description Exemple
th:text Texte (échappé) th:text="${nom}"
th:field Champ de formulaire th:field="*{nom}"
th:object Objet du formulaire th:object="${form}"
th:errors Messages d’erreur th:errors="*{champ}"
th:errorclass Classe CSS si erreur th:errorclass="is-invalid"
th:if Condition th:if="${condition}"
th:each Boucle th:each="item : ${liste}"
th:href URL th:href="@{/chemin}"
th:action Action du formulaire th:action="@{/soumettre}"
th:classappend Ajouter des classes CSS th:classappend="${cond}?'class':''"

Checklist qualité

DTO
☐ Mapper créé pour chaque entité
☐ Aucune entité JPA exposée directement dans les vues
☐ ProduitForm avec @NoArgsConstructor pour Thymeleaf
☐ Records Java 17 pour les DTOs de réponse (immuables)
☐ Tests unitaires pour chaque méthode du mapper

Validation
☐ @NotBlank sur tous les champs texte obligatoires
☐ @Email pour les adresses email
☐ @Size avec min et max sur les chaînes
☐ Messages d'erreur explicites (pas "doit correspondre à...")
☐ BindingResult juste après @Valid/@ModelAttribute

Gestion des erreurs
☐ ResourceNotFoundException pour les 404
☐ @ControllerAdvice avec tous les cas traités
☐ Pages d'erreur Thymeleaf créées (404, 500, erreur)
☐ Messages d'erreur utiles pour l'utilisateur (pas les stack traces)
☐ Logs d'erreur dans le handler (log.error pour les 500, log.warn pour les 4xx)

JPA
☐ FetchType.LAZY sur tous les @ManyToOne et @OneToMany
☐ @EqualsAndHashCode(of = "id") sur toutes les entités
☐ @ToString(exclude = {...}) pour les champs avec relations
☐ Méthodes helper pour les associations bidirectionnelles
☐ @Transactional(readOnly=true) sur les méthodes de lecture

Ressources

Ressource URL
Documentation Spring Boot https://docs.spring.io/spring-boot/docs/current/reference
Thymeleaf https://www.thymeleaf.org/doc/tutorials/3.1/thymeleafspring.html
Bean Validation (Jakarta) https://jakarta.ee/specifications/bean-validation
MapStruct https://mapstruct.org/documentation/stable/reference/html
Bootstrap 5 https://getbootstrap.com/docs/5.3
Baeldung https://www.baeldung.com/spring-boot

Auteur : Philippe Bouget — Spring Boot · Thymeleaf · JPA/Hibernate · Java 17