Le blog

API Platform et Event Sourcing avec Ecotone

Publié le 17 avril 2023

Depuis la version 3 du framework API Platform, et en particulier la nouvelle gestion des State Providers et Persisters, il est maintenant plus simple de gérer des cas d'utilisation avancés comme le Domain Driven Design (DDD). Afin de découpler une ressource, c'est-à-dire séparer la représentation de l'API des interactions métiers permettant de récupérer la donnée ou la persister, il suffit d'utiliser le paramètre provider ou processor d'une opération :

#[ApiResource(
    operations: [
        new Get(
            provider: BookItemProvider::class,
        ),
    ],
)]
class Book

Dans le provider (ou processor), il est alors possible de faire tout ce que vous désirez : récupérer (ou persister) la donnée, envoyer un mail, etc.

C'est en se servant de ces nouvelles possibilités que deux de nos coopérateurs, Mathias Arlaud et Robin Chalas, ont réalisé une présentation d'un exemple de projet mettant en œuvre l'architecture hexagonale et CQRS et les principes du DDD. Vous pouvez retrouver un enregistrement de la présentation donnée à l'API Platform Con 2022 ainsi que le code du projet disponible sur GitHub.

Voici les grands points des choix architecturaux réalisés :

  • Les providers et processors se trouvant dans la couche Infrastructure envoient des queries et des commands en se servant d'un bus qui est implémenté grâce à Symfony Messenger. On voit là l'utilisation classique de l'architecture CQRS.
  • Ces queries et commands sont traitées dans la couche Application en se servant notamment d'un Repository (au sens DDD), implémenté dans la couche Infrastructure en se servant de Doctrine.
  • La couche Domain contient principalement l'entité (au sens DDD) (Book) ainsi que les Value Objects (Price, Author, etc.).

Lorsqu'on s'intéresse au DDD, au CQRS ou encore à l'architecture hexagonale, on entend souvent également parler d'Event Sourcing. C'est en effet une architecture souvent liée qu'il est difficile de mettre en place dans d'autres contextes. Avant de présenter son utilisation dans API Platform, revenons d'abord sur ce qu'est l'Event Sourcing.

L'Event Sourcing ou l'art de ne plus stocker l'état

En soit le principe de base de l'Event Sourcing n'est pas compliqué : plutôt que de stocker l'état de nos entités (au sens DDD) dans la base de données, nous allons stocker une suite d'événements. Ainsi si un utilisateur change la propriété name de l'entité Book, plutôt que de changer la valeur de la colonne name dans la base, nous allons stocker l'événement « Book a modifié sa propriété name avec la valeur Coraline ».

Puisque la base de données ne contient plus l'état actuel de l'entité mais seulement des événements, pour pouvoir retrouver son état actuel, il va être nécessaire de rejouer les événements les uns à la suite des autres.

" class="wp-image-6784"/><figcaption class="wp-element-caption
Schéma de la comparaison entre le stockage de l'état en base de données et des événements en Event Sourcing pour un même modèle

Émettre les bons événements métiers au bon moment n'est pas évident, c'est pourquoi une approche DDD et CQRS est recommandée lorsque l'Event Sourcing est mis en place : les événements de lecture et d'écriture sont alors facilement identifiables et les actions du domaine peuvent correspondre à des événements.

Nous comprenons intuitivement les avantages et les inconvénients de l'Event Sourcing : grâce à lui, il est possible d'accéder à l'historique d'une entité depuis sa création. Ses inconvénients sont nombreux : difficulté et complexité de mise en place et coût en termes de performance.

L'Event Sourcing doit être utilisé seulement pour les besoins métier qui le nécessitent fortement.

Il existe néanmoins des solutions afin de mitiger le coût en termes de performance, au prix d'une complexité accrue. La première solution consiste à stocker l'état résultant des premiers événements : plutôt que de rejouer l'ensemble des événements, nous partons donc de l'état intermédiaire et rejouons seulement les derniers événements. Cette solution s'appelle le snapshotting.

La deuxième solution est très utilisée dans l'Event Sourcing : lorsqu'il est nécessaire de filtrer les entités ou bien de réaliser des calculs ou des statistiques sur celles-ci, plutôt que de rejouer tous les événements pour toutes les entités nécessaires, ces opérations vont être réalisées au fur et à mesure et le résultat va être stocké à part. On parle de « dériver l'état du flot des événements » et ce traitement s'appelle une projection. Une projection peut être créée alors que des événements sont déjà stockés : il va alors être nécessaire de pouvoir exécuter cette projection sur les événements existants et stocker l'état résultant.

D'autres complexités existent, comme lorsque nous souhaitons changer ce que contient un événement. Une des solutions à ce problème est alors le versioning des événements.

