CodeGym /Cours /Spring Core /Migration de ContextFlow vers le component scanning

Migration de ContextFlow vers le component scanning

Spring Core
Niveau 5, Leçon 4
Disponible

1. Ce qui change dans le projet

Quand les rôles des classes sont clairs, la racine du scanning choisie et les packages répartis par responsabilité, il reste la question la plus honnête : est-ce que tout ça peut donner un ContextFlow vivant ? La migration est très concrète : on marque nos composants de longue durée avec des annotations stéréotype, on laisse les objets de domaine avec un simple new, et on transforme AppConfig d'une liste d'enregistrements manuels en point d'entrée avec @ComponentScan.

Pour le contraste, voici le bout d'enregistrement manuel nécessaire pour lancer le scénario avant la migration :

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration // Classe de configuration du conteneur Spring
public class AppConfig {

    @Bean
    public OrderStore orderStore() {
        return new InMemoryOrderStore();
    }

    @Bean
    public OrderIdGenerator orderIdGenerator() {
        return new UuidOrderIdGenerator();
    }

    @Bean
    public OrderPlacementService orderPlacementService(OrderStore orderStore,
                                                       OrderIdGenerator orderIdGenerator) {
        return new OrderPlacementService(orderStore, orderIdGenerator);
    }

    @Bean
    public ScenarioRunner scenarioRunner(OrderPlacementService orderPlacementService) {
        return new ScenarioRunner(orderPlacementService);
    }
}

Ce schéma fonctionne, mais c'est là que commence la routine superflue : chaque nouveau service ou adapteur maison nécessite à nouveau un enregistrement manuel. Notre objectif maintenant est plus simple — arrêter d'énumérer nos composants un par un et laisser le conteneur les trouver selon des règles claires.

2. Ce qu'on n'envoie pas dans le conteneur

Avant de coller des annotations comme des étiquettes « URGENT », il faut garder une frontière simple : tous les objets ne doivent pas obligatoirement être des beans. Dans ContextFlow, les entités de domaine (Order, OrderItem, Customer, les commandes CreateOrderCommand) sont de simples objets créés à l'intérieur d'un scénario. Le conteneur n'a tout simplement rien à stocker en permanence ici.

Le scanning fonctionne bien sur les parties à longue durée de vie du système : services, stores, générateurs, adapteurs d'audit, expéditeurs de notifications. Par analogie, le conteneur Spring est comme le manager d'un restaurant qui embauche le personnel et organise les processus. Mais il ne doit pas stocker chaque steak comme un bean distinct : le steak apparaît à la demande.

C'est pourquoi on fait la migration par rôles : d'abord les services et l'infrastructure, pas tout le domaine d'un coup.

3. La racine du projet pour le scanning

Pour que la migration soit prévisible, ContextFlow reste sous une seule racine com.example.contextflow. En dessous de cette racine, quatre zones nous suffisent :

com.example.contextflow
├─ domain
├─ application
├─ infrastructure
└─ config

C'est déjà suffisant : le scanner verra application et infrastructure, config restera le point d'assemblage, et domain ne se transformera pas en entrepôt de beans aléatoires. On peut maintenant marquer les vrais rôles des classes.

4. Les annotations stéréotype dans ContextFlow

On peut maintenant attribuer les rôles aux vraies classes de ContextFlow. Essaie de voir les annotations stéréotype comme des plaques sur les portes d'un bureau : @Service — scénario/use case, @Repository — couche de stockage, @Component — simple détail d'infrastructure. Le conteneur a besoin de savoir que c'est un composant. Pour les humains, c'est encore plus important de voir son rôle directement dans l'annotation.

@Repository : InMemoryOrderStore comme store

Commençons par l'exemple le plus « propre ». On a le port OrderStore et l'implémentation InMemoryOrderStore. C'est littéralement la couche de stockage, même si c'est en mémoire. Pour Spring, le mot Repository est un marqueur « store / accès aux données ». Même sans base de données, ce marquage rend le code plus lisible : tu ouvres le fichier — et tu vois immédiatement que c'est une partie de la couche de stockage.

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.stereotype.Repository;

