Spring Boot 4 — jeandecode.fr
Partie 00 · Prérequis

Java essentiel pour Spring Boot

Java est un grand langage, mais Spring Boot n'en utilise qu'une partie au quotidien. Ce chapitre couvre exactement ce dont on a besoin — pas plus, pas moins — avec des exercices pour vérifier que chaque notion est acquise avant de passer à la suivante.

1. Le typage statique — la différence fondamentale

Java est un langage à typage statique. Cela signifie que le type de chaque variable est décidé à l'écriture du code, et que le compilateur le vérifie avant même que le programme ne s'exécute.

Concrètement, ce code refuse de compiler :

String nom = "Alice"; nom = 42; // ❌ ERREUR DE COMPILATION // incompatible types: int cannot be converted to String // Le programme ne démarre même pas.

C'est une contrainte au début, mais c'est l'atout majeur de Java en équipe : une grande partie des erreurs sont détectées à la compilation, pas en production. L'IDE peut aussi proposer de l'autocomplétion précise, refactorer en sécurité, et naviguer dans le code.

Types primitifs et types objets

Java distingue deux familles de types. C'est une distinction qui surprend au début mais qui a une raison précise.

Type primitifType objet équivalentContientPeut être null ?
intIntegerEntier 32 bits (−2 milliards à +2 milliards)primitif : non — objet : oui
longLongEntier 64 bits (très grands nombres)primitif : non — objet : oui
doubleDoubleNombre décimalprimitif : non — objet : oui
booleanBooleantrue ou falseprimitif : non — objet : oui
charCharacterUn seul caractèreprimitif : non — objet : oui
—StringChaîne de caractèrestoujours objet — peut être null

Un primitif stocke directement une valeur en mémoire. Il est rapide et léger, mais il ne peut jamais valoir null — un int vaut toujours un nombre, par défaut 0.

Un type objet (aussi appelé wrapper) est une vraie classe qui enveloppe la valeur. Il peut valoir null, ce qui signifie « pas de valeur du tout ».

// Primitif — valeur par défaut 0, jamais null int compteur = 0; compteur = compteur + 1; // 1 // Objet — peut être null Integer age = null; // parfaitement valide age = 30; // Java convertit automatiquement (autoboxing) // ⚠ Le danger : appeler une méthode sur un objet null Integer prix = null; int valeur = prix; // 💥 NullPointerException à l'exécution // Java essaie de "déballer" un null → crash // Toujours vérifier avant de déballer if (prix != null) { int valeurSure = prix; }
Autoboxing / unboxing — Java convertit automatiquement int ↔ Integer. C'est pratique mais dangereux : un Integer valant null converti en int provoque une NullPointerException.
⚠ Piège classique — Utiliser long au lieu de Long pour les IDs

En Spring Data JPA, les identifiants d'entités doivent toujours être des types objets :

@Entity public class Article { @Id @GeneratedValue private long id; // ❌ MAUVAIS — un long vaut 0 par défaut // JPA ne peut pas distinguer "nouvelle entité" (id absent) // d'une entité avec id = 0 @Id @GeneratedValue private Long id; // ✅ BON — null = nouvelle entité, non-null = existante }

La règle : dans une entité JPA, toujours utiliser Long, jamais long, pour le champ @Id.

Le mot-clé var

Depuis Java 10, on peut laisser le compilateur deviner le type d'une variable locale avec var. Le type reste statique — il est simplement inféré à partir de ce qu'on affecte.

public void exemples() { // Le compilateur déduit le type — c'est toujours du typage statique var nom = "Alice"; // le compilateur sait : String var age = 30; // le compilateur sait : int var articles = new ArrayList
(); // le compilateur sait : ArrayList
// ⚠ var n'est PAS du typage dynamique var compteur = 0; // compteur = "texte"; ❌ toujours une erreur de compilation // var est très utile quand le type est verbeux var map = new HashMap>(); // au lieu de : HashMap> map2 = new HashMap>(); } // ❌ var est INTERDIT dans les signatures de méthodes // public var maMethode() { ... } ne compile pas // public void setNom(var nom) { ... } ne compile pas
Quand utiliser var ? — quand le type est évident à la lecture (var user = new User()) ou très verbeux. À éviter quand ça nuit à la lisibilité (var x = service.process() — on ne sait pas ce que ça retourne).
Exo 1Types primitifs et objets

Le code suivant contient trois problèmes. Les identifier et proposer une correction.

@Entity public class Produit { @Id @GeneratedValue private int id; private String nom; private double prix; public boolean estCher() { Integer seuil = null; return prix > seuil; } }
Voir la solution

Problème 1 — private int id : un identifiant JPA doit être Long (objet) et non int (primitif). Sinon JPA ne peut pas distinguer une nouvelle entité d'une entité avec id = 0.

Problème 2 — private double prix : pour de l'argent, double est une mauvaise idée (erreurs d'arrondi en binaire). Il faut utiliser BigDecimal.

Problème 3 — Integer seuil = null; return prix > seuil; : Java va tenter de déballer seuil (null) en int pour la comparaison → NullPointerException à l'exécution.

@Entity public class Produit { @Id @GeneratedValue private Long id; // ✅ Long, pas int private String nom; private BigDecimal prix; // ✅ BigDecimal pour l'argent public boolean estCher() { BigDecimal seuil = new BigDecimal("100.00"); // ✅ pas de null return prix.compareTo(seuil) > 0; // ✅ compareTo pour BigDecimal } }

2. Classes, objets et encapsulation

Tout code Java vit dans une classe. Une classe est un plan de construction : elle décrit quelles données (champs) et quels comportements (méthodes) auront les objets créés à partir d'elle.

package fr.jeandecode.model; // le "chemin" de la classe — doit correspondre au dossier // public = accessible depuis n'importe quelle autre classe du projet public class Article { // ── CHAMPS (l'état de l'objet) ──────────────────────── // private = accessible uniquement à l'intérieur de cette classe // C'est le principe d'ENCAPSULATION : on protège les données private Long id; private String titre; private String contenu; private boolean publie; // ── CONSTRUCTEUR ────────────────────────────────────── // Même nom que la classe, pas de type de retour // Appelé quand on fait : new Article("Mon titre", "Contenu") public Article(String titre, String contenu) { this.titre = titre; // "this" = l'objet en cours de construction this.contenu = contenu; this.publie = false; // valeur par défaut } // ── MÉTHODES (le comportement de l'objet) ───────────── // Getter — donne accès en lecture à un champ privé public String getTitre() { return titre; } // Setter — permet de modifier un champ privé, avec contrôle possible public void setTitre(String titre) { if (titre == null || titre.isBlank()) { throw new IllegalArgumentException("Le titre ne peut pas être vide"); } this.titre = titre; } // Méthode métier — un comportement propre à l'objet public void publier() { this.publie = true; } }
Pourquoi private + getters/setters ? — si le champ était public, n'importe qui pourrait écrire article.titre = null sans contrôle. Avec un setter, on peut valider. C'est l'encapsulation : l'objet garde le contrôle de son propre état.

Créer et utiliser un objet

public class Main { public static void main(String[] args) { // new appelle le constructeur et crée un objet en mémoire Article article = new Article("Spring Boot 4", "Contenu de l'article"); // Appeler une méthode sur l'objet System.out.println(article.getTitre()); // affiche : Spring Boot 4 // Modifier l'objet via un setter article.setTitre("Spring Boot 4.1"); article.publier(); } }

3. Héritage et interfaces

Deux mécanismes permettent de réutiliser et d'organiser du code : l'héritage (extends) et les interfaces (implements).

Les interfaces — un contrat

Une interface définit ce qu'une classe doit savoir faire, sans dire comment. C'est un contrat. Spring en fait un usage massif — comprendre les interfaces est indispensable.

// Une interface ne contient que des signatures de méthodes, pas de code // Elle dit CE QU'IL FAUT SAVOIR FAIRE, pas COMMENT public interface Notificateur { // Pas de corps de méthode — juste la signature void envoyer(String destinataire, String message); // Une interface peut avoir des méthodes par défaut (Java 8+) default void envoyerUrgent(String destinataire, String message) { envoyer(destinataire, "[URGENT] " + message); } }
// implements = "je respecte ce contrat" public class EmailNotificateur implements Notificateur { @Override // annotation qui dit "cette méthode vient de l'interface" public void envoyer(String destinataire, String message) { // Ici, l'implémentation concrète : envoyer un email System.out.println("Email à " + destinataire + " : " + message); } }
// Une autre implémentation du MÊME contrat public class SmsNotificateur implements Notificateur { @Override public void envoyer(String destinataire, String message) { System.out.println("SMS à " + destinataire + " : " + message); } }
public class Main { public static void main(String[] args) { // Le type déclaré est l'INTERFACE, pas la classe concrète // On peut donc changer d'implémentation sans changer le reste du code Notificateur notif = new EmailNotificateur(); notif.envoyer("alice@test.fr", "Bienvenue !"); notif = new SmsNotificateur(); // ← on change juste cette ligne notif.envoyer("0612345678", "Bienvenue !"); // La méthode par défaut est disponible sur les deux notif.envoyerUrgent("0612345678", "Panne serveur"); } }
C'est LE mécanisme central de Spring — un service dépend d'une interface (Notificateur), pas d'une classe concrète. Spring décide au démarrage quelle implémentation injecter. C'est ce qui rend le code testable et flexible.

L'annotation @Override

@Override indique au compilateur : « cette méthode redéfinit une méthode d'une interface ou d'une classe parente ». Si ce n'est pas le cas (faute de frappe dans le nom, mauvais paramètres), le compilateur lève une erreur.

⚠ Piège classique — Oublier @Override

Sans @Override, une faute de frappe passe inaperçue et le bug est difficile à trouver :

public class EmailNotificateur implements Notificateur { // Faute de frappe : "envoyer" écrit "envoyerr" public void envoyerr(String destinataire, String message) { // ❌ System.out.println("Email envoyé"); } // → Erreur de compilation : EmailNotificateur n'implémente pas envoyer() // Mais si l'interface avait une méthode par défaut, le bug serait silencieux ! }

Toujours écrire @Override. L'IDE le propose automatiquement.

4. Les génériques — lire List<String>

Les génériques permettent de dire « cette liste contient des Article » plutôt que « cette liste contient des objets quelconques ». Le compilateur peut alors vérifier qu'on ne met pas n'importe quoi dedans.

La syntaxe est toujours la même : Conteneur<TypeDuContenu>.

// Une liste qui contient des String List noms = new ArrayList<>(); noms.add("Alice"); // noms.add(42); ❌ erreur de compilation — 42 n'est pas un String // Une liste qui contient des Article List
articles = new ArrayList<>(); articles.add(new Article("Titre", "Contenu")); // Une Map : clé String → valeur Article Map parTitre = new HashMap<>(); parTitre.put("spring-boot", new Article("Spring Boot", "...")); // Génériques imbriqués — se lisent de l'extérieur vers l'intérieur Map> parAuteur = new HashMap<>(); // "une Map dont les clés sont des String // et dont les valeurs sont des listes d'Article"

Voici les types génériques qu'on rencontre en permanence dans Spring Boot :

List<Article>
Une liste d'articles — résultat typique de findAll()
Optional<Article>
Peut contenir un article, ou rien — résultat de findById()
Map<String, Article>
Association clé-valeur — clé String, valeur Article
Set<String>
Ensemble sans doublons de chaînes
ResponseEntity<ArticleDto>
Réponse HTTP contenant un ArticleDto — retour de contrôleur
Page<Article>
Une page paginée d'articles — résultat de findAll(Pageable)
JpaRepository<Article, Long>
Repository pour l'entité Article dont l'ID est de type Long
⚠ Piège classique — Le diamant <> vide

Depuis Java 7, on peut omettre le type à droite du = — le compilateur le déduit :

// Verbeux — on répète le type List
articles = new ArrayList
(); // Concis — le compilateur déduit
à partir de la déclaration de gauche List
articles2 = new ArrayList<>(); // ✅ préféré

Attention : new ArrayList<>() ne fonctionne que si le type est déductible du contexte. Sans déclaration à gauche, il faut préciser.

5. Les collections — List, Map, Set

Trois structures de données couvrent la quasi-totalité des besoins :

InterfaceImplémentation couranteCaractéristiqueUsage typique
List<T>ArrayList<T>Ordonnée, doublons autorisés, accès par indexUne liste d'articles à afficher
Set<T>HashSet<T>Pas de doublons, pas d'ordre garantiLes rôles d'un utilisateur
Map<K,V>HashMap<K,V>Association clé → valeur, clés uniquesGrouper des articles par auteur
// ── LIST : ordonnée, doublons OK ───────────────────────── List noms = new ArrayList<>(); noms.add("Alice"); noms.add("Bob"); noms.add("Alice"); // doublon accepté System.out.println(noms.size()); // 3 System.out.println(noms.get(0)); // Alice (accès par index) System.out.println(noms.contains("Bob")); // true // ── SET : pas de doublons ──────────────────────────────── Set rolesUniques = new HashSet<>(); rolesUniques.add("USER"); rolesUniques.add("ADMIN"); rolesUniques.add("USER"); // ignoré — déjà présent System.out.println(rolesUniques.size()); // 2 // ── MAP : clé → valeur ─────────────────────────────────── Map ages = new HashMap<>(); ages.put("Alice", 30); ages.put("Bob", 25); ages.put("Alice", 31); // remplace l'ancienne valeur System.out.println(ages.get("Alice")); // 31 System.out.println(ages.get("Charlie")); // null (clé absente) System.out.println(ages.containsKey("Bob")); // true // Parcourir une Map for (Map.Entry entree : ages.entrySet()) { System.out.println(entree.getKey() + " a " + entree.getValue() + " ans"); } // ── Listes immuables (Java 9+) ─────────────────────────── List roles = List.of("USER", "ADMIN"); // ne peut pas être modifiée // roles.add("MODERATOR"); 💥 UnsupportedOperationException
List.of() vs new ArrayList<>() — List.of() crée une liste immuable (pratique pour des constantes). new ArrayList<>() crée une liste modifiable. Choisir selon le besoin.
Exo 2Manipuler des collections

Écrire une méthode qui prend une List<Article> et retourne une Map<String, Integer> associant chaque auteur au nombre d'articles qu'il a écrits. On suppose que Article a une méthode getAuteur() qui retourne un String.

Voir la solution

Version classique avec une boucle :

public Map compterParAuteur(List
articles) { Map compteurs = new HashMap<>(); for (Article article : articles) { String auteur = article.getAuteur(); // getOrDefault : récupère la valeur ou 0 si la clé n'existe pas encore int actuel = compteurs.getOrDefault(auteur, 0); compteurs.put(auteur, actuel + 1); } return compteurs; }

On verra plus loin une version beaucoup plus courte avec la Stream API.

6. La Stream API — transformer des collections

La Stream API permet d'écrire des transformations de collections de façon déclarative — on décrit ce qu'on veut plutôt que comment le faire. C'est omniprésent dans le code Spring moderne.

Un stream suit toujours la même structure en trois temps :

1. Source
Ouvrir le flux : maListe.stream()
↓
2. Opérations intermédiaires
filter, map, sorted, distinct… — chaînables, renvoient un nouveau stream
↓
3. Opération terminale
collect, forEach, count, findFirst… — ferme le stream et produit un résultat

D'abord : les lambdas

Une lambda est une fonction anonyme écrite en une ligne. La syntaxe est (paramètres) -> expression.

// Un paramètre, une expression — les parenthèses sont optionnelles article -> article.getTitre() // Deux paramètres — parenthèses obligatoires (a, b) -> a.getTitre().compareTo(b.getTitre()) // Aucun paramètre () -> new ArrayList<>() // Plusieurs instructions — accolades + return explicite article -> { String titre = article.getTitre().toUpperCase(); return titre.trim(); } // Référence de méthode — raccourci quand la lambda ne fait qu'appeler une méthode Article::getTitre // équivaut à : article -> article.getTitre() this::convertirEnDto // équivaut à : article -> this.convertirEnDto(article) String::valueOf // équivaut à : x -> String.valueOf(x)

Les opérations principales

// ── FILTER : garder seulement certains éléments ────────── // Prend une lambda qui retourne un boolean List
publies = articles.stream() .filter(article -> article.isPublie()) // garder si la lambda retourne true .collect(Collectors.toList()); // ── MAP : transformer chaque élément ───────────────────── // Prend une lambda qui retourne autre chose List titres = articles.stream() .map(article -> article.getTitre()) // Article → String .collect(Collectors.toList()); // Avec une référence de méthode — plus court, même résultat List titres2 = articles.stream() .map(Article::getTitre) .collect(Collectors.toList()); // ── CHAÎNER : filter puis map ──────────────────────────── List titresPublies = articles.stream() .filter(Article::isPublie) // d'abord filtrer .map(Article::getTitre) // puis transformer .collect(Collectors.toList()); // ── SORTED : trier ─────────────────────────────────────── List
parTitre = articles.stream() .sorted(Comparator.comparing(Article::getTitre)) .collect(Collectors.toList()); // Tri décroissant List
recents = articles.stream() .sorted(Comparator.comparing(Article::getCreatedAt).reversed()) .collect(Collectors.toList()); // ── COUNT / ANYMATCH / FINDFIRST ───────────────────────── long nbPublies = articles.stream().filter(Article::isPublie).count(); boolean auMoinsUnPublie = articles.stream().anyMatch(Article::isPublie); Optional
premier = articles.stream() .filter(a -> a.getTitre().startsWith("Spring")) .findFirst();

Les collecteurs

// Collecter en List — le plus courant List liste = articles.stream() .map(Article::getTitre) .collect(Collectors.toList()); // Depuis Java 16 : raccourci .toList() (retourne une liste immuable) List liste2 = articles.stream() .map(Article::getTitre) .toList(); // Collecter en Set — élimine les doublons Set auteurs = articles.stream() .map(Article::getAuteur) .collect(Collectors.toSet()); // Grouper par une clé — retourne Map> Map> parAuteur = articles.stream() .collect(Collectors.groupingBy(Article::getAuteur)); // Compter par groupe — retourne Map Map nbParAuteur = articles.stream() .collect(Collectors.groupingBy(Article::getAuteur, Collectors.counting())); // Joindre en une chaîne String tousLesTitres = articles.stream() .map(Article::getTitre) .collect(Collectors.joining(", ")); // "Titre 1, Titre 2, Titre 3"
Le cas d'usage n°1 en Spring — convertir une liste d'entités en liste de DTOs : entities.stream().map(this::toDto).toList(). On écrira cette ligne des dizaines de fois.
⚠ Piège classique — Réutiliser un stream déjà consommé

Un stream ne peut être parcouru qu'une seule fois. Après une opération terminale, il est fermé.

// ❌ MAUVAIS Stream
stream = articles.stream(); long count = stream.count(); // opération terminale — stream fermé List
liste = stream.toList(); // 💥 IllegalStateException // ✅ BON — recréer un stream à chaque fois long count2 = articles.stream().count(); List
liste2 = articles.stream().toList();
Exo 3Refaire l'exercice 2 avec la Stream API

Reprendre l'exercice 2 (compter les articles par auteur) et l'écrire avec la Stream API. Objectif : une seule instruction.

Voir la solution

Avec Collectors.groupingBy et Collectors.counting() :

public Map compterParAuteur(List
articles) { return articles.stream() .collect(Collectors.groupingBy(Article::getAuteur, Collectors.counting())); }

Noter que le type de retour est Map<String, Long> et non Map<String, Integer> — Collectors.counting() retourne des Long.

7. Optional — en finir avec les NullPointerException

Optional<T> est une boîte qui contient peut-être une valeur. Au lieu de retourner null et d'espérer que l'appelant y pense, on retourne un Optional qui force à traiter le cas « pas de valeur ».

// ❌ L'ancienne approche — l'appelant peut oublier de vérifier public Article trouverParId(Long id) { Article article = repository.findById(id); // peut retourner null return article; // l'appelant ne sait pas que ça peut être null } // Appel : trouverParId(999).getTitre(); 💥 NullPointerException // ✅ L'approche Optional — le type dit explicitement "peut être vide" public Optional
trouverParId(Long id) { return repository.findById(id); // Spring Data retourne déjà un Optional } // Appel : le compilateur force à gérer les deux cas

Les méthodes d'Optional

Optional
resultat = repository.findById(1L); // ── orElseThrow : lever une exception si vide (LE PLUS UTILISÉ en Spring) ── Article article = resultat.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Article introuvable")); // ── orElse : valeur par défaut ── Article article2 = resultat.orElse(new Article("Titre par défaut", "")); // ── orElseGet : valeur par défaut calculée à la demande (paresseuse) ── // Préférable à orElse quand la valeur par défaut coûte cher à créer Article article3 = resultat.orElseGet(() -> creerArticleParDefaut()); // ── map : transformer si présent ── Optional titre = resultat.map(Article::getTitre); // ── ifPresent : exécuter si présent, ne rien faire sinon ── resultat.ifPresent(a -> System.out.println("Trouvé : " + a.getTitre())); // ── ifPresentOrElse : les deux cas ── resultat.ifPresentOrElse( a -> System.out.println("Trouvé : " + a.getTitre()), () -> System.out.println("Pas trouvé") ); // ── isPresent / isEmpty : tester ── if (resultat.isPresent()) { /* ... */ } if (resultat.isEmpty()) { /* ... */ }

Le pattern typique en Spring

// Chaîner map + orElseThrow — le pattern qu'on écrit le plus souvent public ArticleResponse trouverParId(Long id) { return repository.findById(id) // Optional
.map(ArticleResponse::from) // Optional (si présent) .orElseThrow(() -> // sinon : exception new ResponseStatusException(HttpStatus.NOT_FOUND, "Article " + id + " introuvable")); }
Ce pattern reviendra dans chaque service — récupérer une entité par ID, la convertir en DTO, ou lever une 404 si elle n'existe pas. Le mémoriser fera gagner beaucoup de temps.
⚠ Piège classique — Appeler .get() sur un Optional

Optional.get() existe mais lève une NoSuchElementException si l'Optional est vide — exactement le problème qu'on voulait éviter.

// ❌ MAUVAIS — annule tout l'intérêt d'Optional Article article = repository.findById(id).get(); // 💥 si vide // ✅ BON — traiter explicitement le cas vide Article article2 = repository.findById(id) .orElseThrow(() -> new ArticleNotFoundException(id));

Règle : ne jamais écrire .get(). Utiliser orElseThrow(), orElse() ou ifPresent().

8. Les records — les DTOs en une ligne

Un record est une classe immuable dont Java génère automatiquement le constructeur, les accesseurs, equals(), hashCode() et toString(). C'est parfait pour les DTOs.

// Ce record... public record ArticleResponse(Long id, String titre, boolean publie) {}
// ...remplace tout ce code de classe classique public final class ArticleResponseClassique { private final Long id; private final String titre; private final boolean publie; public ArticleResponseClassique(Long id, String titre, boolean publie) { this.id = id; this.titre = titre; this.publie = publie; } public Long getId() { return id; } public String getTitre() { return titre; } public boolean isPublie() { return publie; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ArticleResponseClassique other)) return false; return Objects.equals(id, other.id) && Objects.equals(titre, other.titre) && publie == other.publie; } @Override public int hashCode() { return Objects.hash(id, titre, publie); } @Override public String toString() { return "ArticleResponseClassique[id=" + id + ", titre=" + titre + ", publie=" + publie + "]"; } }

Utiliser un record

// Créer une instance ArticleResponse resp = new ArticleResponse(1L, "Spring Boot", true); // Accéder aux valeurs — PAS de getXxx(), juste le nom du champ System.out.println(resp.id()); // 1 ← et non resp.getId() System.out.println(resp.titre()); // Spring Boot System.out.println(resp.publie()); // true // toString() automatique System.out.println(resp); // ArticleResponse[id=1, titre=Spring Boot, publie=true] // equals() automatique — comparaison par valeur, pas par référence ArticleResponse resp2 = new ArticleResponse(1L, "Spring Boot", true); System.out.println(resp.equals(resp2)); // true

Validation et méthodes dans un record

public record ArticleRequest(String titre, String contenu, boolean publie) { // Constructeur compact — validation à la construction // Pas de paramètres, pas d'affectations : Java les gère public ArticleRequest { if (titre == null || titre.isBlank()) { throw new IllegalArgumentException("Le titre est obligatoire"); } if (contenu == null || contenu.length() < 10) { throw new IllegalArgumentException("Le contenu doit faire au moins 10 caractères"); } } // On peut ajouter des méthodes public String titreCourt() { return titre.length() > 50 ? titre.substring(0, 47) + "..." : titre; } // Et des méthodes statiques (fabriques) public static ArticleRequest brouillon(String titre) { return new ArticleRequest(titre, "Contenu à rédiger.", false); } }
Un record est immuable — une fois créé, ses champs ne changent jamais. Il n'y a pas de setters. Pour « modifier » un record, on en crée un nouveau. C'est exactement ce qu'on veut pour un DTO.
⚠ Piège classique — Utiliser un record comme entité JPA

Un record est immuable et n'a pas de constructeur sans arguments — JPA en a besoin des deux pour fonctionner (Hibernate crée l'objet vide puis remplit les champs par réflexion).

Règle : record pour les DTOs (entrée et sortie de l'API), class classique pour les entités JPA.

Exo 4Créer un DTO avec validation

Créer un record CreateUserRequest avec un email et un mot de passe. Le constructeur doit refuser :

  • un email qui ne contient pas @
  • un mot de passe de moins de 8 caractères

Ajouter aussi une méthode domaine() qui retourne la partie après le @ de l'email.

Voir la solution
public record CreateUserRequest(String email, String password) { public CreateUserRequest { if (email == null || !email.contains("@")) { throw new IllegalArgumentException("Email invalide : " + email); } if (password == null || password.length() < 8) { throw new IllegalArgumentException( "Le mot de passe doit faire au moins 8 caractères"); } } // Extraire le domaine de l'email public String domaine() { return email.substring(email.indexOf('@') + 1); } }

9. Les exceptions

Java distingue deux familles d'exceptions. La différence a un impact direct sur la façon d'écrire le code.

Exceptions vérifiées (checked)Exceptions non vérifiées (unchecked)
ExemplesIOException, SQLExceptionNullPointerException, IllegalArgumentException
Hérite deExceptionRuntimeException
ObligationLe compilateur force à les gérer (try/catch ou throws)Aucune obligation
En SpringRares — souvent enveloppéesUtilisées partout pour les erreurs métier
// ── Exception vérifiée : le compilateur force la gestion ── public void lireFichier(String chemin) { try { Files.readString(Path.of(chemin)); // peut lever IOException } catch (IOException e) { // On DOIT gérer, sinon ça ne compile pas throw new RuntimeException("Impossible de lire " + chemin, e); } } // ── Exception non vérifiée : aucune obligation ── public void diviser(int a, int b) { if (b == 0) { // Pas besoin de déclarer throws — c'est une RuntimeException throw new IllegalArgumentException("Division par zéro impossible"); } System.out.println(a / b); } // ── Créer sa propre exception métier ── // En Spring, on hérite presque toujours de RuntimeException public class ArticleNotFoundException extends RuntimeException { public ArticleNotFoundException(Long id) { super("Article introuvable : " + id); // super() = message de l'exception } }
Pourquoi RuntimeException en Spring ? — Spring intercepte les exceptions via @RestControllerAdvice et les traduit en réponses HTTP. Les exceptions non vérifiées évitent de polluer toutes les signatures de méthodes avec des throws.

La syntaxe try / catch / finally

try { // Code qui peut lever une exception Article article = service.trouverParId(id); traiter(article); } catch (ArticleNotFoundException e) { // Gérer un type d'exception précis log.warn("Article introuvable : {}", e.getMessage()); } catch (IllegalArgumentException | IllegalStateException e) { // Gérer plusieurs types dans le même bloc — séparateur | log.error("Argument ou état invalide", e); } finally { // Toujours exécuté, exception ou non // Utile pour libérer des ressources log.info("Traitement terminé"); } // try-with-resources : ferme automatiquement la ressource try (var reader = Files.newBufferedReader(Path.of("fichier.txt"))) { String ligne = reader.readLine(); } // reader.close() appelé automatiquement, même en cas d'exception

10. Les annotations

Une annotation est une métadonnée attachée à une classe, une méthode ou un champ. Elle ne fait rien par elle-même — c'est un outil externe (le compilateur, Spring, JPA) qui la lit et agit en conséquence.

C'est un point important à comprendre : @Service ne « fait » rien. C'est Spring qui, au démarrage, scanne les classes, voit l'annotation, et décide de créer un bean.

@Service // lue par Spring → crée un bean public class ArticleService { @Autowired // lue par Spring → injecte la dépendance private ArticleRepository repository; @Transactional // lue par Spring → ouvre une transaction public void sauvegarder(Article article) { @Deprecated // lue par le compilateur → avertissement à l'usage // ... } }
@Override
Compilateur — vérifie que la méthode redéfinit bien une méthode parente
@Deprecated
Compilateur — signale qu'un élément est obsolète
@SuppressWarnings
Compilateur — masque un avertissement précis
@Entity
JPA/Hibernate — cette classe correspond à une table SQL
@Service / @Component
Spring — cette classe est un bean géré par le conteneur
@RestController
Spring — cette classe expose des endpoints HTTP
@Valid
Bean Validation — vérifier les contraintes sur cet objet

Annotations avec paramètres

// Sans paramètre @Service // Un paramètre unique nommé "value" — le nom peut être omis @RequestMapping("/api/articles") @RequestMapping(value = "/api/articles") // équivalent // Plusieurs paramètres — nommés obligatoirement @Column(name = "created_at", nullable = false, updatable = false) // Paramètre tableau @RequestMapping(value = "/api/articles", method = {RequestMethod.GET, RequestMethod.POST}) // Paramètre classe @ExceptionHandler(ArticleNotFoundException.class) // noter le .class

11. Maven — organiser le projet

Maven est l'outil qui gère les dépendances (bibliothèques externes) et le cycle de vie du build (compiler, tester, packager). Tout est décrit dans un fichier pom.xml à la racine du projet.

La structure standard

Maven impose une organisation de dossiers stricte. La respecter, c'est bénéficier de toute l'automatisation.

monprojet/ ├── pom.xml # description du projet et des dépendances │ ├── src/ │ ├── main/ # code de l'application │ │ ├── java/ # code source Java │ │ │ └── fr/jeandecode/ # arborescence des packages │ │ │ ├── MonApplication.java │ │ │ ├── controller/ │ │ │ ├── service/ │ │ │ ├── repository/ │ │ │ └── entity/ │ │ └── resources/ # fichiers de configuration, ressources statiques │ │ ├── application.yml │ │ └── static/ │ │ │ └── test/ # code de test (jamais packagé dans le jar final) │ ├── java/ │ │ └── fr/jeandecode/ │ │ └── service/ArticleServiceTest.java │ └── resources/ │ └── application-test.yml │ └── target/ # généré par Maven — jamais committé dans Git ├── classes/ # .class compilés └── monprojet-0.0.1.jar # jar exécutable final
Le package doit correspondre au dossier — une classe déclarée package fr.jeandecode.service; doit se trouver dans src/main/java/fr/jeandecode/service/. Si les deux ne correspondent pas, ça ne compile pas.

Anatomie du pom.xml

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <!-- Le parent apporte des centaines de versions déjà testées ensemble --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.0</version> </parent> <!-- Identité du projet --> <groupId>fr.jeandecode</groupId> <!-- organisation, style nom de domaine inversé --> <artifactId>monprojet</artifactId> <!-- nom du projet --> <version>0.0.1-SNAPSHOT</version> <!-- SNAPSHOT = version de développement --> <properties> <java.version>21</java.version> </properties> <!-- Les bibliothèques dont le projet a besoin --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> <!-- pas de <version> : héritée du parent --> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

Les commandes du quotidien

# Compiler le projet mvn compile # Compiler + lancer tous les tests mvn test # Compiler + tester + créer le jar dans target/ mvn package # Idem, en sautant les tests (utile pour un build rapide) mvn package -DskipTests # Nettoyer target/ avant de rebuilder — recommandé en cas de comportement bizarre mvn clean package # Démarrer l'application en mode développement mvn spring-boot:run # Voir l'arbre complet des dépendances (utile pour résoudre des conflits de versions) mvn dependency:tree # Lancer le jar généré java -jar target/monprojet-0.0.1-SNAPSHOT.jar
En pratique — IntelliJ IDEA gère Maven graphiquement (panneau Maven à droite). Ces commandes sont surtout utiles en CI/CD, sur un serveur, ou quand l'IDE se comporte étrangement.
⚠ Piège classique — Oublier mvn clean après un changement de version

Maven ne recompile que ce qui a changé. Après une mise à jour de dépendance ou un changement de version de Java, des .class obsolètes peuvent traîner dans target/ et provoquer des erreurs incompréhensibles.

Réflexe : au moindre comportement bizarre, lancer mvn clean package et relancer.

✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • Aucun fichier à créer — chapitre théorique

Étapes

  1. Installer Java 21 (sdk install java 21.0.3-tem via SDKMAN)
  2. Installer Maven (sdk install maven) et vérifier avec mvn -version
  3. Installer IntelliJ IDEA Community ou VS Code + Extension Pack for Java
  4. Faire les 4 exercices de ce chapitre — ne pas passer à la suite avant de les avoir compris
  5. Créer un projet vide sur start.spring.io pour se familiariser avec la structure Maven
Partie 00 · Prérequis

IoC & Injection de dépendances

L'inversion de contrôle est le mécanisme central de Spring — tout le reste en découle. Ce chapitre explique le problème que ça résout, comment le conteneur fonctionne concrètement, et comment écrire du code qui en tire parti.

1. Le problème — sans inversion de contrôle

Prenons un cas concret. On veut un service qui crée un article et envoie un email de notification. Sans Spring, voici ce que ça donne :

public class ArticleService { private ArticleRepository repository; private EmailService emailService; public ArticleService() { // Le service crée LUI-MÊME ses dépendances // Il doit connaître comment les construire, avec quels paramètres DataSource ds = new DataSource("jdbc:postgresql://localhost/blog", "user", "pass"); this.repository = new ArticleRepository(ds); this.emailService = new EmailService(new SmtpConfig("smtp.gmail.com", 587)); } public void creer(Article article) { repository.save(article); emailService.envoyer("admin@site.fr", "Nouvel article : " + article.getTitre()); } }

Ce code fonctionne, mais il pose quatre problèmes sérieux :

Couplage fort ArticleService connaît les détails de construction de EmailService et ArticleRepository. Si l'un change de constructeur, ArticleService casse.
Impossible à tester Tester creer() nécessite une vraie base de données et un vrai serveur SMTP. Impossible d'écrire un test unitaire rapide.
Configuration en dur L'URL de la base et le serveur SMTP sont codés dans la classe. Changer d'environnement (dev / prod) oblige à modifier le code.
Duplication Chaque service qui a besoin d'EmailService en crée une nouvelle instance. Dix services = dix connexions SMTP inutiles.

2. L'inversion de contrôle

L'idée de l'IoC (Inversion of Control) tient en une phrase : ce n'est plus la classe qui crée ses dépendances, c'est quelqu'un d'autre qui les lui donne.

Ce « quelqu'un d'autre », c'est le conteneur Spring. Il crée tous les objets nécessaires, les configure, et les relie entre eux. Chaque objet ainsi géré s'appelle un bean.

1. Démarrage
Spring scanne toutes les classes du projet à la recherche d'annotations
↓ détecte @Service, @Repository, @Component…
2. Création
Spring instancie chaque classe annotée — une seule fois (singleton)
↓ analyse les constructeurs
3. Résolution
Spring identifie de quoi chaque bean a besoin et cherche le bean correspondant
↓ injecte les dépendances
4. Application prête
Tous les beans sont créés et reliés. L'application peut traiter des requêtes.

Voici le même service, réécrit avec l'IoC :

@Service // dit à Spring : "crée un bean de cette classe" public class ArticleService { // final = la référence ne changera jamais après construction private final ArticleRepository repository; private final EmailService emailService; // Spring appelle ce constructeur et fournit les deux dépendances // ArticleService ne sait PAS comment elles sont construites — il s'en fiche public ArticleService(ArticleRepository repository, EmailService emailService) { this.repository = repository; this.emailService = emailService; } public void creer(Article article) { repository.save(article); emailService.envoyer("admin@site.fr", "Nouvel article : " + article.getTitre()); } }
La classe est passée de 'je construis mes dépendances' à 'on me donne mes dépendances' — c'est ça, l'inversion de contrôle. Le contrôle de la création est inversé : il appartient au conteneur, plus à la classe.

Les quatre problèmes disparaissent :

  • Couplage — ArticleService ne connaît plus la construction de ses dépendances.
  • Tests — en test, on peut passer des mocks au constructeur, sans Spring, sans base de données.
  • Configuration — l'URL de la base vient de application.yml, gérée par Spring.
  • Duplication — Spring crée EmailService une seule fois et le partage entre tous les services (singleton).

3. Déclarer un bean — les stéréotypes

Pour que Spring gère une classe, il faut l'annoter. Toutes ces annotations font fondamentalement la même chose (créer un bean), mais elles portent une intention différente qui aide à la lecture du code.

AnnotationÀ utiliser pourSpécificité technique
@ComponentUne classe utilitaire génériqueL'annotation de base — les autres en dérivent
@ServiceLa logique métierAucune — purement sémantique
@RepositoryL'accès aux donnéesTraduit les exceptions JPA en exceptions Spring
@RestControllerUn contrôleur API RESTAjoute @ResponseBody sur chaque méthode
@ControllerUn contrôleur MVC (vues HTML)Retourne des noms de vues, pas du JSON
@ConfigurationUne classe de configurationPeut contenir des méthodes @Bean
@RestController // ← bean de type contrôleur REST @RequestMapping("/api/articles") public class ArticleController { private final ArticleService service; public ArticleController(ArticleService service) { this.service = service; } }
@Service // ← bean de logique métier public class ArticleService { private final ArticleRepository repository; public ArticleService(ArticleRepository repository) { this.repository = repository; } }
// Pas besoin de @Repository ici — Spring Data JPA crée le bean automatiquement // à partir de l'interface. L'annotation serait redondante. public interface ArticleRepository extends JpaRepository { }
@Component // ← bean utilitaire générique public class SlugGenerator { public String generer(String titre) { return titre.toLowerCase().replaceAll("[^a-z0-9]+", "-"); } }

Le scan de composants

Spring ne scanne pas tout le disque dur. Il part de la classe annotée @SpringBootApplication et scanne son package et tous les sous-packages.

// @SpringBootApplication combine trois annotations : // @Configuration — cette classe peut définir des beans // @EnableAutoConfiguration — active l'autoconfiguration Spring Boot // @ComponentScan — scanne fr.jeandecode et tous ses sous-packages @SpringBootApplication public class MonApplication { public static void main(String[] args) { SpringApplication.run(MonApplication.class, args); } }
⚠ Piège classique — Une classe hors du package scanné

Si MonApplication est dans fr.jeandecode, une classe placée dans com.autre.service ne sera jamais détectée, même annotée @Service.

src/main/java/ ├── fr/jeandecode/ │ ├── MonApplication.java ← point de départ du scan │ ├── service/ArticleService.java ✅ scanné (sous-package) │ └── controller/… ✅ scanné │ └── com/autre/ └── service/AutreService.java ❌ JAMAIS scanné — hors de fr.jeandecode

Erreur typique au démarrage : Parameter 0 of constructor required a bean of type '...' that could not be found. Vérifier d'abord que la classe est bien dans le bon package.

4. Les trois façons d'injecter — et laquelle choisir

Spring propose trois mécanismes d'injection. Ils fonctionnent tous, mais un seul est recommandé.

Injection par champ — à éviter

@Service public class ArticleService { @Autowired private ArticleRepository repository; // Spring injecte par réflexion @Autowired private EmailService emailService; // Pas de constructeur — Spring crée l'objet vide puis remplit les champs }

C'est la syntaxe la plus courte, mais elle pose plusieurs problèmes :

  • Non testable sans Spring — les champs sont private et il n'y a pas de constructeur pour les fournir. En test unitaire, on est obligé d'utiliser la réflexion ou de démarrer un contexte Spring.
  • Champs non final — rien n'empêche qu'ils soient réassignés plus tard.
  • Dépendances cachées — en lisant la signature de la classe, on ne voit pas de quoi elle a besoin. Il faut lire tous les champs.
  • Masque les mauvais designs — ajouter une dixième dépendance ne coûte qu'une ligne, donc on ne remarque pas que la classe fait trop de choses.

Injection par setter — cas rares

@Service public class ArticleService { private ArticleRepository repository; @Autowired public void setRepository(ArticleRepository repository) { this.repository = repository; } }

Utile uniquement pour des dépendances vraiment optionnelles, ce qui est rare. Dans la quasi-totalité des cas, une dépendance est obligatoire.

Injection par constructeur — la bonne pratique

@Service public class ArticleService { // final : la référence est fixée à la construction et ne changera jamais private final ArticleRepository repository; private final EmailService emailService; // Depuis Spring 4.3 : @Autowired est IMPLICITE quand il n'y a // qu'un seul constructeur. On peut donc l'omettre. public ArticleService(ArticleRepository repository, EmailService emailService) { this.repository = repository; this.emailService = emailService; } }

Les avantages sont concrets :

  • Testable sans Spring — new ArticleService(mockRepo, mockEmail) suffit.
  • Champs final — immuables, thread-safe, garantis non-null.
  • Dépendances visibles — la signature du constructeur documente exactement ce dont la classe a besoin.
  • Détection des mauvais designs — un constructeur à 8 paramètres saute aux yeux et signale une classe qui fait trop de choses.
  • Échec au démarrage — si une dépendance manque, l'application ne démarre pas. Mieux qu'un NullPointerException en production.

Avec Lombok — le pattern standard

Écrire le constructeur à la main devient répétitif. Lombok le génère automatiquement pour tous les champs final :

@Service @RequiredArgsConstructor // Lombok génère le constructeur pour tous les champs final public class ArticleService { private final ArticleRepository repository; private final EmailService emailService; // Le constructeur est généré à la compilation — invisible dans le code source // mais bien présent dans le .class public void creer(Article article) { repository.save(article); emailService.envoyer("admin@site.fr", article.getTitre()); } }
<!-- Ajouter Lombok au pom.xml --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> <!-- optional : pas propagé aux projets qui dépendent du nôtre --> </dependency>
@Service + @RequiredArgsConstructor + champs final — c'est le trio qu'on retrouve dans quasiment tous les projets Spring modernes. Le mémoriser : ce sera 90% des classes de service qu'on écrira.
⚠ Piège classique — Lombok ne fonctionne pas dans l'IDE

Lombok génère du code à la compilation. Si l'IDE affiche des erreurs du type « cannot find symbol: constructor » alors que Maven compile bien, il manque le plugin Lombok :

  • IntelliJ IDEA — Settings → Plugins → chercher « Lombok » → Install, puis Settings → Build → Compiler → Annotation Processors → cocher Enable annotation processing
  • VS Code — installer l'extension « Lombok Annotations Support for VS Code »
Exo 1Refactorer vers l'injection par constructeur

Le service suivant utilise l'injection par champ. Le réécrire en injection par constructeur, d'abord à la main, puis avec Lombok.

@Service public class CommandeService { @Autowired private CommandeRepository commandeRepository; @Autowired private ProduitService produitService; @Autowired private EmailService emailService; public void passerCommande(Long produitId, int quantite) { // ... } }
Voir la solution

Version 1 — constructeur manuel :

@Service public class CommandeService { private final CommandeRepository commandeRepository; private final ProduitService produitService; private final EmailService emailService; public CommandeService(CommandeRepository commandeRepository, ProduitService produitService, EmailService emailService) { this.commandeRepository = commandeRepository; this.produitService = produitService; this.emailService = emailService; } public void passerCommande(Long produitId, int quantite) { // ... } }

Version 2 — avec Lombok :

@Service @RequiredArgsConstructor public class CommandeService { private final CommandeRepository commandeRepository; private final ProduitService produitService; private final EmailService emailService; public void passerCommande(Long produitId, int quantite) { // ... } }

Noter au passage que ce service a trois dépendances — c'est encore raisonnable. Au-delà de cinq, il faudrait se demander si la classe ne fait pas trop de choses.

5. Injecter par interface — le vrai bénéfice

Jusqu'ici on a injecté des classes concrètes. Le mécanisme prend toute sa puissance quand on injecte des interfaces.

// Le contrat — ce que le service a besoin de pouvoir faire public interface Notificateur { void envoyer(String destinataire, String message); }
@Service // c'est CETTE classe que Spring va instancier public class EmailNotificateur implements Notificateur { @Override public void envoyer(String destinataire, String message) { // envoi réel par SMTP } }
@Service @RequiredArgsConstructor public class ArticleService { // On dépend de l'INTERFACE, pas de la classe concrète private final Notificateur notificateur; public void creer(Article article) { // ArticleService ne sait pas si c'est un email, un SMS ou un webhook notificateur.envoyer("admin@site.fr", "Nouvel article"); } }
Spring résout automatiquement — il voit que ArticleService a besoin d'un Notificateur, cherche un bean qui implémente cette interface, trouve EmailNotificateur, et l'injecte. Changer d'implémentation ne demande de modifier aucune ligne d'ArticleService.

Plusieurs implémentations de la même interface

Si deux classes implémentent Notificateur, Spring ne sait plus laquelle choisir et refuse de démarrer :

Parameter 0 of constructor in fr.jeandecode.service.ArticleService required a single bean, but 2 were found: - emailNotificateur - smsNotificateur

Trois solutions selon le besoin :

// SOLUTION 1 — @Primary : "c'est celle-ci par défaut" @Service @Primary public class EmailNotificateur implements Notificateur { }
// SOLUTION 2 — @Qualifier : choisir explicitement au point d'injection @Service public class ArticleService { private final Notificateur notificateur; public ArticleService(@Qualifier("smsNotificateur") Notificateur notificateur) { this.notificateur = notificateur; // ← force le bean nommé "smsNotificateur" } }
// SOLUTION 3 — injecter TOUTES les implémentations dans une List @Service public class NotificationOrchestrator { // Spring injecte automatiquement tous les beans de type Notificateur private final List notificateurs; public NotificationOrchestrator(List notificateurs) { this.notificateurs = notificateurs; } public void notifierPartout(String destinataire, String message) { notificateurs.forEach(n -> n.envoyer(destinataire, message)); } }
Le nom du bean par défaut — Spring nomme un bean d'après le nom de la classe avec la première lettre en minuscule. EmailNotificateur → bean "emailNotificateur". C'est ce nom qu'on utilise dans @Qualifier.

6. La méthode @Bean — configurer des classes externes

On ne peut pas ajouter @Service à une classe d'une bibliothèque tierce (on n'a pas son code source). Pour ces cas, on déclare le bean dans une classe @Configuration :

@Configuration public class AppConfig { // Cette méthode retourne un objet — Spring en fait un bean // Le nom du bean = le nom de la méthode (ici : "restTemplate") @Bean public RestTemplate restTemplate() { return new RestTemplate(); } // On peut configurer l'objet avant de le retourner @Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } // Une méthode @Bean peut recevoir d'autres beans en paramètre @Bean public NotificationOrchestrator orchestrator(List notificateurs) { return new NotificationOrchestrator(notificateurs); } // Bean conditionnel — créé uniquement si la propriété est à true @Bean @ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true") public CacheManager cacheManager() { return new ConcurrentMapCacheManager("articles"); } }

7. Le cycle de vie et les scopes

Par défaut, Spring crée une seule instance de chaque bean, partagée par toute l'application. C'est le scope singleton.

ScopeNombre d'instancesQuand l'utiliser
singleton (défaut)Une seule pour toute l'application99% des cas — services, repositories, contrôleurs
prototypeUne nouvelle à chaque injectionObjets avec état mutable propre à un usage
requestUne par requête HTTPDonnées liées à la requête courante
sessionUne par session utilisateurPanier d'achat dans une app web classique
@Service @Scope("prototype") // une nouvelle instance à chaque injection public class CompteurService { private int compteur = 0; public void incrementer() { compteur++; } }
⚠ Piège classique — Un état mutable dans un bean singleton

Puisqu'un singleton est partagé par tous les threads, y stocker un état mutable crée des bugs de concurrence très difficiles à reproduire :

@Service public class ArticleService { // ❌ DANGER : champ mutable dans un singleton private Article articleEnCours; // partagé entre TOUTES les requêtes simultanées public void traiter(Long id) { this.articleEnCours = repository.findById(id).orElseThrow(); // Si deux requêtes arrivent en même temps, la deuxième écrase la première envoyerNotification(); // peut envoyer la notification du mauvais article } }

Règle — un bean singleton ne doit contenir que des champs final (ses dépendances). Tout état lié à un traitement doit être une variable locale ou un paramètre de méthode.

@Service @RequiredArgsConstructor public class ArticleService { private final ArticleRepository repository; // ✅ final, immuable public void traiter(Long id) { Article article = repository.findById(id).orElseThrow(); // ✅ variable locale envoyerNotification(article); } }

Les hooks de cycle de vie

@Service public class CacheService { private Map cache; // Appelé après la construction ET l'injection des dépendances @PostConstruct public void initialiser() { this.cache = new ConcurrentHashMap<>(); System.out.println("Cache initialisé"); } // Appelé juste avant l'arrêt de l'application @PreDestroy public void nettoyer() { cache.clear(); System.out.println("Cache vidé"); } }
Pourquoi @PostConstruct et pas le constructeur ? — au moment où le constructeur s'exécute, les dépendances injectées par setter ou par champ ne sont pas encore disponibles. @PostConstruct s'exécute quand tout est prêt.

8. L'autoconfiguration

L'autoconfiguration est ce qui rend Spring Boot si rapide à démarrer. Le principe : Spring Boot observe ce qui est présent dans le classpath et configure automatiquement ce qui va avec.

Exemple concret. En ajoutant simplement cette dépendance :

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>

Spring Boot détecte JPA et Hibernate dans le classpath et crée automatiquement, sans une ligne de code de notre part :

  • Un DataSource configuré à partir des propriétés spring.datasource.*
  • Un EntityManagerFactory Hibernate
  • Un PlatformTransactionManager pour gérer les transactions
  • Un JdbcTemplate
  • Le scan des entités @Entity et la génération des implémentations de repositories

Voir ce que Spring configure

# Afficher le rapport complet des autoconfigurations au démarrage mvn spring-boot:run -Dspring-boot.run.arguments=--debug # Ou dans application.yml : # debug: true
# Le rapport affiche trois sections : Positive matches: ← autoconfigurations ACTIVÉES ----------------- DataSourceAutoConfiguration matched: - @ConditionalOnClass found required class 'javax.sql.DataSource' Negative matches: ← autoconfigurations IGNORÉES et pourquoi ----------------- MongoAutoConfiguration: Did not match: - @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient' Exclusions: ← autoconfigurations explicitement désactivées -----------
Utile en cas de comportement inattendu — si une fonctionnalité ne marche pas, ce rapport dit si l'autoconfiguration correspondante s'est activée, et sinon, quelle condition a échoué.

Surcharger ou désactiver une autoconfiguration

@Configuration public class AppConfig { // Déclarer notre propre bean désactive l'autoconfiguration correspondante // Spring Boot n'écrase jamais un bean défini explicitement @Bean public DataSource dataSource() { return new HikariDataSource(maConfigPersonnalisee()); } }
// Désactiver complètement une autoconfiguration @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) public class MonApplication { }

9. Configuration externalisée et profils

Aucune valeur de configuration ne doit être codée en dur. Spring Boot lit application.yml (ou application.properties) et rend les valeurs accessibles.

Lire une propriété avec @Value

app: nom: "Mon Blog" jwt: secret: "cle-secrete-a-changer-en-production" expiration-ms: 86400000 email: expediteur: "noreply@jeandecode.fr" actif: true
@Service public class JwtService { @Value("${app.jwt.secret}") // injecté depuis application.yml private String secret; @Value("${app.jwt.expiration-ms}") private long expirationMs; // Avec une valeur par défaut (après le :) si la propriété est absente @Value("${app.jwt.issuer:jeandecode}") private String issuer; }

Regrouper avec @ConfigurationProperties

Quand il y a plusieurs propriétés liées, @ConfigurationProperties est plus propre que plusieurs @Value :

@Component @ConfigurationProperties(prefix = "app.jwt") // lie toutes les propriétés app.jwt.* @Getter @Setter public class JwtProperties { private String secret; // ← app.jwt.secret private long expirationMs; // ← app.jwt.expiration-ms (kebab-case → camelCase) private String issuer = "jeandecode"; // valeur par défaut }
@Service @RequiredArgsConstructor public class JwtService { private final JwtProperties props; // injecté comme n'importe quel bean public String generer(String email) { long expiration = props.getExpirationMs(); // ... } }
Avantage de @ConfigurationProperties — les propriétés sont typées, validables avec @Validated, et regroupées logiquement. L'IDE peut aussi proposer l'autocomplétion dans application.yml.

Les profils

Un profil permet d'avoir des configurations différentes selon l'environnement, dans un même fichier ou dans des fichiers séparés.

spring: profiles: active: dev # profil actif par défaut --- spring: config: activate: on-profile: dev # ce bloc s'applique en profil dev datasource: url: jdbc:postgresql://localhost:5432/blog_dev username: postgres password: postgres jpa: show-sql: true # afficher le SQL en dev --- spring: config: activate: on-profile: prod # ce bloc s'applique en profil prod datasource: url: ${DATABASE_URL} # variables d'environnement en production username: ${DATABASE_USER} password: ${DATABASE_PASSWORD} jpa: show-sql: false hibernate: ddl-auto: validate # jamais update en prod !
# Activer un profil au lancement — trois méthodes équivalentes # 1. Argument JVM java -jar app.jar --spring.profiles.active=prod # 2. Variable d'environnement (recommandé en Docker/Kubernetes) export SPRING_PROFILES_ACTIVE=prod java -jar app.jar # 3. Avec Maven en développement mvn spring-boot:run -Dspring-boot.run.profiles=dev

Beans conditionnels par profil

@Service @Profile("prod") // ce bean n'existe QUE en profil prod public class EmailNotificateur implements Notificateur { @Override public void envoyer(String dest, String msg) { // envoi SMTP réel } }
@Service @Profile("!prod") // ce bean existe dans tous les profils SAUF prod public class ConsoleNotificateur implements Notificateur { @Override public void envoyer(String dest, String msg) { System.out.println("[MOCK] Email à " + dest + " : " + msg); } }
Pattern très utile — en développement, on ne veut pas vraiment envoyer d'emails. Deux implémentations de la même interface, activées par profil, résolvent ça élégamment sans un seul if dans le code métier.
Exo 2Bean conditionnel par profil

Créer une interface StockageFichier avec une méthode String sauvegarder(byte[] contenu, String nom).

Fournir deux implémentations :

  • StockageLocal — active en développement, écrit dans un dossier local
  • StockageS3 — active en production uniquement

Le chemin du dossier local doit venir d'une propriété app.stockage.dossier avec pour valeur par défaut /tmp/uploads.

Voir la solution
public interface StockageFichier { String sauvegarder(byte[] contenu, String nom); }
@Service @Profile("!prod") // tous les profils sauf prod public class StockageLocal implements StockageFichier { @Value("${app.stockage.dossier:/tmp/uploads}") // avec valeur par défaut private String dossier; @Override public String sauvegarder(byte[] contenu, String nom) { try { Path chemin = Path.of(dossier, nom); Files.createDirectories(chemin.getParent()); Files.write(chemin, contenu); return chemin.toString(); } catch (IOException e) { throw new RuntimeException("Échec de la sauvegarde de " + nom, e); } } }
@Service @Profile("prod") // uniquement en production @RequiredArgsConstructor public class StockageS3 implements StockageFichier { private final S3Client s3Client; // bean déclaré dans une @Configuration @Value("${app.stockage.bucket}") private String bucket; @Override public String sauvegarder(byte[] contenu, String nom) { s3Client.putObject( PutObjectRequest.builder().bucket(bucket).key(nom).build(), RequestBody.fromBytes(contenu) ); return "https://" + bucket + ".s3.amazonaws.com/" + nom; } }

Le service qui utilise le stockage injecte simplement l'interface — il ne sait pas laquelle des deux implémentations est active :

@Service @RequiredArgsConstructor public class UploadService { private final StockageFichier stockage; // Spring choisit selon le profil actif public String uploader(MultipartFile fichier) throws IOException { return stockage.sauvegarder(fichier.getBytes(), fichier.getOriginalFilename()); } }

10. Les erreurs de démarrage les plus fréquentes

required a bean of type '…' that could not be found
Spring ne trouve pas de bean du type demandé. Causes : classe non annotée (@Service manquant), ou classe hors du package scanné.
required a single bean, but 2 were found
Deux implémentations de la même interface. Résoudre avec @Primary, @Qualifier, ou injecter une List<T>.
The dependencies of some of the beans form a cycle
Dépendance circulaire : A a besoin de B qui a besoin de A. Repenser le découpage, ou en dernier recours utiliser @Lazy.
Could not resolve placeholder 'app.xxx'
Une propriété référencée par @Value("${app.xxx}") n'existe pas dans application.yml. Vérifier l'orthographe ou ajouter une valeur par défaut.
Failed to configure a DataSource
JPA est dans le classpath mais aucune base n'est configurée. Ajouter spring.datasource.url ou retirer la dépendance JPA.
⚠ Piège classique — La dépendance circulaire

C'est l'erreur la plus révélatrice d'un problème de conception :

// ❌ ArticleService a besoin de CommentaireService... @Service @RequiredArgsConstructor public class ArticleService { private final CommentaireService commentaireService; } // ...et CommentaireService a besoin d'ArticleService @Service @RequiredArgsConstructor public class CommentaireService { private final ArticleService articleService; // 💥 cycle }

Spring ne peut pas résoudre : pour créer A il faut B, pour créer B il faut A.

La bonne solution n'est pas technique mais architecturale — extraire la logique partagée dans un troisième service dont les deux dépendent, ou repenser les responsabilités. @Lazy masque le problème sans le résoudre.

✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • monprojet/src/main/java/fr/jeandecode/service/Notificateur.java (interface)
  • monprojet/src/main/java/fr/jeandecode/service/EmailNotificateur.java
  • monprojet/src/main/java/fr/jeandecode/service/ConsoleNotificateur.java
  • monprojet/src/main/java/fr/jeandecode/config/AppConfig.java
  • monprojet/src/main/resources/application.yml (avec profils dev/prod)

Étapes

  1. Créer un projet Spring Boot vide sur start.spring.io
  2. Ajouter Lombok au pom.xml et activer l'annotation processing dans l'IDE
  3. Créer une interface Notificateur et deux implémentations annotées @Profile
  4. Créer un service qui injecte l'interface avec @RequiredArgsConstructor
  5. Configurer application.yml avec deux profils dev et prod
  6. Démarrer avec chaque profil et vérifier que la bonne implémentation est utilisée
  7. Lancer avec --debug et parcourir le rapport d'autoconfiguration
Partie 00 · Prérequis

Panorama des modules Spring Boot 4

Spring Boot est modulaire : on n'installe que ce dont on a besoin. Ce chapitre explique le mécanisme des starters, détaille ceux du quotidien, signale les renommages introduits par Spring Boot 4, et donne un aperçu de Spring Cloud.

1. Qu'est-ce qu'un starter ?

Un starter est une dépendance Maven qui ne contient presque pas de code. Son rôle est de regrouper un ensemble cohérent de bibliothèques et d'activer leur autoconfiguration.

Prenons spring-boot-starter-webmvc. En l'ajoutant, on récupère automatiquement :

$ mvn dependency:tree fr.jeandecode:monprojet:jar:0.0.1-SNAPSHOT └── org.springframework.boot:spring-boot-starter-webmvc:jar:4.1.0 ├── org.springframework.boot:spring-boot-starter:jar:4.1.0 │ ├── org.springframework.boot:spring-boot:jar:4.1.0 │ ├── org.springframework.boot:spring-boot-autoconfigure:jar:4.1.0 │ ├── org.springframework.boot:spring-boot-starter-logging:jar:4.1.0 │ │ ├── ch.qos.logback:logback-classic:jar:1.5.x │ │ └── org.slf4j:jul-to-slf4j:jar:2.0.x │ └── org.yaml:snakeyaml:jar:2.x ├── org.springframework.boot:spring-boot-starter-json:jar:4.1.0 │ └── com.fasterxml.jackson.core:jackson-databind:jar:2.18.x ├── org.springframework.boot:spring-boot-starter-tomcat:jar:4.1.0 │ └── org.apache.tomcat.embed:tomcat-embed-core:jar:11.x ├── org.springframework:spring-web:jar:7.x └── org.springframework:spring-webmvc:jar:7.x
Une ligne dans le pom, une quinzaine de bibliothèques — toutes testées ensemble par l'équipe Spring, avec des versions compatibles garanties. C'est tout l'intérêt : on ne gère plus les conflits de versions à la main.

Deux choses se passent quand on ajoute un starter :

  1. Les bibliothèques arrivent dans le classpath.
  2. Les autoconfigurations associées s'activent au démarrage (voir le chapitre précédent).

2. ⚠ Les renommages de Spring Boot 4

Spring Boot 4 a renommé deux starters très utilisés. Tous les tutoriels et exemples écrits pour Spring Boot 3 utilisent les anciens noms — c'est la première source d'erreurs quand on démarre.

UsageSpring Boot 3 (obsolète)Spring Boot 4 (à utiliser)
API REST / Web MVCspring-boot-starter-webspring-boot-starter-webmvc
Testsspring-boot-starter-testspring-boot-starter-webmvc-test
⚠ Piège classique — Utiliser l'ancien nom de starter

Si on copie un exemple Spring Boot 3 :

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <!-- ❌ n'existe plus en Boot 4 --> </dependency>

Maven affiche une erreur du type Could not resolve dependencies … spring-boot-starter-web:jar:4.1.0 was not found. Il suffit de remplacer web par webmvc.

Autre changement à connaître : dans les tests, @MockBean est remplacé par @MockitoBean. La signature est identique, seul le nom change.

3. Les starters essentiels

spring-boot-starter-webmvc

Le socle de toute API REST. Il apporte Spring MVC, le serveur Tomcat embarqué, et Jackson pour la sérialisation JSON.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency>
@RestController // fourni par le starter @RequestMapping("/api/articles") @RequiredArgsConstructor public class ArticleController { // GET /api/articles?page=0&size=20 @GetMapping public List lister() { return service.findAll(); } // GET /api/articles/42 @GetMapping("/{id}") public ArticleResponse parId(@PathVariable Long id) { return service.findById(id); } // POST /api/articles avec un corps JSON @PostMapping public ResponseEntity creer(@RequestBody ArticleRequest req) { ArticleResponse cree = service.creer(req); return ResponseEntity.status(HttpStatus.CREATED).body(cree); } }
Sérialisation JSON automatique — Jackson convertit les objets Java en JSON et inversement, sans aucune configuration. Un record ArticleResponse(Long id, String titre) devient {"id": 1, "titre": "…"}.

spring-boot-starter-data-jpa

Apporte Hibernate (l'ORM), Spring Data JPA (la génération de repositories), et la gestion des transactions.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Ne pas oublier le driver de la base de données --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> <!-- runtime : nécessaire à l'exécution, pas à la compilation --> </dependency>
// Déclarer l'interface suffit — Spring Data génère l'implémentation au démarrage public interface ArticleRepository extends JpaRepository { // Requête dérivée du nom de la méthode — Spring génère le SQL List
findByPublieTrue(); // SELECT * FROM article WHERE titre LIKE %?% List
findByTitreContaining(String motCle); // Combinaison de critères + tri List
findByPublieTrueOrderByCreatedAtDesc(); // Requête JPQL personnalisée pour les cas complexes @Query("SELECT a FROM Article a WHERE a.auteur.email = :email AND a.publie = true") List
findPubliesParAuteur(@Param("email") String email); }

spring-boot-starter-validation

Active Bean Validation (Jakarta Validation). Permet de déclarer les contraintes directement sur les DTOs.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
public record ArticleRequest( @NotBlank(message = "Le titre est obligatoire") @Size(max = 200, message = "Le titre ne peut pas dépasser 200 caractères") String titre, @NotBlank(message = "Le contenu est obligatoire") String contenu, @Email(message = "Email invalide") String emailContact, @Min(value = 0, message = "Le prix ne peut pas être négatif") BigDecimal prix ) {}
// @Valid déclenche la vérification des contraintes @PostMapping public ResponseEntity creer( @Valid @RequestBody ArticleRequest req) { // ← sans @Valid, aucune validation return ResponseEntity.status(201).body(service.creer(req)); }
⚠ Piège classique — Oublier @Valid

Les annotations @NotBlank, @Size… ne font rien toutes seules. Sans @Valid sur le paramètre du contrôleur, elles sont totalement ignorées et les données invalides passent.

Symptôme typique : un titre vide se retrouve en base alors qu'il y a un @NotBlank. Vérifier que @Valid est bien présent.

spring-boot-starter-security

Ajoute Spring Security. Attention : dès qu'il est présent, toutes les routes deviennent protégées par défaut.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
# Au démarrage, Spring Security génère un mot de passe temporaire en console : Using generated security password: 8f4e2c1a-9b3d-4e5f-a6c7-1d2e3f4a5b6c This generated password is for development use only. Your security configuration must be updated before running your application in production. # Login par défaut : "user" / le mot de passe affiché
// Configuration minimale pour reprendre la main @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // API stateless : pas de CSRF .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated()); return http.build(); } }
Spring Security 7 utilise le lambda DSL — plus de chaînage avec .and(). Chaque bloc de configuration est une lambda. Attention en lisant des tutoriels : la syntaxe pré-Spring Security 6 ne compile plus.

spring-boot-starter-actuator

Expose des endpoints de supervision : santé de l'application, métriques, informations de build. Indispensable en production.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
management: endpoints: web: exposure: # Par défaut, seul /health est exposé — on choisit explicitement les autres include: health,info,metrics,env endpoint: health: show-details: when-authorized # détails visibles seulement aux utilisateurs authentifiés
# Vérifier que l'application est en vie (utilisé par Docker, Kubernetes, load balancers) $ curl http://localhost:8080/actuator/health {"status":"UP"} # Détails (si show-details activé) $ curl http://localhost:8080/actuator/health { "status": "UP", "components": { "db": { "status": "UP", "details": { "database": "PostgreSQL" } }, "diskSpace": { "status": "UP" }, "ping": { "status": "UP" } } } # Métriques disponibles $ curl http://localhost:8080/actuator/metrics
⚠ Piège classique — Exposer tous les endpoints Actuator en production

Ne jamais écrire include: "*" en production. Certains endpoints exposent des informations sensibles :

  • /actuator/env — toutes les variables d'environnement, y compris des secrets
  • /actuator/heapdump — un dump mémoire complet, téléchargeable
  • /actuator/configprops — toute la configuration, mots de passe compris

Exposer uniquement ce qui est nécessaire (health, info, metrics) et protéger le chemin /actuator/** avec Spring Security.

spring-boot-devtools

Redémarre automatiquement l'application quand un fichier compilé change. Uniquement pour le développement.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> <!-- exclu automatiquement du jar de production --> </dependency>
Dans IntelliJ IDEA — DevTools se déclenche à la compilation, pas à la sauvegarde. Activer Build project automatically dans les settings, sinon il faut compiler manuellement (Ctrl+F9).

Lombok

Ce n'est pas un starter Spring, mais il est présent dans la quasi-totalité des projets.

AnnotationGénère
@Getter / @SetterLes accesseurs pour tous les champs
@RequiredArgsConstructorUn constructeur avec tous les champs final
@NoArgsConstructorUn constructeur sans arguments (requis par JPA)
@AllArgsConstructorUn constructeur avec tous les champs
@BuilderLe pattern Builder : Article.builder().titre("…").build()
@Slf4jUn champ log prêt à l'emploi pour les logs
@DataGetter + Setter + toString + equals + hashCode (à éviter sur les entités JPA)
@Service @RequiredArgsConstructor // génère le constructeur @Slf4j // génère : private static final Logger log = ... public class ArticleService { private final ArticleRepository repository; public void creer(Article article) { log.info("Création de l'article : {}", article.getTitre()); // log dispo grâce à @Slf4j repository.save(article); } }
⚠ Piège classique — @Data sur une entité JPA

@Data génère equals() et hashCode() à partir de tous les champs, y compris les relations @ManyToOne et @OneToMany.

Conséquences : chargement de toutes les relations LAZY dès qu'on appelle equals(), boucles infinies dans toString() si deux entités se référencent mutuellement, et comportement erratique dans les HashSet.

Sur une entité JPA, préférer @Getter @Setter @NoArgsConstructor et écrire equals()/hashCode() manuellement à partir de l'@Id seul.

4. Tableau récapitulatif

StarterStatut Boot 4Apporte
spring-boot-starter-webmvc✅ renomméSpring MVC, Tomcat embarqué, Jackson
spring-boot-starter-webmvc-test✅ renomméJUnit 5, Mockito, MockMvc, AssertJ
spring-boot-starter-data-jpainchangéHibernate, Spring Data, transactions
spring-boot-starter-securityinchangéAuthentification, autorisations, filtres
spring-boot-starter-validationinchangéBean Validation (@Valid, @NotBlank…)
spring-boot-starter-actuatorinchangé/actuator/health, métriques
spring-boot-devtoolsinchangéRechargement à chaud en dev
spring-boot-starter-mailinchangéEnvoi d'emails via JavaMail
spring-boot-starter-cacheinchangéAbstraction de cache (@Cacheable)
spring-boot-starter-data-redisinchangéClient Redis, cache distribué
spring-boot-starter-webfluxinchangéProgrammation réactive (non-bloquant)

5. Spring Cloud — l'écosystème microservices

Spring Cloud est un projet distinct de Spring Boot, avec son propre cycle de versions. Il apporte les briques nécessaires aux architectures distribuées.

Ces modules seront utilisés en détail dans le Projet 2 — voici un aperçu pour situer.

ModuleStarterRôle
Eureka Serverspring-cloud-starter-netflix-eureka-serverAnnuaire où les services s'enregistrent
Eureka Clientspring-cloud-starter-netflix-eureka-clientS'enregistrer et découvrir les autres services
Gatewayspring-cloud-starter-gatewayPoint d'entrée unique, routing, filtres
Config Serverspring-cloud-config-serverConfiguration centralisée dans un dépôt Git
Config Clientspring-cloud-starter-configRécupérer sa config au démarrage
OpenFeignspring-cloud-starter-openfeignClient HTTP déclaratif entre services
Circuit Breakerspring-cloud-starter-circuitbreaker-resilience4jRésilience : fallback, retry, timeout

Le BOM Spring Cloud

Spring Cloud ne suit pas la numérotation de Spring Boot. Pour éviter les incompatibilités, on importe un BOM (Bill of Materials) : un fichier qui déclare les versions cohérentes de tous les modules Spring Cloud.

<properties> <java.version>21</java.version> <!-- Spring Cloud 2025.1 "Oakwood" est la version alignée sur Spring Boot 4.x --> <spring-cloud.version>2025.1.0</spring-cloud.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> <!-- import : "récupère toutes les versions déclarées ici" --> </dependency> </dependencies> </dependencyManagement> <dependencies> <!-- Une fois le BOM importé, plus besoin de version sur les modules Spring Cloud --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> </dependencies>
dependencyManagement ≠ dependencies — dependencyManagement déclare uniquement des versions disponibles, sans ajouter les bibliothèques au projet. dependencies les ajoute réellement. Le BOM va dans le premier.
⚠ Piège classique — Mélanger des versions Spring Cloud incompatibles

Chaque version de Spring Cloud est testée avec une version précise de Spring Boot. Utiliser un BOM Spring Cloud prévu pour Boot 3 avec Boot 4 provoque des erreurs très difficiles à diagnostiquer (NoSuchMethodError, ClassNotFoundException au démarrage).

Vérifier la table de compatibilité officielle sur spring.io/projects/spring-cloud avant de fixer la version.

6. Comment choisir ses starters

Une règle simple : ajouter uniquement ce dont on a besoin maintenant. Chaque starter ajoute des bibliothèques, allonge le temps de démarrage et augmente la surface d'attaque.

API REST simple
webmvc + validation + webmvc-test
API REST avec base de données
+ data-jpa + le driver JDBC (postgresql, mysql…)
API REST sécurisée
+ security + JJWT si authentification par token
Application en production
+ actuator pour la supervision
Microservices
+ les starters Spring Cloud nécessaires (Eureka, Gateway, Feign…)

Spring Initializr

Plutôt que d'écrire le pom.xml à la main, utiliser start.spring.io — l'outil officiel qui génère un projet complet avec la bonne structure.

# On peut aussi l'utiliser en ligne de commande avec curl curl https://start.spring.io/starter.zip \ -d type=maven-project \ -d language=java \ -d bootVersion=4.1.0 \ -d javaVersion=21 \ -d groupId=fr.jeandecode \ -d artifactId=monprojet \ -d name=monprojet \ -d packageName=fr.jeandecode \ -d dependencies=webmvc,data-jpa,validation,security,postgresql,lombok,actuator \ -o monprojet.zip unzip monprojet.zip -d monprojet cd monprojet mvn clean verify
IntelliJ IDEA intègre Spring Initializr — File → New → Project → Spring Boot. La même interface que start.spring.io, directement dans l'IDE.
Exo 1Composer un pom.xml

Un projet doit exposer une API REST de gestion de tâches. Il faut :

  • Des endpoints REST retournant du JSON
  • Une persistance en PostgreSQL
  • De la validation sur les données entrantes
  • Une authentification par JWT
  • Un endpoint de health check pour Kubernetes
  • Des tests unitaires et d'intégration

Lister les dépendances nécessaires dans le pom.xml, en respectant les noms Spring Boot 4.

Voir la solution
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.0</version> </parent> <groupId>fr.jeandecode</groupId> <artifactId>gestion-taches</artifactId> <version>0.0.1-SNAPSHOT</version> <properties><java.version>21</java.version></properties> <dependencies> <!-- API REST + JSON — nom Spring Boot 4 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <!-- Persistance JPA --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Validation des DTOs --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Authentification --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- Health check pour Kubernetes --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <!-- Driver PostgreSQL — scope runtime --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- JJWT pour générer et valider les tokens --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency> <!-- Confort de développement --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <!-- Tests — nom Spring Boot 4 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc-test</artifactId> <scope>test</scope> </dependency> <!-- Base H2 en mémoire pour les tests d'intégration --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

Points à noter : webmvc et webmvc-test (noms Boot 4), le driver PostgreSQL en runtime, JJWT avec une version explicite (ce n'est pas un projet Spring), et H2 en test pour les tests d'intégration sans base réelle.

✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • monprojet/pom.xml (créé via start.spring.io)
  • monprojet/src/main/resources/application.yml

Étapes

  1. Générer un projet sur start.spring.io avec Spring Boot 4.1 et Java 21
  2. Sélectionner : Spring Web, Spring Data JPA, Validation, Spring Security, PostgreSQL Driver, Lombok, Actuator
  3. Vérifier dans le pom.xml généré que le starter web est bien webmvc
  4. Ouvrir le projet dans l'IDE et lancer mvn clean verify
  5. Lancer mvn dependency:tree pour voir ce que chaque starter apporte réellement
  6. Démarrer l'application avec --debug et parcourir le rapport d'autoconfiguration
📌 Bilan de la Partie 00
  • Java — typage statique, Long pour les IDs JPA, génériques, Stream API (filter/map/collect), Optional avec orElseThrow, record pour les DTOs.
  • IoC — le conteneur crée et relie les beans. Injection par constructeur avec @RequiredArgsConstructor et champs final. Dépendre des interfaces, pas des classes concrètes.
  • Modules — les starters regroupent bibliothèques et autoconfiguration. Attention aux renommages Boot 4 : webmvc et webmvc-test.

Le Projet 1 met tout ça en pratique en construisant une API REST complète.