CodeGym /Cours /Spring Core /Événements via ApplicationEventPublisher

Événements via ApplicationEventPublisher

Spring Core
Niveau 17, Leçon 2
Disponible

1. Point d'appel de publishEvent()

Si vous ajoutez des événements pour la première fois dans une application, la main est souvent tentée d'insérer publishEvent() « quelque part au début » pour être sûr que tous les gestionnaires aient le temps de réagir. Et c'est là que commence la tragicomédie classique : vous publiez OrderCreatedEvent, puis il s'avère que la commande n'a pas été sauvegardée, le prix a été mal recalculé ou même une exception a été levée. L'événement s'est déjà propagé — mais le fait métier n'existe pas en réalité. C'est un peu comme écrire dans un chat général « je suis déjà en vacances », puis se rappeler que vous n'avez pas envoyé la demande.

Pour éviter de telles « bugs de vacances » en prod (et dans le projet éducatif aussi), gardons une règle simple : publier l'événement uniquement après que l'étape principale du scénario est réellement terminée.

En termes de ContextFlow, cela se dit ainsi :

  • « Commande créée » signifie que la commande est déjà assemblée et sauvegardée dans OrderStore et qu'elle a un identifiant auquel les réactions peuvent se référer.
  • « Commande annulée » signifie que le statut a été changé en CANCELLED, la raison de l'annulation est enregistrée (dans le modèle métier) et l'état final est sauvegardé dans OrderStore.

Il est pratique de représenter le scénario comme une courte séquence d'étapes, où l'événement est un marqueur soigneux entre le noyau et les réactions :

flowchart TD
    %% publishEvent placé après la fixation de l'état, mais avant la sortie de la méthode du scénario
    A["Service de scénario : place/cancel"] --> B["Noyau : changer l'état + sauvegarder"]
    B --> C["publishEvent(Fait métier)"]
    C --> D["Réactions : audit, notification, statistiques (écouteurs)"]

Notez que nous ne discutons pas des écouteurs en détail — c'est la prochaine leçon. Pour l'instant, ce qui nous importe est le point d'appel de publishEvent() : il se situe après le changement d'état et avant la sortie de la méthode du scénario, afin que l'événement soit un « fait » honnête, pas une promesse.

2. ApplicationEventPublisher au lieu de ApplicationContext

Quand vous voyez le mot « publisher », il est facile de décider : « Eh bien, puisque les événements font partie de Spring, je vais maintenant injecter ApplicationContext et tout publier à partir de là ». Techniquement, cela fonctionne, mais méthodiquement (et architecturalement), c'est presque toujours un pas vers l'anti-patron service locator. Aujourd'hui, il est important de faire correctement : le service de scénario doit dépendre d'un contrat étroit dont il a besoin, et non « de tout le conteneur à la fois ».

Regardez une mini-comparaison — elle montre bien pourquoi nous utilisons le publisher dans le service de scénario :

Ce que nous injectons Ce que cela signifie en pratique Pourquoi c'est (ou pas) bien
ApplicationEventPublisher « Je sais publier des événements » Étroit, honnête, n'incite pas à getBean()
ApplicationContext « Je sais tout sur le conteneur » Trop large, incite à transformer le service en “chercheur de beans”
OrderEventsGateway (interface personnalisée) « Je publie des faits métier via mon port » Souvent bien en prod, mais dans ce cours ajoute une couche d'abstraction supplémentaire

Dans notre projet éducatif, nous choisissons la première option : nous injectons ApplicationEventPublisher via le constructeur (par injection de constructeur habituelle), afin que la dépendance soit visible et minimale.

Un petit détail important à prendre comme un fait : ApplicationContext sait lui-même être un publisher, donc Spring peut injecter ApplicationEventPublisher comme dépendance sans configuration supplémentaire. C'est une « capacité intégrée » du contexte, pas quelque chose que nous devons enregistrer manuellement via @Bean.

3. Publication de OrderCreatedEvent

Maintenant, faisons ce pourquoi tout a été commencé : ajoutons la publication de l'événement au scénario de création de commande. Il est important de commencer avec une bonne « géographie des responsabilités ». Le service de scénario (OrderPlacementService) est précisément l'endroit qui sait que la création de la commande a réellement eu lieu : il initie l'action, sauvegarde le résultat et retourne le contrôle à l'extérieur. Donc, c'est lui qui doit déclarer le fait par un événement.

