PHP · Domain-Driven Design

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().

CancelBookingWithInjectedPublisher.php · la version à éviter
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 :

  1. 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.
  2. 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é $events n'est alors jamais initialisée : sur toute réservation chargée depuis la base, le premier appel à cancel() lève une Error (« must not be accessed before initialization »).
  3. La violation des couches : EventPublisher est 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.

AggregateRoot.php
<?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é.

Booking.php
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 :

EventPublisher.php
interface EventPublisher
{
    public function publish(DomainEvent $event): void;
}
CancelBooking.php
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 :

AggregateRootTest.php
#[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é :

EventPublisherThatMustNotBeCalled.php
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 :

CancelBookingTest.php
#[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é :

CancelBookingTest.php
#[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.

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.

💬 Vous travaillez sur un sujet similaire ?

Commentaires

← Retour au blog