Imaginez une entité JPA Utilisateur avec des champs sensibles…
Utilisateur
@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 :
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 :
CreerUtilisateurForm
UtilisateurReponse
ChangerMotDePasseCommande
UtilisateurResume
✅ 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
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
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 ) {}
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; }
// 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(); } }
// 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); } }
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).
Article
Commentaire
ArticleForm
ArticleReponse
CommentaireForm
ArticleMapper
versReponse
versEntite
versFormulaire
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>
// 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); }
@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); }
<!-- 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; }
// 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; }
// 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) { // ... }
@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; }
@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"; } }
<!-- 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>
# 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; } }
Notions complémentaires sur le @ControllerAdvice à consulter.
@ControllerAdvice
// 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"; } }
// 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); } }
<!-- 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>
Spring Boot fournit une page d’erreur par défaut sur /error. Pour la personnaliser :
/error
// 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"; }; } }
// 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); } }
Voici d’autres paramètres que l’on peut préciser pour la création de la table qui va correspondre à l’entité Produit.
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; }
// 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; }
@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); } }
@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; }
@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<>(); }
// 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() { /* ... */ } }
// ── 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; }
@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(); }
// 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); }
<!-- 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>
<!-- 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>
@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); } }
@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(); } }
@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")); } }
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 :
-- 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');
// ── 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); } }
// ── 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 ) {}
Mission 1 — Mise en place
application.properties
Fournisseur
ModeleIa
Tag
Evaluation
spring.jpa.hibernate.ddl-auto=validate
Mission 2 — DTO et Mappers
ModeleForm
EvaluationForm
ModeleReponse
ModeleResume
ModeleMapper
versResume
mettreAJourEntite
EvaluationMapper
Mission 3 — CRUD des modèles
Implémentez le CRUD complet pour les modèles IA :
GET /modeles
GET /modeles/{id}
GET /modeles/nouveau
POST /modeles/nouveau
GET /modeles/{id}/modifier
POST /modeles/{id}/modifier
POST /modeles/{id}/supprimer
Mission 4 — Évaluations
POST /modeles/{id}/evaluer
Mission 5 — Gestion des erreurs
ResourceNotFoundException
ValidationMetierException
GlobalExceptionHandler
error/404.html
error/500.html
error/erreur.html
/modeles/9999
Mission 6 — Tests
ModeleMapperTest
ModeleFormValidationTest
ModeleControllerTest
@WebMvcTest
Bonus : Ajouter un moteur de recherche avec filtres multiples (type, fournisseur, open source, gratuit) et affichage des résultats paginés.
@Valid
@Validated
@ModelAttribute
@ExceptionHandler
@ResponseStatus
@NotNull
@NotBlank
@Size
@Email
@Min
@Max
@DecimalMin/Max
@Pattern
@Mapper
@Mapping
@MappingTarget
th:text
th:text="${nom}"
th:field
th:field="*{nom}"
th:object
th:object="${form}"
th:errors
th:errors="*{champ}"
th:errorclass
th:errorclass="is-invalid"
th:if
th:if="${condition}"
th:each
th:each="item : ${liste}"
th:href
th:href="@{/chemin}"
th:action
th:action="@{/soumettre}"
th:classappend
th:classappend="${cond}?'class':''"
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
Auteur : Philippe Bouget — Spring Boot · Thymeleaf · JPA/Hibernate · Java 17