Avant Spring Boot, démarrer un projet Spring signifiait écrire un web.xml, un applicationContext.xml et un dispatcher-servlet.xml avant la moindre ligne de code métier. Avec Spring Boot, une application web tourne sur un Tomcat embarqué sans fichier XML à écrire (le XML reste possible, mais n'est plus nécessaire). C'est cette réduction de la configuration qui a changé l'écosystème Java.
Cet article présente ce qui fait la force de Spring Boot, comment le configurer correctement, et les erreurs fréquentes à éviter dès le départ. Il vise Spring Boot 3.x, qui exige Java 17 au minimum (les classes de Spring Boot 3.0 sont compilées pour Java 17). Les exemples ont été compilés et exécutés avec Spring Boot 3.5, c'est-à-dire Spring Framework 6.2 et Spring Security 6.5. Spring Boot 4 existe aussi ; ce qui suit ne couvre pas la migration.
Ce que Spring Boot change par rapport à Spring classique
Spring Boot n'est pas un nouveau framework. C'est une couche d'opinions et d'automatisation au-dessus de Spring Framework. La différence fondamentale tient en un concept : l'auto-configuration.
Avec Spring classique, il fallait déclarer explicitement chaque bean, chaque datasource, chaque transaction manager. L'application ne faisait rien que le développeur n'ait explicitement configuré. C'est complet, mais cela donnait des projets lourds à mettre en place.
Spring Boot inverse l'approche. Il regarde les dépendances présentes sur le classpath et configure automatiquement ce qui semble nécessaire. Avec spring-boot-starter-data-jpa et un driver PostgreSQL sur le classpath, Spring Boot crée un DataSource, un EntityManagerFactory et un TransactionManager à partir des propriétés spring.datasource.*, sans une seule ligne de configuration Java. Si le comportement par défaut ne convient pas, on le surcharge : déclarer son propre bean d'un type donné suffit à effacer le bean auto-configuré de ce type. Seul ce bean disparaît, le reste de l'auto-configuration demeure actif (voir la section suivante).
L'annotation @SpringBootApplication sur la classe principale résume cette philosophie :
@SpringBootApplication
@ConfigurationPropertiesScan
public class InvoicingApplication {
public static void main(String[] args) {
SpringApplication.run(InvoicingApplication.class, args);
}
}
Cette unique annotation combine trois choses : @Configuration (la classe peut déclarer des beans), @EnableAutoConfiguration (active l'auto-configuration décrite plus haut) et @ComponentScan (scanne le package courant et ses sous-packages pour trouver les @Component, @Service, @Repository, @Controller). La classe principale doit donc se trouver dans le package racine du projet, sinon les composants situés en dehors de son arborescence ne sont pas détectés.
Le serveur web est embarqué. Pas besoin d'installer Tomcat séparément, de produire un WAR et de le déployer dans un répertoire webapps. Un java -jar app.jar démarre l'application complète. Cette simplification explique la popularité de Spring Boot pour les microservices : chaque service est un JAR exécutable autonome.
Comment fonctionne l'auto-configuration
Les auto-configurations sont des classes @Configuration ordinaires, fournies par Spring Boot et ses starters, dont les beans ne sont créés que si des conditions sont remplies. Leur liste figure dans le fichier META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports de chaque jar concerné, et @EnableAutoConfiguration (inclus dans @SpringBootApplication) les importe au démarrage. Elles sont traitées après les beans de l'application, ce qui laisse à ces derniers la priorité.
Trois conditions reviennent partout :
@ConditionalOnClass: la configuration ne s'applique que si une classe est présente sur le classpath. C'est ce qui fait qu'ajouter un starter ou un driver suffit à activer une fonctionnalité ;@ConditionalOnMissingBean: le bean n'est créé que si l'application n'en définit pas déjà un du même type ;@ConditionalOnProperty: la configuration dépend d'une propriété (havingValue,matchIfMissing).
Voici un exemple dans l'esprit de ce que font les auto-configurations de Spring Boot, avec MailSender de Spring (module spring-boot-starter-mail) :
@AutoConfiguration
@ConditionalOnClass(MailSender.class)
public class NotificationAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "app.notifications.enabled",
havingValue = "true", matchIfMissing = true)
public NotificationService notificationService(MailSender mailSender) {
return new MailNotificationService(mailSender);
}
}
La classe est annotée @AutoConfiguration et listée, pour être prise en compte, dans le fichier META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports du jar qui la porte (ici, du projet lui-même) :
com.example.invoicing.notification.NotificationAutoConfiguration
Dans une application ordinaire, une simple classe @Configuration fait le même travail ; le mécanisme .imports sert aux bibliothèques et aux starters. Avec spring.mail.host renseigné, Spring Boot fournit un MailSender, et le test du projet confirme que NotificationService est alors une MailNotificationService. Si l'application déclare elle-même un bean NotificationService, celui-ci n'est pas créé : le test confirme aussi que le bean de l'application prend la place. C'est le mécanisme qui s'applique quand on définit son propre DataSource ou son propre ObjectMapper. Pour écarter une auto-configuration entière, on utilise @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) ou la propriété spring.autoconfigure.exclude.
Pour voir ce qui a été appliqué ou non, on démarre l'application avec l'option --debug (java -jar app.jar --debug). Spring Boot affiche alors le rapport d'évaluation des conditions (« CONDITIONS EVALUATION REPORT »), avec la liste des « Positive matches » et des « Negative matches » et la raison de chaque décision. Dans l'exemple, NotificationAutoConfiguration matched apparaît parmi les correspondances positives. C'est le premier réflexe quand un bean attendu est absent ou qu'un autre est créé à la place.
Structurer un projet Spring Boot sans finir avec un monolithe
La structure générée par Spring Initializr suffit pour un tutoriel. Pour un vrai projet, il vaut mieux structurer dès le départ plutôt que de refactorer plus tard.
Deux familles de structure coexistent : le tri par couche technique (controller/, service/, repository/) et le tri par fonctionnalité. Cette série d'articles retient le second, en packages minuscules, parce qu'ajouter une fonctionnalité ne touche alors qu'un package :
src/main/java/com/example/invoicing/
├── InvoicingApplication.java
├── config/ # Configuration Spring (sécurité, CORS)
│ └── SecurityConfig.java
├── invoice/ # Tout ce qui concerne les factures
│ ├── Invoice.java
│ ├── InvoiceController.java
│ ├── InvoiceService.java
│ ├── InvoiceRepository.java
│ ├── CreateInvoiceRequest.java
│ ├── InvoiceResponse.java
│ ├── InvoiceProperties.java
│ ├── EmailService.java
│ ├── InvoiceNotFoundException.java
│ └── PdfGenerationService.java
├── client/ # Tout ce qui concerne les clients
│ ├── Client.java
│ ├── ClientController.java
│ ├── ClientService.java
│ └── ClientRepository.java
└── common/ # Gestion centralisée des erreurs
└── GlobalExceptionHandler.java
Les noms suivent les conventions de l'écosystème : XxxController, XxxService, XxxRepository, des entités au singulier (Invoice, Client), des DTO nommés CreateXxxRequest et XxxResponse, des exceptions nommées d'après ce qui s'est passé (InvoiceNotFoundException) et un GlobalExceptionHandler annoté @RestControllerAdvice.
Le piège classique est de mettre la logique métier dans les contrôleurs : le contrôleur reçoit la requête HTTP, valide les données, appelle le repository et transforme les résultats dans une seule méthode très longue. Cela fonctionne jusqu'au jour où la même logique doit être réutilisée dans un job planifié ou un listener d'événement, et le code se retrouve dupliqué.
Le service porte la logique métier. Le contrôleur valide l'entrée, appelle le service et formate la sortie. Le repository se limite à l'accès aux données. Chaque couche a une responsabilité unique, ce qui permet d'écrire des tests unitaires sur la logique métier sans démarrer de serveur HTTP.
Configuration : properties, YAML et profils
Spring Boot cherche sa configuration dans application.properties ou application.yml dans src/main/resources. Les deux formats sont équivalents, c'est une question de préférence. YAML a l'avantage d'une hiérarchie plus lisible quand la configuration grossit :
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://localhost:5432/invoicing
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
default_schema: public
logging:
level:
root: INFO
com.example.invoicing: INFO
Les chemins des endpoints sont déclarés dans les contrôleurs (/api/...) et non via server.servlet.context-path. Avec un context-path, les règles requestMatchers de Spring Security s'écrivent sans ce préfixe, ce qui prête à confusion.
Reprenons les réglages un par un.
ddl-auto: validate plutôt que update. Avec update, Hibernate compare les entités au schéma au démarrage et ajoute ce qui manque : tables, colonnes. Il ne supprime rien et ne modifie pas les colonnes ou contraintes existantes. En développement, c'est pratique. En production, cela pose quand même plusieurs problèmes :
- le schéma évolue sans trace ni revue : aucun script versionné ne dit ce qui a été appliqué ;
- un renommage de champ crée une nouvelle colonne et laisse l'ancienne, avec ses données, orpheline ;
- les modifications que Hibernate ne gère pas (changement de type, nouvelle contrainte sur des données existantes, index spécifiques) restent à faire à la main, donc les environnements divergent ;
- l'application a besoin de droits DDL sur la base à chaque démarrage.
Avec validate, Hibernate vérifie que le schéma existant correspond aux entités et échoue au démarrage dans le cas contraire, sans rien modifier. Les migrations sont écrites en SQL versionné et appliquées par Flyway ou Liquibase. Avec Flyway, il suffit d'ajouter la dépendance : Spring Boot la détecte et exécute au démarrage les scripts de src/main/resources/db/migration, avant l'initialisation de JPA (c'est ce que montre le test du projet, qui démarre avec ddl-auto: validate sur un schéma créé par V1__init.sql).
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Flyway 10 et plus (Spring Boot 3.3 et plus) : module propre à PostgreSQL -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
Spring Boot 3.3.0 gère Flyway 10.10.0 et le module flyway-database-postgresql, alors que Spring Boot 3.2 gère Flyway 9.22 et n'a pas ce module. Les notes de version de Spring Boot 3.3 indiquent qu'avec Flyway 10 le support de plusieurs bases (dont PostgreSQL) est passé dans des modules séparés.
CREATE TABLE client (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE invoice (
id BIGSERIAL PRIMARY KEY,
client_id BIGINT,
total NUMERIC(19, 2)
);
CREATE TABLE invoice_lines (
invoice_id BIGINT NOT NULL REFERENCES invoice (id),
description VARCHAR(255),
amount NUMERIC(19, 2)
);
Toute évolution ultérieure passe par un nouveau fichier (V2__...) : un script déjà appliqué ne se modifie pas, Flyway en vérifie la somme de contrôle.
show-sql: false par défaut. Cette propriété écrit les requêtes sur la sortie standard, sans niveau ni horodatage. En développement, org.hibernate.SQL: DEBUG dans le profil dev est préférable : les requêtes passent alors par le système de journalisation, avec horodatage et niveaux, et peuvent être filtrées.
Les variables ${DB_USERNAME} et ${DB_PASSWORD} sont résolues depuis les variables d'environnement. Sans valeur par défaut, l'application échoue au démarrage si la variable est absente, ce qui est le comportement souhaité. Les mots de passe n'ont rien à faire dans un fichier versionné sur Git, même dans un dépôt privé.
Des propriétés typées avec @ConfigurationProperties
Pour ses propres réglages, @ConfigurationProperties évite les @Value dispersés : il lie un préfixe à un objet typé.
@Validated
@ConfigurationProperties(prefix = "app.invoice")
public record InvoiceProperties(@Min(1) int paymentDelayDays, @Email String senderEmail) {
}
app:
invoice:
payment-delay-days: 30
sender-email: facturation@example.com
La classe s'enregistre avec @ConfigurationPropertiesScan sur la classe principale (ou @EnableConfigurationProperties(InvoiceProperties.class)), puis s'injecte par constructeur comme n'importe quel bean. Le lien tolère les variantes de casse (payment-delay-days pour paymentDelayDays), et @Validated avec des contraintes Bean Validation fait échouer le démarrage si une valeur est invalide. Les auto-configurations de Spring Boot lisent leurs propres propriétés spring.* de cette façon.
Profils : une configuration par environnement
Les profils Spring permettent de surcharger la configuration selon l'environnement :
# application-dev.yml : base locale, logs verbeux
spring:
datasource:
url: jdbc:h2:mem:devdb
jpa:
hibernate:
ddl-auto: create-drop
logging:
level:
com.example.invoicing: DEBUG
org.hibernate.SQL: DEBUG
# application-prod.yml : base distante, pas de DDL automatique
spring:
datasource:
url: jdbc:postgresql://prod-db-server:5432/invoicing
jpa:
hibernate:
ddl-auto: validate
show-sql: false
# Activer le profil au lancement
java -jar app.jar --spring.profiles.active=prod
# Ou via variable d'environnement
export SPRING_PROFILES_ACTIVE=prod
Une limite de ce schéma : H2 en développement et PostgreSQL en production sont deux moteurs différents. Types (JSON, tableaux, uuid), contraintes, sensibilité à la casse, fonctions et migrations Flyway en SQL propre à PostgreSQL peuvent se comporter autrement sous H2, voire ne pas y fonctionner. Un bug qui n'apparaît qu'en production en est l'issue typique. Il est plus sûr de développer et de tester sur le même moteur que la production, par exemple avec PostgreSQL dans Docker ou avec Testcontainers, et de réserver H2 aux démonstrations ou aux tests très simples.
Autre piège classique : un application.yml par défaut contenant les identifiants de développement, en comptant sur le profil prod pour les surcharger. Si le profil n'est pas activé (oubli, erreur de déploiement), l'application démarre avec les identifiants de dev et pointe vers la mauvaise base. Il vaut mieux que la configuration par défaut échoue explicitement plutôt qu'elle fonctionne avec de mauvaises valeurs.
L'injection de dépendances : privilégier le constructeur
Spring propose trois manières d'injecter des dépendances : par champ (@Autowired sur un attribut), par setter et par constructeur. L'injection par constructeur est celle que la documentation Spring recommande pour les dépendances obligatoires.
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final PdfGenerationService pdfService;
private final EmailService emailService;
// Un seul constructeur : Spring l'utilise automatiquement, @Autowired est inutile
public InvoiceService(InvoiceRepository invoiceRepository,
PdfGenerationService pdfService,
EmailService emailService) {
this.invoiceRepository = invoiceRepository;
this.pdfService = pdfService;
this.emailService = emailService;
}
public InvoiceResponse createAndSend(CreateInvoiceRequest request) {
Invoice invoice = new Invoice(request.clientId(), request.lines());
invoice.calculateTotal();
invoice = invoiceRepository.save(invoice);
byte[] pdf = pdfService.generate(invoice);
emailService.sendInvoice(request.clientEmail(), pdf);
return InvoiceResponse.from(invoice);
}
}
Ce choix a plusieurs effets concrets.
Immutabilité. Les dépendances sont des champs final. Elles ne peuvent pas être réassignées après la construction, et l'objet est dans un état complet dès qu'il existe.
Testabilité sans Spring. On passe les mocks directement au constructeur, sans contexte Spring ni réflexion pour renseigner des champs privés :
class InvoiceServiceTest {
@Test
void createAndSend_shouldSaveInvoiceAndSendEmail() {
var repo = mock(InvoiceRepository.class);
var pdfService = mock(PdfGenerationService.class);
var emailService = mock(EmailService.class);
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
var service = new InvoiceService(repo, pdfService, emailService);
var request = new CreateInvoiceRequest(1L, "client@example.com",
List.of(new InvoiceLine("Prestation", new BigDecimal("100.00"))));
service.createAndSend(request);
verify(emailService).sendInvoice(eq("client@example.com"), any());
}
}
L'injection par champ n'empêche pas de tester : @InjectMocks de Mockito sait renseigner des champs privés. Elle oblige à passer par un outil ou par la réflexion, alors que le constructeur permet d'écrire new InvoiceService(...) comme pour n'importe quelle classe Java.
Couplage visible. Un constructeur qui prend six ou sept paramètres signale que la classe a trop de responsabilités. Avec des @Autowired sur des champs, ce nombre reste invisible : on ajoute une dépendance de plus sans que rien ne gêne à la lecture.
Une idée reçue à écarter : avec un @Autowired obligatoire sur un champ, un bean manquant ne donne pas un champ null à l'exécution. L'application refuse de démarrer, exactement comme avec l'injection par constructeur. L'avantage du constructeur se situe dans les trois points ci-dessus, pas dans la détection au démarrage.
Les starters : ne pas réinventer les dépendances
Les starters Spring Boot sont des ensembles de dépendances pré-configurées. Au lieu de chercher quelles versions de Hibernate, Jackson et Tomcat sont compatibles entre elles, un starter garantit la cohérence.
Starters usuels pour une API REST :
<!-- API REST avec Jackson et Tomcat embarqué -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA + Hibernate pour la persistance -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Sécurité : authentification, autorisation, protection CSRF -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bean Validation avec Hibernate Validator (non inclus dans starter-web) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Ajouter spring-boot-starter-security sans configuration personnalisée active une sécurité par défaut : tous les endpoints exigent une authentification, un formulaire de login est généré, et un utilisateur user est créé avec un mot de passe aléatoire affiché dans les logs. C'est déroutant la première fois. Il faut déclarer un SecurityConfig dès l'ajout du starter :
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // API REST stateless, authentification par token
return http.build();
}
}
Ce SecurityConfig ne déclare aucun mécanisme d'authentification : l'authentification par token est décrite dans les séries sur Spring MVC et sur l'application complète. Seules les règles d'accès sont posées ici.
Le csrf.disable() fait souvent lever des sourcils. Sur une API REST stateless où le token est envoyé explicitement dans l'en-tête Authorization, la protection CSRF n'a pas d'objet : l'attaque exploite l'envoi automatique des cookies de session par le navigateur, et une API stateless n'en utilise pas. En revanche, la désactiver sur une application qui s'appuie sur des sessions ou des cookies d'authentification serait une faute de sécurité.
Actuator : le monitoring sans effort supplémentaire
Spring Boot Actuator expose des endpoints HTTP qui renseignent sur l'état de l'application en production, sans outil tiers pour la partie santé.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
L'endpoint /actuator/health vérifie automatiquement la connexion à la base de données, l'espace disque et l'état des composants déclarés. Derrière un répartiteur de charge, c'est lui que le health check interroge pour savoir si l'instance peut recevoir du trafic. Avec Spring Security, il faut l'autoriser explicitement (ligne /actuator/health/** ci-dessus), sinon le répartiteur de charge reçoit un refus (un 403 avec cette configuration, qui ne déclare pas de point d'entrée d'authentification).
/actuator/metrics donne accès à de nombreuses métriques : temps de réponse HTTP, utilisation du pool de connexions JDBC, compteurs JVM. Cet endpoint reste protégé : seul health est en permitAll, /actuator/metrics et prometheus exigent une authentification (un scraper Prometheus doit donc s'authentifier ; le test du projet constate un 403 sur /actuator/metrics sans authentification). L'endpoint prometheus nécessite en plus la dépendance micrometer-registry-prometheus. Combiné avec Prometheus et Grafana, il fournit une supervision complète sans code d'instrumentation.
show-details: when_authorized est important. Par défaut, l'endpoint health ne montre que UP ou DOWN. Les détails (type de base de données, espace disque, connectivité Redis) sont des informations sensibles qu'il vaut mieux ne pas exposer publiquement. when_authorized exige un utilisateur authentifié disposant des rôles configurés dans management.endpoint.health.roles.
Il faut aussi être prudent avec /actuator/env. Depuis Spring Boot 3, les valeurs des propriétés y sont masquées par défaut (management.endpoint.env.show-values, dont la valeur par défaut est never), et seul health est exposé sur le web par défaut. Exposer l'endpoint reste pourtant inutile en production : il révèle la liste des propriétés et des variables, et il suffit de passer show-values à always ou when-authorized pour que les valeurs apparaissent. La sécurité ne devrait pas reposer sur un réglage de masquage.
À retenir
- Schéma de base :
ddl-auto: validateavec des migrations Flyway ou Liquibase versionnées, jamaisupdateen production. - Entités : ne pas les renvoyer telles quelles dans l'API ; des DTO (
CreateXxxRequest,XxxResponse) définissent ce qui est exposé. - Actuator : n'exposer que
health,info,metricsetprometheus, jamaisinclude: *, qui ouvriraitenv,heapdump,threaddump,loggerset d'autres endpoints sans place sur un réseau public. - Injection : par constructeur ; un constructeur à huit paramètres indique un service qui fait trop de choses et gagnerait à être découpé.
- Configuration : aucun identifiant de développement dans la configuration par défaut.
L'objectif est de comprendre pourquoi ces pratiques existent, pas de les appliquer mécaniquement.
Pour continuer
Trois séries prolongent cet article, avec les mêmes conventions de nommage :
- Construire une API REST avec Spring Boot (2 parties) : contrôleur, service, validation, erreurs
ProblemDetail, pagination, tests ; - Spring MVC et Spring Boot (3 parties) : DTO, annotations, Lombok, validation, pagination, sécurité JWT ;
- Construire une application Spring Boot complète (3 parties) : entités, mappers, sécurité JWT avec refresh token, documentation Swagger.