Nous le voyons grâce à cette partie, faire de l'Event Sourcing est complexe et même s'il est possible de le coder soi-même, s'appuyer sur un framework permet de faciliter sa mise en place. Bonne nouvelle, il en existe un en PHP, Ecotone !

Ecotone, le framework de l'Event Sourcing (et plus !)

Ecotone se présente comme un framework « message-driven » : il permet ainsi de faciliter la création d'applications respectant les architectures CQRS et Event Sourcing, et de manière plus générale d'écrire des applications résilientes. Pour mieux comprendre son approche et ses capacités, je vous conseille la lecture de l'article Building Reactive Systems in PHP sur le blog du framework.

On le doit en grande majorité à un seul développeur : Dariusz Gafka.

Le framework possède un certain nombre de fonctionnalités, soit spécifiques aux architectures qu'il permet de mettre en place, soit liées à l'utilisation de son bus, qui sont donc semblables à celles de Symfony Messenger.

Citons quelques possibilités du framework liées aux messages :

  • Possibilité de gérer les messages (événements, commands, queries) de manière asynchrone (avec RabbitMQ, Amazon SQS, Redis, etc.).
  • Ajout possible de délai et de priorité sur les messages.
  • Déduplication automatique des messages.
  • Gestion du patron de conception Outbox permettant d'éviter d'envoyer un message alors que la donnée n'a pas pu être enregistrée en base.
  • Répétition des tentatives de consommation de messages (instantané ou délayé avec exponential backoff ). Il est possible d'ajouter un channel d'erreurs lorsque le nombre d'essais maximum est dépassé ou même de les stocker en base de données et de les visualiser ensuite avec Ecotone Pulse.
  • Gestion du patron de conception Saga afin de gérer des cas complexes de retour en arrière lorsqu'un message a échoué.
  • Un scheduler permettant de prévoir l'envoi de message à l'avance (à comparer au Symfony Scheduler).

Pour l'Event Sourcing uniquement, Ecotone permet aussi beaucoup de choses :

Notons également que pour sérialiser et déséraliser les données, Ecotone s'appuie sur le JMS Serializer.

Ecotone fournit des intégrations avec Symfony et Laravel.

Pour terminer cette présentation, voici mon avis subjectif sur ce framework. Comme vous pouvez le constater, je le trouve impressionnant en termes de fonctionnalités et peu de choses manquent à l'appel. Cependant la documentation manque de clarté et possède beaucoup d'imprécisions : il est souvent nécessaire de parcourir les exemples voire les tests pour mieux comprendre son utilisation. Sur l'aspect DX, le framework utilise beaucoup d'attributs et de « magie » ce qui rend parfois son utilisation peu souple, je vous conseille de respecter scrupuleusement la manière dont il doit être utilisé sous peine d'avoir des erreurs peu agréables à comprendre. Enfin, ses entrailles sont techniquement complexes ce qui ne facilite pas la compréhension de son fonctionnement. J'aimerais néanmoins finir sur une note positive : c'est un framework qui facilite énormément la mise en place de l'Event Sourcing, par exemple dans une API développée avec API Platform, à mes yeux il mérite beaucoup plus d'attention de la part de l'écosystème PHP et j'espère sincèrement qu'à l'avenir il sera amené à se développer.

Les mains dans le cambouis

Maintenant que les explications théoriques et les présentations sont faites, on est parti pour écrire un peu de code.

L'ensemble du code est disponible à cette adresse : https://github.com/alanpoulain/apip-eventsourcing

Nous allons partir d'un projet neuf, mais n'hésitez pas à regarder dans le dépôt pour les fichiers simples, la configuration (attention la configuration est en PHP dans le dépôt et en YAML dans cet article) et les changements de structure (séparation des dossiers en bounded contexts par exemple) :

symfony new bookstore
composer req api ecotone/symfony-bundle ecotone/pdo-event-sourcing ecotone/jms-converter

Nous enregistrons la connexion DBAL pour Ecotone dans les services :

// services/shared.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Shared\:
        resource: '../../src/Shared'
        exclude:
            - '../../src/Shared/Infrastructure/Kernel.php'

    Enqueue\Dbal\DbalConnectionFactory:
        factory: ['Ecotone\Dbal\DbalConnection', 'create']
        arguments:
            $connection: '@Doctrine\DBAL\Connection'

Intéressons-nous à la création d'un livre. Commençons par créer notre ressource dans la couche Infrastructure :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\Resource;

#[ApiResource(
    shortName: 'Book',
    operations: [new Post(processor: CreateBookProcessor::class)],
)]
final class BookResource
{
    // ...
}

Le Processor se contente d'envoyer une commande avec l'ensemble des informations, puis de réaliser une query pour récupérer les informations persistées :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\State\Processor;

final readonly class CreateBookProcessor implements ProcessorInterface
{
    public function __construct(
        private CommandBusInterface $commandBus,
        private QueryBusInterface $queryBus,
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookResource
    {
        // Some assertions

        $command = new CreateBookCommand(
            new BookName($data->name),
            new BookDescription($data->description),
            new Author($data->author),
            new BookContent($data->content),
            new Price($data->price),
        );

        $id = $this->commandBus->dispatch($command);
        $model = $this->queryBus->ask(new FindBookQuery(new BookId($id)));

        return BookResource::fromModel($model);
    }
}

Le bus de commande et le bus de query sont câblés avec Ecotone.

Nous pouvons enfin passer à la création de notre entité. Nous la mettons dans un dossier Model afin de ne pas la confondre avec une entité Doctrine.

<?php

namespace App\BookStore\Domain\Model;

#[EventSourcingAggregate]
final class Book
{
    use WithAggregateVersioning;

    #[AggregateIdentifier]
    private BookId $id;
    private BookName $name;
    private BookDescription $description;
    private Author $author;
    private BookContent $content;
    private Price $price;
    private bool $deleted = false;
}

Remarquez l'utilisation de l'attribut #[EventSourcingAggregate] sur la classe. Il permet d'indiquer à Ecotone que nous allons nous servir de cette entité comme d'un aggregate : les événements vont être rejoués sur lui pour obtenir son état final. L'attribut #[AggregateIdentifier] permet à Ecotone de savoir quel identifiant utiliser et le trait WithAggregateVersioning permet d'ajouter la gestion de versions à l'agrégat.

Notre commande CreateBookCommand doit être gérée : cette gestion doit nécessairement se faire dans notre agrégat avec une méthode statique (fonctionnement d'Ecotone). La méthode retourne un tableau d'événements à jouer.

final class Book
{
    #[CommandHandler]
    public static function create(CreateBookCommand $command): array
    {
        return [
            new BookWasCreated(
                id: new BookId(),
                name: $command->name,
                description: $command->description,
                author: $command->author,
                content: $command->content,
                price: $command->price,
            ),
        ];
    }
}

Nous devons ensuite appliquer l'événement sur l'agrégat grâce à une méthode dédiée. C'est cette méthode qui sera rejouée pour l'événement correspondant.

final class Book
{
    #[EventSourcingHandler]
    public function applyBookWasCreated(BookWasCreated $event): void
    {
        $this->id = $event->id();
        $this->name = $event->name;
        $this->description = $event->description;
        $this->author = $event->author;
        $this->content = $event->content;
        $this->price = $event->price;
    }
}

Avant d'aller plus loin testons que tout va bien. Démarrez un serveur :

symfony serve

Démarrez également une base de données en vous servant par exemple de Docker :

docker run --name postgres -p 5432:5432 -e POSTGRES_USER=app -e POSTGRES_PASSWORD=\!ChangeMe\! -e POSTGRES_DB=app -v database_data:/var/lib/postgresql/data postgres:15

Si tout est bien configuré, vous devriez obtenir le résultat suivant à l'adresse https://localhost:8000/api :

Capture d'écran du framework API Platform pour tester l'API" class="wp-image-6859"/><figcaption class="wp-element-caption
Capture d'écran de l'interface API Platform pour tester l'API

Essayez d'envoyer une requête, vous devriez obtenir l'erreur suivante :

Can't send query to App\\BookStore\\Domain\\Query\\FindBookQuery. No Query Handler defined for it. Have you forgot to add #[QueryHandler] to method?

C'est normal : nous n'avons pas encore géré notre query FindBookQuery. Néanmoins la création d'un livre s'est bien réalisée. Et les événements ont bien été enregistrés en base de données. Assurons-nous-en :

[
  {
    "no": 1,
    "event_id": "3a2d89a7-73a2-47ba-a3bc-866e680c252c",
    "event_name": "App\\BookStore\\Domain\\Event\\BookWasCreated",
    "payload": {"id":{"value":"133d71b1-df04-4d59-9acc-ec287c2c9074"},"name":{"value":"Stardust"},"description":{"value":"Young Tristran Thorn will do anything to win the cold heart of beautiful Victoria."},"author":{"value":"Neil Gaiman"},"content":{"value":"Worth reading"},"price":{"amount":17}},
    "metadata": {"id": "3a2d89a7-73a2-47ba-a3bc-866e680c252c", "revision": 1, "timestamp": 1680192724, "_aggregate_id": "133d71b1-df04-4d59-9acc-ec287c2c9074", "_aggregate_type": "App\\BookStore\\Domain\\Model\\Book", "_aggregate_version": 1},
    "created_at": "2023-03-30 16:12:04.000000"
  }
]

Notre premier événement a bien été enregistré ! Nous devons maintenant récupérer ces informations pour pouvoir répondre à la query. Comment peut-on réaliser cette opération ?

Agrégeons nos événements

Afin de rejouer nos événements pour récupérer notre agrégat dans son état final, Ecotone nous permet de créer facilement un service Repository.

Seule l'interface est à créer. Après avoir ajouté les bons attributs, Ecotone crée (peut-être un peu trop magiquement) le service correspondant :

<?php

namespace App\BookStore\Infrastructure\Ecotone\Repository;

interface EventSourcedBookRepository
{
    #[Repository]
    public function findBy(BookId $bookId): ?Book;

    #[Repository]
    public function getBy(BookId $bookId): Book;

    #[Repository]
    #[RelatedAggregate(Book::class)]
    public function save(BookId $bookId, int $currentVersion, array $events): void;
}

Nous pouvons maintenant répondre à la query avec un handler. Cette fois nous n'avons pas besoin d'ajouter une méthode dans l'agrégat, nous pouvons traiter celle-ci dans un handler externe en ajoutant l'attribut #[QueryHandler] sur la méthode à exécuter :

<?php

namespace App\BookStore\Application\Query;

final readonly class FindBookQueryHandler
{
    public function __construct(private BookRepositoryInterface $repository) {}

    #[QueryHandler]
    public function __invoke(FindBookQuery $query): ?Book
    {
        return $this->repository->ofId($query->id);
    }
}

Vous pouvez remarquer que nous n'utilisons pas directement le Repository d'Ecotone mais le nôtre avec une méthode ofId et qui utilise celui d'Ecotone en dépendance.

N'oubliez pas d'implémenter la conversion de l'agrégat Book en ressource (BookResource) dans le CreateBookProcessor et si tout va bien, en rejouant la requête, nous obtenons désormais le résultat suivant :

{
  "@context": "/api/contexts/Book",
  "@id": "/api/books/8381db41-1911-49d6-bce9-b303cb251ecb",
  "@type": "Book",
  "name": "Stardust",
  "description": "Young Tristran Thorn will do anything to win the cold heart of beautiful Victoria.",
  "author": "Neil Gaiman",
  "content": "Worth reading",
  "price": 17
}

Nous pouvons nous féliciter, nous avons codé la création d'un livre en Event Sourcing ! Nous pouvons réutiliser notre query pour implémenter l'opération de récupération d'un livre. Voici le Provider API Platform :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider;

final readonly class BookItemProvider implements ProviderInterface
{
    public function __construct(private QueryBusInterface $queryBus) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?BookResource
    {
        $model = $this->queryBus->ask(new FindBookQuery(new BookId($uriVariables['id'])));

        return null !== $model ? BookResource::fromModel($model) : null;
    }
}

La création des opérations Patch et Put est similaire à l'opération Post, excepté l'utilisation du BookItemProvider en plus du Processor dédié. La gestion de la commande doit se faire également dans l'agrégat mais contrairement à la création, la méthode n'a pas besoin d'être statique.

La gestion de la suppression d'un livre nécessite un choix d'architecture particulier : il n'est pas possible de supprimer réellement une ressource avec l'Event Sourcing. Il est donc possible de faire uniquement du soft delete : nous créons un champ booléen deleted dans l'agrégat, à false par défaut et il passe à true lors d'une suppression. Il faut ensuite gérer correctement les cas dans le BookRepository. Par exemple pour la méthode ofId :

<?php

namespace App\BookStore\Infrastructure\Ecotone\Repository;

final readonly class BookRepository implements BookRepositoryInterface
{
    public function __construct(private EventSourcedBookRepository $eventSourcedRepository) {}

    public function ofId(BookId $id): ?Book
    {
        if (!$eventSourcedBook = $this->eventSourcedRepository->findBy($id)) {
            return null;
        }

        if ($eventSourcedBook->deleted()) {
            return null;
        }

        return $eventSourcedBook;
    }
}

Enfin vous l'avez peut-être remarqué, il reste une opération pour le CRUD que nous n'avons pas encore implémentée : la récupération de la liste des livres. En effet cette opération n'est pas si simple qu'il n'y parait et peut-être couteuse en termes de performance dans le cas de l'Event Sourcing.

Projeter pour mieux atterrir

Afin de retourner la collection de livres, ou du moins une partie de la collection si elle est paginée, il va être nécessaire de rejouer tous les événements pour tous les livres. De plus, si vous reprenez l'interface du Repository d'Ecotone, vous pouvez constater qu'il n'y a pas de méthode findAll.

Pour nous en sortir nous allons utiliser un concept de l'Event Sourcing : les projections. Je vous renvoie à la présentation théorique au début de l'article pour une présentation générale. Dans notre cas le but est simple : à chaque fois qu'un livre est créé, nous allons projeter son identifiant. Inversement quand il est supprimé, nous allons supprimer son identifiant de la projection. Ainsi nous obtiendrons une liste des identifiants à jour de la collection de livres.

Comment réaliser cette projection avec Ecotone ? Vous vous en doutez : en utilisant un attribut #[Projection] sur une classe dédiée à la projection. Il est également nécessaire d'ajouter des attributs #[EventHandler] sur les méthodes réagissant aux événements que l'on souhaite. Enfin l'attribut #[ProjectionState] sur l'un des paramètres d'une méthode permet de récupérer l'état actuel de la projection dans le but de le modifier (en le retournant). Voici ce que cela donne pour la projection des identifiants :

<?php

namespace App\BookStore\Infrastructure\Ecotone\Projection;

#[Projection('bookList', Book::class)]
final class BookIdsProjection
{
    #[EventHandler]
    public function addBook(BookWasCreated $event, #[ProjectionState] array $bookIdsState): array
    {
        $bookIdsState[] = (string) $event->id();

        return $bookIdsState;
    }

    #[EventHandler]
    public function removeBook(BookWasDeleted $event, #[ProjectionState] array $bookIdsState): array
    {
        if (false !== $index = array_search((string) $event->id(), $bookIdsState, true)) {
            unset($bookIdsState[$index]);
        }

        return array_values($bookIdsState);
    }
}

Une fois la projection en place, il est nécessaire de rejouer les événements déjà existants pour initialiser la projection :

bin/console ecotone:es:initialize-projection bookList

Vérifions que la projection a bien été exécutée en base de données :

[
  {
    "no": 1,
    "name": "bookList",
    "position": {"App\\BookStore\\Domain\\Model\\Book": 5},
    "state": ["133d71b1-df04-4d59-9acc-ec287c2c9074", "8381db41-1911-49d6-bce9-b303cb251ecb"],
    "status": "idle",
    "locked_until": null
  }
]

Pour récupérer ces données, Ecotone fournit des Gateways, sur le même principe que les Repositories (une interface correspondant à un service) :

<?php

namespace App\BookStore\Infrastructure\Ecotone\Projection;

interface BookIdsGateway
{
    #[ProjectionStateGateway('bookList')]
    public function getBookIds(): array;
}

Maintenant que nous avons la liste des identifiants à jour, nous pouvons nous en servir dans notre BookRepository de manière paresseuse. Nous utilisons pour cela un paginateur créé à cet effet (CallbackPaginator) que vous pouvez retrouver ici.

Ne reste plus qu'à créer le query handler (et sa query associée). N'oublions pas le Provider API Platform, qui nécessite un peu de plomberie afin de gérer correctement la pagination :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider;

final readonly class BookCollectionProvider implements ProviderInterface
{
    public function __construct(private QueryBusInterface $queryBus, private Pagination $pagination) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ApiPlatformPaginatorInterface|array
    {
        $offset = $limit = null;

        if ($this->pagination->isEnabled($operation, $context)) {
            $offset = $this->pagination->getPage($context);
            $limit = $this->pagination->getLimit($operation, $context);
        }

        $books = $this->queryBus->ask(new FindBooksQuery(null, $offset, $limit));

        $resources = [];
        foreach ($books as $book) {
            $resources[] = BookResource::fromModel($book);
        }

        if (null !== $offset && null !== $limit && $books instanceof PaginatorInterface) {
            return new Paginator(
                new \ArrayIterator($resources),
                (float) $books->getCurrentPage(),
                (float) $books->getItemsPerPage(),
                (float) $books->getLastPage(),
                (float) $books->getTotalItems(),
            );
        }

        return $resources;
    }
}

Après avoir modifié la ressource, nous obtenons enfin la récupération de la liste des livres :

Capture d'écran d'une page du site d'API Platform" class="wp-image-6885

Filtres, actions : les projections en renfort

Maintenant que nous avons mis en place notre CRUD, nous pouvons passer à des situations un peu plus avancées. Par exemple comment faire pour filtrer notre liste de livres selon son auteur ? La problématique ressemble fortement à celle des identifiants : nous n'allons pas rejouer tous les événements et reconstruire tous les états finaux des livres pour pouvoir ensuite les filtrer. C'est donc une projection qui va réaliser ce filtrage.

Vous allez le constater, le code le plus complexe va se trouver dans la projection, le reste n'est que de la plomberie !

Créons d'abord notre filtre API Platform :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\OpenApi;

final class AuthorFilter implements FilterInterface
{
    public function getDescription(string $resourceClass): array
    {
        return [
            'author' => [
                'property' => 'author',
                'type' => Type::BUILTIN_TYPE_STRING,
                'required' => false,
            ],
        ];
    }
}

Et utilisons-le dans notre ressource :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\Resource;

#[ApiResource(
    shortName: 'Book',
    operations: [
        new GetCollection(
            filters: [AuthorFilter::class],
            provider: BookCollectionProvider::class,
        ),
        // ...
    ],
)]
final class BookResource
{
    // ...
}

Le query handler utilise ensuite la valeur du filtre et appelle la méthode du BookRepository correspondante :

<?php

namespace App\BookStore\Application\Query;

final readonly class FindBooksQueryHandler
{
    public function __construct(private BookRepositoryInterface $bookRepository) {}

    #[QueryHandler]
    public function __invoke(FindBooksQuery $query): iterable
    {
        if (null !== $query->author) {
            return $this->bookRepository->findByAuthor($query->author);
        }

        // ...
    }
}

Et enfin le code dans le BookRepository :

<?php

namespace App\BookStore\Infrastructure\Ecotone\Repository;

final readonly class BookRepository implements BookRepositoryInterface
{
    // ...

    public function findByAuthor(Author $author): iterable
    {
        $byAuthorBookIds = $this->booksByAuthorGateway->getByAuthorBookIds();

        foreach ($byAuthorBookIds[$author->value] ?? [] as $bookId) {
            if ($book = $this->ofId(new BookId($bookId))) {
                yield $book;
            }
        }
    }
}

Comme vous le voyez, la Gateway récupère les données de la projection, à savoir les identifiants des livres classés selon leur auteur.

C'est donc le rôle de la projection d'effectuer ce classement. Le code demande un peu de réflexion car il est nécessaire de gérer tous les cas : ajout d'un livre, changement d'un auteur et suppression.

<?php

namespace App\BookStore\Infrastructure\Ecotone\Projection;

#[Projection('booksByAuthor', Book::class)]
final class BooksByAuthorProjection
{
    #[EventHandler]
    public function addBook(BookWasCreated $event, #[ProjectionState] array $booksByAuthorState): array
    {
        return $this->addBookToAuthorBooks($event->id(), $event->author, $booksByAuthorState);
    }

    #[EventHandler]
    public function updateBook(BookWasUpdated $event, #[ProjectionState] array $booksByAuthorState): array
    {
        if (!$event->author) {
            return $booksByAuthorState;
        }

        $booksByAuthorState = $this->removeBookFromAuthorBooks($event->id(), $booksByAuthorState);

        return $this->addBookToAuthorBooks($event->id(), $event->author, $booksByAuthorState);
    }

    #[EventHandler]
    public function removeBook(BookWasDeleted $event, #[ProjectionState] array $booksByAuthorState): array
    {
        return $this->removeBookFromAuthorBooks($event->id(), $booksByAuthorState);
    }

    private function addBookToAuthorBooks(BookId $bookId, Author $author, array $booksByAuthorState): array
    {
        if (!isset($booksByAuthorState[$author->value])) {
            $booksByAuthorState[$author->value] = [];
        }

        if ($this->findBookAuthor($bookId, $booksByAuthorState)?->isEqualTo($author)) {
            return $booksByAuthorState;
        }

        $booksByAuthorState[$author->value][] = (string) $bookId;

        return $booksByAuthorState;
    }

    private function removeBookFromAuthorBooks(BookId $bookId, array $booksByAuthorState): array
    {
        $previousAuthor = $this->findBookAuthor($bookId, $booksByAuthorState);

        if (!$previousAuthor) {
            return $booksByAuthorState;
        }

        $previousBookIndex = array_search((string) $bookId, $booksByAuthorState[$previousAuthor->value], true);
        unset($booksByAuthorState[$previousAuthor->value][$previousBookIndex]);
        $booksByAuthorState[$previousAuthor->value] = array_values($booksByAuthorState[$previousAuthor->value]);

        return $booksByAuthorState;
    }

    private function findBookAuthor(BookId $bookId, array $booksByAuthorState): ?Author
    {
        foreach ($booksByAuthorState as $author => $books) {
            if (in_array((string) $bookId, $books, true)) {
                return new Author($author);
            }
        }

        return null;
    }
}

N'oubliez pas d'initialiser la projection, et si tout va bien vous pouvez maintenant filtrer la liste des livres par auteur !

Voyons maintenant comment réaliser une action spéciale sur nos livres. Nous allons ajouter une opération pour réaliser une promotion sur un livre. La première question à se poser est la suivante : « Dois-je créer un événement dédié ? ». En effet nous avons tout à fait la possibilité d'utiliser notre événement BookWasUpdated pour réaliser la promotion. Ce choix a une conséquence importante : notre historique d'événements ne contiendra pas l'information qu'une promotion a eu lieu. Ce n'est probablement pas ce que nous voulons : nous allons donc créer l'événement BookWasDiscounted :

<?php

namespace App\BookStore\Domain\Event;

final readonly class BookWasDiscounted implements BookEvent
{
    public function __construct(private BookId $id, public Discount $discount) {}

    public function id(): BookId
    {
        return $this->id;
    }
}

Le Value Object Discount contient le pourcentage de promotion appliqué.

Cette promotion sera appliquée grâce à la commande suivante :

<?php

declare(strict_types=1);

namespace App\BookStore\Domain\Command;

final readonly class DiscountBookCommand implements CommandInterface
{
    public function __construct(public BookId $id, public Discount $discount) {}
}

Nous modifions l'agrégat pour prendre en compte cette commande et l'événement associé :

<?php

namespace App\BookStore\Domain\Model;

#[EventSourcingAggregate]
final class Book
{
    // ...

    #[CommandHandler]
    public function discount(DiscountBookCommand $command): array
    {
        return [new BookWasDiscounted(id: $command->id, discount: $command->discount)];
    }

    #[EventSourcingHandler]
    public function applyBookWasDiscounted(BookWasDiscounted $event): void
    {
        $this->price = $this->price->applyDiscount($event->discount);
    }
}

Tout est en place du côté du domaine et d'Ecotone, il ne reste plus qu'à créer le Processor dédié dans la partie API Platform :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\State\Processor;

final readonly class DiscountBookProcessor implements ProcessorInterface
{
    public function __construct(private CommandBusInterface $commandBus, private QueryBusInterface $queryBus) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookResource
    {
        $command = new DiscountBookCommand(new BookId($bookResource->id), new Discount($data->discountPercentage));

        $this->commandBus->dispatch($command);

        $model = $this->queryBus->ask(new FindBookQuery($command->id));

        return BookResource::fromModel($model);
    }
}

Remarquez que pour récupérer le pourcentage de promotion demandé par l'utilisateur, nous avons besoin d'un payload (ou input) particulier. Nous pouvons le créer directement :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\Payload;

final readonly class DiscountBookPayload
{
    public function __construct(
        #[Assert\Range(min: 0, max: 100)]
        public int $discountPercentage,
    ) {}
}

Et pour finir, nous créons l'opération spéciale :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\Resource;

#[ApiResource(
    shortName: 'Book',
    operations: [
        new Post(
            '/books/{id}/discount.{_format}',
            openapiContext: ['summary' => 'Apply a discount percentage on a Book resource.'],
            input: DiscountBookPayload::class,
            provider: BookItemProvider::class,
            processor: DiscountBookProcessor::class,
        ),
        // ...
    ],
)]
final class BookResource
{
    // ...
}

Et voilà, votre opération spéciale est fonctionnelle ! Par exemple avec la requête suivante :

curl -X 'POST' \
  'https://localhost:8000/api/books/8381db41-1911-49d6-bce9-b303cb251ecb/discount' \
  -H 'accept: application/ld+json' \
  -H 'Content-Type: application/ld+json' \
  -d '{
  "discountPercentage": 10
}'

Nous obtenons :

{
  "@context": "/api/contexts/Book",
  "@id": "/api/books/8381db41-1911-49d6-bce9-b303cb251ecb",
  "@type": "Book",
  "name": "Stardust",
  "description": "Young Tristran Thorn will do anything to win the cold heart of beautiful Victoria.",
  "author": "Neil Gaiman",
  "content": "Very good!",
  "price": 15
}

Attention cependant lorsque vous créez un événement particulier : parcourez une à une vos projections et vérifiez que cet événement ne touche pas l'une d'entre elle. Si vous oubliez de le faire, vous obtiendrez un état incohérent entre l'état de vos projections et de vos agrégats. Si cela arrive, vous pouvez corriger la projection et la rejouer. C'est le cas dans le dépôt d'exemple : une projection permet d'avoir le prix par livre afin de les trier du moins cher au plus cher. L'événement BookWasDiscounted doit être pris en compte pour cette projection.

Revenir dans le passé (nom de Zeus !)

Si vous avez tout suivi jusqu'à cette partie, bravo ! Nous avons réalisé un CRUD de nos livres ainsi que des actions spéciales, le tout en se servant d'événements plutôt que de stocker directement l'état en base de données. Tout ça pour quoi ? Pour avoir accès à tout ce qui s'est passé depuis la création d'une ressource. Nous allons justement voir comment obtenir nos événements avec notre API.

C'est maintenant une routine, on commence par ajouter l'opération API Platform :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\Resource;

#[ApiResource(
    shortName: 'Book',
    operations: [
        new GetCollection(
            '/books/{id}/events.{_format}',
            openapiContext: ['summary' => 'Get events for this Book.'],
            output: BookEvent::class,
            provider: BookEventsProvider::class,
        ),
        // ...
    ],
)]
final class BookResource
{
    // ...
}

