Vous avez déjà ajouté une « petite » réaction à un événement métier — un mail à envoyer, une stat à mettre à jour — et constaté que ça vous obligeait à modifier une classe centrale que dix autres fonctionnalités traversent déjà ? Vous avez déjà dû monter la moitié de l'application juste pour tester une méthode qui, sur le papier, ne fait qu'« une seule chose » ?
Ces douleurs ont une origine commune : un module en appelle directement plusieurs autres. L'appelant connaît tout le monde, et chaque nouveau besoin retombe sur lui.
La communication par événements propose une autre façon de penser l'invocation : au lieu d'appeler les modules concernés, on raconte ce qui vient de se passer et on laisse ceux que ça intéresse réagir. C'est un pattern de conception, pas une fonctionnalité d'un framework : on le retrouve sous le nom de Domain Events, de publish/subscribe, ou d'event bus. On l'illustrera ici avec Spring — qui en offre une implémentation native et sans infrastructure — mais le raisonnement vaut dans n'importe quel langage.
Prenons un cas métier concret, tiré d'un logiciel hospitalier : valider une prescription. Quand une prescription est validée, plusieurs choses doivent se produire :
L'implémentation naïve est celle qu'on écrit tous spontanément : le service de prescription appelle les trois modules, l'un après l'autre.
@Service
public class PrescriptionService {
private final PlanDeSoinService planDeSoin;
private final DossierPatientService dossierPatient;
private final PharmacieService pharmacie;
public PrescriptionService(PlanDeSoinService planDeSoin,
DossierPatientService dossierPatient,
PharmacieService pharmacie) {
this.planDeSoin = planDeSoin;
this.dossierPatient = dossierPatient;
this.pharmacie = pharmacie;
}
public void valider(Prescription prescription) {
log.info(">> Validation de prescription {}", prescription);
planDeSoin.mettreAJour(prescription.patientId());
dossierPatient.ajouterEvenement(prescription.patientId(), "Prescription validée");
pharmacie.reserverStock(prescription.medicament());
log.info("<< Validation terminée");
}
}
Ça marche. Mais regardez le sens des dépendances : Prescription connaît PlanDeSoin, DossierPatient et Pharmacie. Le module le plus métier, le plus central, dépend de trois modules périphériques.
Cette flèche de dépendance qui part de Prescription vers les autres a un coût bien concret.
Pour tester valider(), il faut soit booter tout le contexte Spring, soit mocker les trois services. Le test ne parle plus de la prescription : il parle de plomberie.
Si PharmacieService.reserverStock() change de signature, c'est PrescriptionService qui casse. Un module appelé qui évolue impacte mécaniquement son appelant.
C'est le point le plus insidieux. PrescriptionService doit être modifié :
Or, plus une classe a de raisons de changer, plus elle est instable dans le temps. Et ici, c'est la classe la plus métier qui devient la plus mouvante — exactement l'inverse de ce qu'on veut.
Avant de corriger, posons-nous une question qui paraît bête mais qui éclaire tout : combien de façons existe-t-il d'invoquer une fonction en Java ?
Function<T, R>, Consumer<T>… ;@Transactional, @Async ;CompletableFuture ;Toutes ces façons, de la 1 à la 6, ont un point commun : c'est l'appelant qui choisit qui sera appelé. Même derrière une interface ou un callback, c'est encore Prescription qui décide d'invoker quelque chose.
L'événement renverse cette logique : l'émetteur ne choisit pas, ne connaît même pas, ses destinataires. Il déclare un fait. Ceux que ça concerne s'abonnent. C'est cette inversion qui va sauver notre architecture.
Quelle que soit la techno, ce pattern repose toujours sur trois briques :
Spring fournit ces trois briques clés en main (ApplicationEvent, ApplicationEventPublisher, @EventListener), mais on pourrait tout aussi bien les recréer à la main avec une simple Map<Class, List<Consumer>>, ou s'appuyer sur l'EventBus de Guava, le Mediator de .NET, ou un EventEmitter en Node. Voyons-les une par une, en Spring.
On commence par décrire ce qui s'est passé, sans rien dire de qui doit réagir.
package com.example.bbl.prescription;
public record PrescriptionValidatedEvent(
Long prescriptionId,
Long patientId,
String medicament
) {
}
Trois choses à noter :
record : immuable, sans aucune logique métier dedans. Un event est un porteur de données, pas un service.Validated. Un événement décrit un fait déjà arrivé, jamais un ordre à exécuter (« valide ceci »).prescription. C'est l'émetteur qui définit le contrat ; les autres s'y conforment.On remplace les trois appels directs par une simple publication.
@Service
public class PrescriptionService {
private final ApplicationEventPublisher publisher;
public PrescriptionService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void valider(Prescription prescription) {
log.info(">> Validation de prescription {}", prescription);
publisher.publishEvent(new PrescriptionValidatedEvent(
prescription.id(),
prescription.patientId(),
prescription.medicament()
));
log.info("<< Validation terminée");
}
}
On est passé de 3 dépendances à 0. PrescriptionService ne sait plus qui réagit, ni même si quelqu'un réagit. ApplicationEventPublisher est fourni par Spring : rien à configurer.
Côté plan de soin, on garde la logique métier intacte (mettreAJour) et on ajoute un point d'entrée qui réagit à l'événement.
@Service
public class PlanDeSoinService {
@EventListener
public void onPrescriptionValidated(PrescriptionValidatedEvent event) {
mettreAJour(event.patientId());
}
public void mettreAJour(Long patientId) {
log.info("[PlanDeSoin] MAJ pour patient {}", patientId);
}
}
@EventListener suffit : c'est Spring qui abonne la méthode au démarrage. La méthode onPrescriptionValidated joue le rôle d'adaptateur — elle décide comment ce module réagit à l'événement — tandis que mettreAJour reste de la logique métier pure, testable indépendamment.
Les deux autres modules suivent exactement le même patron :
@Service
public class DossierPatientService {
@EventListener
public void onPrescriptionValidated(PrescriptionValidatedEvent event) {
ajouterEvenement(event.patientId(), "Prescription validée");
}
public void ajouterEvenement(Long patientId, String evenement) {
log.info("[DossierPatient] '{}' pour patient {}", evenement, patientId);
}
}
@Service
public class PharmacieService {
@EventListener
public void onPrescriptionValidated(PrescriptionValidatedEvent event) {
reserverStock(event.medicament());
}
public void reserverStock(String medicament) {
log.info("[Pharmacie] Réservation de stock pour {}", medicament);
}
}
Résultat : les trois modules réagissent exactement comme avant, mais aucun ne connaît PrescriptionService, et PrescriptionService n'en connaît aucun. La flèche de dépendance s'est inversée — elle part désormais des modules périphériques vers le contrat d'événement, pas l'inverse.
C'est ici que le pattern montre sa vraie valeur. Le métier arrive avec un nouveau besoin : notifier un système tiers à chaque validation.
Question : combien de fichiers faut-il modifier ?
package com.example.bbl.notification;
@Service
public class NotificationService {
@EventListener
public void onPrescriptionValidated(PrescriptionValidatedEvent event) {
log.info("[Notification] → prescription {} validée", event.prescriptionId());
}
}
Un seul fichier ajouté. Zéro fichier modifié. PrescriptionService n'a pas bougé d'une virgule, les autres modules non plus.
C'est le principe open/closed appliqué à la lettre : le système est ouvert à l'extension (j'ajoute un comportement) et fermé à la modification (je ne touche pas à l'existant). Et la réciproque est vraie : si demain on veut couper la notification, on supprime ce seul fichier. Aucune trace à nettoyer ailleurs.
Spring nous a tout donné gratuitement — mais pour bien voir que la magie n'est pas dans le framework, recodons le bus à la main. Le pattern publish/subscribe tient en une trentaine de lignes : un registre qui associe un type d'événement à la liste de ses abonnés.
public class EventBus {
private final Map<Class<?>, List<Consumer<Object>>> subscribers = new HashMap<>();
public <T> void subscribe(Class<T> eventType, Consumer<T> listener) {
subscribers
.computeIfAbsent(eventType, k -> new ArrayList<>())
.add((Consumer<Object>) listener);
}
public void publish(Object event) {
subscribers
.getOrDefault(event.getClass(), List.of())
.forEach(listener -> listener.accept(event));
}
}
C'est tout. publish() ne connaît pas ses abonnés : il regarde dans la Map qui s'est inscrit pour ce type d'événement, et les appelle. L'émetteur publie sans rien savoir :
public class PrescriptionService {
private final EventBus bus;
public PrescriptionService(EventBus bus) {
this.bus = bus;
}
public void valider(Prescription prescription) {
bus.publish(new PrescriptionValidatedEvent(
prescription.id(),
prescription.patientId(),
prescription.medicament()
));
}
}
…et chaque module s'abonne de son côté, au démarrage :
EventBus bus = new EventBus();
PlanDeSoinService planDeSoin = new PlanDeSoinService();
PharmacieService pharmacie = new PharmacieService();
bus.subscribe(PrescriptionValidatedEvent.class, e -> planDeSoin.mettreAJour(e.patientId()));
bus.subscribe(PrescriptionValidatedEvent.class, e -> pharmacie.reserverStock(e.medicament()));
On retrouve exactement les trois briques du début — l'événement, l'émetteur qui publie sur le bus, les abonnés — et le même bénéfice : PrescriptionService ne dépend que du bus, jamais des modules qui réagissent.
Ce que Spring apporte par-dessus ce squelette, c'est le confort et la robustesse : l'abonnement automatique via @EventListener (pas de subscribe() manuel à câbler), l'intégration transactionnelle, la gestion d'erreurs, le support de l'asynchrone… Mais le cœur du découplage, lui, ne tient qu'à cette Map. C'est aussi pour ça qu'on retrouve le même pattern partout : l'EventBus de Guava, le Mediator de .NET, l'EventEmitter de Node — tous des variations autour de ces trente lignes.
Le pattern est simple ; son implémentation, elle, a des subtilités. Les deux pièges qui suivent sont propres à un bus synchrone et en mémoire comme celui de Spring — c'est exactement le genre de détail qui sépare la théorie du code qui tourne en production. On les illustre avec les annotations Spring, mais le raisonnement (cohérence transactionnelle, isolation des erreurs) se pose à l'identique avec n'importe quel bus in-process.
Imaginez que valider() soit @Transactional. La pharmacie réserve du stock dans son listener… puis la transaction rollback. On vient de réserver du stock pour une prescription qui n'existe finalement pas.
Le problème : par défaut, un @EventListener s'exécute dans la transaction en cours, au moment du publishEvent(). La solution est de n'exécuter le listener qu'après le commit.
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true // s'exécute aussi hors transaction (utile en démo/test)
)
public void onPrescriptionValidated(PrescriptionValidatedEvent event) { ... }
AFTER_COMMIT (la phase par défaut) garantit que le listener n'est appelé que si la transaction a réussi.fallbackExecution = true est important : sans transaction, un @TransactionalEventListener est silencieusement ignoré. Ce flag le fait quand même s'exécuter — ce qui évite des « mais pourquoi mon listener ne part pas ?! » en test.BEFORE_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION.publishEvent() est synchrone. Les listeners s'exécutent les uns après les autres, dans le même thread. Conséquence : si l'un d'eux lève une exception, elle remonte et interrompt toute la chaîne.
Concrètement, si la pharmacie plante…
@EventListener
public void onPrescriptionValidated(PrescriptionValidatedEvent event) {
if (event.medicament().contains("Doliprane")) {
throw new RuntimeException("Stock pharmacie KO !");
}
reserverStock(event.medicament());
}
…alors le plan de soin et le dossier patient peuvent ne jamais être mis à jour, selon l'ordre d'exécution. Une panne locale devient une panne globale.
La parade : chaque listener isole ses propres erreurs.
@EventListener
public void onPrescriptionValidated(PrescriptionValidatedEvent event) {
try {
reserverStock(event.medicament());
} catch (Exception e) {
log.error("[Pharmacie] Échec réservation, on isole l'erreur", e);
// TODO : retry, dead-letter, alerting…
}
}
Si l'isolation manuelle devient répétitive, une alternative plus propre consiste à enregistrer un ApplicationEventMulticaster custom avec un ErrorHandler global.
Les événements brillent quand :
Mais ce n'est pas une solution universelle. À éviter quand :
Ce dernier point dépend de l'implémentation. Un bus in-process — celui de Spring comme la plupart des EventBus applicatifs — vit en mémoire, dans le même processus. Si l'application tombe entre deux listeners, l'événement est perdu. Dès qu'on a besoin de persistance, de rejeu ou de communication inter-services, on ne parle plus d'événements applicatifs mais de messaging (Kafka, RabbitMQ…). C'est l'étape d'après, avec d'autres garanties — et d'autres coûts. Bonne nouvelle : c'est le même vocabulaire (event, publish, subscribe), donc la transition est surtout une question d'infrastructure, pas de remise en cause du modèle.
Pour finir, ce qui fait la différence entre un usage propre et un plat de spaghettis événementiels :
Le couplage fort, ce n'est pas un défaut de discipline — c'est ce qui sort naturellement du clavier quand on écrit « valider une prescription, puis faire A, B et C ». Le problème, c'est que le module le plus métier finit par dépendre de tout le reste, et devient le plus instable.
Les événements renversent cette dépendance avec une mécanique minimaliste, toujours la même quelle que soit la techno :
On y gagne un découplage fort, une meilleure testabilité (la logique métier redevient des méthodes simples), une évolutivité réelle (ajouter un comportement = ajouter un fichier), et un premier pas naturel vers une architecture orientée domaine.
Spring n'est ici qu'un véhicule : il fournit l'événement (un record), le bus (ApplicationEventPublisher) et l'abonnement (@EventListener) sans aucune infrastructure. Mais le vrai sujet, c'est le pattern. Bien utilisé, il suffit à transformer un module central et redouté en un module stable que plus personne n'a besoin de toucher. Et le jour où les besoins dépassent l'in-process, vous aurez déjà raisonné en événements : le passage au messaging — ou simplement à une autre techno — n'en sera que plus naturel.