@Repository // Stéréotype : couche de stockage / accès aux données (même si c'est in-memory)
public class InMemoryOrderStore implements OrderStore {

    // "Pseudo-base" thread-safe en mémoire pour que l'exemple soit réaliste
    private final Map<String, Order> orders = new ConcurrentHashMap<>();

    @Override
    public void save(Order order) {
        // Clé = id de la commande ; valeur = la commande elle-même
        orders.put(order.id(), order);
    }
}

Remarque une petite chose : on ne parle pas ici de « design idéal du store ». Ce qui compte, c'est que la classe devient un candidat à la découverte. Tout le reste — plus tard, quand viendra le moment de complexifier le projet.

Si order.id() te choque parce que ton Order n'est pas un record — pas de panique. L'essence de l'exemple n'est pas dans le modèle de domaine. Dans un vrai projet tu appelleras order.getId() ou ce qui est convenu chez toi.

@Service : OrderPlacementService comme service applicatif

Marquons maintenant le service principal du scénario. L'important ici est de retenir : @Service n'est pas « une façon spéciale d'injecter ». C'est un marqueur sémantique qui indique qu'on a devant nous la couche service : orchestration, scénarios, use cases.

Faisons la classe minimalement honnête : dépendances par constructeur, un seul constructeur, pas de tours de passe-passe.

import org.springframework.stereotype.Service;

@Service // Stéréotype : service applicatif (orchestration / use case)
public class OrderPlacementService {

    // Dépendance sur le port de stockage (l'implémentation concrète sera injectée depuis le contexte)
    private final OrderStore orderStore;

    // Dépendance sur le générateur d'identifiants (doit lui aussi être un bean)
    private final OrderIdGenerator idGenerator;

    // Injection par constructeur : Spring résoudra les dépendances par type
    public OrderPlacementService(OrderStore orderStore, OrderIdGenerator idGenerator) {
        this.orderStore = orderStore;
        this.idGenerator = idGenerator;
    }
}

Une question se pose facilement ici : « Comment Spring sait-il qu'il faut injecter OrderStore et OrderIdGenerator ? » La réponse est simple : il recherche les beans correspondants par type. À ce stade, il suffit de comprendre que si le contexte contient un OrderStore et un OrderIdGenerator, le conteneur pourra assembler le service. Les règles détaillées seront vues plus loin dans le cours, mais la base fonctionne déjà.

Ajoutons une petite méthode pour montrer que le service fait quelque chose d'utile et que ScenarioRunner peut l'appeler :

public String placeOrder() {
    // On génère un identifiant (détail d'infrastructure, pratique pour l'exemple)
    String id = idGenerator.nextId();

    // On crée l'objet de domaine dans le flux métier (ce N'est PAS un bean !)
    orderStore.save(new Order(id));

    // On retourne l'id pour que le scénario puisse afficher quelque chose
    return id;
}

C'est déjà suffisant pour que le conteneur assemble la chaîne vivante ScenarioRunner -> OrderPlacementService -> OrderStore / OrderIdGenerator. L'audit et les notifications s'ajoutent selon le même principe : ce sont les mêmes dépendances gérées par le conteneur, juste avec un rôle différent.

@Component : le générateur d'identifiants et les beans « simples »

Il y a des classes qu'on ne veut appeler ni service ni repository, mais qui ont quand même intérêt à vivre dans le conteneur. Le générateur d'identifiants en est un bon exemple. Ce n'est pas un service de scénario ni un store. C'est juste un composant d'infrastructure.

import java.util.UUID;

import org.springframework.stereotype.Component;

@Component // Composant "simple" : ni service ni repository, mais nécessaire au conteneur
public class UuidOrderIdGenerator implements OrderIdGenerator {

    @Override
    public String nextId() {
        // En prod tu réfléchirais au format, longueur, collisions etc. ; ici c'est l'idée qui compte
        return "ORD-" + UUID.randomUUID();
    }
}

Et l'interface. Si tu l'as déjà — parfait ; sinon, voici la version minimale :

