Couverture de l'article Event Bus : Le secret d'une architecture Symfony réellement découplée
Retour aux articles

L'agence

WanadevStudio

Event Bus : Le secret d'une architecture Symfony réellement découplée

Imaginez : votre utilisateur clique sur "Commander". En coulisses, le domaine Stock doit décrémenter les quantités, le domaine Facturation doit générer une facture, et le domaine Notification doit envoyer un email de confirmation. Trois domaines, une seule action... et un spaghetti de dépendances en perspective. 🍝

Et si ces domaines pouvaient collaborer sans jamais se connaître ?

C'est exactement ce que permet l'Event Bus. Mais avant de foncer tête baissée, une question se pose : Symfony propose déjà l'EventDispatcher pour gérer les événements. Alors pourquoi introduire un nouveau concept ?

Spoiler : ce ne sont pas les mêmes outils, et les confondre peut vous coûter cher.

Dans cet article, nous allons démystifier leurs différences et découvrir comment l'Event Bus de Symfony Messenger vous permet de construire une architecture réellement découplée.

Ce que vous allez apprendre :

  • Les différences fondamentales entre EventDispatcher et Event Bus
  • Quand utiliser l'un plutôt que l'autre
  • Comment configurer un Event Bus avec Symfony Messenger
  • Créer une architecture événementielle découplée

ℹ️ Prérequis : Cet article fait suite à l'article CQRS avec Symfony Messenger : Domptez la complexité de vos applications. Assurez-vous d'avoir compris les concepts de Commands et Queries avant de continuer.

Sommaire

  1. EventDispatcher vs Event Bus : Quelle différence ?
  2. Quand utiliser l'Event Bus plutôt que l'EventDispatcher ?
  3. Étape 1 : Configuration de l'Event Bus
  4. Étape 2 : Interface dédiée pour les Events
  5. Étape 3 : Créer l'EventBus
  6. Étape 4 : Créer votre premier Event
  7. Étape 5 : Les Handlers d'événements
  8. Étape 6 : Intégration dans le CommandHandler
  9. Conclusion

🤔 EventDispatcher vs Event Bus : Quelle différence ?

Avant de plonger dans l'implémentation, comprenons les différences fondamentales entre ces deux approches.

L'EventDispatcher de Symfony

L'EventDispatcher est un composant natif de Symfony qui permet de déclencher et écouter des événements de manière synchrone.

// Dispatch d'un événement avec EventDispatcher
$this->eventDispatcher->dispatch(new BookCreatedEvent($book));

Caractéristiques :

  • 🔄 Synchrone : Les listeners sont exécutés immédiatement
  • 🔗 Couplé : Le dispatcher connaît les listeners
  • 📦 En mémoire : Tout se passe dans la même requête
  • Rapide : Pas de sérialisation ni de transport

L'Event Bus de Messenger

L'Event Bus est un bus de messages dédié aux événements métier, utilisant Symfony Messenger.

// Dispatch d'un événement avec Event Bus
$this->eventBus->dispatch(new BookCreatedEvent($book->getId()));

Caractéristiques :

  • 🔄 Sync ou Async : Vous choisissez le mode de traitement
  • 🔓 Découplé : Le publisher ne connaît pas les subscribers
  • 📤 Transportable : Peut être envoyé vers une queue (RabbitMQ, Redis, etc.)
  • 🎯 Scalable : Permet de distribuer le traitement sur plusieurs workers

Tableau comparatif

