Enregistré, pas injecté
Un événement de domaine annonce qu'une chose s'est produite, par exemple « la réservation a été annulée ». Le publier pendant que l'agrégat traite l'annulation, c'est l'annoncer avant de savoir si elle sera vraiment enregistrée en base.
La solution n'est pas une configuration plus astucieuse : il faut attendre que la base de données ait confirmé l'annulation avant de publier quoi que ce soit.
Dans cet article, on va voir pourquoi publier depuis l'agrégat revient à mentir, comment lui faire enregistrer ses événements au lieu de les publier, et à quel endroit les publier : dans le use case, une fois save() réussi.
Le contexte : une annulation que le reste du système doit connaître
Cette série utilise un petit domaine de réservation hôtelière. Sa racine d'agrégat, Booking, porte une liste de frais et une règle métier : annuler moins de 48 heures avant l'arrivée ajoute une pénalité égale à la moitié du prix de la chambre. Les montants sont des Money, le Value Object du premier article.
Lorsque Booking::cancel() s'exécute et applique une pénalité, le reste du système (l'envoi d'emails, la comptabilité) doit être prévenu.
Le problème : la publication prématurée
La solution la plus intuitive est aussi la plus dangereuse : injecter un publisher d'événements (EventPublisher) dans le constructeur de l'agrégat, et l'appeler directement dans cancel().
final class BookingWithInjectedPublisher
{
private BookingStatus $status;
public function __construct(
private readonly EventPublisher $events,
public readonly BookingId $id,
) {
}
public function cancel(\DateTimeImmutable $now): void
{
// ... penalty logic ...
$this->events->publish(new BookingCancelled(EventId::generate(), $this->id, $now));
$this->status = BookingStatus::Cancelled;
}
}
À première vue, le code se lit proprement. En réalité, cette approche a trois défauts :
- Le mensonge transactionnel : si la base de données lève une exception juste après (connexion perdue, violation de contrainte), l'application plante, mais l'événement est déjà parti. Un email d'annulation vient d'être envoyé au client pour une réservation qui est restée active en base.
- Le piège de l'ORM : si l'agrégat est mappé directement par Doctrine, l'ORM le recharge depuis la base sans jamais appeler son constructeur. La propriété
$eventsn'est alors jamais initialisée : sur toute réservation chargée depuis la base, le premier appel àcancel()lève uneError(« must not be accessed before initialization »). - La violation des couches :
EventPublisherest un port de la couche Application. L'injecter dans l'agrégat fait dépendre le domaine du reste de l'application, alors qu'il ne devrait dépendre de rien.
La solution ? L'agrégat ne doit plus rien publier. Il doit simplement enregistrer les faits en mémoire.
La solution : l'agrégat enregistre, il ne publie pas
Pour que l'agrégat n'ait plus besoin de publisher, on lui donne une classe de base abstraite. Son unique rôle est de se souvenir des événements survenus, sans savoir ce qu'ils signifient.
<?php
declare(strict_types=1);
namespace App\Booking\Domain;
use App\Booking\Domain\Event\DomainEvent;
abstract class AggregateRoot
{
/** @var DomainEvent[] */
private array $recordedEvents = [];
protected function recordThat(DomainEvent $event): void
{
$this->recordedEvents[] = $event;
}
/** @return DomainEvent[] */
public function pullEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
}
Les événements implémentent DomainEvent, une interface vide, qui sert uniquement d'étiquette : grâce à elle, recordThat() refuse tout ce qui n'est pas un événement.
La méthode pullEvents() renvoie les événements enregistrés et vide le tableau interne : un second appel ne renvoie rien. Le même use case ne peut donc pas publier deux fois le même événement. En revanche, le code qui réagit à l'événement (l'envoi d'email, par exemple) peut toujours le recevoir deux fois : c'est le sujet de l'article 4 (en anglais).
Grâce à cette base, Booking ne dépend plus de rien d'extérieur. Il se contente d'appeler recordThat() au moment où un fait devient vrai. Dans cancel(), cela arrive deux fois : quand une pénalité est ajoutée, puis quand la réservation passe au statut annulé.
public function cancel(\DateTimeImmutable $now): void
{
if ($this->status === BookingStatus::Cancelled) {
return;
}
if ($this->stay->hoursUntilCheckIn($now) < 48) {
$penalty = new Charge(
ChargeId::generate(),
ChargeType::CancellationPenalty,
$this->roomRateCharge()->percentageOfAmount(50),
'Late cancellation penalty (less than 48h before check-in)',
);
$this->charges[] = $penalty;
$this->recordThat(new CancellationPenaltyCharged(
EventId::generate(),
$this->id,
$penalty->id,
$penalty->amount,
$now,
));
}
$this->status = BookingStatus::Cancelled;
$this->recordThat(new BookingCancelled(EventId::generate(), $this->id, $now));
}
Rien dans ce code ne parle à Symfony, à Messenger ou à un serveur SMTP. L'agrégat ne sait rien de l'infrastructure.
Le use case : là où la vérité est confirmée
C'est le use case qui enchaîne les étapes. C'est aussi le seul endroit qui sait deux choses à la fois : si la sauvegarde a réussi, et quels événements l'agrégat a enregistrés.
C'est ici, et nulle part ailleurs, que l'interface EventPublisher trouve sa place :
interface EventPublisher
{
public function publish(DomainEvent $event): void;
}
final readonly class CancelBooking
{
public function __construct(
private BookingRepository $bookings,
private EventPublisher $events,
) {
}
public function __invoke(CancelBookingCommand $command): void
{
$booking = $this->bookings->get($command->bookingId);
$booking->cancel($command->now);
$this->bookings->save($booking);
foreach ($booking->pullEvents() as $event) {
$this->events->publish($event);
}
}
}
Le use case charge l'agrégat, exécute la logique métier, sauvegarde, et seulement ensuite publie. Si save() lève une exception, la boucle foreach n'est jamais atteinte. L'implémentation de EventPublisher, MessengerEventPublisher, se trouve dans la couche Infrastructure. Elle transmet chaque événement au bus de Messenger, comme dans toute API construite sur Symfony. Le bus distribue l'événement, mais ne décide jamais s'il a eu lieu.
En pratique : le prouver par des tests
Vérifier que cancel() enregistre les bons événements, dans le bon ordre, ne demande que l'agrégat lui-même :
#[Test]
public function cancelRecordsThePenaltyThenTheCancellationEvent(): void
{
$checkIn = new \DateTimeImmutable('2027-01-10 15:00:00');
$booking = BookingBuilder::aBooking()->checkingInOn($checkIn)->build();
$booking->cancel($checkIn->modify('-24 hours'));
$events = $booking->pullEvents();
self::assertCount(2, $events);
self::assertInstanceOf(CancellationPenaltyCharged::class, $events[0]);
self::assertInstanceOf(BookingCancelled::class, $events[1]);
}
Pas de publisher à simuler, pas de bus à mocker, pas de conteneur Symfony. C'est tout l'intérêt de garder Messenger hors du domaine.
Au niveau du use case, deux implémentations de test suffisent pour prouver la thèse de l'article : publish() ne s'exécute jamais si save() a échoué. La première est un publisher qui refuse d'être appelé :
final class EventPublisherThatMustNotBeCalled implements EventPublisher
{
public function publish(DomainEvent $event): void
{
throw new \LogicException('publish() must not run unless save() already succeeded.');
}
}
La seconde, FailingBookingRepository, renvoie la réservation demandée mais lève une \RuntimeException('Connection lost.') à chaque save(). Le test :
#[Test]
public function publishNeverRunsUnlessSaveDid(): void
{
$booking = BookingBuilder::aBooking()->build();
$useCase = new CancelBooking(
new FailingBookingRepository($booking),
new EventPublisherThatMustNotBeCalled(),
);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Connection lost.');
$useCase(new CancelBookingCommand($booking->id, new \DateTimeImmutable()));
}
cancel() a bien enregistré les événements en mémoire, mais ils n'atteignent jamais un publisher. Si CancelBooking publiait avant la fin de save(), ce publisher lèverait une \LogicException au lieu de la \RuntimeException attendue, et le test échouerait. Pas besoin d'assertion en plus : c'est ce publisher qui fait la vérification.
Ce test ne voit pas tout. Si le use case oubliait de publier, il passerait quand même. D'où un second test, qui vérifie qu'après un save() réussi, chaque événement enregistré est bien publié :
#[Test]
public function publishRunsForEveryRecordedEventOnceSaveSucceeded(): void
{
$checkIn = new \DateTimeImmutable('2027-01-10 15:00:00');
$booking = BookingBuilder::aBooking()->checkingInOn($checkIn)->build();
$publisher = new RecordingEventPublisher();
$useCase = new CancelBooking(new InMemoryBookingRepository($booking), $publisher);
$useCase(new CancelBookingCommand($booking->id, $checkIn->modify('-24 hours')));
self::assertCount(2, $publisher->published());
}
Points de vigilance
Publier depuis le use case règle le problème de départ, mais crée trois risques plus petits. Ils ont la même cause : rien n'oblige à écrire le code dans le bon ordre.
- Oublier d'appeler
pullEvents(). La réservation est bien annulée en base, mais aucun email ne part et la comptabilité n'est jamais prévenue. Aucune exception, aucun avertissement : seul le second test ci-dessus le détecte. - Appeler
pullEvents()avantsave(). En inversant ces deux lignes, on retombe exactement dans le problème de départ : les événements partent avant que la sauvegarde soit confirmée. C'est ce que le premier test détecte : l'implémentation de test du publisher serait appelée avant l'échec desave(). - Appeler
pullEvents()dans le repository. Déplacer la publication dansBookingRepository::save()évite de l'oublier dans le use case, et c'est bien pour ça que c'est tentant. Mais c'est alors le repository qui décide quand l'application annonce ce qui s'est passé, alors que son seul rôle est d'écrire en base. Et le jour où on le remplace par une autre implémentation, en mémoire pour les tests par exemple, la publication des événements disparaît avec lui sans prévenir. Un listener DoctrinepostFlushqui publierait les événements des agrégats sauvegardés a exactement le même défaut, en plus caché : rien dans le use case ne montre que la publication a lieu.
La limite : et si la publication échoue après save() ?
Tout ce qui précède suppose que la boucle de publication de CancelBooking s'exécute dans la même requête que save(). Si le processus s'arrête entre les deux, relancer la commande ne rattrape rien : cancel() ne fait rien si la réservation est déjà annulée, donc aucun événement n'est enregistré, ni publié. Aucun email ne partira jamais, et aucune erreur ne le signalera.
C'est la même chose si publish() échoue au milieu de la boucle : pullEvents() a déjà vidé les événements de l'agrégat, donc ceux qui n'ont pas encore été publiés sont perdus, et relancer la commande ne les retrouve pas.
Enfin, tout cet article suppose que save() valide sa transaction. Si le use case s'exécute dans un middleware transactionnel, comme doctrine_transaction de Messenger, la transaction n'est validée qu'après la fin du use case : les événements partent alors avant la validation. L'article 4 traite ce cas.
Une outbox transactionnelle résout exactement ces problèmes. Faut-il en mettre une en place ici ? C'est le sujet de Does This Need an Outbox? (en anglais).
Conclusion
L'interface EventPublisher n'a jamais été une mauvaise idée en soi. L'erreur tenait uniquement au moment et à l'endroit d'où elle était appelée.
L'agrégat enregistre, le use case publie une fois la sauvegarde confirmée : l'application n'annonce plus rien trop tôt. Un événement de domaine ne doit être annoncé que lorsque la base de données a confirmé ce qu'il affirme.
Si votre domaine dépend encore de Symfony (son bus, ses services, son conteneur), c'est l'un des premiers points que j'examine lors d'un audit de code.