practical event sourcing
DESCRIPTION
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.netTRANSCRIPT
SourcingEventPractical
@mathiasverraes
Mathias VerraesStudent of Systems Meddler of Models Labourer of Legacy
verraes.net mathiasverraes
Elephant in the Room Podcast with @everzet
elephantintheroom.io @EitRoom
DDDinPHP.org
The Big Picture
Client
Write Model
Read Model
DTOCommands
Even
ts
CQRS: http://verraes.net/2013/12/fighting-bottlenecks-with-cqrs/
Write Model
Even
tsEv
ents
Read Model
This talk
Event Sourcing
Using on object’s
history to reconstitute its
State
Express
history as a series of
Domain Events
Something that has happened in the past
that is of interest to the business
Domain Event
!
happened in the past !
Express
history in the
Ubiquitous Language
Relevant to the business.
!
First class citizens of the Domain Model
Domain Events
interface DomainEvent { /** * @return IdentifiesAggregate */ public function getAggregateId(); }
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; } }
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; } }
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; } }
Domain Events are
immutable
RecordsEvents
$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
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
// 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; } !}
Protecting Invariants
$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”); }) !);
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; } // ... }
$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
234
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) {…}
Aggregates record events
Aggregates protect invariants
Possible outcomes !
nothing one or more events
exception
Aggregates do not expose state
Reconstituting Aggregates
!$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 );
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); }
private function applyProductWasAddedToBasket( ProductWasAddedToBasket $event) { ! $productId = $event->getProductId(); ! if(!$this->productIsInBasket($productId)) { $this->products[$productId] = 0; } ! ++$this->productCountById[$productId]; ! } ! private function applyProductWasRemovedFromBasket( ProductWasRemovedFromBasket $event) { $productId = $event->getProductId(); --$this->productCountById[$productId]; }
public static function reconstituteFrom( AggregateHistory $aggregateHistory) { $basketId = $aggregateHistory->getAggregateId(); $basket = new Basket($basketId); ! foreach($aggregateHistory as $event) { $basket->apply($event); } return $basket; } ! private function apply(DomainEvent $event) { $method = 'apply' . get_class($event); $this->$method($event); } !
Projections
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() } }
Fat events The good kind of duplication
Individual read models for every unique
use case
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'); } } }
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
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
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
Event Store
Immutable Append-only
You can’t change history
interface NaiveEventStore { public function commit(DomainEvents $events); ! /** @return AggregateHistory */ public function getAggregateHistoryFor(IdentifiesAggregate $id); ! /** @return DomainEvents */ public function getAll(); } !
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;
Performance
The Event Store is an immutable, append-only
database: infinite caching
Querying events happens by aggregate id only
Read models are faster than joins
Aggregate snapshots, if need be
Testing
// 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() ]);
verraes.net !
joind.in/10911 !
buttercup-php/protects !
mathiasverraes
verraes.net !
joind.in/10911 !
buttercup-php/protects !
mathiasverraes