Aller au contenu

Configuration

Le même jar doit tourner sur ton poste avec H2 et en production avec PostgreSQL, sans être reconstruit. Ce chapitre montre d'où Spring Boot tire ses réglages, comment les lire proprement dans ton code, et comment garder les secrets hors du dépôt.

Dans ce chapitre

  • application.properties ou application.yml, et les propriétés courantes
  • @Value et ses limites, puis @ConfigurationProperties avec un record validé
  • Le relaxed binding : todo.max-open, TODO_MAX_OPEN, --todo.max-open
  • L'ordre de priorité des sources, vérifié
  • Les profils, les secrets, et Actuator en bref
  • L'auto-configuration vue par --debug

application.properties ou application.yml

La configuration par défaut vit dans src/main/resources, donc dans le jar. Celle du projet, sans ses commentaires :

src/main/resources/application.properties (extrait)
spring.application.name=todo-api
spring.datasource.url=jdbc:h2:mem:todo
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
todo.max-open=50
management.endpoints.web.exposure.include=health,info

Spring Boot lit aussi le YAML. Ce fichier application.yml, à la place du précédent, donne exactement la même appli (vérifié) :

src/main/resources/application.yml
spring:
  application:
    name: todo-api
  datasource:
    url: jdbc:h2:mem:todo
  h2:
    console:
      enabled: true
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false

todo:
  max-open: 50

management:
  endpoints:
    web:
      exposure:
        include: health,info

Les clés sont les mêmes ; YAML évite de répéter les préfixes, au prix d'une indentation qui compte (comme en Python). Choisis un format : si les deux fichiers existent, application.properties l'emporte en cas de conflit.

Les propriétés courantes