Nous utilisons un output différent puisque la réponse contiendra les événements et non la ressource Book.

Le Provider fait office de passe-plat pour la query dédiée FindBookEventsQuery (remarquez l'utilisation du ArrayPaginator fourni par API Platform pour gérer facilement la pagination à partir d'un tableau) :

<?php

namespace App\BookStore\Infrastructure\ApiPlatform\State\Provider;

final readonly class BookEventsProvider implements ProviderInterface
{
    public function __construct(private QueryBusInterface $queryBus, private Pagination $pagination) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): PaginatorInterface|array
    {
        $bookEvents = $this->queryBus->ask(new FindBookEventsQuery(new BookId($uriVariables['id'])));
        $bookEvents = iterator_to_array($bookEvents);

        if ($this->pagination->isEnabled($operation, $context)) {
            $offset = $this->pagination->getPage($context);
            $limit = $this->pagination->getLimit($operation, $context);

            return new ArrayPaginator($bookEvents, ($offset - 1) * $limit, $limit);
        }

        return $bookEvents;
    }
}

Le handler appelle le BookRepository qui contient la méthode pour récupérer les événements :

<?php

namespace App\BookStore\Infrastructure\Ecotone\Repository;

final readonly class BookRepository implements BookRepositoryInterface
{
    public function __construct(
        private EventStore $eventStore,
        // ...
    ) {}

    public function findEvents(BookId $id): iterable
    {
        $matcher = (new MetadataMatcher())->withMetadataMatch(LazyProophEventStore::AGGREGATE_ID, Operator::EQUALS(), (string) $id);

        $events = $this->eventStore->load(Book::class, 1, null, $matcher);

        foreach ($events as $event) {
            yield $event->getPayload();
        }
    }
}

Vous voyez que la méthode fait appel à l'Event Store pour récupérer les événements et utilise également un matcher pour ne récupérer que les événements associés au livre demandé (cette façon de faire n'étant malheureusement pas encore documentée).

En faisant l'appel suivant :

curl -X 'GET' \
  'https://localhost:8000/api/books/8381db41-1911-49d6-bce9-b303cb251ecb/events?page=1' \
  -H 'accept: application/ld+json'

Nous obtenons :

{
  "@context": "/api/contexts/Book",
  "@id": "/api/books/8381db41-1911-49d6-bce9-b303cb251ecb/events",
  "@type": "hydra:Collection",
  "hydra:totalItems": 3,
  "hydra:member": [
    {
      "@type": "BookWasCreated",
      "@id": "/api/.well-known/genid/fa6ed43b678fef3ff6cb",
      "name": {
        "@type": "BookName",
        "@id": "/api/.well-known/genid/50a7cc505356c512bdc5",
        "value": "Stardust"
      },
      "description": {
        "@type": "BookDescription",
        "@id": "/api/.well-known/genid/c7da1d7e376e06996a94",
        "value": "Young Tristran Thorn will do anything to win the cold heart of beautiful Victoria."
      },
      "author": {
        "@type": "Author",
        "@id": "/api/.well-known/genid/76b61512ec3cd81a14f8",
        "value": "Neil Gaiman"
      },
      "content": {
        "@type": "BookContent",
        "@id": "/api/.well-known/genid/aa82af56f9c818c1af57",
        "value": "Worth reading"
      },
      "price": {
        "@type": "Price",
        "@id": "/api/.well-known/genid/8dfc424d3d584903abf5",
        "amount": 17
      }
    },
    {
      "@type": "BookWasUpdated",
      "@id": "/api/.well-known/genid/91c1db7d829e433b68ca",
      "content": {
        "@type": "BookContent",
        "@id": "/api/.well-known/genid/a874ab75731dc9aaabb9",
        "value": "Very good!"
      }
    },
    {
      "@type": "BookWasDiscounted",
      "@id": "/api/.well-known/genid/469c9c706fff0777e080",
      "discount": {
        "@type": "Discount",
        "@id": "/api/.well-known/genid/d9ce43d5500bd49c0e80",
        "percentage": 10
      }
    }
  ]
}

Conclusion

Grâce à Ecotone et à la souplesse d'API Platform 3, il est possible de mettre en place une architecture complexe utilisant l'Event Sourcing. Néanmoins cela demande une bonne maitrise à la fois des concepts d'API Platform mais aussi de la façon de fonctionner d'Ecotone. J'espère que cet article vous permettra de déterminer si ce choix peut être pertinent sur votre projet.

J'aimerais néanmoins insister une dernière fois sur ce point : ne mettez en place l'Event Sourcing que si les besoins métiers l'exigent réellement. Pour avoir une historisation des opérations réalisées sur une ressource si vous utilisez Doctrine, il existe par exemple le très efficace auditor-bundle. L'Event Sourcing a une approche élégante et permet d'avoir une vue claire de tous les événements de votre application, mais de nombreuses problématiques se posent et des pièges jalonnent son utilisation.

Le blog

Pour aller plus loin