Ni l'entité métier Order, ni OrderStore, ni une classe « utility » ne sont un bon point de publication. Si on met la publication à l'intérieur de Order, on obtient un modèle métier qui dépend de Spring (alors que notre domaine doit vivre dans le monde Java, pas dans org.springframework.*). Si on met la publication dans OrderStore, on obtient un « dépôt avec effets secondaires », où l'événement est publié non pas selon la logique du scénario, mais « sur le fait de la sauvegarde » — et cela devient rapidement confus.

Ci-dessous — un fragment minimal du service qui publie l'événement après la sauvegarde :

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

@Service
public class OrderPlacementService {
    // Injects narrow contract: the service only needs to publish events, not the whole context
    private final ApplicationEventPublisher publisher;

    public OrderPlacementService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void place(Order order) {
        // Here only the publishEvent mechanism is shown — no save (see next example)
        // We put in the event the minimally sufficient data for reactions: order id and customer id
        publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
    }
}

Oui, ici il n'y a pas encore de orderStore.save(order) — j'ai intentionnellement montré la « mécanique nue » de la publication pour que vous ne vous noyiez pas dans les détails métier. Dans le code réel de ContextFlow, l'événement doit apparaître après la sauvegarde.

Rendons l'exemple plus proche de la réalité, mais toujours compact. Supposons que le service sauvegarde la commande, puis publie OrderCreatedEvent :

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

@Service
public class OrderPlacementService {
    // The store records the outcome of the scenario (in the educational project it's in-memory, but same idea)
    private final OrderStore orderStore;

    // Publisher — point to declare the business fact externally
    private final ApplicationEventPublisher publisher;

    public OrderPlacementService(OrderStore orderStore, ApplicationEventPublisher publisher) {
        this.orderStore = orderStore;
        this.publisher = publisher;
    }

    public void place(Order order) {
        // 1) First fix the state: the order is indeed created and saved
        orderStore.save(order);

        // 2) Then declare the fact: "order created" (not "we plan to create it")
        publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
    }
}

Notez deux choses.

Premièrement — ApplicationEventPublisher est injecté comme une dépendance ordinaire. Pas de ApplicationContext, pas de « donnez-moi tout le conteneur, je me débrouillerai ». C'est un « réflexe musculaire » important : vous demandez à Spring exactement ce dont vous avez besoin.

Deuxièmement — OrderCreatedEvent est créé directement sur le lieu de la publication, car c'est là que nous avons les données nécessaires à portée de main. Et c'est normal. Quand on débute, on a tendance à vouloir externaliser la « création d'événement » dans une classe séparée OrderEventFactory, mais dans un projet éducatif, cela crée souvent plus de bruit que de bénéfice. Nous externaliserons la création dans une méthode séparée seulement si nous voyons que le code commence vraiment à gonfler.

source : signification et choix de this

