Upgrade to Pro — share decks privately, control downloads, hide ads and more …

What happened?

What happened?

An introduction to CQRS and event sourcing in PHP

Marijn Huizendveld

August 21, 2014
Tweet

More Decks by Marijn Huizendveld

Other Decks in Programming

Transcript

  1. <?php ! final class Product { private $id; private $name;

    ! public function __construct($aName) { $this->name = (string) $aName; } ! public function getId() { return $this->id; } ! public function getName() { return $this->id; } }
  2. <?php ! class Customer { private $id; private $name; private

    $address; private $gender; ! public function getName() { return $this->name; } public function getAddress() { return $this->address; } public function getGender() { return $this->gender; } ! public function setName($name) { $this->name = $name; } public function setAddress($address) { $this->address = $address; } public function setGender($gender) { $this->gender = $gender; } }
  3. <?php ! /** * Publications are the contextual point for

    all other objects * * @author Marijn Huizendveld <[email protected]> * @version $Revision: 163 $ changed by $Author: buroknapzak $ * * @copyright De Baas Media (2010) */ class kluPublicationPeer extends BasekluPublicationPeer { /** * Select the publications that have recently been updated. * * @param string $arg_connection * * @return array */ static public function retrieveRecentlyUpdatedForDomain ($arg_domainUnifiedResourceName = NULL, $arg_connection = NULL) { $sql = "SELECT * FROM (SELECT `publications`.`id` AS ID " . ", `publications`.`name` AS NAME " . ", `publications`.`unified_resource_name` AS UNIFIED_RESOURCE_NAME " . ", `publications`.`domain_unified_resource_name` AS DOMAIN_UNIFIED_RESOURCE_NAME " . ", `publications`.`introduction` AS INTRODUCTION " . ", `publications`.`created_at` AS CREATED_AT " . ", MAX(`revision_requests`.`applied_at`) AS UPDATED_AT " . ", `publications`.`deleted_at` AS DELETED_AT " . "FROM `revision_requests` " . "LEFT JOIN `updates` " . "ON (`updates`.`id` = `revision_requests`.`update_id`) " . "LEFT JOIN `update_collections` " . "ON `updates`.`collection_unified_resource_name` = `update_collections`.`unified_resource_name` " . "LEFT JOIN `publications` " . "ON (`publications`.`id` = `revision_requests`.`publication_id`) " . "WHERE `updates`.`revision_request_id` IS NULL " . "AND `publications`.`deleted_at` IS NULL " . "AND `publications`.`domain_unified_resource_name` = ? " . "AND `update_collections`.`publish_at` <= NOW() " . "AND `update_collections`.`publish_until` > NOW() " . "GROUP BY `revision_requests`.`publication_id` " . "ORDER BY `updated_at` DESC) " . "`publications` ORDER BY `updated_at` DESC"; $connection = NULL === $arg_connection ? Propel::getConnection(self::DATABASE_NAME) : $arg_connection; $statement = $connection->prepareStatement($sql); return self::populateObjects($statement->executeQuery(array(0 => $arg_domainUnifiedResourceName), ResultSet::FETCHMODE_NUM)); } }
  4. No!

  5. <?php ! class Customer { private $id; private $name; private

    $address; private $gender; ! public function getName() { return $this->name; } public function getAddress() { return $this->address; } public function getGender() { return $this->gender; } ! public function setName($name) { $this->name = $name; } public function setAddress($address) { $this->address = $address; } public function setGender($gender) { $this->gender = $gender; } }
  6. <?php ! /** * Publications are the contextual point for

    all other objects * * @author Marijn Huizendveld <[email protected]> * @version $Revision: 163 $ changed by $Author: buroknapzak $ * * @copyright De Baas Media (2010) */ class kluPublicationPeer extends BasekluPublicationPeer { /** * Select the publications that have recently been updated. * * @param string $arg_connection * * @return array */ static public function retrieveRecentlyUpdatedForDomain ($arg_domainUnifiedResourceName = NULL, $arg_connection = NULL) { $sql = "SELECT * FROM (SELECT `publications`.`id` AS ID " . ", `publications`.`name` AS NAME " . ", `publications`.`unified_resource_name` AS UNIFIED_RESOURCE_NAME " . ", `publications`.`domain_unified_resource_name` AS DOMAIN_UNIFIED_RESOURCE_NAME " . ", `publications`.`introduction` AS INTRODUCTION " . ", `publications`.`created_at` AS CREATED_AT " . ", MAX(`revision_requests`.`applied_at`) AS UPDATED_AT " . ", `publications`.`deleted_at` AS DELETED_AT " . "FROM `revision_requests` " . "LEFT JOIN `updates` " . "ON (`updates`.`id` = `revision_requests`.`update_id`) " . "LEFT JOIN `update_collections` " . "ON `updates`.`collection_unified_resource_name` = `update_collections`.`unified_resource_name` " . "LEFT JOIN `publications` " . "ON (`publications`.`id` = `revision_requests`.`publication_id`) " . "WHERE `updates`.`revision_request_id` IS NULL " . "AND `publications`.`deleted_at` IS NULL " . "AND `publications`.`domain_unified_resource_name` = ? " . "AND `update_collections`.`publish_at` <= NOW() " . "AND `update_collections`.`publish_until` > NOW() " . "GROUP BY `revision_requests`.`publication_id` " . "ORDER BY `updated_at` DESC) " . "`publications` ORDER BY `updated_at` DESC"; $connection = NULL === $arg_connection ? Propel::getConnection(self::DATABASE_NAME) : $arg_connection; $statement = $connection->prepareStatement($sql); return self::populateObjects($statement->executeQuery(array(0 => $arg_domainUnifiedResourceName), ResultSet::FETCHMODE_NUM)); } }
  7. <?php ! class CustomerReadModel { private $id; private $name; private

    $address; private $gender; ! public function getName() { return $this->name; } public function getAddress() { return $this->address; } public function getGender() { return $this->gender; } }
  8. <?php ! class Customer { private $id; private $name; private

    $address; private $gender; ! public function setName($name) { $this->name = $name; } public function setAddress($address) { $this->address = $address; } public function setGender($gender) { $this->gender = $gender; } }
  9. <?php ! class Customer { private $id; private $name; private

    $address; private $gender; ! private function __construct($customerId, $name, $gender, Address $address) { $this->id = $customerId; $this->name = $name; $this->gender = $gender; $this->address = $address; } static public function signUp($customerId, $name, $gender, Address $address) { return new Customer($customerId, $name, $gender, $address); } ! public function moveToNewLivingAddress(Address $newAddress) { $this->address = $newAddress; } }
  10. <?php ! final class CustomerCommandHandler implements HandlesCommands { ! private

    $customers; ! public function __construct (CustomerRepository $aRepository) { $this->customers = $aRepository; } ! public function couldYou (Command $aCommand) { if ($aCommand instanceof PleaseSignCustomerUp) { $this->signUp($aCommand); } elseif ($aCommand instanceof PleaseMoveCustomerToNewLivingAddress) { $this->moveCustomerToNewLiving($aCommand); } else { // Intentionally empty: this will only happen during development. } } ! private function signUp (PleaseSignCustomerUp $aCommand) { $aCustomerId = $aCommand->customerId(); ! try { $aCustomer = $this->lockerServices->find($anEvent); // TODO: it exists, talk to the business how to deal with this } catch (SorryCustomerCouldNotBeFound $anException) { $aCustomer = Customer::signUp( $aCustomerId, $aCommand->name(), $aCommand->gender(), $aCommand->address() ); } ! $this->customers->add($aCustomer); } ! private function moveCustomerToNewLiving (PleaseMoveCustomerToNewLivingAddress $aCommand) { $aCustomerId = $aCommand->customerId(); $aCustomer = $this->customers->find($anEvent); ! $aCustomer->moveToNewLivingAddress($aCommand->address()); ! $this->lockerServices->add($aLockerService); } }
  11. <?php ! /** * Publications are the contextual point for

    all other objects * * @author Marijn Huizendveld <[email protected]> * @version $Revision: 163 $ changed by $Author: buroknapzak $ * * @copyright De Baas Media (2010) */ class kluPublicationPeer extends BasekluPublicationPeer { /** * Select the publications that have recently been updated. * * @param string $arg_connection * * @return array */ static public function retrieveRecentlyUpdatedForDomain ($arg_domainUnifiedResourceName = NULL, $arg_connection = NULL) { $sql = "SELECT * FROM (SELECT `publications`.`id` AS ID " . ", `publications`.`name` AS NAME " . ", `publications`.`unified_resource_name` AS UNIFIED_RESOURCE_NAME " . ", `publications`.`domain_unified_resource_name` AS DOMAIN_UNIFIED_RESOURCE_NAME " . ", `publications`.`introduction` AS INTRODUCTION " . ", `publications`.`created_at` AS CREATED_AT " . ", MAX(`revision_requests`.`applied_at`) AS UPDATED_AT " . ", `publications`.`deleted_at` AS DELETED_AT " . "FROM `revision_requests` " . "LEFT JOIN `updates` " . "ON (`updates`.`id` = `revision_requests`.`update_id`) " . "LEFT JOIN `update_collections` " . "ON `updates`.`collection_unified_resource_name` = `update_collections`.`unified_resource_name` " . "LEFT JOIN `publications` " . "ON (`publications`.`id` = `revision_requests`.`publication_id`) " . "WHERE `updates`.`revision_request_id` IS NULL " . "AND `publications`.`deleted_at` IS NULL " . "AND `publications`.`domain_unified_resource_name` = ? " . "AND `update_collections`.`publish_at` <= NOW() " . "AND `update_collections`.`publish_until` > NOW() " . "GROUP BY `revision_requests`.`publication_id` " . "ORDER BY `updated_at` DESC) " . "`publications` ORDER BY `updated_at` DESC"; $connection = NULL === $arg_connection ? Propel::getConnection(self::DATABASE_NAME) : $arg_connection; $statement = $connection->prepareStatement($sql); return self::populateObjects($statement->executeQuery(array(0 => $arg_domainUnifiedResourceName), ResultSet::FETCHMODE_NUM)); } }
  12. <?php ! /** * Publications are the contextual point for

    all other objects * * @author Marijn Huizendveld <[email protected]> * @version $Revision: 163 $ changed by $Author: buroknapzak $ * * @copyright De Baas Media (2010) */ class kluPublicationPeer extends BasekluPublicationPeer { /** * Select the publications that have recently been updated. * * @param string $arg_connection * * @return array */ static public function retrieveRecentlyUpdatedForDomain ($arg_domainUnifiedResourceName = NULL, $arg_connection = NULL) { $sql = "SELECT * FROM recently_updated WHERE domain = ? ORDER BY `updated_at` DESC"; $connection = NULL === $arg_connection ? Propel::getConnection(self::DATABASE_NAME) : $arg_connection; $statement = $connection->prepareStatement($sql); return self::populateObjects($statement->executeQuery(array(0 => $arg_domainUnifiedResourceName), ResultSet::FETCHMODE_NUM)); } }
  13. – Greg Young “(…) storing current state as a series

    of events and rebuilding state within the system by replaying that series of events.”
  14. <?php ! final class BasketWasPickedUp implements DomainEvent { private $basketId;

    ! public function __construct($basketId) { $this->basketId = (string) $basketId; } ! public function getAggregateId() { return $this->basketId; } }
  15. <?php ! final class Basket implements RecordsEvents { public static

    function pickUp(BasketId $basketId) { $basket = new BasketV2(); ! $basket->recordThat( new BasketWasPickedUp($basketId) ); ! return $basket; } protected function ackwnoledgesBasketWasPickedUp(BasketWasPickedUp $anEvent) { $this->basketId = $anEvent->basketId(); } ! private $basketId; private $latestRecordedEvents = []; private function __construct(BasketId $basketId) { // private constructor } ! private function recordThat(DomainEvent $domainEvent) { $this->latestRecordedEvents[] = $domainEvent; $this->ackwnoledge($domainEvent); } ! private function ackwnoledge(DomainEvent $event) { $method = 'ackwnoledge' . $event->name(); ! $this->$method($event); } ! public function getRecordedEvents() { return new DomainEvents($this->latestRecordedEvents); } ! public function clearRecordedEvents() { $this->latestRecordedEvents = []; } }
  16. <?php ! final class DebriefingOutboxEntryProjector { ! private $entries; !

    public function __construct (DebriefingOutboxEntryRepository $aRepository) { $this->entries = $aRepository; } ! /** * @param \Hank\Event $anEvent * * @throws \Entropt\LockerControl\DebriefingOutboxEntry\SorryDebriefingOutboxEntryCouldNotBePersisted */ public function project (DomainEvent $anEvent) { if ($anEvent instanceof DebriefCouldNotBeSent) { $this->debriefCouldNotBeSent($anEvent); } elseif ($anEvent instanceof DebriefWasSent) { $this->debriefWasSent($anEvent); } else { // intentionally empty: we don't want to break any long running processes } } ! private function debriefCouldNotBeSent (DebriefCouldNotBeSent $anEvent) { $this->entries->add(new DebriefingOutboxEntry(new Event($anEvent->event(), $anEvent->day()))); } ! private function debriefWasSent (DebriefWasSent $anEvent) { $aReference = new Event($anEvent->event(), $anEvent->day()); ! try { $anOnGoingEvent = $this->entries->find($aReference); ! $this->entries->remove($anOnGoingEvent); } catch (SorryDebriefingOutboxEntryCouldNotBeFound $anException) { // intentionally empty: we wanted to remove it, if it's not there than we just move on. } } }
  17. // Something happens ! $scenario->given($aPreCondition) // events ->andGiven($anotherPreCondition) ->when($aCommand) //

    commands ->then($anOutcome) // events ->andThen($anotherOutcome) ->butNothingElseShouldHaveHappened();
  18. <?php ! final class PleaseMakeLockersAvailableForRentTest extends CommandHandlerTestCase { ! /**

    * @test * @dataProvider ProvideNameOfEventDayOfEventNameOfLockerHostAndListOfAvailableLockers::that_are_not_nil */ public function makes_lockers_available_for_rent ( NameOfEvent $aName, DayOfEvent $aDay, NameOfLockerHost $aHost, UniqueLockerNumbers $aListOfAvailableLockers ) { $this->testScenarioThat ->when(PleaseMakeLockersAvailableForRent::v1($aName, $aDay, TimeOfCommand::rightNow(), $aHost, $aListOfAvailableLockers)) ! ->then(LockersWereMadeAvailableForRent::v1($aName, $aDay, TimeOfEvent::rightNow(), $aHost, $aListOfAvailableLockers)) ->butNothingElseShouldHaveHappened(); } ! /** * @test * @dataProvider ProvideNameOfEventDayOfEventNameOfLockerHostAndListOfAvailableLockers::that_are_not_nil */ public function ignores_command_when_an_event_with_identical_name_already_exists ( NameOfEvent $aName, DayOfEvent $aDay, NameOfLockerHost $aHost, UniqueLockerNumbers $aListOfAvailableLockers ) { $this->testScenarioThat ->given(LockersWereMadeAvailableForRent::v1($aName, $aDay, TimeOfEvent::fromString('Thursday March 6th 2014, 12:44:20 UTC'))) ! ->when(PleaseMakeLockersAvailableForRent::v1($aName, $aDay, TimeOfCommand::rightNow(), $aHost, $aListOfAvailableLockers)) ! ->thenNothingShouldHaveHappened(); } }