Alessandro Lai / @AlessandroLai
We focused the problem, now how do we solve it?
"The context is King"
A.Brandolini⛔ Cons
✅ Pro
⛔ Cons
✅ Pro
|
Adding a new payment method to our payments service provider
class CoreArchitectureTest extends ArchitectureTest
{
public function testCoreDomainDependencies(): Rule
{
return $this->newRule
->classesThat(Selector::haveClassName('Facile\Domain\*'))
->canOnlyDependOn()
->classesThat(Selector::haveClassName('Facile\Domain\*'))
->classesThat(Selector::haveClassName(\Webmozart\Assert::class))
->classesThat(Selector::haveClassName(\Ramsey\Uuid\UuidInterface::class))
->build();
}
}
|
composer require eventsauce/eventsauce
https://github.com/eventsaucephp/eventsauce
namespace EventSauce\EventSourcing;
interface AggregateRootId
{
public function toString(): string;
public static function fromString(string $aggregateRootId): self;
}
namespace Facile\Domain\Model;
use EventSauce\EventSourcing\AggregateRootId;
abstract class AbstractAggregateRootId implements AggregateRootId
{
private function __construct(private UuidInterface $uuid){}
public static function create(): static
{
return new static(Uuid::uuid4());
}
public function toString(): string
{
return $this->uuid->toString();
}
public static function fromString(string $aggregateRootId): static
{
return new static(Uuid::fromString($aggregateRootId));
}
}
namespace Facile\Domain\Model;
final class ProductId extends AbstractAggregateRootId
{
}
namespace EventSauce\EventSourcing\Serialization;
interface SerializablePayload
{
public function toPayload(): array;
public static function fromPayload(array $payload): self;
}
namespace Facile\Domain\Events;
final class ProductCreated implements SerializablePayload
{
private function __construct(
private ProductId $productId,
private \DateTimeImmutable $creationDate
) {
}
public static function create(ProductId $productId): self
{
$creationDate = new \DateTimeImmutable();
return new self($productId, $creationDate);
}
// next slide...
}
final class ProductCreated implements SerializablePayload
{
// ...previous slide
public function toPayload(): array
{
return [
'productId' => $this->productId->toString(),
'creationDate' => $this->creationDate->format(\DATE_ATOM),
];
}
public static function fromPayload(array $payload): self
{
return new self(
ProductId::fromString($payload['productId']),
\DateTimeImmutable::createFromFormat(\DATE_ATOM, $payload['creationDate'])
);
}
}
namespace EventSauce\EventSourcing;
interface AggregateRoot
{
public function aggregateRootId(): AggregateRootId;
public function aggregateRootVersion(): int;
public function releaseEvents(): array;
public static function reconstituteFromEvents(
AggregateRootId $aggregateRootId,
Generator $events
): static;
}
namespace Facile\Domain\Model;
use EventSauce\EventSourcing\AggregateRoot;
use EventSauce\EventSourcing\AggregateRootBehaviour;
final class Product implements AggregateRoot
{
use AggregateRootBehaviour;
}
final class Product implements AggregateRoot
{
use AggregateRootBehaviour;
public static function create(ProductId $productId): self
{
$product = new self($productId);
$productCreated = ProductCreated::create($productId);
$product->recordThat($productCreated);
return $product;
}
}
namespace EventSauce\EventSourcing;
trait AggregateRootBehaviour
{
// ...
private AggregateRootId $aggregateRootId;
private function __construct(AggregateRootId $aggregateRootId)
{
$this->aggregateRootId = $aggregateRootId;
}
protected function recordThat(object $event): void
{
$this->apply($event);
$this->recordedEvents[] = $event;
}
// ...
}
namespace EventSauce\EventSourcing;
trait AggregateAlwaysAppliesEvents
{
private int $aggregateRootVersion = 0;
protected function apply(object $event): void
{
$parts = explode('\\', get_class($event));
$this->{'apply' . end($parts)}($event);
++$this->aggregateRootVersion;
}
}
namespace Facile\Domain\Model;
final class Product implements AggregateRoot
{
// ...
private ?\DateTimeImmutable $creationDate = null;
public function applyProductCreated(ProductCreated $productCreated): void
{
$this->creationDate = $productCreated->getCreationDate();
}
}
namespace EventSauce\EventSourcing;
trait AggregateRootBehaviour
{
// ...
public static function reconstituteFromEvents(
AggregateRootId $aggregateRootId,
Generator $events
): static
{
$aggregateRoot = new static($aggregateRootId);
foreach ($events as $event) {
$aggregateRoot->apply($event);
}
// ...
return $aggregateRoot;
}
}
namespace EventSauce\EventSourcing;
interface AggregateRootRepository
{
public function retrieve(AggregateRootId $aggregateRootId): object;
public function persist(object $aggregateRoot): void;
// ...
}
namespace Facile\Infrastructure\Repository;
use EventSauce\EventSourcing\EventSourcedAggregateRootRepository;
final class ProductRepository extends EventSourcedAggregateRootRepository
{
}
namespace EventSauce\EventSourcing;
class EventSourcedAggregateRootRepository implements AggregateRootRepository
{
public function __construct(
string $aggregateRootClassName,
MessageRepository $messageRepository,
MessageDispatcher $dispatcher = null,
// ...
) {
// ...
$this->dispatcher = $dispatcher ?: new SynchronousMessageDispatcher();
}
public function retrieve(AggregateRootId $aggregateRootId): object
{
$className = $this->aggregateRootClassName;
$events = $this->retrieveAllEvents($aggregateRootId);
return $className::reconstituteFromEvents($aggregateRootId, $events);
}
// next slide...
// ...previous slide
public function persist(object $aggregateRoot): void
{
// ...
$this->persistEvents(
$aggregateRoot->aggregateRootId(),
$aggregateRoot->aggregateRootVersion(),
...$aggregateRoot->releaseEvents()
);
}
public function persistEvents(AggregateRootId $id, int $version, object ...$events): void
{
$messages = array_map(function (object $event) {
// Events are transformed into messages
}, $events);
$this->messageRepository->persist(...$messages);
$this->dispatcher->dispatch(...$messages);
}
}
composer require eventsauce/message-repository-for-doctrine-v2
CREATE TABLE IF NOT EXISTS `your_table_name` (
`event_id` BINARY(16) NOT NULL,
`aggregate_root_id` BINARY(16) NOT NULL,
`version` int(20) unsigned NULL,
`payload` varchar(16001) NOT NULL,
PRIMARY KEY (`event_id`),
KEY (`aggregate_root_id`),
KEY `reconstitution` (`aggregate_root_id`, `version` ASC)
)
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci ENGINE=InnoDB;
{
"headers": {
"__aggregate_root_id": "b62a84f8-72f0-11ec-90d6-0242ac120003",
"__aggregate_root_type": "facile.domain.model.product",
"__aggregate_root_version": 1,
"__event_type": "facile.domain.events.product_created",
"__time_of_recording": "2022-02-08 09:44:36.407236+0000",
"__aggregate_root_id_type": "facile.domain.model.product_id",
"__event_id": "24b38b08-9042-4035-bbff-6bd75295e1b9"
},
"payload": {
"productId": "b62a84f8-72f0-11ec-90d6-0242ac120003",
"creationDate": "2022-02-08T10:44:36+01:00"
}
}
$product = $productRepository->retrieve($aggregateRootId);
$product->addOrder($command);
$productRepository->persist($product);
namespace Facile\Domain\Model;
class Product implements AggregateRoot
{
// ...
public function addOrder(AddOrderCommand $command): void
{
if (null !== $this->order) {
throw new \DomainException('Order already present');
}
$orderAdded = OrderAdded::create($command);
$this->recordThat($orderAdded);
}
public function applyOrderAdded(OrderAdded $orderAdded): void
{
$this->order = new Order($orderAdded->getOrderId(), $orderAdded->getMoney());
}
}
use EventSauce\EventSourcing\MessageConsumer;
class OrdersReadModel implements MessageConsumer
{
public function handle(Message $message)
{
$aggregateRootId = $message->aggregateRootId();
$event = $message->event();
if ($event instanceof OrderAdded){
# Instantiates a custom entity and persist it
}
}
}
class ProductRepository
extends EventSourcedAggregateRootRepository
implements ProductRepositoryInterface
{
public function retrieve(AggregateRootId $id): object
{
$product = parent::retrieve($id);
if ($product === null) {
$doctrineProduct = $this->doctrineRepository->find($id);
$product = Product::reconstituteFromEvents(
$this->eventGenerator->generateFrom($doctrineProduct)
);
}
return $product;
}
}
final class Prodotto implements AggregateRoot
{
// ...
public function reservePHPayment(ReservePayment $command): void
{
if (/* ... */) {
// domain logic & validation...
}
$event = new PHPaymentReservationCreated(/* ... */);
// add the event to the aggregate
$this->recordThat($event);
// register the same stuff in the Doctrine entity
$this->getDoctrineProduct()->addNewReservation($event)
}
// ...
}
class ReservePHPaymentHandler implements MessageHandlerInterface
{
public function __invoke(ReservePHPayment $command): void
{
$id = $command->getRootId();
// retrieve both the aggregate and the Doctrine entity
$product = $this->productRepository->retrieve($id);
// invoke the domain logic
$product->reservePHPayment($command);
// atomic flush due to single DB connection
$this->doctrineRepository->beginTransaction();
$this->productRepository->persist($product);
$this->doctrineRepository->flush();
$this->doctrineRepository->commit();
}
}
class ProductRepository
extends EventSourcedAggregateRootRepository
implements ProductRepositoryInterface
{
public function retrieve(AggregateRootId $id): object
{
$product = parent::retrieve($id);
if ($this->eventsAreOutdated($product)) {
$this->purgeEvents($id);
$product = null;
}
if ($product === null) {
// ...
}
return $product;
}
// ...
}