Chaque module documente ses propriétés (la liste complète : « Common Application Properties » dans la doc de Spring Boot, et l'autocomplétion de ton IDE). Celles que tu croiseras le plus :

Propriété Défaut Rôle
server.port 8080 port HTTP
spring.application.name aucun nom affiché dans chaque ligne de log
logging.level.<paquet> INFO niveau de log d'un paquet (debug, warn...)
spring.datasource.url une base H2 au nom aléatoire base de données
spring.jackson.serialization.indent-output false JSON indenté, lisible à l'œil
spring.threads.virtual.enabled false virtual threads (Threads et concurrence)
management.endpoints.web.exposure.include health endpoints Actuator exposés

Exemple : logging.level.org.hibernate.SQL=debug affiche chaque requête SQL envoyée par Hibernate. Un POST /api/todos donne :

DEBUG ... org.hibernate.SQL : select count(t1_0.id) from todo t1_0 where t1_0.done=false
DEBUG ... org.hibernate.SQL : insert into todo (created_at,done,title,id) values (?,?,?,default)

Lire une propriété : @Value

La façon la plus directe d'injecter une valeur dans un bean :

@Component
public class ValueDemo {
    public ValueDemo(@Value("${todo.max-open}") int maxOpen,              // (1)!
                     @Value("${todo.welcome:Bienvenue}") String welcome) { // (2)!
        // ...
    }
}
  1. ${...} est un placeholder : remplacé par la valeur de la propriété, puis converti en int.
  2. Après :, la valeur par défaut si la propriété n'existe pas.

Pratique pour une valeur isolée, mais la clé est une chaîne recopiée partout où on s'en sert, rien ne vérifie la valeur (-3 passerait), et une faute de frappe sans valeur par défaut fait échouer le démarrage avec PlaceholderResolutionException: Could not resolve placeholder 'todo.max-opn' in value "${todo.max-opn}". Pour un groupe de réglages, il y a mieux.

@ConfigurationProperties avec un record

Les réglages de l'appli sont regroupés dans un record, lié au préfixe todo :

src/main/java/xyz/dunkan/todo/todo/TodoProperties.java
@ConfigurationProperties(prefix = "todo") // (1)!
@Validated // (2)!
public record TodoProperties(
        // Nombre maximum de tâches non terminées en même temps (50 si absent de la config)
        @DefaultValue("50") @Positive int maxOpen) { // (3)!
}
  1. Chaque composant du record correspond à une propriété : maxOpen ↔ todo.max-open.
  2. Les contraintes de validation sont vérifiées au démarrage : une configuration invalide empêche l'appli de démarrer.
  3. @DefaultValue (de Spring Boot) donne la valeur si la propriété est absente ; @Positive (de Jakarta Validation, comme @NotBlank dans les DTO) exige un nombre strictement positif.
src/main/java/xyz/dunkan/todo/TodoApiApplication.java (extrait)
@SpringBootApplication
@ConfigurationPropertiesScan // (1)!
public class TodoApiApplication {
  1. Enregistre comme beans les records @ConfigurationProperties trouvés sous ce paquet. Sans elle : Parameter 1 of constructor in xyz.dunkan.todo.todo.TodoService required a bean of type 'xyz.dunkan.todo.todo.TodoProperties' that could not be found.

TodoService reçoit alors un TodoProperties comme n'importe quel bean et lit properties.maxOpen(). Un record immuable, typé, validé, documenté en un seul endroit ; et dans un test, un simple new TodoProperties(2). Avec --todo.max-open=0 :

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target xyz.dunkan.todo.todo.TodoProperties failed:

    Property: todo.maxOpen
    Value: "0"
    Origin: "todo.max-open" from property source "commandLineArgs"
    Reason: must be greater than 0

Le message dit d'où vient la mauvaise valeur (ici la ligne de commande). Une valeur non numérique (abc) empêche aussi le démarrage : failed to convert java.lang.String to ... int. L'équivalent Python avec pydantic-settings (exécuté) :

class TodoSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="TODO_")
    max_open: PositiveInt = 50           # lu dans TODO_MAX_OPEN

settings = TodoSettings()                # ValidationError si TODO_MAX_OPEN=0

En Rust, ce serait un struct qui dérive Deserialize, rempli par la crate config.

Le relaxed binding

Une même propriété s'écrit différemment selon la source, et Spring Boot fait le lien tout seul (relaxed binding, liaison souple). Toutes ces écritures ont été vérifiées sur todo.max-open :

Source Écriture
application.properties todo.max-open=10 (forme canonique), todo.maxOpen, todo.max_open
variable d'environnement TODO_MAX_OPEN=10 ou TODO_MAXOPEN=10
ligne de commande --todo.max-open=10
propriété système Java java -Dtodo.max-open=10 -jar ...

La règle pour les variables d'environnement : majuscules, _ à la place des ., tirets supprimés (TODO_MAXOPEN) ; Spring accepte aussi TODO_MAX_OPEN, plus lisible. Dans ton code (@Value, prefix), écris toujours la forme canonique : minuscules et tirets.

L'ordre de priorité

Quand une propriété est définie à plusieurs endroits, la source la plus haute l'emporte (ordre vérifié avec todo.max-open) :

  1. les arguments de la ligne de commande (--todo.max-open=4) ;
  2. les propriétés système Java (-Dtodo.max-open=3) ;
  3. les variables d'environnement (TODO_MAX_OPEN=2) ;
  4. les fichiers hors du jar, dans le dossier de lancement : d'abord les variantes de profil (application-prod.properties), puis les fichiers de base ; à chaque fois, le sous-dossier config/ l'emporte sur le dossier lui-même ;
  5. les fichiers dans le jar : application-{profil}.properties, puis application.properties ;
  6. la valeur par défaut (@DefaultValue("50")).

Le fichier du jar donne les valeurs par défaut, l'environnement de lancement les adapte. Démonstration :

$ TODO_MAX_OPEN=20 java -jar target/todo-api-0.0.1-SNAPSHOT.jar --todo.max-open=1
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Acheter du pain"}'
{"id":1,"title":"Acheter du pain","done":false,"createdAt":"2026-10-21T16:39:29.625907Z"}
$ curl -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -d '{"title": "Lire la doc"}'
{"detail":"Too many open todos (max 1): finish or delete some first","instance":"/api/todos","status":409,"title":"Conflict","maxOpen":1}

La ligne de commande (1) bat la variable d'environnement (20), qui bat le fichier (50). L'exercice 3 montre comment voir toutes les sources d'une propriété.

Les profils

Un profil est un nom (dev, prod...) qui active des fichiers et des beans supplémentaires. Le profil prod du projet complète application.properties :

src/main/resources/application-prod.properties (extrait)
spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
spring.h2.console.enabled=false
server.forward-headers-strategy=native

On l'active avec SPRING_PROFILES_ACTIVE=prod (c'est ce que fait le Dockerfile du projet) ou --spring.profiles.active=prod. Le log de démarrage l'annonce : The following 1 profile is active: "prod" (sans profil : No active profile set, falling back to 1 default profile: "default").

Pourquoi ${SPRING_DATASOURCE_URL}, alors que le relaxed binding relie déjà la variable SPRING_DATASOURCE_URL à spring.datasource.url ? Pour échouer tôt. Sans ces lignes, si la variable manque en production, spring.datasource.url=jdbc:h2:mem:todo du fichier de base s'applique : l'appli démarre sans broncher (HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:todo, vérifié), sur une base en mémoire que chaque redémarrage efface. Avec elles, la valeur reste le texte ${SPRING_DATASOURCE_URL} et le démarrage s'arrête :

