1. Signes d'un AppConfig gonflé
Quand le projet est petit, une seule classe de configuration semble être une excellente idée : vous ouvrez le fichier, vous voyez « comment tout est assemblé », vous le fermez, et vous continuez votre vie. Mais AppConfig a un super-pouvoir désagréable : il grandit plus vite que vous n'avez le temps de dire « je vais ajouter un tout petit bean ». Après les propriétés, profils, ressources, règles de locale et MessageSource, des décisions avec des raisons de changement complètement différentes commencent à y vivre, et c'est cela qui rend le config peu pratique.
Imaginez le parcours typique d'un projet d'apprentissage ContextFlow. D'abord, nous avons ajouté le scan des composants, puis quelques @Bean d'infrastructure, ensuite les réglages à partir des propriétés, puis les modèles Resource, maintenant les messages. Au final, la configuration ressemble à un tiroir de cuisine où, à un moment donné, on commence à mettre des fourchettes, des piles, un tournevis, et une clé étrange « on ne sait pas à quoi elle sert, mais c'est dommage de la jeter ».
Le problème de la classe de configuration géante ne se manifeste généralement pas par « beaucoup de lignes » (bien que ce soit aussi le cas), mais par le fait que des choses de mondes différents se retrouvent côte à côte : par exemple, un bean pour MessageSource, un bean pour ReportOutputManager, un bean pour choisir la Locale, un bean pour autre chose — et tout cela est mélangé avec @ComponentScan. Vous ouvrez le fichier pour trouver un petit réglage, et vous lisez soudainement la moitié d'un roman.
Voici à quoi ressemble la « symptomatologie » (sans dramatisme, juste selon les ressentis du développeur) :
| Symptôme | Ce qui se passe dans la tête | En quoi c'est gênant dans la vie |
|---|---|---|
| Dans une même classe : scan, locale et rapports | « Je venais chercher du sel, et je me retrouve à une réunion de locataires » | Difficile à lire, difficile à expliquer, difficile à maintenir |
| Le config est souvent modifié par différentes personnes | « Pourquoi ai-je un conflit Git sur les méthodes @Bean ? » | Conflits de fusion et un constant « oups, j'ai accidentellement cassé ton réglage » |
| Beaucoup de méthodes @Bean avec des noms similaires | « Ce formatter() — celui-ci ou l'autre ? » | Une légère confusion se transforme en bugs graves de câblage |
| Une logique « si ceci, sinon cela » apparaît dans le config | « Pourquoi une décision métier vit-elle dans le config ? » | La configuration se transforme en une deuxième couche de l'application |
Et c'est là que nous avons besoin de l'outil suivant : apprendre à fragmenter la configuration de manière à ce que le projet devienne plus lisible, et non plus « complexe pour la complexité ».
2. Module de configuration
Quand je dis « module de configuration », je ne fais pas référence au multi-module Gradle (c'est une autre histoire et ce n'est pas aujourd'hui). Ici, un module est simplement une petite classe @Configuration qui est responsable d'une partie compréhensible du câblage. Et l'idée clé est très mature : nous divisons la configuration non par le nombre de beans, mais par les raisons de changement.
Un bon module de configuration est donc une classe pour laquelle vous pouvez dire une phrase humaine : « Il est responsable de la localisation », ou « Il est responsable de l'assemblage de la couche core et du scan des composants », ou « Il est responsable de l'infrastructure des rapports ». Si vous essayez de décrire le module comme « eh bien… ce sont des choses générales diverses », cela signifie que vous venez d'inventer le futur MiscConfig qui deviendra une décharge en une semaine.
Pour ContextFlow, la logique de division est généralement naturelle :
- un module responsable du @ComponentScan et du « démarrage » de l'application en tant qu'ensemble de composants ;
- un module responsable du MessageSource et des bundles de messages ;
- un module responsable de la règle de sélection de la Locale (par exemple, la locale préférée du client ou la valeur par défaut) ;
- un module responsable des rapports et de leurs paramètres de sortie (si nécessaire).
Notez bien : un module est essentiellement un « morceau de composition root », mais pas en un seul bloc, découpé avec soin. Le conteneur Spring aime cette discipline : il se fiche que vous ayez un config ou dix, mais vous, vous vous en souciez.
Une mini-idée utile à garder en tête : @Configuration n'est pas « un composant de plus », mais une carte d'assemblage. Quand la carte devient trop grande et qu'il y a trop de couches, il est logique de la diviser. Pas parce que « c'est la coutume », mais parce que vous voulez revoir la structure, et non le chaos.
Pour y parvenir, le module a besoin d'une simple vertu : il doit faire du câblage, et non « vivre la vie de l'application ». Si à l'intérieur d'une méthode @Bean une logique complexe commence à apparaître, vous construisez accidentellement une deuxième couche métier dans le config. Et les configs n'avaient pas prévu cela.
3. @Import pour l'assemblage des modules
Quand la configuration est divisée en modules, une nouvelle question apparaît : « OK, mais comment rassembler tout cela en un seul point d'entrée ? » Et c'est là qu'intervient @Import. C'est une annotation qui dit à Spring : « Prends ces classes et inclus-les dans la configuration du contexte ». Pour faire simple, @Import est le « collage » des modules de configuration en un seul ensemble.
Il est important de comprendre @Import non comme une « chose magique », mais comme une action très directe. L'idée sous-jacente est : vous pourriez enregistrer manuellement les classes dans le contexte, à peu près comme ceci :
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ManualRegisterDemo {
public static void main(String[] args) {
// Exemple "manuel" : création du contexte sans configuration de départ.
try (var ctx = new AnnotationConfigApplicationContext()) {
// Enregistrement explicite des classes de configuration — comme si on faisait @Import.
ctx.register(CoreConfig.class, MessageConfig.class);
// Après l'enregistrement, il faut "démarrer" le conteneur : traiter les configs et créer les beans.
ctx.refresh();
} // try-with-resources garantit la fermeture correcte du contexte.
}
}
@Import fait la même chose, mais déclarativement, au niveau de la configuration. Nous n'allons pas transformer la leçon en « les entrailles de refresh() », mais il est important de saisir le moment : l'import fonctionne à la phase d'enregistrement de la configuration, c'est-à-dire avant que Spring commence à créer les objets réels.
Voici la forme la plus reconnaissable de configuration racine :
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false) // Config-carte : pas besoin de proxy CGLIB pour les méthodes @Bean.
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // Rassemblons explicitement les modules en un seul contexte.
public class AppConfig {
// Idéalement, pas de @Bean ici : seulement une "table des matières" et la composition des modules.
}
Maintenant, AppConfig n'est plus un entrepôt de méthodes @Bean, mais une carte courte : on voit de quelles parties le contexte est assemblé.
Pour « lire » correctement une telle configuration, il est utile de garder un schéma simple en tête :
flowchart TD
%% Le schéma aide à "lire" le câblage de haut en bas : racine -> modules -> beans/composants.
AppConfig["AppConfig (root)"] --> CoreConfig["CoreConfig: component scan"]
AppConfig --> MessageConfig["MessageConfig: MessageSource"]
AppConfig --> LocalizationConfig["LocalizationConfig: Locale rule"]
CoreConfig --> Components["@Component/@Service/@Repository beans"]
MessageConfig --> MsgBean["bean: messageSource"]
LocalizationConfig --> LocaleBeans["beans: defaultLocale, localeSelector"]
@Import a deux avantages pratiques que vous ressentirez presque immédiatement.
Le premier — l'explicitness. Vous ouvrez AppConfig et vous voyez la structure de l'application, au lieu d'essayer de la reconstruire mentalement à partir de 30 méthodes.
Le second — le contrôle. L'import ne dépend pas de l'emplacement de la classe ni de son inclusion dans @ComponentScan. Vous décidez vous-même ce qui entre dans l'assemblage.
Et une autre nuance qu'il est utile de lever à l'avance : l'ordre des classes dans @Import({...}) n'est pas un « ordre de création des beans » dans le style « d'abord ceci, puis cela ». Le conteneur crée les beans en fonction des dépendances, du cycle de vie et de ses propres règles. Donc @Import concerne la composition de la configuration, pas un « script d'exécution ».
4. Refactorisation de AppConfig en modules
Maintenant, faisons l'étape la plus pratique : montrons comment AppConfig peut être découpé en modules pour qu'il soit plus facile à vivre après. L'idée n'est pas de créer d'urgence une « architecture idéale », mais d'obtenir un assemblage transparent : une classe montre la carte, les modules contiennent les décisions concrètes.
Commençons par CoreConfig. Son rôle est de dire à Spring où chercher vos classes @Service/@Component. Et il y a un point important : si vous scannez tout com.example.contextflow, vous risquez d'absorber accidentellement les configs elles-mêmes via le scan, et @Import deviendra soit superflu, soit des doublons et des redéfinitions apparaîtront. Donc un teaching-default raisonnable ici est de scanner uniquement les paquets où les composants vivent réellement.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false) // Config sans @Bean : il définit les règles de scan des composants.
@ComponentScan(basePackages = {
// Scannons seulement les paquets applicatifs pour que les configs ne soient pas attirés accidentellement.
"com.example.contextflow.application",
"com.example.contextflow.infrastructure"
})
public class CoreConfig {
// Classe vide — normal : ici ce sont les annotations et leurs paramètres qui importent.
}
application.i18n est la même couche application, donc OrderTextService arrive ici tranquillement via le scan. Et le paquet config n'est pas inclus spécialement dans le scan : nous l'assemblons explicitement via @Import.
Maintenant, le module de messages. Il est responsable de l'apparition dans le contexte d'un bean nommé messageSource. Et oui, le nom est important — nous en avons discuté : ApplicationContext le cherche précisément comme point de recherche standard des messages.
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
@Configuration(proxyBeanMethods = false)
public class MessageConfig {
@Bean
MessageSource messageSource() {
// Par défaut, Spring cherche un bean nommé "messageSource" pour la localisation des messages.
var source = new ResourceBundleMessageSource();
// "messages" signifie que des fichiers comme messages.properties / messages_ru.properties seront recherchés.
source.setBasename("messages");
return source;
}
}
Maintenant, le module de localisation. Sa tâche n'est pas de stocker des textes, mais de définir la règle de sélection de la locale et de donner à l'application un ou deux petits beans qui aident à cela. Par exemple, LocaleSelector, qui choisit la locale préférée du client, et si elle n'est pas définie, prend la valeur par défaut.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Locale;
@Configuration(proxyBeanMethods = false)
public class LocalizationConfig {
@Bean
Locale defaultLocale(@Value("${contextflow.locale.default}") String tag) {
// Nous prenons la valeur des properties (ex : "ru-RU", "en-US") et la convertissons en Locale.
return Locale.forLanguageTag(tag);
}
@Bean
LocaleSelector localeSelector(Locale defaultLocale) {
// Nous assemblons la règle de sélection de la locale : si le client n'a pas de locale préférée, nous utilisons la valeur par défaut.
return new LocaleSelector(defaultLocale);
}
}
Et enfin, le AppConfig racine, qui rassemble tout cela en un seul point d'entrée :
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false) // Root config : généralement sans @Bean et sans logique.
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // "Carte d'assemblage" explicite du contexte.
public class AppConfig {
}
Ressentez la différence : maintenant, AppConfig est une « table des matières », et non plus un « tome deux avec annexes ».
Et le démarrage de l'application reste aussi simple qu'avant, mais vous avez maintenant une structure de configuration plus propre :
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Nous démarrons le contexte avec AppConfig racine, qui importe déjà tous les modules.
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Nous récupérons le bean d'entrée du scénario et démarrons l'application.
ctx.getBean(ScenarioRunner.class).run();
} // À la sortie, fermons correctement le contexte (ressources, cycle de vie).
}
}
Même si vous ne vous souvenez pas par cœur de ce que contient MessageConfig, vous savez déjà où chercher : la structure se lit de haut en bas. C'est ce cas rare où « diviser en fichiers » a vraiment rendu le système plus simple, et non l'a fragmenté.
5. @Import et @ComponentScan ensemble
Lorsque vous essayez la configuration modulaire pour la première fois, on a souvent l'impression que @Import et @ComponentScan sont des concurrents : soit « tout importer », soit « tout scanner ». En pratique, ils fonctionnent plutôt comme deux outils pour différents types de tâches, et votre objectif est qu'ils ne se gênent pas mutuellement.
@ComponentScan est pratique pour les classes qui sont des « composants applicatifs ordinaires » : services, dépôts (dans notre sens — stockage en mémoire), expéditeurs de notifications, audit et ainsi de suite. Ces classes « vivent » à l'intérieur de notre code, elles sont nombreuses, et nous voulons vraiment que le conteneur les trouve automatiquement.
@Import est pratique pour la configuration en tant que « carte d'assemblage ». Nous voulons presque toujours inclure les configs explicitement, car leur présence modifie la structure du contexte. Si un config est « inclus accidentellement parce qu'il est tombé dans le scan », vous obtenez l'effet « d'où ça vient ? ». Et c'est précisément pourquoi, dans CoreConfig ci-dessus, nous avons restreint le scan à application / infrastructure, laissant les configs en dehors de la zone de scan.
Un autre point important — la discipline des noms de beans. Dans une configuration modulaire, il est très facile de créer accidentellement deux méthodes @Bean avec le même nom dans différents modules, par exemple deux messageSource() ou deux defaultLocale(). Ce qui se passera ensuite dépend des paramètres du conteneur et du contexte spécifique : parfois une définition remplacera l'autre, parfois le contexte plantera au démarrage. Les deux sont de mauvaises surprises. Donc la modularité n'annule pas la précision : si le module est responsable des messages, c'est là que doit se trouver l'unique messageSource(), et nulle part ailleurs.
Une autre nuance subtile : les classes de configuration sont aussi des beans. Si vous faites accidentellement en sorte que la même config soit incluse deux fois dans le contexte (via le scan et via @Import), vous risquez d'obtenir soit un « doublon de définition », soit « pourquoi mes beans apparaissent-ils soudainement deux fois ? ». Donc une règle simple pour le débutant est : soit on importe les configs explicitement, soit on les inclut dans le scan de manière consciente, mais on ne fait pas les deux chemins simultanément.
Si vous gardez cela en tête, vous obtenez un modèle très paisible :
- les composants applicatifs arrivent via le scan ;
- les éléments d'infrastructure et « précisément configurés » arrivent via @Bean dans les modules ;
- le niveau supérieur de l'assemblage est décrit brièvement via @Import.
6. Erreurs typiques avec la configuration modulaire
Quand vous commencez à fragmenter le config, vous voulez souvent le faire « le plus joli possible ». Et parfois cela conduit à ce que la configuration devienne plus complexe que l'application elle-même. Voici quelques erreurs qui surviennent le plus souvent au début, et qui donnent au débutant l'impression que @Import est une « bureaucratie inutile ». En réalité, le problème réside généralement dans la façon dont nous avons découpé les modules et dont nous les avons assemblés.
Erreur n°1 : diviser la configuration par le nombre de méthodes @Bean plutôt que par le sens.
Si vous créez un fichier « pour deux beans » simplement parce que « c'est plus petit », vous obtenez rapidement 15 micro-configs impossibles à mémoriser. Le module doit répondre à la question « pourquoi existe-t-il » et avoir une raison de changement claire. Alors il peut y avoir plus de fichiers, mais la lecture devient plus facile.
Erreur n°2 : laisser @ComponentScan("com.example.contextflow") et importer en même temps des configs du même paquet.
Cette approche semble inoffensive jusqu'à ce qu'un doublon ou une redéfinition de beans se produise. Si vous voulez que la configuration soit explicite, le scan doit couvrir les composants applicatifs, mais ne pas attirer les configs « automatiquement ». Dans un projet d'apprentissage, c'est particulièrement important : nous apprenons à voir le câblage, pas à deviner d'où il vient.
Erreur n°3 : transformer AppConfig en un « géant » en dispersant simplement les méthodes dans les classes.
Parfois, on nettoie AppConfig des méthodes @Bean, mais on commence à y écrire d'énormes listes @Import({...}) sans structure, et on nomme les modules de manière vague. En conséquence, la « carte » devient aussi illisible que l'ancien config monolithique. AppConfig doit être court et parlant, et les noms des modules doivent refléter leur responsabilité.
Erreur n°4 : cacher la logique métier à l'intérieur des configs.
La configuration est l'endroit où nous créons et lions des objets, pas l'endroit où nous résolvons des questions métier. Si à l'intérieur d'une méthode @Bean vous commencez à calculer des règles complexes (surtout liées au modèle métier), au bout de quelques jours vous ne pourrez plus distinguer la logique « réelle » de l'application de la logique « d'assemblage ».
Erreur n°5 : conflit de noms de beans entre modules.
Dans une configuration modulaire, le nom par défaut d'un bean @Bean est le nom de la méthode. Deux méthodes defaultLocale() dans différents modules semblent « et alors, elles sont dans des classes différentes », mais pour le conteneur, ce sont deux candidats pour le même nom de bean. Il est préférable de discipliner les modules à l'avance pour que les beans d'infrastructure clés aient un point de définition unique.
GO TO FULL VERSION