Critère EventDispatcher Event Bus (Messenger)
Mode d'exécution Synchrone uniquement Synchrone ou Asynchrone
Transport En mémoire Queue, Redis, RabbitMQ, etc.
Sérialisation Non Oui (pour l'async)
Retry automatique Non Oui
Dead Letter Queue Non Oui
Traçabilité Limitée Complète (Stamps)
Scalabilité Limitée Haute
Découplage Faible Fort

🎯 Quand utiliser l'Event Bus plutôt que l'EventDispatcher ?

Utilisez l'Event Bus quand :

Vous avez besoin d'asynchronisme

  • Envoi d'emails après une action
  • Génération de PDF
  • Synchronisation avec des services externes
  • Tâches longues ou coûteuses

Vous voulez découpler vos domaines métier

  • Le domaine "Commande" informe le domaine "Stock" sans le connaître
  • Le domaine "Utilisateur" notifie le domaine "Email" d'une inscription

Vous avez besoin de fiabilité

  • Retry automatique en cas d'échec
  • Dead Letter Queue pour les messages en erreur
  • Traçabilité des événements

Vous voulez scaler votre application

  • Traitement distribué sur plusieurs workers
  • Gestion de pics de charge

Gardez l'EventDispatcher quand :

L'action doit être immédiate et dans la même transaction

  • Événements Doctrine (prePersist, postUpdate, etc.)
  • Modification de la requête/réponse HTTP
  • Événements du Kernel Symfony

La performance est critique

  • Pas de sérialisation
  • Pas de transport réseau

L'événement est technique et non métier

  • Événements du framework
  • Logging synchrone

🏗️ Étape 1 : Configuration de l'Event Bus

Reprenons notre configuration Messenger et ajoutons un troisième bus dédié aux événements.

Configuration (config/packages/messenger.yaml)

framework:
    messenger:
        default_bus: command.bus

        buses:
            # Bus dédié aux Commands (écriture)
            command.bus:
                middleware:
                    - doctrine_ping_connection
                    - validation
                    - dispatch_after_current_bus
                    - doctrine_transaction

            # Bus dédié aux Queries (lecture)
            query.bus:
                middleware:
                    - doctrine_ping_connection
                    - dispatch_after_current_bus

            # 🆕 Bus dédié aux Events (notification)
            event.bus:
                default_middleware:
                    enabled: true
                    allow_no_handlers: true  # ⚠️ Important !
                middleware:
                    - doctrine_ping_connection
                    - dispatch_after_current_bus

Point clé : allow_no_handlers: true

Cette option est cruciale pour l'Event Bus ! Contrairement aux Commands et Queries qui doivent toujours avoir un handler, un événement peut n'avoir aucun subscriber ou en avoir plusieurs.

Command  → 1 Handler (obligatoire)
Query    → 1 Handler (obligatoire)
Event    → 0, 1 ou N Handlers (flexible)

🏷️ Étape 2 : Interface dédiée pour les Events

Pour que Symfony route automatiquement vos handlers vers le bon bus, nous utilisons une interface dédiée :

EventHandlerInterface

namespace App\MessageHandler;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

/**
 * Les handlers implémentant cette interface seront automatiquement
 * enregistrés sur le event.bus
 */
#[AutoconfigureTag('messenger.message_handler', ['bus' => 'event.bus'])]
interface EventHandlerInterface
{
}

📦 Étape 3 : Créer l'EventBus

Comme pour les Commands et Queries, créons une classe dédiée pour simplifier l'envoi des événements.

EventBus

namespace App\MessageBus;

use Symfony\Component\Messenger\MessageBusInterface;

class EventBus
{
    public function __construct(
        private readonly MessageBusInterface $eventBus,
    ) {}

    /**
     * Publie un événement sur le bus.
     *
     * Contrairement aux Commands/Queries, nous ne récupérons pas
     * de valeur de retour car un événement est une notification
     * "fire and forget".
     */
    public function publish(object $event): void
    {
        $this->eventBus->dispatch($event);
    }
}

Différence avec CommandBus et QueryBus

Bus Méthode Retour Sémantique
CommandBus command() mixed "Fais cette action et dis-moi le résultat"
QueryBus query() mixed "Donne-moi cette information"
EventBus publish() void "Ceci s'est passé" (fire and forget)

💡 Point clé : Un événement est une notification d'un fait passé. Le publisher ne s'attend pas à une réponse.


📝 Étape 4 : Créer votre premier Event

Reprenons notre exemple de bibliothèque. Quand un livre est ajouté, nous voulons notifier d'autres parties de l'application.

L'Event : BookCreatedEvent

namespace App\Domain\Book\Event;

/**
 * Événement émis lorsqu'un livre est créé.
 */
class BookCreatedEvent
{
    public function __construct(
        public readonly int $bookId,
    ) {}
}

Bonnes pratiques pour les Events

Règle Explication
ID uniquement Ne transmettez que l'identifiant, les handlers récupèrent les données
Immutable Utilisez readonly pour garantir l'immutabilité
Passé composé Nommez au passé : BookCreated, OrderPlaced, UserRegistered
Léger Un événement doit être le plus simple possible

💡 Pourquoi seulement l'ID ? Les handlers récupèrent les données depuis le cache de l'ORM (Unit of Work) en mode synchrone, ou depuis la base de données en mode asynchrone. Cela évite les problèmes de données obsolètes et réduit la taille des messages.


🎯 Étape 5 : Les Handlers d'événements

Un événement peut avoir plusieurs handlers. Chaque handler traite l'événement de manière indépendante.

Handler 1 : Envoi d'email

namespace App\Domain\Notification\EventHandler;

use App\Domain\Book\Event\BookCreatedEvent;
use App\Domain\Book\Repository\BookRepository;
use App\MessageHandler\EventHandlerInterface;
use App\Services\Exception\NotFoundException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

class SendBookCreatedEmailHandler implements EventHandlerInterface
{
    public function __construct(
        private readonly MailerInterface $mailer,
        private readonly BookRepository $bookRepository,
    ) {}

    public function __invoke(BookCreatedEvent $event): void
    {
        $book = $this->bookRepository->find($event->bookId);

        if (!$book instanceof Book) {
            throw new NotFoundException(Book::class, $event->bookId);
        }

        $email = (new Email())
            ->from('library@example.com')
            ->to('admin@example.com')
            ->subject('📚 Nouveau livre ajouté : ' . $book->getTitle())
            ->html(sprintf(
                '<p>Le livre <strong>%s</strong> de %s a été ajouté à la bibliothèque.</p>',
                $book->getTitle(),
                $book->getAuthor()
            ));

        $this->mailer->send($email);
    }
}

Handler 2 : Mise à jour des statistiques

namespace App\Domain\Statistics\EventHandler;

use App\Domain\Book\Event\BookCreatedEvent;
use App\Domain\Book\Repository\BookRepository;
use App\Domain\Statistics\Service\StatisticsService;
use App\MessageHandler\EventHandlerInterface;
use App\Services\Exception\NotFoundException;

class UpdateBookStatisticsHandler implements EventHandlerInterface
{
    public function __construct(
        private readonly StatisticsService $statisticsService,
        private readonly BookRepository $bookRepository,
    ) {}

    public function __invoke(BookCreatedEvent $event): void
    {
        $book = $this->bookRepository->find($event->bookId);

        if (!$book instanceof Book) {
            throw new NotFoundException(Book::class, $event->bookId);
        }

        $this->statisticsService->incrementBookCount();
        $this->statisticsService->recordNewBook($book);
    }
}

Flux d'exécution

              📚 BookCreatedEvent publié
                        │
            ┌───────────┴───────────┐
            │                       │
            ▼                       ▼
┌───────────────────────┐ ┌───────────────────────┐
│ SendBookCreatedEmail  │ │ UpdateBookStatistics  │
│       Handler         │ │       Handler         │
└───────────┬───────────┘ └───────────┬───────────┘
            │                         │
            ▼                         ▼
       ✉️ Email                  📊 Stats
        envoyé                 mises à jour

🔄 Étape 6 : Intégration dans le CommandHandler

Modifions notre AddBookCommandHandler pour publier l'événement après la création.

AddBookCommandHandler avec Event

namespace App\Domain\Book\Message\Command;

use App\Domain\Book\Dto\BookDto;
use App\Domain\Book\Entity\Book;
use App\Domain\Book\Event\BookCreatedEvent;
use App\MessageBus\EventBus;
use App\MessageHandler\CommandHandlerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\ObjectMapper\ObjectMapper;

class AddBookCommandHandler implements CommandHandlerInterface
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly ObjectMapper $objectMapper,
        private readonly EventBus $eventBus,
    ) {}

    public function __invoke(AddBookCommand $command): BookDto
    {
        // 1️⃣ Création de l'entité
        $book = (new Book())
            ->setTitle($command->title)
            ->setAuthor($command->author);

        // 2️⃣ Persistence (le flush est automatique grâce au middleware doctrine_transaction)
        $this->entityManager->persist($book);

        // 3️⃣ Publication de l'événement avec l'ID uniquement
        $this->eventBus->publish(new BookCreatedEvent($book->getId()));

        // 4️⃣ Retour du DTO
        return $this->objectMapper->map($book, BookDto::class);
    }
}

