Traditionally, we create structural models for our applications, and store the state of these models in our databases.
But there are alternatives: Event Sourcing is the idea that you can store all the domain events that affect an entity, and replay these events to restore the object's state. This may sound counterintuitive, because of all the years we've spent building relational, denormalized database schemas. But it is in fact quite simple, elegant, and powerful.
In the past year, I've had the pleasure of building and shipping two event sourced systems. In this session, I will show practical code, to give you a feel of how you can build event sourced models using PHP.
Mathias Verraes is a recovering music composer turned programmer, consultant, blogger, speaker, and podcaster. He advises companies on how to build enterprise web applications for complex business domains . For some weird reason, he enjoys working on large legacy projects: the kind where there’s half a million lines of spaghetti code, and nobody knows how to get the codebase under control. He’s the founder of the Domain-Driven Design Belgium community. When he’s not working, he’s at home in Kortrijk, Belgium, helping his two sons build crazy Lego train tracks.
http://verraes.net
17. final class ProductWasAddedToBasket implements DomainEvent
{
private $basketId, $productId, $productName;
!
public function __construct(
BasketId $basketId, ProductId $productId, $productName
) {
$this->basketId = $basketId;
$this->productName = $productName;
$this->productId = $productId;
}
!
public function getAggregateId()
{
return $this->basketId;
}
!
public function getProductId()
{
return $this->productId;
}
!
public function getProductName()
{
return $this->productName;
}
}
18. final class ProductWasRemovedFromBasket implements DomainEvent
{
private $basketId;
private $productId;
!
public function __construct(BasketId $basketId, ProductId $productId)
{
$this->basketId = $basketId;
$this->productId = $productId;
}
!
public function getAggregateId()
{
return $this->basketId;
}
!
public function getProductId()
{
return $this->productId;
}
}
19. final class BasketWasPickedUp implements DomainEvent
{
private $basketId;
!
public function __construct(BasketId $basketId)
// You may want to add a date, user, …
{
$this->basketId = $basketId;
}
!
public function getAggregateId()
{
return $this->basketId;
}
}
22. $basket = Basket::pickUp(BasketId::generate());
$basket->addProduct(new ProductId('AV001'), “The Last Airbender");
$basket->removeProduct(new ProductId('AV001'));
!
!
$events = $basket->getRecordedEvents();
!
it("should have recorded 3 events",
3 == count($events));
!
it("should have a BasketWasPickedUp event",
$events[0] instanceof BasketWasPickedUp);
!
it("should have a ProductWasAddedToBasket event",
$events[1] instanceof ProductWasAddedToBasket);
!
it("should have a ProductWasRemovedFromBasket event",
$events[2] instanceof ProductWasRemovedFromBasket);
!
!
// Output:
✔ It should have recorded 3 events
✔ It should have a BasketWasPickedUp event
✔ It should have a ProductWasAddedToBasket event
✔ It should have a ProductWasRemovedFromBasket event
TestFrameworkInATweet https://gist.github.com/mathiasverraes/9046427
23. final class Basket implements RecordsEvents
{
public static function pickUp(BasketId $basketId)
{
$basket = new Basket($basketId);
$basket->recordThat(
new BasketWasPickedUp($basketId)
);
return $basket;
}
!
public function addProduct(ProductId $productId, $name)
{
$this->recordThat(
new ProductWasAddedToBasket($this->basketId, $productId, $name)
);
}
!
public function removeProduct(ProductId $productId)
{
$this->recordThat(
new ProductWasRemovedFromBasket($this->basketId, $productId)
);
}
!
// continued on next slide
24. // continued: final class Basket implements RecordsEvents
!
private $basketId;
!
private $latestRecordedEvents = [];
!
private function __construct(BasketId $basketId)
{
$this->basketId = $basketId;
}
!
public function getRecordedEvents()
{
return new DomainEvents($this->latestRecordedEvents);
}
!
public function clearRecordedEvents()
{
$this->latestRecordedEvents = [];
}
!
private function recordThat(DomainEvent $domainEvent)
{
$this->latestRecordedEvents[] = $domainEvent;
}
!
}
26. $basket = Basket::pickUp(BasketId::generate());
!
$basket->addProduct(new ProductId('AV1'), “The Last Airbender");
$basket->addProduct(new ProductId('AV2'), "The Legend of Korra");
$basket->addProduct(new ProductId('AV3'), “The Making Of Avatar”);
!
it("should disallow adding a fourth product",
throws(‘BasketLimitReached’, function () use($basket) {
$basket->addProduct(new ProductId('AV4'), “The Last Airbender Movie”);
})
!
);
27. final class Basket implements RecordsEvents
{
private $productCount = 0;
!
public function addProduct(ProductId $productId, $name)
{
$this->guardProductLimit();
$this->recordThat(
new ProductWasAddedToBasket($this->basketId, $productId, $name)
);
++$this->productCount;
}
!
private function guardProductLimit()
{
if ($this->productCount >= 3) {
throw new BasketLimitReached;
}
}
!
public function removeProduct(ProductId $productId)
{
$this->recordThat(
new ProductWasRemovedFromBasket($this->basketId, $productId)
);
--$this->productCount;
}
// ...
}
28. $basket = Basket::pickUp(BasketId::generate());
!
$productId = new ProductId(‘AV1');
!
$basket->addProduct($productId, “The Last Airbender");
$basket->removeProduct($productId);
$basket->removeProduct($productId);
!
it(“shouldn't record an event when removing a Product
that is no longer in the Basket”,
!
count($basket->getRecordedEvents()) == 3
!
);
1
2
3
4
29. final class Basket implements RecordsEvents
{
private $productCountById = [];
!
public function addProduct(ProductId $productId, $name)
{
$this->guardProductLimit();
$this->recordThat(new ProductWasAddedToBasket(…));
!
if(!$this->productIsInBasket($productId)) {
$this->productCountById[$productId] = 0;
}
!
++$this->productCountById[$productId];
}
!
public function removeProduct(ProductId $productId)
{
if(! $this->productIsInBasket($productId)) {
return;
}
!
$this->recordThat(new ProductWasRemovedFromBasket(…);
!
--$this->productCountById;
}
private function productIsInBasket(ProductId $productId) {…}
35. !
$basket = Basket::pickUp($basketId);
$basket->addProduct($productId, “The Last Airbender");
!
$events = $basket->getRecordedEvents();
!
// persist events in an event store, retrieve at a later time
!
$reconstitutedBasket = Basket::reconstituteFrom(
new AggregateHistory($basketId, $retrievedEvents)
);
!
it("should be the same after reconstitution",
$basket == $reconstitutedBasket
);
36. final class Basket implements RecordsEvents, IsEventSourced
{
public function addProduct(ProductId $productId, $name)
{
$this->guardProductLimit();
$this->recordThat(new ProductWasAddedToBasket(…));
!
// No state is changed!
}
!
public function removeProduct(ProductId $productId)
{
if(! $this->productIsInBasket($productId)) {
return;
}
!
$this->recordThat(new ProductWasRemovedFromBasket(…));
!
// No state is changed!
}
!
private function recordThat(DomainEvent $domainEvent)
{
$this->latestRecordedEvents[] = $domainEvent;
!
$this->apply($domainEvent);
}
40. final class BasketProjector
{
public function projectProductWasAddedToBasket(
ProductWasAddedToBasket $event)
{
INSERT INTO baskets_readmodel
SET
`basketId` = $event->getBasketId(),
`productId` = $event->getProductId(),
`name` = $event->getName()
}
public function projectProductWasRemovedFromBasket(
ProductWasRemovedFromBasket $event)
{
DELETE FROM baskets_readmodel
WHERE
`basketId` = $event->getBasketId()
AND `productId` = $event->getProductId()
}
}
43. final class BlueProductsSoldProjection
{
public function projectProductWasIntroducedInCatalog(
ProductWasIntroducedInCatalog $event)
{
if($event->getColor() == 'blue') {
$this->redis->sAdd('blueProducts', $event->getProductId());
}
}
!
public function projectProductWasAddedToBasket(
ProductWasAddedToBasket $event)
{
if($this->redis->sIsMember($event->getProductId())) {
$this->redis->incr('blueProductsSold');
}
}
!
public function projectProductWasRemovedFromBasket(
ProductWasRemovedFromBasket $event)
{
if($this->redis->sIsMember($event->getProductId())) {
$this->redis->decr('blueProductsSold');
}
}
}
44. LessonWasScheduled
{ SchoolId, GroupId, TeacherId, Subject, WeekDay, Timeslot }
!
=>
!
GroupScheduleProjector
Group 1A Monday Tuesday Wednesday Thursday Friday
09:00
Math
Ada
German
Friedrich
Math
Ada
Chemistry
Niels
Economy
Nicholas
10:00
French
Albert
Math
Ada
Physics
Isaac
PHP
Rasmus
History
Julian
11:00
Sports
Felix
PHP
Rasmus
PHP
Rasmus
German
Friedrich
Math
Ada
45. LessonWasScheduled
{ SchoolId, GroupId, TeacherId, Subject, WeekDay, Timeslot }
!
=>
!
TeacherScheduleProjector
Ada!
Math
Monday Tuesday Wednesday Thursday Friday
09:00
Group 1A
School 5
Group 1A
School 5
Group 6C
School 9
Group 5B
School 9
10:00
Group 1B
School 5
Group 1A
School 5
Group 6C
School 9
Group 5B
School 9
11:00
Group 2A
School 5
Group 5B
School 9
Group 1A
School 5
46. PupilWasEnlistedInGroup
{ PupilId, SchoolId, GroupId }
LessonWasScheduled
{ SchoolId, GroupId, TeacherId, Subject, WeekDay, Timeslot }
!
=>
!
TeacherPermissionsProjector
Ada Pupil 1
Ada Pupil 3
Friedrich Pupil 1
Friedrich Pupil 7
Ada Pupil 8
Julian Pupil 3
49. interface NaiveEventStore
{
public function commit(DomainEvents $events);
!
/** @return AggregateHistory */
public function getAggregateHistoryFor(IdentifiesAggregate $id);
!
/** @return DomainEvents */
public function getAll();
}
!
50. CREATE TABLE `buttercup_eventstore` (
`streamId` varbinary(16) NOT NULL,
`streamVersion` bigint(20) unsigned NOT NULL,
`streamContract` varchar(255) NOT NULL,
`eventDataContract` varchar(255) NOT NULL,
`eventData` text NOT NULL,
`eventMetadataContract` varchar(255) NOT NULL,
`eventMetadata` text NOT NULL,
`utcStoredTime` datetime NOT NULL,
`correlationId` varbinary(16) NOT NULL,
`causationId` varbinary(16) NOT NULL,
`causationEventOrdinal` bigint(20) unsigned,
PRIMARY KEY (`streamId`,`streamVersion`,`streamContract`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
58. // it should disallow evaluating pupils without planning them first
!
$scenario->given([
new EvaluationWasPlanned(…)
]);
!
$scenario->when(
new EvaluatePupil(…)
);
!
$scenario->then([
$scenario->throws(new CantEvaluateUnplannedPupil(…))
]);
!
——————————————————————————————————————————————————————————————————————————-
!
$scenario->given([
new EvaluationWasPlanned(…),
new PupilWasPlannedForEvaluation(…)
]);
!
$scenario->when(
new EvaluatePupil(…)
);
!
$scenario->then([
new PupilWasEvaluated()
]);