Aller au contenu
>_ developpeur-python
Tous les articles
ArchitectureDesign PatternPythonEvent-Driven

Le pattern Event Bus : avantages, inconvénients et quand l'utiliser

Sébastien Mizrahi ,
Le pattern Event Bus : avantages, inconvénients et quand l'utiliser

Le pattern Event Bus est un mécanisme de communication où des composants publient des événements sur un canal central, et où d'autres composants s'y abonnent pour réagir, sans que les uns connaissent les autres. Son intérêt tient en un mot : le découplage. Son coût, aussi : on gagne en souplesse ce qu'on perd en lisibilité du flux. Voici comment il fonctionne, ce qu'il apporte vraiment, ce qu'il coûte, et les situations où je le recommande ou l'évite.

Le principe : publier, s'abonner, ne pas se connaître

Dans une architecture classique, quand le service A a besoin que le service B fasse quelque chose, il l'appelle directement. A dépend de B, connaît son adresse et sa signature. Multipliez les dépendances de ce type et vous obtenez un plat de spaghetti où chaque composant en connaît dix autres.

L'Event Bus renverse la logique. Un composant se contente de publier un événement, par exemple "une commande a été créée", sur un bus. Il ne sait pas, et ne veut pas savoir, qui va réagir. D'autres composants s'abonnent aux types d'événements qui les intéressent et réagissent à leur rythme. L'émetteur et les abonnés ne se connaissent pas ; le bus sert d'intermédiaire.

Diagramme du pattern Event Bus : émetteurs, bus d'événements et abonnés Un événement est publié une fois. Plusieurs abonnés réagissent indépendamment, et l'émetteur ignore qui écoute.

Comment ça marche concrètement

Le coeur du pattern est simple. Un Event Bus tient un registre qui associe un type d'événement à une liste de fonctions abonnées, et distribue chaque événement publié à ces fonctions.

Un choix compte beaucoup ici, et c'est une préférence que j'assume : faire circuler des objets typés en entrée comme en sortie, plutôt que des dict ou des list. Un événement devient une classe avec des champs nommés, et un abonné retourne lui aussi un objet de réponse typé. On y gagne l'autocomplétion, la vérification par le type checker, un refactoring plus sûr et un contrat lisible : la forme de l'événement est écrite noir sur blanc, pas devinée à partir d'un dictionnaire.

from dataclasses import dataclass, field
from collections import defaultdict
from typing import Callable
import threading
import uuid

@dataclass
class Event:
    correlation_id: str = field(default_factory=lambda: str(uuid.uuid4()))

@dataclass
class OrderCreated(Event):
    order_id: int = 0
    total: float = 0.0

class EventBus:
    def __init__(self) -> None:
        self._subscribers: dict[type, list[Callable]] = defaultdict(list)

    def subscribe(self, event_type: type, handler: Callable) -> None:
        self._subscribers[event_type].append(handler)

    def publish(self, event: Event) -> None:
        """Synchronous: pass the typed event to each handler, in order."""
        for handler in self._subscribers[type(event)]:
            handler(event)

    def publish_async(self, event: Event) -> None:
        """Non-blocking: run each handler in its own thread and return at once."""
        for handler in self._subscribers[type(event)]:
            threading.Thread(target=handler, args=(event,), daemon=True).start()

Côté utilisation, on branche des abonnés sur une classe d'événement, puis on publie une instance. L'émetteur ne mentionne aucun des abonnés.

bus = EventBus()
bus.subscribe(OrderCreated, send_confirmation_email)
bus.subscribe(OrderCreated, update_inventory)

# The publisher does not know who listens
bus.publish(OrderCreated(order_id=4271, total=79.90))

# Same event, handlers run in the background without blocking the caller
bus.publish_async(OrderCreated(order_id=4271, total=79.90))

Ajouter une réaction, par exemple pousser la commande vers un outil d'analyse, revient à écrire un nouvel abonné et à l'enregistrer. Le code qui publie l'événement ne change pas d'une ligne. C'est exactement ce qu'on recherche.

Autre aspect que j'apprécie : comme le même objet est transmis à chaque abonné, dans l'ordre où ils sont appelés, un abonné peut enrichir l'événement avant les suivants. Le premier ajoute une donnée qu'il a calculée, le deuxième s'en sert, et ainsi de suite, sans qu'aucun ne connaisse l'autre. L'événement typé sert alors de contexte partagé qui s'étoffe au fil de la chaîne. Dans une version plus avancée, on ordonne les abonnés par priorité pour maîtriser cet enchaînement.

Voici la même mécanique vue en UML. L'EventBus garde une liste d'abonnés (agrégation), les émetteurs et l'EventBus dépendent de l'Event qui circule, et chaque abonné concret implémente une même interface EventHandler.

Diagramme UML de classes du pattern Event Bus : EventBus, Event, interface EventHandler et abonnés concrets Le diagramme de classes : l'EventBus agrège des EventHandler, sans connaître leurs implémentations concrètes.

Traitement asynchrone : threads, parallélisme et traçabilité

La version synchrone exécute les abonnés l'un après l'autre, dans le thread de l'émetteur. Dès que les réactions sont lentes (envoyer un email, appeler une API, écrire en base), on ne veut plus faire attendre l'émetteur. La méthode publish_async ci-dessus lance chaque abonné dans son propre thread et rend la main immédiatement.

Quand on a besoin de déclencher les abonnés en parallèle mais d'attendre qu'ils aient tous fini, par exemple pour agréger leurs résultats, on lance un thread par abonné puis on les joint. L'identifiant de corrélation porté par l'événement suit alors tout l'enchaînement dans les journaux.

def publish_parallel(self, event: Event) -> list:
    """Run every handler in parallel, then wait for all of them to finish."""
    results: list = []
    lock = threading.Lock()

    def run(handler: Callable) -> None:
        result = handler(event)
        with lock:
            results.append(result)

    threads = [threading.Thread(target=run, args=(handler,))
               for handler in self._subscribers[type(event)]]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    return results

Deux points méritent l'attention. D'abord, à cause du GIL, les threads Python ne parallélisent pas le calcul pur ; ils sont utiles pour des abonnés qui passent leur temps à attendre des entrées-sorties (réseau, base, fichiers), ce qui est le cas le plus fréquent pour des réactions à un événement. Pour du calcul intensif, on se tourne vers des processus ou un vrai courtier de messages. Ensuite, le correlation_id répond directement au reproche de traçabilité que j'aborde plus bas : en le propageant dans chaque abonné et dans chaque ligne de log, on reconstitue l'arbre d'appel complet d'un événement, même quand tout tourne en parallèle. Dans une implémentation de production, on ajoute souvent aussi des abonnés ordonnés par priorité et des hooks avant et après publication, pour la journalisation ou la métrologie.

Les avantages

Le découplage, d'abord. Émetteurs et abonnés évoluent chacun de leur côté : on ajoute, on retire ou on modifie un abonné sans jamais toucher au code qui publie. Sur un système qui grandit, le risque de régression chute nettement.

Il y a un effet plus structurel, et franchement sous-estimé : le bus casse les dépendances circulaires. Quand deux modules s'appellent directement, le cycle arrive vite, A importe B qui finit par réimporter A. Avec des événements, les modules ne s'importent plus du tout. Chaque domaine métier se retrouve isolé mécaniquement derrière ses événements, ce qui garde le découpage propre à mesure que le code grossit.

L'extensibilité en découle. Brancher une fonctionnalité sur un événement existant ne coûte presque rien, et tous les besoins transverses (journalisation, notifications, statistiques, audit) se greffent en écoutant les bons événements, sans polluer la logique principale.

Le traitement asynchrone devient naturel lui aussi. Si le bus met les événements dans une file, l'émetteur n'attend pas. Une commande est confirmée tout de suite pendant que l'email, la facture et la mise à jour du stock partent en arrière-plan. C'est la philosophie que j'applique dans Alfred, ma plateforme de chatbot, où certaines actions sont déclenchées puis traitées de façon découplée.

Reste la testabilité : un abonné se teste en lui envoyant un événement, sans monter tout le système. C'est bête, mais sur un projet qui dure, ça compte.

Les inconvénients

Le revers est réel, et autant le regarder en face. En tête : la perte de lisibilité du flux. Quand la logique passe par des événements, lire le code ne suffit plus pour savoir ce qui se passe après une publication, il faut connaître la liste des abonnés. Un flux explicite se suit ligne à ligne. Un flux événementiel, non.

Le débogage s'en ressent. Suivre une trace à travers un bus, surtout asynchrone, est plus délicat qu'un appel de fonction direct. On atténue nettement ce point avec une journalisation structurée, un identifiant de corrélation, et surtout en enregistrant l'arbre d'appel de chaque événement : une ligne du type Emetteur --> AbonneA --> AbonneB avec la durée d'exécution de chacun. C'est ce que je mets systématiquement en place, car pouvoir reconstituer après coup le chemin complet d'un événement change tout pour le diagnostic. Sans ces garde-fous, comprendre pourquoi une réaction a eu lieu, ou n'a pas eu lieu, prend du temps.

Puis il y a l'ordre et les garanties de livraison. Que se passe-t-il si un abonné échoue ? Si deux événements arrivent dans le désordre ? Un bus en mémoire ne rejoue rien après un crash. Dès qu'on veut de la fiabilité, il faut un vrai courtier de messages (Redis, RabbitMQ, Kafka), et la complexité opérationnelle grimpe d'un cran.

Dernier point, plus sournois : le couplage caché. Les composants ne se connaissent pas directement, mais ils partagent un contrat implicite : la structure des événements. Changer le format d'un événement peut casser des abonnés silencieusement. Le découplage du code ne supprime pas le couplage sur les données. C'est un argument de plus pour des événements typés : modifier un champ fait réagir le type checker chez les abonnés, là où un dictionnaire aurait cassé sans prévenir.

Quand l'utiliser, et quand l'éviter

Ma règle est pragmatique. L'Event Bus brille quand un même fait métier déclenche plusieurs réactions indépendantes qui évolueront séparément, quand vous voulez ajouter des comportements transverses sans toucher au coeur, ou quand le découplage entre modules ou services est un objectif assumé de l'architecture.

À l'inverse, je l'évite quand le flux est simple et linéaire : si A doit appeler B puis C dans un ordre précis, un appel direct est plus clair et plus facile à déboguer. Je m'en méfie aussi quand la logique est fortement transactionnelle et exige des garanties strictes, car l'asynchronisme complique la cohérence. Introduire un bus "au cas où", sans besoin concret de découplage, ajoute surtout de l'indirection.

Ce qu'il faut retenir

L'Event Bus est un excellent outil de découplage et d'extensibilité, à condition d'accepter son prix : un flux moins lisible et un débogage plus exigeant. Ce n'est pas une amélioration gratuite, c'est un compromis. Bien employé, sur des points où plusieurs réactions indépendantes doivent cohabiter, il rend un système plus souple et plus évolutif. Employé partout par réflexe, il transforme un code lisible en boîte noire.

Vous hésitez sur l'architecture d'un système Python, entre appels directs, event bus et découpage en services ? C'est le genre de décision que j'aide à trancher. Voyez mes expertises en architecture ou parlons-en.

Un projet Python ou IA à concrétiser ?

Discutons de votre besoin.

Me contacter