⚠️ Attention au timing !

Le middleware dispatch_after_current_bus assure que l'événement est traité après la transaction du Command Bus. Cela garantit que :

  1. Le livre est bien persisté en base avant le traitement de l'événement
  2. Si la transaction échoue, l'événement n'est jamais traité
  3. Les handlers de l'événement voient des données cohérentes

🎊 Conclusion

Félicitations ! Vous maîtrisez maintenant l'Event Bus avec Symfony Messenger !

Architecture complète CQRS + Event Bus

┌─────────────────────────────────────────────────────────────────┐
│                  ARCHITECTURE CQRS + EVENT BUS                  │
└─────────────────────────────────────────────────────────────────┘

     COMMANDS              QUERIES              EVENTS
     (Écriture)            (Lecture)            (Notification)
         │                     │                     │
         ▼                     ▼                     ▼
   ┌───────────┐         ┌───────────┐         ┌───────────┐
   │CommandBus │         │ QueryBus  │         │ EventBus  │
   └─────┬─────┘         └─────┬─────┘         └─────┬─────┘
         │                     │                     │
   ┌─────▼─────┐         ┌─────▼─────┐         ┌─────▼─────┐
   │Middlewares│         │Middlewares│         │Middlewares│
   │-validation│         │-ping DB   │         │-ping DB   │
   │-transaction│        │           │         │           │
   └─────┬─────┘         └─────┬─────┘         └─────┬─────┘
         │                     │                     │
   ┌─────▼─────┐         ┌─────▼─────┐         ┌─────▼─────┐
   │ 1 Handler │         │ 1 Handler │         │ N Handlers│
   │  (Write)  │         │  (Read)   │         │ (Notify)  │
   └─────┬─────┘         └─────┬─────┘         └─────┬─────┘
         │                     │                     │
         ▼                     ▼             ┌───────┴───────┐
      BookDto              BookDto           ▼               ▼
                                          Email           Stats

Récapitulatif

Aspect EventDispatcher Event Bus
Quand l'utiliser ? Événements techniques, framework Événements métier, domaine
Handlers 1 ou plusieurs 0, 1 ou plusieurs
Découplage Faible Fort
Scalabilité Limitée Haute

Règles d'or

  1. Event = Fait passé : Nommez au passé (BookCreated, pas CreateBook)
  2. Fire and forget : Ne pas attendre de retour
  3. ID uniquement : Ne transmettez que l'identifiant, les handlers récupèrent les données
  4. Immutabilité : Utilisez readonly
  5. Léger : Un événement doit être le plus simple possible

Merci d'avoir lu cet article ! 🙏

Des questions ? N'hésitez pas à partager votre expérience avec l'Event Bus dans les commentaires !


📚 Ressources complémentaires

Commentaires

Il n'y a actuellement aucun commentaire. Soyez le premier !