La classe ApplicationEvent a un champ obligatoire source. Dans la plupart des scénarios applicatifs, vous n'utilisez pas source dans la logique métier, mais il est utile pour le diagnostic et la lecture des logs/débogage : on peut comprendre qui a exactement publié l'événement. Donc this (c'est-à-dire OrderPlacementService lui-même) est un choix tout à fait normal.

Si vous ressentez une douleur intérieure « je ne veux pas exposer le service comme source », vous pouvez passer quelque chose de plus abstrait (par exemple, la chaîne "order-placement"), mais pour la transparence pédagogique, « this » est l'option la plus simple : vous savez exactement d'où cet événement est sorti.

4. OrderCancelledEvent dans OrderCancellationService

L'annulation de commande est le deuxième scénario principal du projet. Et il est plus intéressant en termes de « point de publication », car l'annulation n'est pas simplement « quelque chose s'est produit », mais un changement de statut et souvent la présence d'une raison. Pour un débutant, c'est un exemple idéal pour ressentir que l'événement est le résultat du scénario, et non une « étape interne ».

Le service d'annulation fait généralement deux choses : changer l'état de la commande (via le modèle métier) et sauvegarder le résultat (via OrderStore). Et ce n'est qu'après cela qu'il publie l'événement « commande annulée ».

Forme minimale :

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

@Service
public class OrderCancellationService {
    private final OrderStore orderStore;
    private final ApplicationEventPublisher publisher;

    public OrderCancellationService(OrderStore orderStore, ApplicationEventPublisher publisher) {
        this.orderStore = orderStore;
        this.publisher = publisher;
    }

    public void cancel(Order order, String reason) {
        // 1) First change the domain state
        order.cancel(reason);

        // 2) Then save the result (so the event reflects a stored fact)
        orderStore.save(order);

        // 3) Only then publish the event with the minimal payload (id + reason)
        publisher.publishEvent(new OrderCancelledEvent(this, order.getId(), reason));
    }
}

Ici, il est important que publishEvent() vienne après order.cancel(reason) et après la sauvegarde. Pour notre OrderStore en mémoire, « save » semble presque jouet, mais dans le modèle mental du cours, cela joue le rôle de la « validation du résultat du scénario ». Si nous avions une base de données, nous devrions aussi penser aux transactions, mais cela relève des cours suivants (et nous n'y entrons pas pour ne pas faire une « excursion dans les disciplines voisines »).

Autre nuance : l'événement doit porter des données minimales suffisantes, pour que les gestionnaires n'aient pas à deviner ce qui s'est passé. Pour l'annulation, la raison est une partie importante du fait. Elle peut être utilisée pour l'audit et la notification. Donc mettre reason dans l'événement est logique.

5. Lisibilité du scénario : un fait — un publishEvent()

Quand vous avez pris goût aux événements, la tentation apparaît de les publier à chaque micro-étape. « Commande sauvegardée — événement. Prix calculé — événement. Canal de notification choisi — événement. Passage à la méthode suivante — événement. » Cela semble « flexible », mais se transforme très vite en « soupe d'événements », dans laquelle il est impossible de comprendre ce qui est réellement un fait métier.

Pour garder le système cohérent, il est utile de suivre une discipline simple : dans une méthode de scénario, on publie un événement par fait terminé. Dans place() — OrderCreatedEvent. Dans cancel() — OrderCancelledEvent. Et c'est tout.

On peut même le formaliser comme un mini-patron de méthode de scénario :

Partie de la méthode Ce qui se passe ici Publions-nous un événement ?
Préparation vérification/collecte de données non
Noyau du scénario changement d'état + sauvegarde non (encore tôt)
Déclaration du fait publishEvent(...) oui
Fin return / sortie non

Si vous voyez deux publishEvent() à la suite dans une même méthode — ce n'est pas forcément une erreur, mais c'est presque toujours une occasion de s'arrêter et de se demander : « Est-ce que je publie deux faits différents, ou est-ce que je fragmente simplement un fait en morceaux ? »

Petit truc pour la lisibilité : une méthode privée séparée pour la publication

Si la ligne publisher.publishEvent(new ...) commence à gêner la lecture (par exemple, un payload complexe), on peut l'extraire dans une méthode privée. Cela ne fait pas de magie — améliore simplement le « flux visuel » du scénario :

public void place(Order order) {
    // 1) Fix the state
    orderStore.save(order);

    // 2) Extract the publication into a separate method so that place() reads linearly
    publishCreated(order);
}

private void publishCreated(Order order) {
    // publishEvent — marker of a completed fact
    publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}

Cette technique est particulièrement utile lorsque la méthode du scénario contient déjà plusieurs étapes du noyau (par exemple, tarification, génération d'id, sauvegarde) et que vous souhaitez que publishEvent() soit lu comme un « marqueur » distinct du fait terminé.

6. Refactorisation : réactions via les événements

Nous effectuons maintenant un virage architectural important. Avant les événements, un service typique de création de commande ressemblait à un « orchestre qui joue de tous les instruments en même temps » : il sauvegarde, écrit l'audit et envoie des notifications. Oui, c'est plus simple pour démarrer, mais cela ne passe pas à l'échelle : chaque nouvelle réaction ajoute une nouvelle dépendance et une nouvelle ligne de code au service.

Exemple « comme avant » (très simplifié, mais reconnaissable) :

public void place(Order order) {
    // The scenario knows everything at once: about the store, audit, and notifications
    orderStore.save(order);

    // Reactions are "embedded" in the scenario — the service grows with dependencies
    auditService.recordCreated(order.getId());
    notificationDispatchService.sendCreated(order.getId());
}

Après l'introduction des événements, nous commençons à évoluer vers la forme « le service enregistre le fait et le déclare », et les réactions vivent séparément :

public void place(Order order) {
    // 1) Fix the outcome of the scenario
    orderStore.save(order);

    // 2) Declare the fact: the reactions will run in separate listeners
    publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}

Notez qu'à cette étape, le service de scénario devient plus court et plus honnête. Il cesse de savoir quelles réactions existent. Aujourd'hui, ce sont l'audit et les notifications, demain les statistiques, après-demain la « mise à jour d'un rapport simple » — et le service de scénario n'en est pas modifié.

Oui, après une telle refactorisation, le débutant se pose naturellement la question : « Mais où sont maintenant l'audit et les notifications, ont-ils disparu ? » Non, ils n'ont pas disparu. Ils migreront vers des listeners — c'est-à-dire des beans séparés qui s'abonneront à OrderCreatedEvent et feront leur travail. Cela sera dans la prochaine leçon, où nous commencerons à écrire des gestionnaires via ApplicationListener<T>.

7. Erreurs typiques de publication

Erreur n° 1 : publication de l'événement avant la fin de l'étape principale.
Le problème le plus fréquent est de mettre publishEvent() avant que la commande soit réellement sauvegardée ou que le statut ait vraiment changé. De l'extérieur, cela semble « tout fonctionner », jusqu'à ce qu'une exception survienne au milieu de la méthode. Alors certaines réactions ont pu s'exécuter, mais le fait métier n'a pas eu lieu. Il est donc préférable de suivre une règle de fer : l'événement est publié après que le noyau du scénario est terminé.

Erreur n° 2 : injection de ApplicationContext au lieu de ApplicationEventPublisher.
Au débutant, il semble pratique de « prendre le contexte, on se débrouillera », mais c'est presque toujours le début d'une pensée de service locator. Dans une semaine, le service aura context.getBean(...), dans deux semaines, le choix manuel d'implémentations, dans trois semaines, vous commencerez à soigner l'architecture par « recherche de beans ». Dans le service de scénario, nous injectons un publisher étroit et nous nous réjouissons que le code reste correct.

Erreur n° 3 : publication de l'événement depuis le modèle métier (Order).
On a parfois envie de faire joli : « la commande sait elle-même qu'elle est annulée, qu'elle publie elle-même l'événement ». Le problème est que Order devrait alors connaître Spring et avoir accès au publisher. En conséquence, le modèle métier devient dépendant du framework, moins testable et moins portable. Dans notre cours, le domaine reste du Java pur, et la publication est la tâche des services d'application.

Erreur n° 4 : l'événement emporte « tout et n'importe quoi » (y compris un objet Order mutable).
Si on met un objet commande complet dans l'événement et que cet objet est modifié ailleurs, les écouteurs peuvent voir « une autre commande » ou un état qui change en cours de route. Pour un premier cours, il est préférable de garder l'événement comme un petit instantané immuable du fait : des identifiants et les champs nécessaires, sans lourdeur superflue.

Erreur n° 5 : plusieurs publishEvent() dans un même scénario « au cas où ».
Cela commence généralement avec de bonnes intentions : « ayons OrderSavedEvent, et OrderCreatedEvent, et OrderPricedEvent ». Mais au final, le lecteur du code perd la compréhension de ce qui est un vrai fait métier et ce qui n'est qu'une étape technique. Dans le ContextFlow pédagogique, nous publions des événements seulement pour les faits vraiment significatifs : commande créée, commande annulée. Tout le reste est laissé en code direct à l'intérieur du noyau du scénario.

1
Mission
Spring Core, niveau 17, leçon 2
Bloqué
Publication d'un événement après la sauvegarde du certificat
Publication d'un événement après la sauvegarde du certificat
1
Mission
Spring Core, niveau 17, leçon 2
Bloqué
RecordingEventPublisher comme point de publication observable
RecordingEventPublisher comme point de publication observable
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION