Retour aux articles
SymfonyDDD3 avril 2026

Événements de domaine avec Symfony Messenger : sortir les effets de bord de vos agrégats

Un agrégat qui envoie un email quand une commande est expédiée connaît le SMTP. C'est le mauvais type de connaissance pour une classe dont le seul travail est de protéger un invariant métier.

Mauvais - l'agrégat va chercher dans l'infrastructure

final class Order
{
    public function ship(Mailer $mailer): void
    {
        $this->status = OrderStatus::SHIPPED;
        $mailer->send(new ShippingConfirmationEmail($this->customerEmail));
    }
}

Order dépend maintenant de Mailer, qui dépend d'un transport, qui dépend d'une configuration. Tester ship() veut dire mocker un service d'email pour vérifier une transition d'état. Et chaque nouvel effet de bord - une notification Slack, une mise à jour de points de fidélité - veut dire élargir encore le constructeur de l'agrégat.

Bon - l'agrégat enregistre, Messenger distribue

final class Order
{
    private array $events = [];

    public function ship(): void
    {
        $this->status = OrderStatus::SHIPPED;
        $this->events[] = new OrderWasShipped($this->id);
    }

    public function releaseEvents(): array
    {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
}

final class ShipOrderHandler
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly MessageBusInterface $eventBus,
    ) {}

    public function __invoke(ShipOrder $command): void
    {
        $order = $this->orders->getById($command->orderId);
        $order->ship();
        $this->orders->save($order);

        foreach ($order->releaseEvents() as $event) {
            $this->eventBus->dispatch($event);
        }
    }
}

OrderWasShipped est un objet simple sans dépendance au framework. Symfony Messenger le distribue à autant de handlers que nécessaire - un mailer, une notification Slack, un projecteur de modèle de lecture - sans que Order sache qu'ils existent. Tester ship() devient une assertion sur releaseEvents(), plus un mock.

Le compromis : une classe d'événement et un handler par effet de bord, là où un appel de méthode direct suffisait en une ligne. Ça vaut le coup dès qu'un agrégat a plus d'une raison de notifier l'extérieur, ou dès que ces effets de bord doivent survivre à une nouvelle tentative sans rejouer le changement d'état lui-même.