// Port de génération d'identifiants : l'implémentation peut être quelconque (UUID, sequence, snowflake, etc.)
public interface OrderIdGenerator {
    String nextId();
}

On n'ajoute pas de conditions ni de configurations complexes. On dit juste : « Cette classe est un composant. Conteneur, crée-la s'il te plaît ».

@Component : audit et notifications

Pour que le scénario paraisse un peu plus réaliste, on a généralement un audit et des notifications. Ce qui compte maintenant n'est pas de « construire un système de notifications », mais de montrer que le scanning sait assembler autre chose que des services.

import org.springframework.stereotype.Component;

@Component // Adapteur d'infrastructure : écrit l'audit dans la console (implémentation pédagogique)
public class ConsoleAuditWriter implements AuditWriter {

    @Override
    public void write(String message) {
        // Dans un vrai projet, ce serait un logger / fichier / système externe
        System.out.println("[AUDIT] " + message); // [AUDIT] ...
    }
}

Et les notifications :

import org.springframework.stereotype.Component;

@Component // Adapteur d'infrastructure : envoie les notifications dans la console (implémentation pédagogique)
public class ConsoleNotificationSender implements NotificationSender {

    @Override
    public void send(String message) {
        // Dans un vrai projet, ce serait email / sms / push / file de messages
        System.out.println("[NOTIFY] " + message); // [NOTIFY] ...
    }
}

Les interfaces peuvent rester aussi simples que possible :

// Port d'audit : la couche métier écrit les événements, l'infrastructure décide "où"
public interface AuditWriter {
    void write(String message);
}

// Port de notifications : la couche métier forme le message, l'infrastructure le délivre
public interface NotificationSender {
    void send(String message);
}

Même si le placeOrder() actuel ne tire pas encore tous ces adapteurs dans une seule méthode, pour le modèle du conteneur ce sont les mêmes composants applicatifs propres : ils sont à l'intérieur de la racine com.example.contextflow, reçoivent un stéréotype et deviennent candidats à l'enregistrement.

@Component : ScenarioRunner comme point d'entrée

Le dernier élément important — ScenarioRunner. Ce n'est pas vraiment un service, même si ça se fait parfois, mais son rôle est clair : c'est le point d'entrée de notre application console.

import org.springframework.stereotype.Component;

@Component // Point d'entrée du scénario : ce qu'on va lancer depuis le main
public class ScenarioRunner {

    private final OrderPlacementService orderPlacementService;

    // Spring injectera le service applicatif s'il a été trouvé par le scanning
    public ScenarioRunner(OrderPlacementService orderPlacementService) {
        this.orderPlacementService = orderPlacementService;
    }

    public void run() {
        // On lance le use case et on affiche le résultat — pour voir que tout est assemblé
        String orderId = orderPlacementService.placeOrder();
        System.out.println("Created orderId=" + orderId); // Created orderId=ORD-...
    }
}

Remarque un effet psychologique important. Avant, ScenarioRunner pouvait être créé manuellement quelque part dans main, et tu y passais les dépendances, puis les dépendances des dépendances, et ainsi de suite. Maintenant le conteneur assemble lui-même cet arbre. Du coup, main redevient petit et ennuyeux. Et c'est un compliment.

5. AppConfig : le scanning à la place de la liste manuelle de beans

Après avoir marqué les rôles, la configuration maigrit considérablement. Elle n'a plus besoin de connaître chaque classe par son nom — il suffit de définir la racine du scanning.

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration // On active le mode configuration
@ComponentScan(basePackages = "com.example.contextflow") // Frontière de recherche des composants
public class AppConfig {
    // Plus besoin d'énumérer des méthodes @Bean pour chaque classe maison
}

Cette variante couvre parfaitement les composants applicatifs propres. Mais il est important de garder la frontière en tête : le scanning n'est pas un remplacement universel de toute configuration. Les classes tierces, l'infrastructure spécialement configurée et les objets qu'on ne peut pas simplement annoter sont généralement décrits explicitement. C'est pourquoi le scanning est un bon défaut pour les classes maison, mais pas une religion « on annote absolument tout ».

