1. Introduction
Quand l’application démarre, c’est agréable. Mais les projets Spring ont un effet secondaire sournois : ils démarrent souvent même lorsque vous ne comprenez pas complètement pourquoi tout fonctionne. Résultat, dans la tête il reste « de la magie » : des annotations, des configurations diverses, des événements par-ci, un aspect par-là… et tout cela semble ne pas appartenir à un seul système, mais à des démos disparates. L’assemblage final est nécessaire pour que vous ayez un modèle mental cohérent : un chemin, une logique, un seul ApplicationContext.
Dans cette leçon, nous nous en tiendrons à un axe simple : main() → création du contexte → enregistrement de la configuration → lecture de l’environnement/profils/propriétés → création des beans → exécution du scénario. Tout le reste (ressources, messages, événements, post-processeurs, AOP, branche XML, tests) ne sera pas énuméré, mais soigneusement « intégré » dans ce chemin, comme des détails dans un mécanisme global.
Pour ne pas perdre le fil, voici un schéma compact de « ce qui est relié à quoi » :
flowchart TD A["main()"] --> B["ApplicationContext"] B --> C["Config modules @Import"] B --> D["Environment: profiles + properties"] B --> E["Bean definitions -> beans"] E --> F["ScenarioRunner.run()"] F --> G["Use-case services"] G --> H["publishEvent()"] H --> I["@EventListener side effects"] E --> J["Support: BFPP/BPP/FactoryBean/AOP"] C --> K["Legacy XML bridge"] B --> L["Spring Test context"]
Le sens du diagramme n’est pas d’apprendre les flèches par cœur. Le sens est que c’est un seul système où chaque mécanisme a un « point d’entrée » dans le flux commun.
2. Point d’entrée : main() et ApplicationContext
Un démarrage d’application normal, c’est le moment où vous devez voir qui est le chef. Et dans notre cours, le chef n’est pas « l’annotation du jour », mais ApplicationContext. Dans Spring Boot, il semble souvent que l’application démarre « toute seule » (parce que SpringApplication.run() fait beaucoup de travail pour vous). Mais aujourd’hui, nous restons délibérément dans du Spring Core pur : vous créez le contexte à la main, vous choisissez les profils, vous enregistrez la configuration et vous appelez refresh(). Ce n’est pas une « routine superflue », c’est une radioscopie pédagogique.
Un point d’entrée minimal et honnête de la version finale de ContextFlow pourrait ressembler à ceci :
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowMain {
public static void main(String[] args) {
// Le contexte doit être fermé explicitement sinon @PreDestroy/destroyMethod peuvent ne pas s'exécuter.
try (var context = new AnnotationConfigApplicationContext()) {
// Le profil est important AVANT refresh() : il influence quels beans seront enregistrés.
context.getEnvironment().setActiveProfiles("demo");
// Enregistrer la configuration racine de l'application (composition root).
context.register(AppConfig.class);
// Lancer la phase d'assemblage : lecture des configs, création des bean definitions, instanciation des beans, etc.
context.refresh();
// Le lancement du scénario est la frontière de démarrage, ici un getBean() manuel est acceptable.
context.getBean(ScenarioRunner.class).run();
}
}
}
Remarquez deux choses.
Premièrement — le profil est défini avant refresh(). C’est important, car les profils influencent quels beans seront enregistrés et créés. Si vous définissez le profil après refresh(), vous obtiendrez l’effet classique « j’ai changé de mode mais rien n’a changé » (et vous soupçonnerez un complot, alors que ce sera de votre faute).
Deuxièmement — try-with-resources. Fermer le contexte n’est pas une « cérémonie ». C’est la raison pour laquelle @PreDestroy et destroyMethod s’exécutent effectivement, et que les ressources (fichiers, flux, gestionnaires de sortie) sont fermées correctement. Spring ne peut pas fermer le contexte « par la pensée » si vous ne le fermez pas.
Et oui : vous voyez ici un « getBean() manuel », mais c’est uniquement à la frontière de démarrage, comme vous le faisiez auparavant avec la composition root en Java pur. La couche métier ne doit pas se transformer en « professeur getBean ».
3. AppConfig : configuration modulaire au lieu d’un tas géant
Quand le projet est petit, une seule classe de configuration semble normale. Mais dès que vous ajoutez des profils, des ressources, des messages, du reporting, une branche XML legacy et une couche de support, « un seul AppConfig pour tout » devient la version textuelle d’un garage où vous stockez à la fois un vélo, un réfrigérateur et de vieux diplômes. La configuration modulaire n’est pas nécessaire pour la beauté, mais pour la lisibilité : vous ouvrez AppConfig et vous voyez de quelles parties se compose l’application.
Dans l’assemblage final, il est pratique que la configuration supérieure soit presque vide — elle se contente « d’empiler les modules » :
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import({
// Base de l'application : services domaine/ports/référentiels etc.
CoreConfig.class,
// Profils (dev/demo/test) et variabilité de comportement.
ProfilesConfig.class,
// Rapports, formatage et sortie.
ReportingConfig.class,
// Pont vers le fragment XML legacy.
LegacyBridgeConfig.class,
// Extensions infrastructurelles Spring : BFPP/BPP/FactoryBean/AOP et diagnostic.
SupportConfig.class
})
public class AppConfig {
}
Maintenant, une idée importante : @Import n’est pas « une autre façon d’enregistrer ». C’est une façon de rendre le câblage lisible. Vos modules ne sont pas « des couches architecturales pour le diagramme », mais une navigation pratique dans le projet.
Par exemple, CoreConfig s’occupe de l’assemblage de base des ports et services domaine, ProfilesConfig de la variabilité de mode (dev/demo/test), ReportingConfig des rapports et de la sortie, LegacyBridgeConfig de la connexion du fragment XML, et SupportConfig de tout ce qui touche aux post-processeurs, factory beans, diagnostic et AOP.
Si vous voulez vérifier que la modularité fonctionne, posez-vous la question : « Si demain l’audit dans les fichiers tombe en panne, dans quel module vais-je chercher ? » Dans une bonne configuration modulaire, la réponse sera presque automatique.
4. Environnement : profils, propriétés et conversion
Ce qui est pratique dans Spring, c’est que la logique métier peut rester « propre » tandis que le comportement de l’application change via la configuration. Mais cela a une condition : la configuration doit être chargée avant que vous exécutiez le scénario. Autrement dit, votre ApplicationContext construit d’abord l’environnement et crée les beans nécessaires, puis vous exécutez le cas d’utilisation. C’est comme préparer la cuisine (sortir les ingrédients, allumer la cuisinière) avant de faire cuire l’omelette, plutôt que d’essayer d’allumer la cuisinière en plein milieu de la cuisson.
Dans ContextFlow, nous suivons un modèle simple : il y a un fichier de base contextflow.properties, ainsi que des variantes spécifiques aux profils, par exemple contextflow-demo.properties et contextflow-test.properties. Il est important de ne pas confondre cela avec les conventions Boot : dans Spring pur, ces sources spécifiques aux profils sont connectées explicitement via @PropertySource ou un enregistrement programmatique, la simple correspondance du nom avec le profil ne suffit pas au conteneur. Ensuite, vous les lisez via @Value ou Environment.
Supposons, par exemple, que nous voulions gérer le format du rapport et le répertoire de sortie :
# contextflow.properties
contextflow.report.format=text
contextflow.report.output-dir=build/contextflow/reports
Et ensuite, dans la configuration, « atterrir » cela dans l’infrastructure (simplifié) :
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ReportingConfig {
@Bean
ReportOutputManager reportOutputManager(
// La valeur sera injectée par le conteneur lors de la création du bean.
@Value("${contextflow.report.output-dir}") String outputDir) {
// Ici nous ne conservons délibérément que l'infrastructure (où écrire), pas la logique métier.
return new ReportOutputManager(outputDir);
}
}
Ici, un point important pour le modèle mental : @Value n’est pas « de la magie de chaînes ». Cela fait partie du mécanisme que le conteneur exécute au démarrage, avant que vous lanciez le scénario. Si la valeur est absente, vous le saurez soit au démarrage, soit au moment de la création du bean concerné (selon le caractère paresseux/empressé de la création). C’est l’approche « fail-fast » : il vaut mieux échouer immédiatement que « en plein milieu de l’exécution de la commande ».
Et où est la conversion ? Dans la version finale, vous ne voulez pas écrire if ("text".equals(format)) ... dans le service métier. Par conséquent, le format du rapport devient, par exemple, une énumération, et la chaîne des propriétés est transformée en ce type via ConversionService et votre convertisseur. La logique est la même : tout est préparé par le conteneur avant l’exécution du scénario.
5. Resource et MessageSource
Quand vous entendez pour la première fois « ressources » et « messages », vous pourriez être tenté de penser : « ça concerne l’interface utilisateur et le web ». Mais dans une application non-web, c’est encore plus simple et plus honnête : les modèles de notifications et les en-têtes de rapports sont des artefacts externes qui doivent vivre à côté du code, être versionnés et chargés de manière prévisible. Et les textes localisés sont un moyen de ne pas intégrer les chaînes dans le code Java, surtout lorsqu’ils sont utilisés à la fois dans les notifications, les rapports et la sortie console.
Imaginez que vous ayez un modèle de notification de création de commande :
src/main/resources/templates/notifications/order-created.txt
Et un catalogue de modèles chargé au démarrage (l’initialisation « vit » dans le cycle de vie, pas dans le premier appel d’une méthode métier). Exemple très simplifié :
import jakarta.annotation.PostConstruct;
import org.springframework.core.io.Resource;
public class NotificationTemplateCatalog {
// Resource n'est pas un "java.io.File" : c'est une abstraction Spring qui sait travailler avec classpath, URL, etc.
private final Resource orderCreatedTemplate;
public NotificationTemplateCatalog(Resource orderCreatedTemplate) {
// La dépendance vient du conteneur comme n'importe quelle autre.
this.orderCreatedTemplate = orderCreatedTemplate;
}
@PostConstruct
void init() {
// Cette phase est appelée par le conteneur après la création du bean : pratique pour des vérifications rapides et la préparation.
System.out.println("Modèles prêts : " + orderCreatedTemplate.exists()); // Modèles prêts : true
}
}
L’important n’est pas que nous affichions true. L’important est que vous voyez la place du mécanisme dans le tableau d’ensemble : une ressource n’est pas « juste un fichier », c’est une dépendance que le conteneur peut fournir à un bean, et le bean peut la vérifier et la préparer dans la phase d’init.
Avec MessageSource, l’histoire est similaire. Vous connectez des bundles messages.properties, messages_ru.properties, messages_en.properties, puis vous obtenez un message par clé et par locale. Et c’est encore une fois une partie de l’assemblage global : le conteneur fournit MessageSource comme infrastructure, et la couche métier/infrastructure l’utilise pour le texte.
6. Cas d’utilisation : scénario → service → événement, et les effets de bord dans les écouteurs
Quand un projet grandit, la dégradation la plus désagréable est lorsque le service de cas d’utilisation « principal » se transforme en moissonneuse-batteuse : il enregistre la commande, calcule la remise, écrit l’audit, envoie des notifications, met à jour les statistiques et, dans un coin, génère un rapport « au cas où ». Un tel service fonctionne généralement, mais il est difficile à lire : chaque changement devient une mini-opération à cœur ouvert.
Les événements dans notre projet résolvent exactement cela : le cas d’utilisation effectue l’action principale et publie le fait, et les effets de bord sont exécutés par des écouteurs séparés.
Version miniature de OrderPlacementService :
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void place(String orderId) {
// Le service fait l'essentiel : publie le fait métier, ne traîne pas avec lui l'audit/les notifications/les statistiques.
publisher.publishEvent(new OrderCreatedEvent(orderId));
// Important : par défaut, c'est synchrone (les écouteurs s'exécutent dans le même thread).
}
}
Audit en tant qu’écouteur (simplifié) :
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// L'effet de bord vit séparément : le cas d'utilisation ne sait pas que quelqu'un l'audite.
System.out.println("AUDIT commandeId=" + event.orderId()); // AUDIT commandeId=...
}
}
À ce stade, il est utile de vous rappeler votre modèle mental des jours précédents : les événements sont par défaut synchrones. Autrement dit, publishEvent() ne « met pas en file d’attente », mais appelle les écouteurs directement dans le thread actuel. Cela signifie que le cas d’utilisation n’est « terminé » que lorsque tous les gestionnaires ont fini. Et c’est normal pour notre application non-web pédagogique.
Du point de vue de l’assemblage final, l’important est autre : le service de cas d’utilisation ne sait pas qui écoute l’événement. Il sait seulement qu’il publie le fait « commande créée ». C’est là le découplage souple qui rend le système moins fortement couplé.
7. Couche de support : BFPP, BPP, FactoryBean et AOP
Il est facile de transformer les mécanismes internes de Spring en mauvaise habitude : on veut ajouter un Aware partout, une factory par-ci, un post-processeur par-là, et soudainement le code métier ressemble à la documentation Spring plutôt qu’à de la logique métier. C’est pourquoi dans notre projet il existe une couche séparée support.* : là vivent les post-processeurs, FactoryBean, les beans Aware de diagnostic et l’AOP. Ce n’est pas « une couche de plus pour le plaisir d’avoir des couches », c’est une zone sanitaire : les spécificités Spring sont isolées.
Commençons par deux points d’extension qui influencent le démarrage.
BeanFactoryPostProcessor vit dans la phase « les métadonnées ne sont pas encore devenues des objets ». C’est idéal pour un sanity check : vérifier que les propriétés clés existent, qu’il n’y a pas de contradictions critiques, que les définitions de beans obligatoires sont présentes. Exemple hypothétique (fortement simplifié pour ne pas se noyer dans l’API) :
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
public class ContextFlowSanityCheckBeanFactoryPostProcessor
implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
// Ce point est exécuté AVANT la création des beans : on travaille avec les bean definitions, pas avec les objets.
System.out.println("Vérification de cohérence : nombreDeDefinitions=" + bf.getBeanDefinitionCount());
}
}
BeanPostProcessor travaille quant à lui avec des objets (avant/après init). Il est pratique pour faire du diagnostic, du « tracking » de composants, un léger wrapping. Par exemple, journaliser le fait de l’initialisation de certains beans, sans entrer dans chaque classe. C’est ce qu’on appelle « la magie infrastructurelle » qui ne gâche pas la couche métier.
Ensuite, FactoryBean. Nous l’utilisons exactement une seule fois et seulement là où il explique vraiment le conteneur : ReportFormatterFactoryBean choisit l’implémentation du formateur selon la configuration. L’important est que le « formateur » est un produit de la fabrique, et la fabrique elle-même est aussi un bean. Et cela peut être vu si vous vous souvenez de l’astuce &beanName qui donne accès à la fabrique elle-même.
Ensuite, un bean Aware de diagnostic. Il peut obtenir le nom, Environment, le contexte — mais seulement en tant que composant isolé, pour que les services métier ne commencent pas à chercher des beans manuellement. Si un service métier reçoit ApplicationContext, c’est presque toujours un smell : vous êtes passé de la DI au service locator.
Et enfin l’AOP. L’aspect ServiceTimingAspect ajoute un comportement technique autour des méthodes de la couche service (par exemple, mesure du temps). Et ici, l’assemblage final aide particulièrement : vous commencez à voir que l’aspect n’est pas « de la magie du compilateur », mais le résultat du modèle basé sur les proxies, construit par le conteneur via des mécanismes infrastructurels.
8. XML legacy : un pont isolé
Le XML dans les projets modernes n’est pas la norme, mais c’est une réalité que l’on rencontre dans le code legacy. Et le plus important que vous devez retenir de cette partie du cours : le XML, c’est le même Spring, juste une syntaxe différente pour décrire le modèle de bean. Ce n’est pas une « technologie séparée », et encore moins « de la magie noire ». Si vous comprenez les bean definitions, les scopes, le câblage et le cycle de vie, alors le XML se lit presque comme « des annotations, mais en texte ».
Dans la version finale de ContextFlow, le fragment XML est connecté comme un pont, par exemple via @ImportResource dans un module séparé :
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
// Pont vers le legacy : module séparé pour que le XML ne soit pas « étalé » sur tout le projet.
@ImportResource("classpath:legacy/legacy-notification-context.xml")
public class LegacyBridgeConfig {
}
Et à l’intérieur du XML, vous voyez des choses familières — bean id, class et, si nécessaire, les dépendances :
<!-- Définition de bean classique : le même bean model, juste dans une syntaxe différente. -->
<bean id="consoleAuditWriter"
class="com.example.contextflow.infrastructure.audit.ConsoleAuditWriter"/>
L’astuce de l’assemblage final est que la branche XML ne dilue pas le projet : elle est isolée. Vous ne transformez pas l’application en XML-first, vous savez simplement vivre avec un morceau de legacy en attendant sa migration.
9. Tests : vérifier l’assemblage, pas « y croire »
Tester la configuration Spring, ce n’est pas « tout couvrir par des tests », c’est arrêter d’avoir peur de casser accidentellement le câblage, les profils, les overrides de propriétés, les écouteurs ou le pont legacy. Idéalement, les tests devraient utiliser le même AppConfig que le lancement réel, sinon vous testez un « univers différent » et vous vous étonnez qu’en production ce soit différent.
Un smoke test minimal peut être très petit — parfois il suffit que le contexte se monte :
import org.junit.jupiter.api.Test;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(AppConfig.class)
@ActiveProfiles("test")
class ContextSmokeTest {
@Test
void contextStarts() {
// si le contexte ne démarre pas — le test échoue de lui-même (au montage du TestContext).
}
}
Ça ressemble presque à un « test vide », mais il vérifie quelque chose de très important : le câblage, les profils, les sources de propriétés, la conversion, l’enregistrement des écouteurs, le support du XML legacy, le support de l’AOP et la présence de vos beans de support. Autrement dit, il vérifie exactement ce qu’il est impossible de vérifier avec des tests unitaires simples.
Et là encore, notez la ligne commune : le test est simplement un point d’entrée différent dans le même ApplicationContext. Dans le modèle mental final, « lancement de l’application » et « lancement du contexte dans un test » ne sont pas des mondes différents, c’est un seul mécanisme dans des modes différents.
10. Erreurs typiques de l’assemblage final
Erreur n°1 : expliquer le projet par une liste d’annotations.
Quand le final se résume à « nous avons @Configuration, @Profile, @EventListener et @Aspect », l’étudiant a à nouveau l’impression de magie. Les annotations commencent à vivre leur propre vie, sans lien avec l’exécution du programme. Il est important de garder le focus sur le flux : main → ApplicationContext → création des beans → exécution du scénario. Les annotations ne sont qu’un moyen de décrire ce chemin, pas son substitut.
Erreur n°2 : perdre le contour général à cause de petits détails.
Il est facile de s’enliser dans la discussion « où se trouve le modèle » ou « comment la ressource est chargée », en oubliant qu’il s’agit d’un cas particulier. Si le mécanisme général d’assemblage n’est pas compris, les détails ne sauvent rien — ils surchargent seulement. Il faut d’abord avoir une vision claire de l’ensemble : comment le conteneur est assemblé, comment les beans sont liés, comment le scénario est lancé.
Erreur n°3 : mélanger orchestration et effets de bord.
Quand le service de cas d’utilisation commence à la fois à gérer le scénario, à écrire dans le journal, à envoyer des événements et à faire de l’audit, il grossit et perd en lisibilité. Les événements deviennent une formalité, et les écouteurs font double emploi. La bonne structure : le service décrit le scénario, et les effets de bord sont externalisés dans des gestionnaires séparés.
Erreur n°4 : considérer le XML comme un « autre Spring ».
Le XML est souvent perçu comme quelque chose d’étranger et d’obsolète, ce qui pousse à l’éviter ou à le craindre. En réalité, c’est le même conteneur, juste décrit avec une syntaxe différente. Si vous traduisez le XML en modèle familier « bean → id → class → dépendances », il devient compréhensible et prévisible.
Erreur n°5 : oublier le point d’entrée et le scénario de lancement.
Dans l’assemblage final, il est important non seulement « ce qu’il y a », mais aussi « ce qui se passe au lancement ». Si on ne peut pas expliquer rapidement quel bean initie le scénario et comment il est appelé depuis main(), alors l’image est encore floue. Un point d’entrée clair est une ancre qui maintient toute l’architecture.
GO TO FULL VERSION