Caused by: java.lang.IllegalArgumentException: 'url' must start with "jdbc"

Un bean peut aussi dépendre d'un profil : posé sur une classe @Component ou @Configuration (ou sur une méthode @Bean), @Profile("dev") ne crée les beans concernés que si dev est actif, et @Profile("!prod") partout sauf en production (exercice 2).

Les secrets

  • Jamais dans le dépôt : un mot de passe commité reste dans l'historique Git, même effacé ensuite. Le projet ne contient aucun secret, seulement des placeholders.
  • Par l'environnement : la plateforme injecte les variables au lancement. Dans Dokploy, ce sont les variables d'environnement de l'application ; avec Docker, docker run -e SPRING_DATASOURCE_PASSWORD=... (voir Déployer).
  • Jamais dans les logs ni exposés : l'endpoint Actuator env, qui liste toute la configuration, n'est pas exposé (/actuator/env répond 404), et même exposé, il masque les valeurs par défaut ("value":"******").

Actuator en bref

Le starter Actuator ajoute des endpoints de supervision sous /actuator. Le projet en expose deux (management.endpoints.web.exposure.include=health,info) :

$ curl http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}
$ curl http://localhost:8080/actuator/info
{"build":{"artifact":"todo-api","name":"todo-api","version":"0.0.1-SNAPSHOT","group":"xyz.dunkan"}}

health dit si l'appli (et sa base) répond : c'est ce que surveille un orchestrateur. info affiche ici les informations de build générées par le plugin Maven (objectif build-info du pom.xml). Le chapitre Déployer y reviendra.

Sous le capot : l'auto-configuration

Chaque module de Spring Boot contient des classes d'auto-configuration, listées dans un fichier META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports de son jar. Ce sont des @Configuration ordinaires, dont chaque @Bean est gardé par des conditions. Celle de Jackson, très simplifiée :

@AutoConfiguration
@ConditionalOnClass(JsonMapper.class)          // (1)!
public final class JacksonAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean                  // (2)!
    JsonMapper jacksonJsonMapper(JsonMapper.Builder builder) {
        return builder.build();                // (3)!
    }
}
  1. Ne s'applique que si Jackson est dans le classpath.
  2. Ne crée ce bean que si aucun bean JsonMapper n'existe déjà.
  3. Le builder a reçu les réglages de Boot : propriétés spring.jackson.*, format des ProblemDetail...

Lance l'appli avec --debug : le CONDITIONS EVALUATION REPORT donne, pour chaque auto-configuration, les conditions remplies (Positive matches) ou non (Negative matches) :

   JacksonAutoConfiguration#jacksonJsonMapper matched:
      - @ConditionalOnMissingBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) did not find any beans (OnBeanCondition)

D'où la règle : déclarer ton propre bean désactive celui de Boot. Avec un @Bean public JsonMapper jsonMapper() { return JsonMapper.builder().build(); } dans ton code, le rapport devient :

   JacksonAutoConfiguration#jacksonJsonMapper:
      Did not match:
         - @ConditionalOnMissingBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) found beans of type 'tools.jackson.databind.json.JsonMapper' jsonMapper (OnBeanCondition)

Piège : remplacer un bean de Boot fait perdre ses réglages

Ton JsonMapper tout neuf n'a pas les réglages que Boot appliquait au sien. Résultat réel, les erreurs changent de format :

{"detail":"Todo 999 not found","instance":"/api/todos/999","properties":null,"status":404,"title":"Not Found","type":null}

Pour ajuster Jackson sans tout remplacer, utilise une propriété (spring.jackson.serialization.indent-output=true) ou un bean qui personnalise le builder de Boot :

@Bean
public JsonMapperBuilderCustomizer indentJson() {
    return builder -> builder.enable(SerializationFeature.INDENT_OUTPUT);
}

Même logique ailleurs : déclare un DataSource et le pool HikariCP auto-configuré disparaît.

Exercices

1. Une limite de résultats. Ajoute une propriété todo.max-results (100 par défaut, entre 1 et 1000) : GET /api/todos ne doit jamais renvoyer plus de tâches. Vérifie avec --todo.max-results=2, puis ce qui se passe avec --todo.max-results=5000.

Solution
TodoProperties.java
@ConfigurationProperties(prefix = "todo")
@Validated
public record TodoProperties(
        @DefaultValue("50") @Positive int maxOpen,
        // Nombre maximum de tâches renvoyées par GET /api/todos
        @DefaultValue("100") @Positive @Max(1000) int maxResults) {
}

