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
EventDispatcheretEvent Bus - Quand utiliser l'un plutôt que l'autre
- Comment configurer un
Event BusavecSymfony 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
- EventDispatcher vs Event Bus : Quelle différence ?
- Quand utiliser l'Event Bus plutôt que l'EventDispatcher ?
- Étape 1 : Configuration de l'Event Bus
- Étape 2 : Interface dédiée pour les Events
- Étape 3 : Créer l'EventBus
- Étape 4 : Créer votre premier Event
- Étape 5 : Les Handlers d'événements
- Étape 6 : Intégration dans le CommandHandler
- 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 :
- Le livre est bien persisté en base avant le traitement de l'événement
- Si la transaction échoue, l'événement n'est jamais traité
- 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
- Event = Fait passé : Nommez au passé (
BookCreated, pasCreateBook) - Fire and forget : Ne pas attendre de retour
- ID uniquement : Ne transmettez que l'identifiant, les handlers récupèrent les données
- Immutabilité : Utilisez
readonly - 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 !
Commentaires
Il n'y a actuellement aucun commentaire. Soyez le premier !