CodeGym /Cours /Spring Core /ContextFlow : listeners pour les effets secondaires

ContextFlow : listeners pour les effets secondaires

Spring Core
Niveau 18, Leçon 4
Disponible

1. Services use-case lourds

À ce stade, toutes les règles principales du jour sont visibles : un listener peut être une simple méthode de bean, un événement est approprié pour les réactions à un fait, plusieurs listeners doivent être rendus prévisibles, et publishEvent() est synchrone par défaut. Maintenant, nous allons simplement rassembler cela dans ContextFlow pour que les services use-case cessent d'être un dépôt de tous les effets secondaires à la fois.

Voici un exemple typique de service d'annulation surchargé. Ce n'est pas « horrible-horrible », mais un code tout à fait naturel qui apparaît en premier si on ne délimite pas la frontière de responsabilité :

import org.springframework.stereotype.Service;

@Service
public class OrderCancellationService {

    public void cancel(Order order) {
        // Core step: modifions l'état de la commande et sauvegardons
        order.cancel();
        orderStore.save(order);

        // Side effects: réactions à un fait métier déjà survenu (annulation)
        auditService.recordCancelled(order.getId());
        notificationService.sendCancelled(order.getId(), order.getChannel());
        statisticsService.incrementCancelled();
    }
}

Visuellement, tout va bien, mais la charge sémantique est mélangée : les deux premières lignes sont le core step (nous avons réellement annulé la commande), et ensuite viennent les réactions. Tant qu'il y a trois réactions, c'est supportable. Quand il y en aura sept, vous commencerez à détester cette méthode, même si elle n'est pas en faute.

2. Core step et listeners

La frontière est très simple ici. create et cancel restent eux-mêmes dans les services use-case : là, l'état de la commande est modifié et le résultat est sauvegardé. audit, notification et statistics sont des réactions à un fait déjà survenu, donc nous les extrayons dans des listeners.

Pour l'annulation, il est pratique d'exprimer ce fait par un événement payload ordinaire :

import com.example.contextflow.domain.model.NotificationChannel;

// Événement : commande annulée. Aucune logique – seulement les données nécessaires aux gestionnaires.
public record OrderCancelledEvent(
    String orderId,                 // identifiant de la commande
    NotificationChannel channel      // canal dans lequel envoyer la notification
) { }

Pour la création de commande, la logique est la même : un OrderCreatedEvent séparé avec le même contrat minimal.

3. OrderPlacementService : core step + événement

Commençons par la création de commande, car c'est là qu'apparaît le plus souvent le « combiné » : sauvegarder, calculer le prix, envoyer, enregistrer… L'idée du refactoring est simple : tout ce qui concerne le fait même de la création reste ici. Tout ce qui est réaction part dans les listeners. Nous ne faisons pas de « magie », nous transférons simplement le code dans un endroit plus approprié – comme un déménagement dans un nouvel appartement, mais sans cartons et avec moins de disputes.

Point clé : nous publions l'événement après que le core step est terminé. Si les auditeurs s'attendent à ce que la commande existe déjà dans le store, alors nous sauvegardons d'abord, puis nous publions.

Voici à quoi ressemble le « cœur » de la méthode après le refactoring :

import org.springframework.context.ApplicationEventPublisher;

public void place(Order order) {
    // Core step: enregistrons la création de la commande dans le store
    orderStore.save(order);

    // Side effects seront à l'extérieur : ici nous publions seulement le fait que la commande est créée
    publisher.publishEvent(
        new OrderCreatedEvent(order.getId(), order.getChannel())
    );
}

Notez l'effet psychologique : la méthode se lit à nouveau comme un seul scénario. Nous avons « fait le fait » et « annoncé au monde que le fait s'est produit ». Rien d'autre. Plus de « et en plus… et en plus… ».

Pour que cette partie fonctionne, le service a besoin de ApplicationEventPublisher. Nous l'injectons comme n'importe quelle dépendance – par le constructeur :

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {

    // Publisher d'événements Spring : via lui nous « communiquons » les faits dans le domaine
    private final ApplicationEventPublisher publisher;

    public OrderPlacementService(ApplicationEventPublisher publisher) {
        // Injection par constructeur pour que la dépendance soit obligatoire et explicite
        this.publisher = publisher;
    }
}

Les autres dépendances (OrderStore, pricing, etc.) ne sont pas montrées ici – elles restent, simplement le service use-case n'est plus obligé de connaître l'audit et les notifications.

4. OrderCancellationService : core step + événement

