Practical Event Sourcing

Preview:

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.net

Citation preview

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