6. Lancement et vérification du résultat

On peut maintenant faire ce pour quoi tout ça a été mis en place : monter le contexte et lancer ScenarioRunner sans new manuel à chaque étape de la chaîne.

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ContextFlowMain {

    public static void main(String[] args) {
        // Le contexte trouvera lui-même les composants via @ComponentScan et assemblera le graphe de dépendances
        try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
            // On récupère le "point d'entrée" du scénario comme bean et on le lance
            context.getBean(ScenarioRunner.class).run();
        }
    }
}

Si tout est fait correctement, tu verras quelque chose comme :

Created orderId=ORD-7d2f7c4a-8d3f-4c3c-b6f1-5e9b0c3b6e3a

Ton id sera différent, et c'est normal : ce qui compte n'est pas l'UUID exact, mais que ScenarioRunner ait démarré sans assemblage manuel des dépendances.

Si tu veux vérifier rapidement que le contexte est bien vivant, tu peux afficher momentanément le nombre de bean definitions :

System.out.println("Bean definitions = " + context.getBeanDefinitionCount());
// Le nombre dépend des beans d'infrastructure Spring, pas seulement de tes composants

Si ScenarioRunner est trouvé et que placeOrder() s'exécute sans assemblage manuel des dépendances, la migration est réussie : le conteneur a lui-même trouvé les composants et assemblé le graphe.

7. Erreurs typiques lors de la migration de ContextFlow vers le scanning

Erreur n° 1 : tu as marqué les classes avec des annotations stéréotype, mais tu lances le contexte sans @ComponentScan ou avec le mauvais AppConfig.
Le symptôme est connu : NoSuchBeanDefinitionException, alors que @Service et @Repository semblent en place. Le conteneur ne cherche pas les composants dans toute la JVM « par défaut » ; il lit la configuration précise avec laquelle tu l'as monté.

Erreur n° 2 : tu as oublié une dépendance au milieu de la chaîne.
Scénario très fréquent : OrderPlacementService est déjà marqué @Service, ScenarioRunner est trouvé, mais UuidOrderIdGenerator ou ConsoleAuditWriter sont restés des classes ordinaires sans annotation stéréotype. Alors ce n'est pas le service lui-même qui plante, mais son assemblage par constructeur : le conteneur ne sait tout simplement pas quoi mettre dans le paramètre.

Erreur n° 3 : tu as conservé une ancienne méthode @Bean et activé en même temps le scanning pour la même classe.
On obtient ainsi un double enregistrement du même type. Tant que tu récupères le bean par nom, ça peut ne pas être évident. Mais dès que le conteneur doit choisir un candidat par type, tu feras rapidement connaissance avec NoUniqueBeanDefinitionException.

Erreur n° 4 : après un refactoring, la classe s'est retrouvée hors de com.example.contextflow.
L'annotation sur la classe ne sauve pas à elle seule. Si le composant est hors de la racine du scanning, pour le conteneur il n'existe tout simplement pas. C'est pourquoi après un déplacement de packages, vérifie d'abord la frontière du scanning, et seulement ensuite — les constructeurs et les imports.

Erreur n° 5 : tu as commencé à annoter les objets de domaine et les commandes parce que « si le scanning est activé, laissons Spring tout gérer ».
Le conteneur gonfle alors et le modèle du projet devient flou. Order, Customer et les commandes de scénario doivent naître dans le flux métier, et non vivre comme des beans singleton à côté des services et de l'infrastructure.

1
Mission
Spring Core, niveau 5, leçon 4
Bloqué
TicketDesk avec scanning au lieu de liste manuelle
TicketDesk avec scanning au lieu de liste manuelle
1
Mission
Spring Core, niveau 5, leçon 4
Bloqué
NoteArchive avec scanning et audit
NoteArchive avec scanning et audit
1
Étude/Quiz
Composants Spring, niveau 5, leçon 4
Indisponible
Composants Spring
Scanning et enregistrement des beans
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION