02 Jul 2023
Has decidido implementar tu proyecto basado en Event Sourcing. ¿Ya elegiste qué librería vas a usar como base para implementar Event Sourcing? En este post vamos a analizar los casos de uso planteados en el conocido proyecto Buttercup-protects usando EventSauce.
Uno de los proyectos que más me ayudó a entender Event Sourcing a nivel de código fue Buttercup-protects. Se trata de un tutorial sencillo de seguir junto con una serie de interfaces a implementar para tener Event Sourcing en nuestra aplicación.
Cuando usamos Event Sourcing, el estado de las entidades principales de nuestro modelo, los aggregate roots, se persiste como una secuencia de eventos. Luego, cada vez que necesitemos trabajar con nuestro aggregate root, lo vamos a reconstituir a partir de esa secuencia de eventos.
Hay mucha información con explicaciones más detalladas sobre el tema. Dejo un par de enlaces en inglés que pueden ser un buen punto de partida:
A la hora de implementar un proyecto con Event Sourcing (o siguiendo cualquier otro patrón) siempre está la opción de desarrollar todo desde cero. Este enfoque nos da el control absoluto de cada aspecto del proyecto, aunque es costoso en tiempo de desarrollo. La alternativa es elegir librerías existentes, idealmente con comunidades activas, donde se cubren los casos comunes y podemos aprovechar la experiencia de quienes usan y enriquecen dicha librería. Una de las librerías más conocidas en el mundo de Event Sourcing con PHP es EventSauce. En este post, voy a dejar los apuntes que hice estudiando EventSauce, usando el caso planteado en Buttercup-protects.
Este es el repositorio donde dejaré el código del post:
https://github.com/imefisto/buttercup-protects-with-eventsauce
Enlace a 01-IdentifiesAggregate.php en Buttercup-protects.
El primer capítulo del tutorial nos explica que usaremos un value object para identificar nuestros aggregates.
Antes de implementar nuestra clase BasketId
, vamos a escribir los tests que nos permitirán validar el comportamiento que esperamos.
Este es el primer test que aparece al final de la sección de Buttercup-protects:
$basketId = BasketId::fromString('12345678-90ab-cdef-1234-567890abcedf1234');
it("should cast to string",
(string) $basketId == '12345678-90ab-cdef-1234-567890abcedf1234');
it("should equal instances with the same type and value",
(new BasketId('same'))->equals(new BasketId('same')));
it("should not equal instances with a different value",
!(new BasketId('other'))->equals(new BasketId('same')));
Voy a implementar el mismo test, usando pest. Vamos con la primer funcionalidad, que es el cast
a string
:
// tests/IdentifiesAggregateTest.php
it(
'should cast to string',
function () {
$basketId = BasketId::fromString('12345678-90ab-cdef-1234-567890abcedf1234');
expect((string) $basketId)
->toBe('12345678-90ab-cdef-1234-567890abcedf1234');
}
);
it(
'should equal instances with the same type and value',
function () {
expect ((new BasketId('same'))->equals(new BasketId('same')))
->toBeTrue();
}
);
it(
'should not equal instances with a different value',
function () {
expect ((new BasketId('other'))->equals(new BasketId('same')))
->toBeFalse();
}
);
Ahora vamos a implementar la clase BasketId
para que pase nuestro test. EventSauce dispone de la interfaz AggregateRootId, la cual es requerida para la persistencia de los eventos que se van a generar en nuestra aplicación.
<?php
namespace Imefisto\ButtercupProtectsWithEventsauce;
use EventSauce\EventSourcing\AggregateRootId;
final class BasketId implements AggregateRootId
{
public function __construct(private string $aggregateRootId)
{
}
public function toString(): string
{
return $this->aggregateRootId;
}
public function __toString(): string
{
return $this->toString();
}
public static function fromString(string $aggregateRootId): static
{
return new self($aggregateRootId);
}
public function equals(AggregateRootId $id)
{
return (string) $id == $this->aggregateRootId;
}
}
A primera vista es redundante tener dos métodos que retornen el string, pero la interfaz AggregateRootId
requiere implementar el método toString
y el test que representa un requerimiento, requiere __toString
para hacer el cast, así que implementamos ambos.
Si ejecutamos ./vendor/bin/pest
, deberíamos ver que nuestro código pasa la prueba.
Ya hemos dado el primer paso del recorrido. Tenemos un Value Object que nos permitirá identificar nuestros aggregate roots.
Enlace a 02-DomainEvent.php en Buttercup-protects.
El siguiente paso en el tutorial es la creación de los eventos que representan los hechos de interés en nuestro modelo. Vamos a crear el test que está al final de la sección en Buttercup-protects, directamente adaptado a pest
:
// tests/DomainEventsTest.php
$event = new ProductWasAddedToBasket(
new BasketId('BAS1'),
new ProductId('PRO1'),
'The Princess Bride'
);
it(
'should equal another instance with the same value',
function () use ($event) {
expect (
$event->basketId->equals(new BasketId('BAS1'))
)->toBeTrue();
}
);
it(
'should expose a ProductId',
function () use ($event) {
expect (
$event->productId->equals(new ProductId('PRO1'))
)->toBeTrue();
}
);
it(
'should expose a productName',
function () use ($event) {
expect($event->productName)
->toBe('The Princess Bride');
}
);
Buttercup no nos dice nada acerca de cómo debemos crear ProductId. Sin embargo, fieles al TDD, vamos a crear una clase ProductId que nos proporcione la funcionalidad mínima para pasar los tests:
final class ProductId
{
public function __construct(public readonly string $id)
{
}
public function equals(ProductId $other)
{
return (string) $other == $this->id;
}
public function __toString()
{
return $this->id;
}
}
Lo propio haremos con el evento ProductWasAddedToBasket
. Implementamos una versión básica primero y más adelante podemos ir cambiando según lo necesitemos.
A diferencia del test original donde se usan los métodos getAggregateId
, getProductId
y getProductName
para retornar información almacenada en el evento, he utilizado directamente los atributos basketId
, productId
y productName
respectivamente. Estos atributos readonly
fueron añadidos en PHP +8.1. Son una manera sencilla de expresar que nuestros eventos no modifican los valores que reciben en su constructor.
final class ProductWasAddedToBasket
{
public function __construct(
public readonly BasketId $basketId,
public readonly ProductId $productId,
public readonly string $productName
) {
}
}
En esta sección validamos que nuestro aggregate root sea capaz de generar los eventos del dominio:
// tests/RecordsEvents.php
$basketId = BasketId::fromString(Uuid::uuid7());
$basket = Basket::pickUp($basketId);
$basket->addProduct(new ProductId('TPB123'), 'The Princess Bride');
$basket->removeProduct(new ProductId('TPB123'));
$events = $basket->releaseEvents();
// ... tests
A diferencia de Buttercup-protects, que requiere la implementación de los métodos getRecordedEvents
y clearRecordedEvents
, aquí usamos el método propuesto por EventSauce, releaseEvents
, dentro del trait AggregateRootBehaviour
. Otro método implementado en el mencionado trait es recordThat
, que es el que usamos dentro de los métodos públicos del aggregate root:
// src/Basket.php
final class Basket implements AggregateRoot
{
use AggregateRootBehaviour;
public function __construct(private BasketId $id)
{
}
public static function pickUp(BasketId $id): self
{
$basket = new self($id);
$basket->recordThat(
new BasketWasPickedUp($id)
);
return $basket;
}
public function addProduct(ProductId $productId, string $productName): void
{
$this->recordThat(
new ProductWasAddedToBasket(
$this->id,
$productId,
$productName
)
);
}
public function removeProduct(ProductId $productId): void
{
$this->recordThat(
new ProductWasRemovedFromBasket(
$this->id,
$productId
)
);
}
// ...
}
Esta sección dentro de Buttercup-protects es para demostrar cómo un aggregate root asegura que se cumplan las reglas que pudieran existir en el dominio. La regla en cuestión es “una canasta no puede tener más de tres productos”. En caso de que se intente violar esa regla, se debe disparar una excepción, como lo expresa el test:
// tests/ProtectsInvariants.php
it(
'should disallow adding a fourth product',
function () {
$basketId = BasketId::fromString(Uuid::uuid7());
$basket = Basket::pickUp($basketId);
$basket->addProduct(new ProductId('TPB1'), "The Princess Bride");
$basket->addProduct(new ProductId('TPB2'), "The book");
$basket->addProduct(new ProductId('TPB3'), "The DVD");
$basket->addProduct(new ProductId('TPB4'), "The video game");
}
)->throws(BasketLimitReached::class);;
Para pasar la prueba, debemos mantener la cuenta de los productos que se agregan y se quitan de la canasta, junto con el método guardProductLimit
que lanza la excepción BasketLimitReached
si ya tenemos 3 productos:
// src/Basket.php
// ...
private int $productCount = 0;
// ...
public function addProduct(ProductId $productId, string $productName): void
{
$this->guardProductLimit();
$this->recordThat( /* .. */ );
++$this->productCount;
}
private function guardProductLimit()
{
if ($this->productCount >= 3) {
throw new BasketLimitReached;
}
}
public function removeProduct(ProductId $productId): void
{
$this->recordThat( /* .. */ );
--$this->productCount;
// ...
Además de usar excepciones, podemos añadir reglas que simplemente prevengan que se dispare un evento. En esta sección, la regla que se expresa es “si intentamos remover un producto que ya fue removido, no debería dispararse un nuevo evento”:
// tests/ProtectsMoreInvariants.php
it(
'should not record an event when removing a Product that is no longer in the Basket',
function () {
$basketId = BasketId::fromString(Uuid::uuid7());
$basket = Basket::pickUp($basketId);
$productId = new ProductId('TPB1');
$basket->addProduct($productId, "The Princess Bride");
$basket->removeProduct($productId);
$basket->removeProduct($productId);
expect(
$basket->releaseEvents()
)->toHaveCount(3);
}
);
Para este test, se requiere el siguiente código en src/Basket.php
:
// src/Basket.php
private array $products = [];
public function addProduct(ProductId $productId, string $productName): void
{
// ...
if (!$this->productIsInBasket($productId)) {
$this->products[(string) $productId] = 0;
}
++$this->products[(string) $productId];
}
// ...
public function removeProduct(ProductId $productId): void
{
if(! $this->productIsInBasket($productId)) {
return;
}
// ...
--$this->products[(string) $productId];
// ...
}
private function productIsInBasket(ProductId $productId): bool
{
return
array_key_exists((string) $productId, $this->products)
&& $this->products[(string)$productId] > 0;
}
// ...
Aquí es donde veremos algunas diferencias entre la propuesta de Buttercup-protects y la implementación de EventSauce, aunque en ambos casos llegamos al mismo fin: poder reconstruir un aggregate root a partir de una lista de eventos. Vamos al test y la explicación de las diferencias:
// tests/IsEventSourcedTest.php
it(
'should be the same after reconstitution',
function () {
$basketId = BasketId::fromString(Uuid::uuid7());
$basket = Basket::pickUp($basketId);
$productId = new ProductId('TPB1');
$basket->addProduct($productId, "The Princess Bride");
$basket->addProduct(new ProductId('TPB2'), "The book");
$basket->removeProduct($productId);
$events = $basket->releaseEvents();
$generator = (function () use ($events) {
yield from $events;
return count($events);
})();
$reconstitutedBasket = Basket::reconstituteFromEvents(
$basketId,
$generator
);
expect($basket)
->toEqual($reconstitutedBasket);
}
);
A diferencia de Buttercup-protects, en donde la reconstitución de un aggregate root a partir de los eventos es usando un objeto AggregateHistory
, en EventSauce, el método reconstituteFromEvents
recibe un generador. Además, dicho generador debe retornar el número de eventos (return count($events)
), que es lo que usa EventSauce para determinar la versión del aggregate root.
Estas son las líneas añadidas al archivo Basket.php
, para pasar el test de esta sección:
final class Basket implements AggregateRoot
{
private array $products = [];
// ...
public function addProduct(ProductId $productId, string $productName): void
{
// remover la línea "++$this->productCount;"
}
public function removeProduct(ProductId $productId): void
{
if(! $this->productIsInBasket($productId)) {
return;
}
// ...
// remover la línea "--$this->productCount;"
}
private function productIsInBasket(ProductId $productId): bool
{
return
array_key_exists((string) $productId, $this->products)
&& $this->products[(string)$productId] > 0;
}
public function applyBasketWasPickedUp(BasketWasPickedUp $event)
{
$this->product = [];
$this->productCount = 0;
}
public function applyProductWasAddedToBasket(ProductWasAddedToBasket $event)
{
$productId = $event->productId;
if(!$this->productIsInBasket($productId)) {
$this->products[(string) $productId] = 0;
}
++$this->products[(string) $productId];
++$this->productCount;
}
public function applyProductWasRemovedFromBasket(ProductWasRemovedFromBasket $event)
{
--$this->products[(string) $event->productId];
--$this->productCount;
}
}
Observar que el incremento y decremento del atributo productCount
ahora se realiza dentro de los métodos apply*
. Modificar atributos dentro de los métodos apply*
es lo que permite que esas operaciones también se realicen durante la reconstitución de un aggregate.
En el proyecto Buttercup-protects, se usan métodos when*
concatenado al nombre de evento, en lugar de apply*
seguido del nombre de evento, como propone EventSauce. La funcionalidad es la misma.
Esta sección no tiene tests. Es para definir un EventStore que persiste los eventos en Memoria. En el caso de EventSauce, ya viene con una implementación equivalente, que es InMemoryMessageRepository. Lo que en Buttercup-protects se llama EventStore, en EventSauce es un MessageRepository.
Enlace a 08-AggregateRepository en Buttercup-protects.
Este es el último apartado. Vamos a reconstituir un aggregate a partir de los eventos almacenados en el EventStore / MessageRepository. Para el test, vamos a utilizar la implementación de MessageRepository
, que viene con EventSauce, que es InMemoryMessageRepository
:
// tests/AggregateRepositoryTest.php
$basketId = BasketId::fromString(Uuid::uuid7());
$basket = Basket::pickUp($basketId);
$basket->addProduct(new ProductId('TPB1'), "The Princess Bride");
$baskets = new BasketRepository(new InMemoryMessageRepository());
$baskets->persist($basket);
$reconstitutedBasket = $baskets->retrieve($basketId);
expect($basket)
->toEqual($reconstitutedBasket);
A diferencia de los métodos add
y get
, para persistir y retornar un aggregate root respectivamente, del repositorio presentado en el test de Buttercup-protects, aquí usamos persist
y retrieve
que son los métodos que propone EventSauce para los mismos fines. El resto del test es bastante similar al de Buttercup-protects.
A la hora de implementar nuestro propio repositorio, EventSauce nos lo pone muy sencillo ya que solo necesitamos extender EventSourcedAggregateRootRepository
e invocar su constructor indicando la clase del aggregate que vamos a persistir:
<?php
// src/BasketRepository.php
namespace Imefisto\ButtercupProtectsWithEventsauce;
use EventSauce\EventSourcing\EventSourcedAggregateRootRepository;
use EventSauce\EventSourcing\MessageRepository;
class BasketRepository extends EventSourcedAggregateRootRepository
{
public function __construct(MessageRepository $messageRepository) {
parent::__construct(
Basket::class,
$messageRepository
);
}
}
Eso es todo por parte del repositorio. En nuestro aggregate root debo modificar la forma en la que persistimos el id: en lugar de usar la propiedad id
, debemos usar aggregateRootId
, que es lo que requiere el trait AggregateRootBehaviour
de EventSauce. Esto lo conseguimos removiendo el constructor del archivo src/Basket.php
y reemplazando las referencias a $this->id
por $this->aggregateRootId
. A continuación dejo el archivo src/Basket.php
en su versión final:
<?php
namespace Imefisto\ButtercupProtectsWithEventsauce;
use EventSauce\EventSourcing\AggregateRoot;
use EventSauce\EventSourcing\AggregateRootBehaviour;
final class Basket implements AggregateRoot
{
private array $products = [];
private int $productCount = 0;
use AggregateRootBehaviour;
public static function pickUp(BasketId $id): self
{
$basket = new self($id);
$basket->recordThat(
new BasketWasPickedUp($id)
);
return $basket;
}
public function addProduct(ProductId $productId, string $productName): void
{
$this->guardProductLimit();
$this->recordThat(
new ProductWasAddedToBasket(
$this->aggregateRootId,
$productId,
$productName
)
);
}
private function guardProductLimit()
{
if ($this->productCount >= 3) {
throw new BasketLimitReached;
}
}
public function removeProduct(ProductId $productId): void
{
if(! $this->productIsInBasket($productId)) {
return;
}
$this->recordThat(
new ProductWasRemovedFromBasket(
$this->aggregateRootId,
$productId
)
);
}
private function productIsInBasket(ProductId $productId): bool
{
return
array_key_exists((string) $productId, $this->products)
&& $this->products[(string)$productId] > 0;
}
public function applyBasketWasPickedUp(BasketWasPickedUp $event)
{
$this->product = [];
$this->productCount = 0;
}
public function applyProductWasAddedToBasket(ProductWasAddedToBasket $event)
{
$productId = $event->productId;
if(!$this->productIsInBasket($productId)) {
$this->products[(string) $productId] = 0;
}
++$this->products[(string) $productId];
++$this->productCount;
}
public function applyProductWasRemovedFromBasket(ProductWasRemovedFromBasket $event)
{
--$this->products[(string) $event->productId];
--$this->productCount;
}
}
Ahora nuestro aggregate root es compatible con la propuesta de EventSauce.
En este post implementamos el código para pasar los tests propuestos por Buttercup-protects para que nuestra aplicación siga los lineamientos de Event Sourcing. Hemos basado nuestra pequeña aplicación en EventSauce que, además de los casos de uso que hemos visto en este post, cubre muchos otros aspectos de Event Sourcing como Snapshotting, publicación de eventos, serialización, etc.
¡Tu mensaje fue recibido! Una vez que sea aprobado, estará visible para los demás visitantes.
Cerrar