Dans TodoService.list : todos.stream().limit(properties.maxResults()).map(TodoResponse::from).toList() (simple, mais toutes les lignes sont lues en base : le chapitre Base de données (JPA) montre comment limiter en SQL). Le compilateur signale new TodoProperties(2) dans TodoServiceTest : remplace par new TodoProperties(2, 100). Avec trois tâches et --todo.max-results=2 :

$ curl http://localhost:8080/api/todos
[{"id":3,"title":"Trois","done":false,"createdAt":"2026-10-21T16:22:11.212196Z"},{"id":2,"title":"Deux","done":false,"createdAt":"2026-10-21T16:22:11.161568Z"}]

Et --todo.max-results=5000 empêche le démarrage : Property: todo.maxResults, Value: "5000", Reason: must be less than or equal to 1000.

2. Un profil de développement. Crée un profil dev qui insère deux tâches de démonstration au démarrage et affiche les requêtes SQL. Sans profil, la liste doit rester vide.

Solution
src/main/java/xyz/dunkan/todo/DevDataConfig.java
@Configuration
@Profile("dev") // ces beans n'existent que si le profil "dev" est actif
public class DevDataConfig {

    @Bean
    public CommandLineRunner demoTodos(TodoService service) {
        return args -> {
            service.create(new CreateTodoRequest("Découvrir Spring Boot"));
            service.create(new CreateTodoRequest("Lire le chapitre Configuration"));
        };
    }
}
src/main/resources/application-dev.properties
logging.level.org.hibernate.SQL=debug

Sans profil, curl http://localhost:8080/api/todos renvoie []. Avec --spring.profiles.active=dev :

$ curl http://localhost:8080/api/todos
[{"id":2,"title":"Lire le chapitre Configuration","done":false,"createdAt":"2026-10-21T16:23:10.820335Z"},{"id":1,"title":"Découvrir Spring Boot","done":false,"createdAt":"2026-10-21T16:23:10.751964Z"}]

et les logs montrent The following 1 profile is active: "dev" puis les insert into todo .... Les tests, lancés sans profil, ne voient pas ces données.

3. Qui a gagné ? Dans le profil dev seulement, expose l'endpoint Actuator env avec les valeurs en clair, puis lance TODO_MAX_OPEN=20 java -jar target/todo-api-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev --todo.max-open=10 et interroge /actuator/env/todo.max-open.

Solution
src/main/resources/application-dev.properties (ajout)
# JAMAIS en production : expose toute la configuration, secrets compris
management.endpoints.web.exposure.include=health,info,env
management.endpoint.env.show-values=always
$ curl -s http://localhost:8080/actuator/env/todo.max-open | jq -c '.property'
{"source":"commandLineArgs","value":"10"}
$ curl -s http://localhost:8080/actuator/env/todo.max-open | jq -r '.propertySources[].name'
server.ports
commandLineArgs
servletConfigInitParams
servletContextInitParams
systemProperties
systemEnvironment
random
Config resource 'class path resource [application-dev.properties]' via location 'optional:classpath:/'
Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'
applicationInfo
Management Server

property donne la valeur retenue et sa source. propertySources liste les sources dans l'ordre de priorité ; celles qui définissent la propriété y portent leur valeur : 10 pour commandLineArgs, 20 pour systemEnvironment (avec l'origine System Environment Property "TODO_MAX_OPEN"), 50 pour application.properties. Sans show-values=always, chaque valeur s'affiche ******.

À retenir

  • La configuration par défaut est dans application.properties (ou .yml) dans le jar ; l'environnement de lancement la surcharge.
  • Priorité : ligne de commande > propriétés système > variables d'environnement > fichiers hors du jar > fichiers du jar > valeurs par défaut.
  • Une propriété s'écrit todo.max-open dans un fichier, TODO_MAX_OPEN en variable d'environnement, --todo.max-open= en argument : c'est le relaxed binding.
  • Préfère @ConfigurationProperties sur un record, avec @Validated : réglages typés, regroupés, vérifiés au démarrage. @Value reste pour une valeur isolée.
  • Les profils (application-prod.properties, SPRING_PROFILES_ACTIVE, @Profile) adaptent fichiers et beans à l'environnement ; un placeholder obligatoire fait échouer tôt.
  • Les secrets passent par des variables d'environnement, jamais par le dépôt.
  • L'auto-configuration crée ses beans sous conditions (@ConditionalOnClass, @ConditionalOnMissingBean) ; --debug affiche le rapport, et ton propre bean remplace celui de Boot avec tous ses réglages.