Après la création de la commande, l'annulation est le deuxième scénario où les effets secondaires prolifèrent traditionnellement. Et il est important de ne pas se tromper : l'annulation n'est pas « notifier » ni « enregistrer un audit ». L'annulation consiste à changer le statut de la commande et à sauvegarder les modifications. Tout le reste sont des réactions, même si elles sont obligatoires selon les règles métier (l'obligation ne fait pas de la réaction un core step, elle la rend simplement importante).

Faisons de la méthode d'annulation la même « mince » : modifier l'état → sauvegarder → publier OrderCancelledEvent.

import org.springframework.context.ApplicationEventPublisher;

public void cancel(Order order) {
    // Core step: modifions l'état de la commande
    order.cancel();

    // Core step: sauvegardons les modifications pour que les auditeurs voient un état cohérent
    orderStore.save(order);

    // Publions le fait métier « commande annulée » pour tous les side effects
    publisher.publishEvent(
        new OrderCancelledEvent(order.getId(), order.getChannel())
    );
}

Ici, nous ne perdons pas le contrôle du scénario : par défaut, les listeners sont synchrones, donc une exception d'un gestionnaire critique reviendra à l'extérieur via publishEvent(). Le code est devenu plus propre, mais le succès et l'erreur restent observables depuis cancel().

5. @EventListener : audit, notifications, statistiques

Maintenant, le plus agréable : nous déplaçons les side effects là où ils ont leur place. Il est facile d' « en faire trop » et de créer un listener géant « pour tout ». Nous ne ferons pas cela. Nous aurons trois gestionnaires, et chacun fait exactement une chose claire. Cela conserve la lisibilité et aide au débogage : si les notifications tombent en panne, vous ouvrez le listener de notifications, et vous ne cherchez pas parmi des milliers de lignes « où est ce sendCancelled… ».

Commençons par l'audit d'annulation. Nous l'extrayons dans AuditOrderEventsListener et rendons l'ordre explicite : l'audit s'exécute en premier.

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
public class AuditOrderEventsListener {

    private final AuditService audit;

    public AuditOrderEventsListener(AuditService audit) {
        // Service d'audit – dépendance séparée, le listener l'appelle simplement
        this.audit = audit;
    }

    @Order(1) // Nous fixons explicitement l'ordre : l'audit doit s'exécuter avant les autres réactions
    @EventListener
    public void onCancelled(OrderCancelledEvent event) {
        // Réaction à l'événement : enregistrer le fait d'annulation dans l'audit
        audit.recordCancelled(event.orderId());
    }
}

Maintenant les notifications. Il est avantageux d'appeler non pas un expéditeur spécifique, mais notre service orchestreur existant, par exemple NotificationDispatchService (qui choisira lui-même le canal/l'implémentation comme vous l'avez fait au Jour 8 via @Primary, @Qualifier ou Map<String, T>).

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
public class NotificationOrderEventsListener {

    private final NotificationDispatchService notifications;

    public NotificationOrderEventsListener(NotificationDispatchService notifications) {
        // L'orchestrateur de notifications décide lui-même comment envoyer (EMAIL/SMS/...)
        this.notifications = notifications;
    }

    @Order(2) // Les notifications viennent après l'audit (si l'audit échoue, les notifications ne sont pas envoyées)
    @EventListener
    public void onCancelled(OrderCancelledEvent event) {
        // Réaction : envoyer une notification d'annulation sur le canal approprié
        notifications.sendCancelled(event.orderId(), event.channel());
    }
}

Et enfin les statistiques. Dans un projet d'apprentissage, les statistiques peuvent être très simples : des compteurs de commandes créées et annulées en mémoire. L'important est de montrer que la réaction est externalisée et que le service use-case n'en sait plus rien.

Pour une baseline de démonstration observable, fixons également les statistiques en troisième – alors l'ordre dans la console sera prévisible sans devinette.

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
public class StatisticsOrderEventsListener {

    private final StatisticsService statistics;

    public StatisticsOrderEventsListener(StatisticsService statistics) {
        // Service de statistiques – responsabilité séparée
        this.statistics = statistics;
    }

    @Order(3) // Pour la chaîne de démo, nous gardons les statistiques après l'audit et les notifications
    @EventListener
    public void onCancelled(OrderCancelledEvent event) {
        // Ici l'event n'est pas utilisé – seul le fait de l'annulation nous importe
        statistics.incrementCancelled();
    }
}

Dans le code normal, l'ordre pour les statistiques n'est nécessaire que si vous avez réellement une exigence sur la place de cet effet secondaire. Ici, nous le fixons simplement pour que le flux d'appels assemblé soit lisible sans devinette.

6. Vérifions le call flow et l'ordre

Maintenant, vérifions simplement que la baseline assemblée se comporte comme elle le devrait après @Order et la livraison synchrone par défaut : le service use-case se termine seulement après les listeners, et l'ordre des réactions est visible directement dans la console.

Imaginons que nous avons un simple runner de scénario qui annule la commande :

import org.springframework.stereotype.Component;

@Component
public class ScenarioRunner {

    private final OrderCancellationService cancellation;

    public ScenarioRunner(OrderCancellationService cancellation) {
        // Dans le runner, nous n'avons besoin que d'un seul service use-case pour démontrer le flux d'appels
        this.cancellation = cancellation;
    }

    public void runCancel(Order order) {
        System.out.println("before cancel"); // point « avant le scénario »
        cancellation.cancel(order);          // à l'intérieur, publishEvent() appellera les listeners
        System.out.println("after cancel");  // point « après le scénario »
    }
}

Si les listeners impriment temporairement des lignes de diagnostic, vous verrez approximativement cet ordre :

before cancel
AUDIT cancelled orderId=o-1
NOTIFY cancelled orderId=o-1 channel=EMAIL
stats.cancelled=1
after cancel

Ce qui est important ici n'est pas le println lui-même, mais l'ordre des lignes. after cancel apparaît en dernier, donc publishEvent() a attendu les listeners, et le @Order fixé a donné une séquence claire audit → notification → statistics.

Sur le schéma, c'est le même flux, mais cette fois sur le vrai refactor ContextFlow :

flowchart TD
    A["OrderCancellationService.cancel()"] --> B["order.cancel(); orderStore.save(order)"]
    B --> C["publisher.publishEvent(OrderCancelledEvent)"]
    C --> D1["AuditOrderEventsListener (@Order 1)"]
    C --> D2["NotificationOrderEventsListener (@Order 2)"]
    C --> D3["StatisticsOrderEventsListener (@Order 3)"]
    D1 --> E["retour dans publishEvent"]
    D2 --> E
    D3 --> E
    E --> F["retour dans cancel()"]

Erreurs dans les listeners

Et avec les erreurs, le tableau est le même : si l'un des listeners lance une exception, elle reviendra dans cancel() via publishEvent(). Pour notre baseline, c'est bien : le scénario reste fail-fast, et nous n'avons pas à deviner s'il est terminé ou non.

7. Erreurs typiques lors du travail avec les listeners

Quand vous faites ce refactoring pour la première fois, le projet commence généralement à devenir plus propre en 10 minutes – et c'est là que se cache le piège : on veut continuer à tout sortir dans des events, jusqu'à ce que le service use-case devienne un « publieur vide ». Il est préférable de s'arrêter et de vérifier quelques pièges typiques.

Erreur n°1 : laisser les appels directs et ajouter simultanément des listeners (double exécution).
C'est le bug de transition le plus fréquent : vous avez ajouté publisher.publishEvent(...), mais vous avez oublié de supprimer auditService.record... et notificationService.send... du service. En conséquence, l'audit et les notifications sont exécutés deux fois, et vous suspectez Spring de sorcellerie pendant quelques heures, alors que seul un appel supplémentaire est en cause.

Erreur n°2 : créer un seul listener-monstre « universel » pour audit+notifications+statistiques.
Techniquement cela fonctionne, mais architecturalement vous avez simplement déplacé le combiné du service vers le listener. Une telle classe s'enrichit rapidement de conditions et devient plus difficile à maintenir que la méthode d'origine. Dans un projet d'apprentissage, il est préférable de suivre la règle : un listener – une responsabilité claire.

Erreur n°3 : publier l'événement trop tôt, avant la fin du core step.
Si vous avez publié OrderCancelledEvent et que vous ne sauvegardez la commande qu'ensuite, les listeners peuvent lire l'ancien état ou même ne pas trouver la commande dans le store. L'ordre « d'abord modifier l'état, puis publier le fait » n'est pas une question d'esthétique, c'est un moyen de préserver la prévisibilité.

Erreur n°4 : essayer de construire un processus métier via @Order.
@Order n'est pas un remplacement pour un scénario normal. Il est nécessaire pour que plusieurs side effects indépendants soient appelés dans un ordre compréhensible. Si vous commencez à construire via @Order une chaîne « d'abord étape 1, puis étape 2, puis étape 3 » – probablement vous avez déjà extrait le core step du service use-case et perdu la lisibilité du scénario.

Erreur n°5 : rendre les listeners paresseux « pour l'optimisation ».
Les gestionnaires paresseux (lazy) dans un tel projet apportent rarement un bénéfice réel, mais peuvent facilement produire des effets étranges : le gestionnaire peut ne pas être prêt au moment où vous publiez l'événement, ou vous pouvez obtenir des surprises au démarrage. Pour un teaching default, il est préférable de garder les listeners comme des singleton-beans normaux sans @Lazy.

1
Mission
Spring Core, niveau 18, leçon 4
Bloqué
Annulation de commande avec trois écouteurs séparés
Annulation de commande avec trois écouteurs séparés
1
Mission
Spring Core, niveau 18, leçon 4
Bloqué
Création d'une commande avec condition des notification-listeners
Création d'une commande avec condition des notification-listeners
1
Étude/Quiz
Événements Spring, niveau 18, leçon 4
Indisponible
Événements Spring
Traitement des événements dans Spring
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION