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 primitif | Type objet équivalent | Contient | Peut être null ? |
int | Integer | Entier 32 bits (−2 milliards à +2 milliards) | primitif : non — objet : oui |
long | Long | Entier 64 bits (très grands nombres) | primitif : non — objet : oui |
double | Double | Nombre décimal | primitif : non — objet : oui |
boolean | Boolean | true ou false | primitif : non — objet : oui |
char | Character | Un seul caractère | primitif : non — objet : oui |
| — | String | Chaîne de caractères | toujours 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 :
| Interface | Implémentation courante | Caractéristique | Usage typique |
List<T> | ArrayList<T> | Ordonnée, doublons autorisés, accès par index | Une liste d'articles à afficher |
Set<T> | HashSet<T> | Pas de doublons, pas d'ordre garanti | Les rôles d'un utilisateur |
Map<K,V> | HashMap<K,V> | Association clé → valeur, clés uniques | Grouper 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) |
| Exemples | IOException, SQLException | NullPointerException, IllegalArgumentException |
| Hérite de | Exception | RuntimeException |
| Obligation | Le compilateur force à les gérer (try/catch ou throws) | Aucune obligation |
| En Spring | Rares — souvent enveloppées | Utilisé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
- Installer Java 21 (
sdk install java 21.0.3-tem via SDKMAN) - Installer Maven (
sdk install maven) et vérifier avec mvn -version - Installer IntelliJ IDEA Community ou VS Code + Extension Pack for Java
- Faire les 4 exercices de ce chapitre — ne pas passer à la suite avant de les avoir compris
- 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 pour | Spécificité technique |
@Component | Une classe utilitaire générique | L'annotation de base — les autres en dérivent |
@Service | La logique métier | Aucune — purement sémantique |
@Repository | L'accès aux données | Traduit les exceptions JPA en exceptions Spring |
@RestController | Un contrôleur API REST | Ajoute @ResponseBody sur chaque méthode |
@Controller | Un contrôleur MVC (vues HTML) | Retourne des noms de vues, pas du JSON |
@Configuration | Une classe de configuration | Peut 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.
| Scope | Nombre d'instances | Quand l'utiliser |
singleton (défaut) | Une seule pour toute l'application | 99% des cas — services, repositories, contrôleurs |
prototype | Une nouvelle à chaque injection | Objets avec état mutable propre à un usage |
request | Une par requête HTTP | Données liées à la requête courante |
session | Une par session utilisateur | Panier 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.javamonprojet/src/main/java/fr/jeandecode/service/ConsoleNotificateur.javamonprojet/src/main/java/fr/jeandecode/config/AppConfig.javamonprojet/src/main/resources/application.yml (avec profils dev/prod)
Étapes
- Créer un projet Spring Boot vide sur start.spring.io
- Ajouter Lombok au
pom.xml et activer l'annotation processing dans l'IDE - Créer une interface
Notificateur et deux implémentations annotées @Profile - Créer un service qui injecte l'interface avec
@RequiredArgsConstructor - Configurer
application.yml avec deux profils dev et prod - Démarrer avec chaque profil et vérifier que la bonne implémentation est utilisée
- 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 :
- Les bibliothèques arrivent dans le classpath.
- 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.
| Usage | Spring Boot 3 (obsolète) | Spring Boot 4 (à utiliser) |
| API REST / Web MVC | spring-boot-starter-web | spring-boot-starter-webmvc |
| Tests | spring-boot-starter-test | spring-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.
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.
| Annotation | Génère |
@Getter / @Setter | Les accesseurs pour tous les champs |
@RequiredArgsConstructor | Un constructeur avec tous les champs final |
@NoArgsConstructor | Un constructeur sans arguments (requis par JPA) |
@AllArgsConstructor | Un constructeur avec tous les champs |
@Builder | Le pattern Builder : Article.builder().titre("…").build() |
@Slf4j | Un champ log prêt à l'emploi pour les logs |
@Data | Getter + 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
| Starter | Statut Boot 4 | Apporte |
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-jpa | inchangé | Hibernate, Spring Data, transactions |
spring-boot-starter-security | inchangé | Authentification, autorisations, filtres |
spring-boot-starter-validation | inchangé | Bean Validation (@Valid, @NotBlank…) |
spring-boot-starter-actuator | inchangé | /actuator/health, métriques |
spring-boot-devtools | inchangé | Rechargement à chaud en dev |
spring-boot-starter-mail | inchangé | Envoi d'emails via JavaMail |
spring-boot-starter-cache | inchangé | Abstraction de cache (@Cacheable) |
spring-boot-starter-data-redis | inchangé | Client Redis, cache distribué |
spring-boot-starter-webflux | inchangé | 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.
| Module | Starter | Rôle |
| Eureka Server | spring-cloud-starter-netflix-eureka-server | Annuaire où les services s'enregistrent |
| Eureka Client | spring-cloud-starter-netflix-eureka-client | S'enregistrer et découvrir les autres services |
| Gateway | spring-cloud-starter-gateway | Point d'entrée unique, routing, filtres |
| Config Server | spring-cloud-config-server | Configuration centralisée dans un dépôt Git |
| Config Client | spring-cloud-starter-config | Récupérer sa config au démarrage |
| OpenFeign | spring-cloud-starter-openfeign | Client HTTP déclaratif entre services |
| Circuit Breaker | spring-cloud-starter-circuitbreaker-resilience4j | Ré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
- Générer un projet sur start.spring.io avec Spring Boot 4.1 et Java 21
- Sélectionner : Spring Web, Spring Data JPA, Validation, Spring Security, PostgreSQL Driver, Lombok, Actuator
- Vérifier dans le
pom.xml généré que le starter web est bien webmvc - Ouvrir le projet dans l'IDE et lancer
mvn clean verify - Lancer
mvn dependency:tree pour voir ce que chaque starter apporte réellement - 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.