Date post: | 06-May-2015 |
Category: |
Technology |
Upload: | mathiasverraes |
View: | 5,275 times |
Download: | 1 times |
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