1. Une base — différents modes
Pratiquement toute application ne vit pas dans un seul « monde idéal », mais dans plusieurs. Nous l’exécutons localement pour voir rapidement ce qui se passe, nous exécutons une version démo pour montrer le comportement aux collègues ou au client, et nous exécutons aussi un mode test où l’on souhaite de la détermination et l’absence d’effets secondaires. Ce n’est pas un luxe, mais un moyen de ne pas perdre la tête dès un petit projet.
Prenons notre ContextFlow. Dans la vie réelle, nous voulons que le lancement local (dev) soit le plus simple possible : tout est visible dans la console, rien ne nécessite de configuration de fichiers, rien « ne se perd ». Pour le mode démo (demo), nous voulons un comportement différent : par exemple, écrire l’audit dans un fichier pour conserver des artefacts, et laisser les notifications dans la console pour ne pas dépendre de systèmes externes. Pour le mode test (test), nous voulons des règles encore plus strictes : la génération d’identifiants doit être prévisible, les notifications doivent être désactivées ou remplacées par du no-op, et les opérations sur fichiers doivent être aussi sûres que possible et ne pas s’étaler sur le disque.
Pour se faire une idée concrète, il est utile de garder en tête une esquisse grossière des modes. Ce n’est pas une configuration finale, mais une carte provisoire : il est important de voir ce qui change d’un mode à l’autre.
| Mode | Ce qui est important dans ce lancement | Quel comportement attendons-nous |
|---|---|---|
| dev | voir rapidement ce que fait l’application | maximum d’observabilité, minimum d’effort |
| demo | montrer le processus et laisser des artefacts | comportement visible plus des résultats « tangibles » comme un fichier d’audit |
| test | reproductibilité et absence de bruit | valeurs déterministes et minimum d’effets secondaires |
Le point clé est le suivant : les modes ne doivent pas changer le sens du processus métier. Une commande est créée et annulée de la même manière. Seule change la manière dont nous « observons » le processus et les effets externes que nous autorisons dans ce mode. Il ne s’agit donc pas d’une logique métier différente, mais de conditions infrastructurelles différentes pour le même scénario.
2. Mauvaise option : « mode » dans le service métier
Lorsqu’un développeur rencontre pour la première fois « il faut trois modes », le chemin le plus naturel est d’ajouter une variable mode dans le code métier et de faire la fête au if-else. Cela semble rapide et fonctionne même… jusqu’au moment où le projet devient plus grand qu’une seule classe. Ensuite, le if-else commence à se multiplier et, comme tout organisme vivant, tente de survivre dans chaque service.
Imaginez que nous ayons créé un service de notifications « intelligent » et que nous lui ayons appris à comprendre le mode de fonctionnement. À première vue, c’est pratique : en test, on n’envoie tout simplement rien. En y regardant de plus près, le service ne dépend plus seulement de NotificationSender, mais aussi de dans quel mode le conteneur est assemblé. Autrement dit, nous avons introduit une connaissance de l’environnement à l’intérieur de la couche métier.
import com.example.contextflow.domain.ports.NotificationSender;
public class NotificationDispatchService {
private final NotificationSender sender;
// Mauvais : le service commence à connaître l'environnement (dev/demo/test) et pas seulement la tâche du domaine
private final String mode;
public NotificationDispatchService(NotificationSender sender, String mode) {
// Important : sender est la bonne dépendance (port)
this.sender = sender;
// Mauvais : mode est une dépendance de configuration « cachée » de la couche métier
this.mode = mode;
}
public void notifyOrderCreated(String message) {
// Le « mode silencieux » dans les tests semble pratique,
// mais cela mélange la logique métier avec les modes de construction de l'application
if ("test".equals(mode)) return;
sender.send(message);
}
}
Le problème n’est pas le if lui-même. Le problème est que le service métier est soudainement devenu « dépendant du profil ». Vous ne pouvez plus lire le constructeur et comprendre ce qu’est cette classe : s’agit-il d’envoyer des notifications ou de construire conditionnellement des environnements ? Pire encore, vous commencez à répéter ce schéma ailleurs. Et très vite, vous avez if ("demo") dans l’audit, if ("test") dans la génération de rapports, if ("dev") dans le logging… et vous revenez à la même douleur que vous avez rencontrée au début du cours : des dépendances cachées et un bootstrap qui s’étend, mais cette fois caché à l’intérieur de services « normaux ».
Il y a aussi une douleur d’ingénierie plus subtile. À un moment donné, les modes ne sont plus trois mais quatre ou cinq, et chaque nouvelle branche est une nouvelle chance de casser le comportement accidentellement. Le code commence à ressembler à une « forêt de conditions », et une forêt, comme on le sait, est facile à perdre. Surtout si vous êtes junior et que vous n’avez pas encore la super-compétence de garder un grand contexte en tête (ce qui est normal : le cerveau a aussi un scope et un lifecycle).
3. Mauvaise option : if-else dans le code de câblage
L’étape naturelle suivante après avoir réalisé « il ne faut pas mettre cela dans les services métier » est de déplacer le if-else dans la configuration. C’est effectivement mieux : au moins le code métier redevient « propre », et le choix de l’implémentation se fait à un seul endroit. Mais il y a une nuance : si vous faites une condition à l’intérieur d’une méthode @Bean, vous continuez à coder les modes comme une logique manuelle, et non comme un modèle déclaratif de construction de conteneur.
Exemple souvent : nous lisons une propriété, et en fonction de celle-ci, nous retournons une implémentation différente.
import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.infrastructure.audit.ConsoleAuditWriter;
import com.example.contextflow.infrastructure.audit.FileAuditWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@Configuration
class AuditConfig {
@Bean
AuditWriter auditWriter(Environment env) {
// Nous lisons le paramètre depuis Environment (généralement depuis application.properties / application.yml)
String mode = env.getProperty("contextflow.audit.mode", "console");
// Important : le choix de l'implémentation se fait manuellement via une condition,
// ce qui transforme la configuration en un « mini-programme »
return "file".equalsIgnoreCase(mode) ? new FileAuditWriter() : new ConsoleAuditWriter();
}
}
Pourquoi ce n’est toujours pas « ça », même si cela fonctionne formellement ? Parce que nous transformons la configuration en un mini-programme avec des branchements et une matrice de modes cachée. Aujourd’hui, une condition, demain trois, après-demain vous commencez à dépendre de combinaisons de valeurs, et votre couche de configuration devient un petit « interpréteur de conditions ». À ce stade, les débutants ont souvent cette pensée : « Et si on faisait un mega-config avec 20 if-else ? ». On peut. Cela va même fonctionner. Mais le lire sera plus douloureux que la documentation de BeanFactoryPostProcessor (et nous n’y sommes même pas encore arrivés).
Et encore une nuance importante : lorsque vous décidez via un if-else à l’intérieur d’un @Bean, le conteneur ne voit toujours qu’une définition de bean auditWriter. Il ne voit pas les variantes alternatives comme des entités séparées que l’on peut activer/désactiver. De ce fait, le diagnostic et la compréhension de « quelle configuration est active actuellement » deviennent moins transparents.
Donc la « condition dans la configuration » est une étape de transition normale, surtout si vous avez un tout petit projet, mais nous voulons un modèle plus scalable, basé sur le conteneur. Et c’est là que les profils entrent en scène.
4. Construction avec les profils
Un profil dans Spring est un moyen de dire au conteneur : « Dans ce mode, enregistre certains beans, et dans un autre, d’autres beans ». Le mot important ici est enregistre. Les profils ne fonctionnent pas comme un « commutateur de comportement d’un objet déjà créé », mais comme un filtre lors de la phase de construction du ApplicationContext. Le conteneur a essentiellement la possibilité d’assembler différentes « configurations » d’une même application.
Il est utile de garder une image simple en tête : d’abord Spring lit les métadonnées sur les beans (BeanDefinition), puis décide lesquels participent à la construction, et seulement ensuite crée les objets et les relie. Les profils interviennent précisément au moment « quelles définitions de beans prenons-nous en compte ».
flowchart TD
%% Les profils agissent au stade « quelles définitions de beans participent à la construction »
A["Démarrage d'ApplicationContext"] --> B["Lecture et enregistrement des définitions de beans"]
B --> C{"Profil correspond ?"}
C -->|oui| D["On garde la définition de bean"]
C -->|non| E["On ignore la définition de bean"]
D --> F["Création des objets et câblage"]
F --> G["Application prête à fonctionner"]
Les profils sont la réponse à la question : comment faire pour que la même application puisse avoir différentes implémentations d’infrastructure sans forcer le code métier à connaître les modes. Si vous avez regardé l’esquisse des modes ci-dessus, les profils sont exactement le mécanisme qui permet au conteneur en test de ne pas enregistrer le notificateur « réel » et en demo de brancher l’audit fichier.
Nous n’entrons pas encore dans la syntaxe @Profile ; ici il est plus important de fixer l’idée elle-même : un profil est une caractéristique de la construction du conteneur, et non un état du modèle de domaine. Le profil ne doit pas « vivre » dans Order.status et ne doit pas être passé le long d’une chaîne de méthodes. Il vit dans l’Environment du conteneur et influence quels beans apparaîtront dans le contexte.
5. Profils et propriétés
Une confusion très fréquente chez les débutants : « puisque nous savons déjà lire les paramètres depuis les properties, pourquoi avons-nous besoin de profils en plus ? ». La réponse — parce que ce sont des niveaux de configuration différents. Les properties définissent des valeurs, les profils modifient la composition de l’application. C’est comme la différence entre « de quelle couleur est la voiture » et « la voiture a-t-elle un moteur ou est-ce un vélo ». Les deux concernent le transport, mais le niveau de décision est très différent.
Regardons un exemple simple de configuration par propriété : ReportOutputManager a un chemin où écrire les rapports. Cette valeur est effectivement pratique à conserver dans .properties et à modifier sans recompilation.
import org.springframework.beans.factory.annotation.Value;
public class ReportOutputManager {
private final String outputDir;
public ReportOutputManager(@Value("${contextflow.report.output-dir}") String outputDir) {
// Les propriétés (properties) configurent les paramètres de l'implémentation déjà choisie
this.outputDir = outputDir;
}
}
Ici, le conteneur crée le même bean, simplement avec une valeur différente de outputDir dans des environnements différents. Et c’est parfait.
Et maintenant imaginez une autre tâche : en test, nous ne voulons pas du tout écrire de fichiers d’audit. Pas « écrire dans un autre répertoire », mais « ne pas écrire dans un fichier en tant que concept ». Nous voulons remplacer toute l’implémentation de AuditWriter. C’est là que les propriétés deviennent un outil maladroit : vous allez essayer de faire contextflow.audit.mode=none, puis écrire des if-else dans AuditWriter ou dans la configuration. Cela nous ramène aux points 2–3.
Pour ne pas mélanger ces deux mondes, il est pratique de garder un tableau comparatif :
| Question | Propriétés | Profils |
|---|---|---|
| Que configurons-nous ? | les valeurs à l’intérieur des beans | l’ensemble des beans et leurs implémentations |
| Quand cela affecte-t-il ? | lors de la création du bean (injection de valeurs) | avant la création des beans (filtrage de l’enregistrement) |
| Où stocker la logique ? | dans la configuration des valeurs | dans la configuration de la construction |
| Exemple typique | chemin output-dir, nom de l’application | ConsoleAuditWriter vs FileAuditWriter, UuidIdGenerator vs Deterministic |
Et il y a une conclusion importante ici. Une bonne configuration ressemble à une cuisine bien organisée. Les épices (properties) sont sur l’étagère et on peut les changer tous les jours. Mais la cuisinière, le four et le micro-ondes (profiles) sont déjà l’« équipement de la cuisine » : vous ne voulez pas assembler un four à partir d’épices à chaque fois. Et vous ne pouvez pas remplacer un four par des épices, même en essayant.
6. Modes de ContextFlow : dev, demo, test
Maintenant, concrétisons tout cela sur notre projet sans entrer dans des annotations spécifiques. Il est plus important de voir maintenant quel problème d’ingénierie les profils résolvent dans ContextFlow et pourquoi ils le font au niveau du conteneur.
Dans ContextFlow, nous avons déjà les bonnes prémisses : les services applicatifs dépendent des ports (interfaces), ce qui signifie que nous pouvons changer les implémentations sans réécrire les services. Par exemple, NotificationDispatchService n’a pas du tout à savoir que dans le profil test les notifications sont désactivées. Il appelle honnêtement sender.send(...), et le conteneur en test injecte NoOpNotificationSender.
Voici à quoi devrait ressembler un tel service dans un univers sain :
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
// Important : le service dépend du port (interface), pas de l'implémentation concrète
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
// Le conteneur injectera lui-même le bean sender approprié en fonction du profil de construction
this.sender = sender;
}
public void notifyOrderCreated(String message) {
// La méthode métier reste « propre » : aucun mode/env/if-else lié à l'environnement
sender.send(message);
}
}
Remarquez ce qui manque : pas de mode, pas de Environment, pas de « si test — retourne ». Le service fait exactement son travail, et le conteneur décide quel NotificationSender est considéré comme « actif » dans ce mode de construction.
Il en va de même pour l’audit. AuditService ou toute autre couche qui écrit de l’audit ne doit pas avoir de logique interne comme « en dev imprime, en demo écrit dans un fichier ». Il doit dépendre de AuditWriter, et le profil détermine quel AuditWriter est enregistré. En ce sens, les profils sont un moyen de renforcer la discipline architecturale : la couche métier ne connaît pas l’environnement, l’environnement gère la construction.
Pour ContextFlow, il suffit de garder en tête une simple association : dev et demo sont nécessaires pour l’observabilité, test pour la prévisibilité. Au niveau des ports, cela signifie ceci : en dev, le conteneur choisit les implémentations les plus visibles comme l’audit console et les notifications console ; en demo, il conserve la même simplicité mais peut sauvegarder des artefacts comme un fichier d’audit dans build/ ; en test, il injecte un OrderIdGenerator déterministe, des notifications no-op et des effets secondaires aussi sûrs que possible.
Remarquez à quel point les profils s’intègrent naturellement dans notre domaine. Nous n’inventons pas une « raison pédagogique », nous décrivons les besoins réels du développement : confort du lancement local, confort de la démonstration et confort des tests prévisibles. Il ne reste plus qu’à le formaliser comme une construction normale au niveau du conteneur : sans if-else dans les services et sans transformer la configuration en un roman conditionnel de 300 pages.
7. Erreurs typiques lors de l’utilisation des profils
Erreur n°1 : considérer le profil comme un état métier.
Quand profile commence à être passé via des paramètres de méthodes ou enregistré dans des objets du domaine (order.setProfile("demo")), c’est le signe que la frontière est franchie. Le profil ne fait pas partie de la logique métier, c’est un mécanisme de configuration du conteneur. Il doit déterminer quels beans sont créés, et non comment se comporte une commande spécifique.
Erreur n°2 : résoudre les modes via des if-else à l’intérieur des services.
À première vue, c’est simple : on ajoute if (mode.equals("demo")) et tout fonctionne. Mais vous mélangez ainsi la logique métier avec l’environnement. La classe cesse d’être transparente : son comportement ne dépend pas seulement des données d’entrée, mais aussi de l’état externe. L’injection de dépendances (DI) permet justement de déplacer cette condition dans la construction du conteneur et de garder les services propres.
Erreur n°3 : créer un profil pour chaque petit réglage.
Quand apparaissent profile=notifications-on, profile=notifications-off, profile=audit-file, profile=audit-console, le système devient rapidement ingérable. Un profil concerne un mode de fonctionnement (un ensemble de beans), pas des drapeaux individuels. Les petites différences doivent vivre dans les properties, et non gonfler le nombre de profils.
Erreur n°4 : mélanger les profils, les propriétés et les qualificateurs sans modèle explicite.
Si une partie des décisions est prise via @Profile, une autre via @Value, une autre via @Qualifier, à un moment donné on ne sait plus qui est responsable de quoi. La configuration devient un « lieu magique » où tout agit sur tout. Une bonne règle : le profil choisit l’implémentation, les propriétés configurent son comportement.
Erreur n°5 : s’attendre à ce que le profil puisse être « changé à la volée ».
Les profils sont appliqués lors de l’initialisation de l’ApplicationContext. Si vous pensez que vous pouvez changer de profil pendant l’exécution de l’application et que tout se reconfigurera, c’est un modèle incorrect. Pour les changements à l’exécution, il existe d’autres mécanismes (feature flags, properties), mais pas les profils.
GO TO FULL VERSION