├── Classes └── Etg24 │ └── EventSourcing │ ├── Message.php │ ├── Command │ ├── Bus │ │ ├── AsyncCommandBusInterface.php │ │ ├── Exception │ │ │ └── UnableToHandleCommandException.php │ │ ├── CommandBusInterface.php │ │ └── InternalCommandBus.php │ ├── CommandInterface.php │ ├── Handler │ │ ├── Exception │ │ │ └── UnableToHandleCommandException.php │ │ ├── CommandHandlerInterface.php │ │ └── CommandHandler.php │ ├── Command.php │ └── Controller │ │ ├── DomainCommandArgumentDefinition.php │ │ ├── DomainCommandController.php │ │ ├── Aspect │ │ └── RegisteringDomainCommandsAspect.php │ │ └── DomainCommand.php │ ├── Exception │ ├── EmptyStreamException.php │ └── EventNotAppliedException.php │ ├── Store │ ├── Backend │ │ ├── Exception │ │ │ ├── OptimisticLockException.php │ │ │ ├── EventStreamNotFoundException.php │ │ │ └── IncompatibleModelException.php │ │ ├── StoreBackendInterface.php │ │ ├── Translator │ │ │ └── WritableEventTranslator.php │ │ └── EventStoreBackend.php │ └── Repository.php │ ├── Event │ ├── Handler │ │ ├── Exception │ │ │ └── UnableToHandleEventException.php │ │ ├── ImmediateEventHandlerInterface.php │ │ ├── EventHandlerInterface.php │ │ └── AbstractEventHandler.php │ ├── Bus │ │ ├── BusInterface.php │ │ └── InternalEventBus.php │ └── DomainEvent.php │ ├── Queue │ ├── QueueInterface.php │ ├── Message.php │ ├── BeanstalkQueue.php │ └── ImmediateQueue.php │ ├── Uuid.php │ ├── Projection │ ├── Hydration │ │ ├── HydratorInterface.php │ │ ├── ArrayHydrator.php │ │ └── DtoHydrator.php │ ├── Annotations │ │ ├── Column.php │ │ └── Table.php │ ├── ProjectorInterface.php │ ├── AbstractProjector.php │ ├── AbstractModelProjector.php │ ├── AbstractMysqlProjector.php │ ├── ProjectionBuilder.php │ └── Command │ │ └── ProjectionCommandController.php │ ├── Serializer │ ├── MessageSerializerInterface.php │ ├── JsonSerializer.php │ └── ArraySerializer.php │ ├── AggregateRootInterface.php │ ├── EntityInterface.php │ ├── EntitySourcing.php │ ├── AggregateSourcing.php │ └── EventSourcing.php ├── Configuration ├── Production │ └── Objects.yaml ├── Testing │ └── Objects.yaml ├── Development │ └── Objects.yaml ├── Objects.yaml └── Settings.yaml ├── composer.json ├── LICENSE ├── Tests └── Unit │ └── AggregateTest.php └── README.md /Classes/Etg24/EventSourcing/Message.php: -------------------------------------------------------------------------------- 1 | commandId = Uuid::next(); 15 | } 16 | 17 | /** 18 | * @return string 19 | */ 20 | public function __toString() { 21 | return get_class($this) . ':' . $this->commandId; 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Command/Handler/CommandHandlerInterface.php: -------------------------------------------------------------------------------- 1 | definition = isset($values['definition']) ? $values['definition'] : $values['value']; 21 | } 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/AggregateRootInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function getUncommittedChanges(); 21 | 22 | /** 23 | * Empties the uncommitted changes 24 | * 25 | * @return void 26 | */ 27 | public function markChangesAsCommitted(); 28 | 29 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "etg24/eventsourcing", 3 | "type": "typo3-flow-package", 4 | "description": "A TYPO3 Flow package for using EventSourcing", 5 | "version": "0.9.0", 6 | "license": ["MIT"], 7 | "authors": [ 8 | { 9 | "name": "Akii", 10 | "email": "zedd@akii.de", 11 | "role": "Developer" 12 | } 13 | ], 14 | "require": { 15 | "typo3/flow": "dev-master", 16 | "dbellettini/eventstore-client": "~0.4", 17 | "pda/pheanstalk": "3.0.*" 18 | }, 19 | "autoload": { 20 | "psr-0": { 21 | "Etg24\\EventSourcing": "Classes" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | Etg24: 2 | EventSourcing: 3 | Store: 4 | Backend: 5 | EventStoreBackend: 6 | url: 'http://127.0.0.1:2113' 7 | 8 | Queue: 9 | BeanstalkQueue: 10 | host: '127.0.0.1' 11 | port: '%Pheanstalk\PheanstalkInterface::DEFAULT_PORT%' 12 | tube: 'etg24_eventsourcing_eventbus_queue' 13 | timeout: NULL 14 | 15 | Command: 16 | Controller: 17 | # When enabled, all commands will be available through the CLI 18 | enabled: true 19 | 20 | # when set to true, commands will be available through CLI 21 | # but not shown on ./flow help 22 | markAsInternal: false -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Event/DomainEvent.php: -------------------------------------------------------------------------------- 1 | occurredOn = $occurredOn->format(self::DATE_FORMAT); 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Projection/Annotations/Table.php: -------------------------------------------------------------------------------- 1 | name = isset($values['name']) ? $values['name'] : $values['value']; 26 | } 27 | 28 | if (isset($values['indexes'])) { 29 | $this->indexes = $values['indexes']; 30 | } 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Queue/Message.php: -------------------------------------------------------------------------------- 1 | recipient = $recipient; 22 | $this->payload = $payload; 23 | } 24 | 25 | /** 26 | * @return string 27 | */ 28 | public function getRecipient() { 29 | return $this->recipient; 30 | } 31 | 32 | /** 33 | * @return array 34 | */ 35 | public function getPayload() { 36 | return $this->payload; 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Store/Backend/StoreBackendInterface.php: -------------------------------------------------------------------------------- 1 | $message->getRecipient(), 31 | 'payload' => $message->getPayload() 32 | ]); 33 | 34 | $this->pheanstalk 35 | ->useTube($this->tube) 36 | ->put($data); 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Projection/ProjectorInterface.php: -------------------------------------------------------------------------------- 1 | dtoClassName = $dtoClassName; 18 | } 19 | 20 | /** 21 | * @param array $row 22 | * @return object 23 | */ 24 | public function hydrate(array $row) { 25 | $dto = $this->getNewDtoInstance(); 26 | 27 | foreach ($row as $column => $value) { 28 | if (ObjectAccess::isPropertySettable($dto, $column)) { 29 | ObjectAccess::setProperty($dto, $column, $value); 30 | } 31 | } 32 | 33 | return $dto; 34 | } 35 | 36 | /** 37 | * @return object 38 | */ 39 | protected function getNewDtoInstance() { 40 | return new $this->dtoClassName(); 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Serializer/JsonSerializer.php: -------------------------------------------------------------------------------- 1 | arraySerializer->serialize($message) 25 | ); 26 | } 27 | 28 | /** 29 | * @param string $serializedMessage 30 | * @return Message 31 | */ 32 | public function unserialize($serializedMessage) { 33 | if (is_string($serializedMessage) === FALSE) { 34 | throw new \InvalidArgumentException('The JsonSerializer can only unserialize strings.', 1427369767); 35 | } 36 | 37 | return $this->arraySerializer->unserialize( 38 | json_decode($serializedMessage, TRUE) 39 | ); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Queue/ImmediateQueue.php: -------------------------------------------------------------------------------- 1 | objectManager->get($message->getRecipient()); 36 | $event = $this->arraySerializer->unserialize($message->getPayload()); 37 | 38 | $handler->handle($event); 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 etg24 GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/EntityInterface.php: -------------------------------------------------------------------------------- 1 | 39 | */ 40 | public function getUncommittedChanges(); 41 | 42 | /** 43 | * Empties the uncommitted changes 44 | * 45 | * @return void 46 | */ 47 | public function markChangesAsCommitted(); 48 | 49 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Store/Backend/Translator/WritableEventTranslator.php: -------------------------------------------------------------------------------- 1 | arraySerializer->serialize($event); 28 | 29 | $eventType = $serializedEvent['messageType']; 30 | $data = $serializedEvent['payload']; 31 | 32 | unset($data['aggregateId']); 33 | unset($data['version']); 34 | 35 | return WritableEvent::newInstance($eventType, $data); 36 | } 37 | 38 | /** 39 | * @param Event $event 40 | * @param string $eventType 41 | * @return DomainEvent 42 | * @throws IncompatibleModelException 43 | */ 44 | public function fromEvent(Event $event, $eventType) { 45 | $data = $event->getData(); 46 | 47 | return $this->arraySerializer->unserialize([ 48 | 'messageType' => $eventType, 49 | 'payload' => $data 50 | ]); 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Command/Controller/DomainCommandArgumentDefinition.php: -------------------------------------------------------------------------------- 1 | name = $name; 29 | $this->required = $required; 30 | $this->description = $description; 31 | $this->argumentType = $argumentType; 32 | } 33 | 34 | /** 35 | * @return string 36 | */ 37 | public function getArgumentType() { 38 | return $this->argumentType; 39 | } 40 | 41 | public function getDescription() { 42 | return '[' . $this->argumentType . ']' . "\t" . parent::getDescription(); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Projection/AbstractProjector.php: -------------------------------------------------------------------------------- 1 | initializeHydrator(); 29 | } 30 | 31 | protected function initializeHydrator() { 32 | if ($this->projectionClassName !== NULL) { 33 | $this->hydrator = new DtoHydrator($this->projectionClassName); 34 | } else { 35 | $this->hydrator = new ArrayHydrator(); 36 | } 37 | } 38 | 39 | public function getProjectionClassName() { 40 | return $this->projectionClassName; 41 | } 42 | 43 | /** 44 | * @param array $result 45 | * @return array 46 | */ 47 | protected function hydrateResult(array $result) { 48 | $hydrator = $this->hydrator; 49 | 50 | return array_map(function($row) use ($hydrator) { 51 | return $hydrator->hydrate($row); 52 | }, $result); 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /Tests/Unit/AggregateTest.php: -------------------------------------------------------------------------------- 1 | subjectType; 51 | $events = $this->given(); 52 | 53 | // only load the subject when events are given 54 | if (count($events) > 0) { 55 | $this->subject = $subjectType::loadFromEventStream($events); 56 | } 57 | 58 | $this->onHandler()->handle($this->when()); 59 | $this->producedEvents = $this->subject->getUncommittedChanges(); 60 | } catch (\Exception $e) { 61 | $this->caughtException = $e; 62 | } 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Event/Handler/AbstractEventHandler.php: -------------------------------------------------------------------------------- 1 | subscribedToEvents); 20 | } 21 | 22 | /** 23 | * @param DomainEvent $event 24 | * @throws UnableToHandleEventException 25 | */ 26 | public function handle(DomainEvent $event) { 27 | $handleMethod = $this->getHandleMethodForEvent($event); 28 | 29 | if (method_exists($this, $handleMethod) === FALSE) { 30 | throw new UnableToHandleEventException('The event "' . get_class($event) . '" could not be handled by the event handler "' . get_class($this) . '".', 1427365186); 31 | } 32 | 33 | $this->$handleMethod($event); 34 | } 35 | 36 | /** 37 | * @param DomainEvent $event 38 | * @return string 39 | */ 40 | protected function getHandleMethodForEvent(DomainEvent $event) { 41 | $eventName = $this->getEventName($event); 42 | return 'handle' . $eventName . 'Event'; 43 | } 44 | 45 | /** 46 | * @param DomainEvent $event 47 | * @return string 48 | */ 49 | protected function getEventName(DomainEvent $event) { 50 | $classNameParts = explode('\\', get_class($event)); 51 | return array_pop($classNameParts); 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/EntitySourcing.php: -------------------------------------------------------------------------------- 1 | aggregateRoot = $aggregateRoot; 26 | } 27 | 28 | /** 29 | * @param \Closure $getNextVersion 30 | * @return void 31 | */ 32 | public function setVersionGenerator(\Closure $getNextVersion) { 33 | $this->versionGenerator = $getNextVersion; 34 | } 35 | 36 | /** 37 | * @param DomainEvent $event 38 | */ 39 | protected function applyNewEvent(DomainEvent $event) { 40 | $event->version = $this->getNextVersion(); 41 | 42 | $this->apply($event); 43 | $this->uncommittedEvents[] = $event; 44 | } 45 | 46 | /** 47 | * @param EntityInterface $entity 48 | */ 49 | protected function registerEntity(EntityInterface $entity) { 50 | $entityIdentifier = $entity->getIdentifier(); 51 | 52 | if (array_key_exists($entityIdentifier, $this->entities) === TRUE) { 53 | throw new \InvalidArgumentException('The entity with identifier "' . $entityIdentifier . '" is already registered.', 1426251092); 54 | } 55 | 56 | $entity->setAggregateRoot($this->aggregateRoot); 57 | $entity->setVersionGenerator($this->versionGenerator); 58 | 59 | $this->entities[$entityIdentifier] = $entity; 60 | } 61 | 62 | /** 63 | * @return integer 64 | */ 65 | protected function getNextVersion() { 66 | $versionGenerator = $this->versionGenerator; 67 | return $versionGenerator(); 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Serializer/ArraySerializer.php: -------------------------------------------------------------------------------- 1 | $type, 23 | 'payload' => $data 24 | ]; 25 | } 26 | 27 | /** 28 | * @param array $serializedMessage 29 | * @return Message 30 | */ 31 | public function unserialize($serializedMessage) { 32 | if (is_array($serializedMessage) === FALSE) { 33 | throw new \InvalidArgumentException('The ArraySerializer can only unserialize arrays.', 1427369045); 34 | } 35 | 36 | if (array_key_exists('messageType', $serializedMessage) === FALSE || array_key_exists('payload', $serializedMessage) === FALSE || is_array($serializedMessage['payload']) === FALSE) { 37 | throw new \InvalidArgumentException('The serialized message is corrupted.', 1427369459); 38 | } 39 | 40 | $messageType = str_replace('.', '\\', $serializedMessage['messageType']); 41 | 42 | if (class_exists($messageType) === FALSE) { 43 | throw new \InvalidArgumentException('Unserialization for message of type "' . $messageType . '" failed. No such class.', 1427369534); 44 | } 45 | 46 | $message = new $messageType(); 47 | foreach ($serializedMessage['payload'] as $propertyName => $propertyValue) { 48 | if (ObjectAccess::isPropertySettable($message, $propertyName)) { 49 | ObjectAccess::setProperty($message, $propertyName, $propertyValue); 50 | } 51 | } 52 | 53 | return $message; 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/AggregateSourcing.php: -------------------------------------------------------------------------------- 1 | apply($event); 33 | } 34 | 35 | $aggregate->version = $event->version; 36 | 37 | return $aggregate; 38 | } 39 | 40 | /** 41 | * @param DomainEvent $event 42 | */ 43 | protected function applyNewEvent(DomainEvent $event) { 44 | $event->version = $this->getNextVersion(); 45 | 46 | $this->apply($event); 47 | $this->uncommittedEvents[] = $event; 48 | } 49 | 50 | /** 51 | * @param EntityInterface $entity 52 | */ 53 | protected function registerEntity(EntityInterface $entity) { 54 | $aggregate = $this; 55 | $entityIdentifier = $entity->getIdentifier(); 56 | 57 | if (array_key_exists($entityIdentifier, $this->entities) === TRUE) { 58 | throw new \InvalidArgumentException('The entity with identifier "' . $entityIdentifier . '" is already registered.', 1426251092); 59 | } 60 | 61 | $entity->setAggregateRoot($this); 62 | $entity->setVersionGenerator(function() use ($aggregate) { 63 | return $aggregate->getNextVersion(); 64 | }); 65 | 66 | $this->entities[$entityIdentifier] = $entity; 67 | } 68 | 69 | /** 70 | * @return integer 71 | */ 72 | protected function getNextVersion() { 73 | return ++$this->version; 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Command/Bus/InternalCommandBus.php: -------------------------------------------------------------------------------- 1 | objectManager); 29 | 30 | foreach ($eventHandlerClassNames as $eventHandlerClassName) { 31 | $this->commandHandlers[$eventHandlerClassName] = $this->objectManager->get($eventHandlerClassName); 32 | } 33 | } 34 | 35 | /** 36 | * @todo Use CommandInterface 37 | * @param Command $command 38 | * @throws UnableToHandleCommandException 39 | */ 40 | public function handle(Command $command) { 41 | $commandHandled = FALSE; 42 | 43 | foreach ($this->commandHandlers as $commandHandler) { 44 | if ($commandHandler->canHandleCommand($command)) { 45 | $commandHandler->handle($command); 46 | $commandHandled = TRUE; 47 | break; 48 | } 49 | } 50 | 51 | if ($commandHandled === FALSE) { 52 | throw new UnableToHandleCommandException('The command "' . get_class($command) . '" could not be handled by the command bus.', 1428327683); 53 | } 54 | } 55 | 56 | /** 57 | * @param ObjectManagerInterface $objectManager 58 | * @return array Class names of all command handler class names 59 | * @Flow\CompileStatic 60 | */ 61 | static public function getCommandHandlerImplementationClassNames(ObjectManagerInterface $objectManager) { 62 | $reflectionService = $objectManager->get(ReflectionService::class); 63 | return $reflectionService->getAllImplementationClassNamesForInterface(CommandHandlerInterface::class); 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Projection/AbstractModelProjector.php: -------------------------------------------------------------------------------- 1 | persistenceManager->persistAll(); 27 | } 28 | 29 | /** 30 | * Clears and rebuilds the projection persistence structure 31 | * 32 | * @return void 33 | */ 34 | public function build() { 35 | foreach ($this->findAll() AS $object) { 36 | $this->deleteById( 37 | $this->persistenceManager->getIdentifierByObject($object) 38 | ); 39 | } 40 | } 41 | 42 | /** 43 | * @param string $identifier 44 | * @return NULL|object 45 | */ 46 | public function findById($identifier) { 47 | return $this->persistenceManager->getObjectByIdentifier($identifier, $this->projectionClassName); 48 | } 49 | 50 | /** 51 | * @return object[] 52 | */ 53 | public function findAll() { 54 | return $this->createQuery() 55 | ->execute() 56 | ->toArray(); 57 | } 58 | 59 | /** 60 | * @return integer 61 | */ 62 | public function countAll() { 63 | return $this->createQuery() 64 | ->count(); 65 | } 66 | 67 | /** 68 | * @param string $identifier 69 | */ 70 | public function deleteById($identifier) { 71 | $object = $this->findById($identifier); 72 | 73 | // todo: check if we want to throw an exception here 74 | if ($object !== NULL) { 75 | $this->persistenceManager->remove($object); 76 | $this->persistenceManager->persistAll(); 77 | } 78 | } 79 | 80 | /** 81 | * @return QueryInterface 82 | */ 83 | protected function createQuery() { 84 | return $this->persistenceManager->createQueryForType($this->projectionClassName); 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Command/Controller/DomainCommandController.php: -------------------------------------------------------------------------------- 1 | arguments->removeAll(); 32 | 33 | /** @var DomainCommandArgumentDefinition[] $commandArguments */ 34 | $commandArguments = $this->request->getCommand()->getArgumentDefinitions(); 35 | 36 | foreach ($commandArguments as $commandArgument) { 37 | $this->arguments->addNewArgument( 38 | $commandArgument->getName(), 39 | $commandArgument->getArgumentType(), 40 | $commandArgument->isRequired() 41 | ); 42 | } 43 | } 44 | 45 | protected function callCommandMethod() { 46 | $preparedArguments = array(); 47 | /** @var Argument $argument */ 48 | foreach ($this->arguments as $argument) { 49 | $preparedArguments[] = $argument->getValue(); 50 | } 51 | 52 | $commandResult = call_user_func_array(array($this, $this->commandMethodName), $preparedArguments); 53 | 54 | if (is_string($commandResult) && strlen($commandResult) > 0) { 55 | $this->response->appendContent($commandResult); 56 | } elseif (is_object($commandResult) && method_exists($commandResult, '__toString')) { 57 | $this->response->appendContent((string)$commandResult); 58 | } 59 | } 60 | 61 | public function execute() { 62 | $class = new ReflectionClass($this->request->getCommand()->getControllerCommandName()); 63 | 64 | // convert "NULL" to NULL 65 | $arguments = array_map(function($argument) { 66 | if ($argument === 'NULL') { 67 | return NULL; 68 | } 69 | 70 | return $argument; 71 | }, func_get_args()); 72 | 73 | $command = $class->newInstanceArgs($arguments); 74 | $this->commandBus->handle($command); 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Command/Handler/CommandHandler.php: -------------------------------------------------------------------------------- 1 | commandNameSpacePrefix === NULL) { 22 | $commandHandlerClassName = get_class($this); 23 | $this->commandNameSpacePrefix = substr($commandHandlerClassName, 0, strpos($commandHandlerClassName, 'CommandHandler')) . 'Command'; 24 | } 25 | 26 | return $this->commandNameSpacePrefix; 27 | } 28 | 29 | /** 30 | * @param Command $command 31 | * @return boolean 32 | */ 33 | public function canHandleCommand(Command $command) { 34 | if ($this->commandIsWithinHandlerNameSpace($command) === FALSE) { 35 | return FALSE; 36 | } 37 | 38 | $commandName = $this->getCommandName($command); 39 | $commandHandleMethod = $this->getCommandHandleMethod($commandName); 40 | 41 | return method_exists($this, $commandHandleMethod); 42 | } 43 | 44 | /** 45 | * @param Command $command 46 | * @return boolean 47 | */ 48 | protected function commandIsWithinHandlerNameSpace(Command $command) { 49 | $commandClassName = get_class($command); 50 | return (strpos($commandClassName, $this->getCommandNameSpacePrefix()) === 0); 51 | } 52 | 53 | /** 54 | * @param Command $command 55 | * @throws UnableToHandleCommandException 56 | */ 57 | public function handle(Command $command) { 58 | if ($this->canHandleCommand($command) === FALSE) { 59 | throw new UnableToHandleCommandException('The command "' . get_class($command) . '" cannot be handled by this handler.', 1428327525); 60 | } 61 | 62 | $commandName = $this->getCommandName($command); 63 | $commandHandleMethod = $this->getCommandHandleMethod($commandName); 64 | 65 | $this->$commandHandleMethod($command); 66 | } 67 | 68 | /** 69 | * @param Command $command 70 | * @return string 71 | */ 72 | protected function getCommandName(Command $command) { 73 | $nameParts = explode('\\', get_class($command)); 74 | return (string) array_pop($nameParts); 75 | } 76 | 77 | /** 78 | * @param string $commandName 79 | * @return string 80 | */ 81 | protected function getCommandHandleMethod($commandName) { 82 | return 'handle' . $commandName . 'Command'; 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Store/Repository.php: -------------------------------------------------------------------------------- 1 | aggregateClassName = preg_replace(array('/\\\Repository\\\/', '/Repository$/'), array('\\Model\\', ''), get_class($this)); 40 | $this->streamName = str_replace('\\', '.', $this->aggregateClassName); 41 | } 42 | 43 | /** 44 | * @param string $identifier 45 | * @return NULL|AggregateRootInterface 46 | */ 47 | public function find($identifier) { 48 | try { 49 | $stream = $this->getStreamForIdentifier($identifier); 50 | $eventStream = $this->backend->load($stream); 51 | 52 | $aggregateClass = $this->aggregateClassName; 53 | $aggregate = $aggregateClass::loadFromEventStream($eventStream); 54 | 55 | return $aggregate; 56 | } catch (EventStreamNotFoundException $e) { 57 | return NULL; 58 | } 59 | } 60 | 61 | /** 62 | * @param AggregateRootInterface $aggregate 63 | * @throws OptimisticLockException 64 | */ 65 | public function save(AggregateRootInterface $aggregate) { 66 | if ($aggregate instanceof $this->aggregateClassName === FALSE) { 67 | throw new \InvalidArgumentException('The given object ("' . get_class($aggregate) . '") is not an aggregate this repository manages ("' . $this->aggregateClassName . '").', 1427115916); 68 | } 69 | 70 | if (trim($aggregate->getIdentifier()) === '') { 71 | throw new \InvalidArgumentException('The identifier for the given aggregate "' . get_class($aggregate) . '" must not be empty.'); 72 | } 73 | 74 | $stream = $this->getStreamForIdentifier($aggregate->getIdentifier()); 75 | $changes = $aggregate->getUncommittedChanges(); 76 | $this->backend->append($stream, $changes); 77 | 78 | foreach ($changes as $change) { 79 | $this->eventBus->publish($change); 80 | } 81 | 82 | $aggregate->markChangesAsCommitted(); 83 | } 84 | 85 | /** 86 | * @param string $identifier 87 | * @return string 88 | */ 89 | protected function getStreamForIdentifier($identifier) { 90 | return $this->streamName . '-' . $identifier; 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Projection/AbstractMysqlProjector.php: -------------------------------------------------------------------------------- 1 | databaseConnection = $objectManager->getConnection(); 42 | } 43 | 44 | protected function initializeObject() { 45 | /** @var Table $table */ 46 | $table = $this->reflectionService->getClassAnnotation($this->projectionClassName, Table::class); 47 | 48 | if ($table !== NULL) { 49 | $this->tableName = $table->name; 50 | } 51 | } 52 | 53 | /** 54 | * Clears and rebuilds the projection persistence structure 55 | * 56 | * @return void 57 | */ 58 | public function build() { 59 | $this->projectionBuilder->build($this->projectionClassName); 60 | } 61 | 62 | /** 63 | * @param string $identifier 64 | * @return NULL|object 65 | */ 66 | public function findById($identifier) { 67 | $rows = $this->databaseConnection->fetchAll('SELECT * FROM ' . $this->tableName . ' WHERE id = :id LIMIT 1', ['id' => $identifier]); 68 | $firstResult = array_shift($rows); 69 | 70 | if ($firstResult === NULL) { 71 | return NULL; 72 | } 73 | 74 | return $this->hydrator->hydrate($firstResult); 75 | } 76 | 77 | /** 78 | * @return object[] 79 | */ 80 | public function findAll() { 81 | $rows = $this->databaseConnection->fetchAll('SELECT * FROM ' . $this->tableName); 82 | return $this->hydrateResult($rows); 83 | } 84 | 85 | /** 86 | * @return integer 87 | */ 88 | public function countAll() { 89 | $rows = $this->databaseConnection->fetchAll('SELECT COUNT(*) AS row_count FROM ' . $this->tableName); 90 | return (integer) $rows[0]['row_count']; 91 | } 92 | 93 | /** 94 | * @param string $identifier 95 | */ 96 | public function deleteById($identifier) { 97 | $statement = $this->databaseConnection->prepare( 98 | 'DELETE FROM ' . $this->tableName . ' WHERE id = :id' 99 | ); 100 | 101 | $statement->execute([ 102 | 'id' => $identifier 103 | ]); 104 | } 105 | 106 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Projection/ProjectionBuilder.php: -------------------------------------------------------------------------------- 1 | databaseConnection = $objectManager->getConnection(); 28 | } 29 | 30 | /** 31 | * @var ReflectionService 32 | * @Flow\Inject 33 | */ 34 | protected $reflectionService; 35 | 36 | /** 37 | * @param string $className 38 | */ 39 | public function build($className) { 40 | if ($this->reflectionService->isClassAnnotatedWith($className, Table::class) === FALSE) { 41 | throw new \InvalidArgumentException('The class "' . $className . '" is not annotated properly.', 1428331094); 42 | } 43 | 44 | /** @var Table $table */ 45 | $table = $this->reflectionService->getClassAnnotation($className, Table::class); 46 | $query = $this->createTableDefinitionQueryForClassName($className); 47 | 48 | $this->databaseConnection->executeQuery('DROP TABLE IF EXISTS ' . $table->name); 49 | $this->databaseConnection->executeQuery($query); 50 | } 51 | 52 | /** 53 | * @param string $className 54 | * @return string 55 | */ 56 | public function showQuery($className) { 57 | if ($this->reflectionService->isClassAnnotatedWith($className, Table::class) === FALSE) { 58 | throw new \InvalidArgumentException('The class "' . $className . '" is not annotated properly.', 1428332546); 59 | } 60 | 61 | return $this->createTableDefinitionQueryForClassName($className); 62 | } 63 | 64 | /** 65 | * @param string $className 66 | * @return string 67 | */ 68 | protected function createTableDefinitionQueryForClassName($className) { 69 | /** @var Table $table */ 70 | $table = $this->reflectionService->getClassAnnotation($className, Table::class); 71 | 72 | $columns = $this->reflectionService->getPropertyNamesByAnnotation($className, Column::class); 73 | 74 | $columnDefinitions = []; 75 | foreach ($columns as $columnName) { 76 | /** @var Column $column */ 77 | $column = $this->reflectionService->getPropertyAnnotation($className, $columnName, Column::class); 78 | $columnDefinitions[] = '`' . $columnName . '` ' . $column->definition; 79 | } 80 | 81 | if ($table->indexes !== NULL) { 82 | $columnDefinitions[] = $table->indexes; 83 | } 84 | 85 | return 'CREATE TABLE ' . $table->name . ' (' . implode(', ', $columnDefinitions) . ') ENGINE = InnoDB'; 86 | } 87 | 88 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Event/Bus/InternalEventBus.php: -------------------------------------------------------------------------------- 1 | objectManager); 44 | 45 | foreach ($eventHandlerClassNames as $eventHandlerClassName) { 46 | $this->eventSubscribers[$eventHandlerClassName] = $this->objectManager->get($eventHandlerClassName); 47 | } 48 | } 49 | 50 | /** 51 | * @param DomainEvent $event 52 | * @return void 53 | */ 54 | public function publish(DomainEvent $event) { 55 | foreach ($this->eventSubscribers as $eventHandler) { 56 | if ($eventHandler->canHandleEvent($event)) { 57 | if ($eventHandler instanceof ImmediateEventHandlerInterface) { 58 | $this->handleEvent($event, $eventHandler); 59 | } else { 60 | $this->queueEvent($event, $eventHandler); 61 | } 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * @param DomainEvent $event 68 | * @param EventHandlerInterface $eventHandler 69 | */ 70 | protected function handleEvent(DomainEvent $event, EventHandlerInterface $eventHandler) { 71 | try { 72 | $eventHandler->handle($event); 73 | } catch (\Exception $e) { 74 | // todo: log to event error log 75 | } 76 | } 77 | 78 | /** 79 | * @param DomainEvent $event 80 | * @param EventHandlerInterface $eventHandler 81 | */ 82 | protected function queueEvent(DomainEvent $event, EventHandlerInterface $eventHandler) { 83 | $serializedEvent = $this->arraySerializer->serialize($event); 84 | $recipient = get_class($eventHandler); 85 | 86 | $this->queue->queue(new Message($recipient, $serializedEvent)); 87 | } 88 | 89 | /** 90 | * @param ObjectManagerInterface $objectManager 91 | * @return array Class names of all subscribers 92 | * @Flow\CompileStatic 93 | */ 94 | static public function getEventHandlerImplementationClassNames(ObjectManagerInterface $objectManager) { 95 | $reflectionService = $objectManager->get(ReflectionService::class); 96 | return $reflectionService->getAllImplementationClassNamesForInterface(EventHandlerInterface::class); 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/EventSourcing.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected $uncommittedEvents = []; 18 | 19 | /** 20 | * List of child entities the aggregate manages 21 | * 22 | * @var array 23 | */ 24 | protected $entities = []; 25 | 26 | /** 27 | * @return string 28 | */ 29 | public function getIdentifier() { 30 | return $this->identifier; 31 | } 32 | 33 | /** 34 | * @return DomainEvent[] 35 | */ 36 | public function getUncommittedChanges() { 37 | $events = $this->uncommittedEvents; 38 | 39 | /** @var EntityInterface $entity */ 40 | foreach ($this->entities as $entity) { 41 | $events = array_merge($events, $entity->getUncommittedChanges()); 42 | } 43 | 44 | usort($events, function(DomainEvent $event1, DomainEvent $event2) { 45 | return ($event1->version < $event2->version) ? -1 : 1; 46 | }); 47 | 48 | return $events; 49 | } 50 | 51 | public function markChangesAsCommitted() { 52 | $this->uncommittedEvents = []; 53 | 54 | /** @var EntityInterface $entity */ 55 | foreach ($this->entities as $entity) { 56 | $entity->markChangesAsCommitted(); 57 | } 58 | } 59 | 60 | /** 61 | * @param DomainEvent $event 62 | * @throws EventNotAppliedException 63 | */ 64 | protected function apply(DomainEvent $event) { 65 | $applyMethod = $this->getEventApplyMethod($event); 66 | 67 | if (method_exists($this, $applyMethod) === TRUE) { 68 | $this->$applyMethod($event); 69 | return; 70 | } 71 | 72 | $entityHandledEvent = FALSE; 73 | 74 | /** @var EntityInterface $entity */ 75 | foreach ($this->entities as $entity) { 76 | if ($entity->canApplyEvent($event) === TRUE) { 77 | $entity->apply($event); 78 | $entityHandledEvent = TRUE; 79 | break; 80 | } 81 | } 82 | 83 | if ($entityHandledEvent === FALSE) { 84 | throw new EventNotAppliedException('The event "' . get_class($event) . '" could not be applied to the aggregate "' . get_class($this) . '".', 1426252366); 85 | } 86 | } 87 | 88 | /** 89 | * @param EntityInterface $entity 90 | */ 91 | protected function unregisterEntity(EntityInterface $entity) { 92 | if (array_key_exists($entity->getIdentifier(), $this->entities) === FALSE) { 93 | throw new \InvalidArgumentException('The entity with identifier "' . $entity->getIdentifier() . '" is not registered.', 1426251309); 94 | } 95 | 96 | unset($this->entities[$entity->getIdentifier()]); 97 | } 98 | 99 | /** 100 | * @param DomainEvent $event 101 | * @return string 102 | */ 103 | protected function getEventApplyMethod(DomainEvent $event) { 104 | $parts = explode('\\', get_class($event)); 105 | $eventName = array_pop($parts); 106 | $applyMethod = 'on' . $eventName; 107 | return $applyMethod; 108 | } 109 | 110 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Command/Controller/Aspect/RegisteringDomainCommandsAspect.php: -------------------------------------------------------------------------------- 1 | getAvailableCommands()) && setting(Etg24.EventSourcing.Command.Controller.enabled)") 30 | */ 31 | public function registerDomainModelCommands(JoinPointInterface $joinPoint) { 32 | $proxy = $joinPoint->getProxy(); 33 | 34 | $currentCommands = ObjectAccess::getProperty($proxy, 'availableCommands', TRUE); 35 | 36 | // commands have been initialized 37 | if ($currentCommands !== NULL) { 38 | return $joinPoint->getAdviceChain()->proceed($joinPoint); 39 | } 40 | 41 | $commands = $joinPoint->getAdviceChain()->proceed($joinPoint); 42 | $domainCommands = $this->getDomainCommands(); 43 | $allCommands = array_merge($commands, $domainCommands); 44 | 45 | ObjectAccess::setProperty($proxy, 'availableCommands', $allCommands, TRUE); 46 | return $allCommands; 47 | } 48 | 49 | /** 50 | * @return Cli\Command[] 51 | */ 52 | protected function getDomainCommands() { 53 | $cliCommands = []; 54 | $domainCommandClassNames = $this->reflectionService->getAllImplementationClassNamesForInterface(Domain\CommandInterface::class); 55 | 56 | foreach ($domainCommandClassNames as $domainCommandClassName) { 57 | if ($this->reflectionService->isClassAbstract($domainCommandClassName) === TRUE) { 58 | continue; 59 | } 60 | 61 | $cliCommands[] = $this->buildDomainCommand($domainCommandClassName); 62 | } 63 | 64 | return $cliCommands; 65 | } 66 | 67 | /** 68 | * @param string $commandClassName 69 | * @return Cli\Command 70 | */ 71 | protected function buildDomainCommand($commandClassName) { 72 | return new DomainCommand( 73 | DomainCommandController::class, 74 | $commandClassName 75 | ); 76 | } 77 | 78 | /** 79 | * @param JoinPointInterface $joinPoint 80 | * @return mixed Result of the target method 81 | * @Flow\Around("class(TYPO3\Flow\Cli\Request) && method(.*->getCommand())") 82 | */ 83 | public function replaceCommandWithDomainCommand(JoinPointInterface $joinPoint) { 84 | /** @var Request $proxy */ 85 | $proxy = $joinPoint->getProxy(); 86 | 87 | if ($proxy->getControllerObjectName() === DomainCommandController::class) { 88 | ObjectAccess::setProperty( 89 | $proxy, 90 | 'command', 91 | $this->buildDomainCommand($proxy->getControllerCommandName()), 92 | TRUE 93 | ); 94 | } 95 | 96 | return $joinPoint->getAdviceChain()->proceed($joinPoint); 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Store/Backend/EventStoreBackend.php: -------------------------------------------------------------------------------- 1 | eventStore = new EventStore($this->eventStoreUrl); 41 | } 42 | 43 | /** 44 | * @param string $identifier 45 | * @param DomainEvent[] $changes 46 | * @throws OptimisticLockException 47 | */ 48 | public function append($identifier, array $changes) { 49 | $events = []; 50 | $version = NULL; 51 | 52 | /** @var DomainEvent $event */ 53 | foreach ($changes as $event) { 54 | if ($version === NULL) { 55 | $version = $event->version; 56 | } 57 | 58 | $events[] = $this->eventTranslator->toWritableEvent($event); 59 | } 60 | 61 | try { 62 | $this->eventStore->writeToStream( 63 | $identifier, 64 | new WritableEventCollection($events), 65 | $version -1 66 | ); 67 | } catch (WrongExpectedVersionException $e) { 68 | throw new OptimisticLockException($e->getMessage(), 1427104266); 69 | } 70 | } 71 | 72 | /** 73 | * @todo lazy loading 74 | * 75 | * @param string $eventStream 76 | * @return DomainEvent[] 77 | * @throws EventStreamNotFoundException 78 | */ 79 | public function load($eventStream) { 80 | try { 81 | $feed = $this->eventStore->openStreamFeed($eventStream, EntryEmbedMode::RICH()); 82 | } catch (StreamNotFoundException $e) { 83 | throw new EventStreamNotFoundException($e->getMessage(), 1427104251); 84 | } 85 | 86 | if ($feed->hasLink(LinkRelation::LAST())) { 87 | $feed = $this->eventStore->navigateStreamFeed($feed, LinkRelation::LAST()); 88 | } else { 89 | $feed = $this->eventStore->navigateStreamFeed($feed, LinkRelation::FIRST()); 90 | } 91 | 92 | $rel = LinkRelation::PREVIOUS(); 93 | 94 | $domainEvents = []; 95 | 96 | while ($feed !== NULL) { 97 | /** @var Entry[] $entries */ 98 | $entries = array_reverse($feed->getEntries()); 99 | 100 | foreach ($entries as $entry) { 101 | $event = $this->eventStore->readEvent( 102 | $entry->getEventUrl() 103 | ); 104 | 105 | if ($event === NULL) { 106 | continue; 107 | } 108 | 109 | $domainEvent = $this->eventTranslator->fromEvent($event, $entry->getType()); 110 | $domainEvent->version = $entry->getVersion(); 111 | 112 | $domainEvents[] = $domainEvent; 113 | } 114 | 115 | $feed = $this->eventStore->navigateStreamFeed($feed, $rel); 116 | } 117 | 118 | return $domainEvents; 119 | } 120 | 121 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Projection/Command/ProjectionCommandController.php: -------------------------------------------------------------------------------- 1 | reflectionService->getAllImplementationClassNamesForInterface(ProjectorInterface::class); 39 | $projections = []; 40 | 41 | foreach ($projectorClassNames as $projectorClassName) { 42 | $projector = $this->getProjectorByName($projectorClassName); 43 | 44 | try { 45 | $count = $projector->countAll(); 46 | } catch (DBALException $e) { 47 | $count = 'n/a'; 48 | } 49 | 50 | $projections[] = [ 51 | 'projection' => $projector->getProjectionClassName(), 52 | 'projector' => $projectorClassName, 53 | 'count' => $count 54 | ]; 55 | } 56 | 57 | $this->output->outputTable($projections, [ 58 | 'Projection', 'Projector', 'Count' 59 | ]); 60 | } 61 | 62 | /** 63 | * Clears and rebuilds the projection persistence structure 64 | * 65 | * @param string $projectorName 66 | */ 67 | public function buildProjectionCommand($projectorName) { 68 | $projector = $this->getProjectorByName($projectorName); 69 | $projector->build(); 70 | } 71 | 72 | /** 73 | * Replays projections for a given projector and stream 74 | * 75 | * @param string $projectorName 76 | * @param string $eventStreamName 77 | * @throws EventStreamNotFoundException 78 | */ 79 | public function replayProjectionCommand($projectorName, $eventStreamName) { 80 | $projector = $this->getProjectorByName($projectorName); 81 | $stream = $this->store->load($eventStreamName); 82 | 83 | $projector->build(); 84 | foreach ($stream as $event) { 85 | if ($projector->canHandleEvent($event)) { 86 | $projector->handle($event); 87 | } 88 | } 89 | } 90 | 91 | public function replayAllProjectionsCommand() { 92 | $projectorClassNames = $this->reflectionService->getAllImplementationClassNamesForInterface(ProjectorInterface::class); 93 | // todo: find a better way of doing this 94 | 95 | try { 96 | $stream = $this->store->load('events'); 97 | } catch (EventStreamNotFoundException $e) { 98 | $stream = []; 99 | } 100 | 101 | /** @var AbstractProjector[] $projectors */ 102 | $projectors = []; 103 | foreach ($projectorClassNames as $projectorClassName) { 104 | $projector = $this->getProjectorByName($projectorClassName); 105 | 106 | try { 107 | $projector->build(); 108 | $projectors[] = $projector; 109 | } catch (\Exception $e) { 110 | $this->outputLine('Unable to build projection table for projector "' . $projectorClassName . '": ' . $e->getMessage()); 111 | } 112 | } 113 | 114 | foreach ($stream as $event) { 115 | foreach ($projectors as $projector) { 116 | if ($projector->canHandleEvent($event)) { 117 | $projector->handle($event); 118 | } 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * @param string $projectorName 125 | * @return ProjectorInterface 126 | */ 127 | protected function getProjectorByName($projectorName) { 128 | return $this->objectManager->get($projectorName); 129 | } 130 | 131 | } -------------------------------------------------------------------------------- /Classes/Etg24/EventSourcing/Command/Controller/DomainCommand.php: -------------------------------------------------------------------------------- 1 | controllerClassName = $controllerClassName; 29 | $this->controllerCommandName = $domainModelCommandClassName; 30 | 31 | $this->generateCommandIdentifier(); 32 | } 33 | 34 | protected function generateCommandIdentifier() { 35 | $matchCount = preg_match('/^(?P\w+(?:\\\\\w+)*)\\\\Command\\\\(?P\w+)$/', $this->controllerCommandName, $matches); 36 | if ($matchCount !== 1) { 37 | throw new \InvalidArgumentException('Invalid domain command class name "' . $this->controllerCommandName . '". Make sure your domain command is in a folder named "Command".', 1431076873); 38 | } 39 | 40 | $packageNamespaceParts = explode('\\', $matches['PackageNamespace']); 41 | 42 | $packageKey = implode('.', $packageNamespaceParts); 43 | array_shift($packageNamespaceParts); 44 | $packageKeyWithoutVendor = implode('.', $packageNamespaceParts); 45 | 46 | $this->commandIdentifier = strtolower($packageKey . ':' . $packageKeyWithoutVendor . ':' . $matches['CommandName']); 47 | } 48 | 49 | /** 50 | * Returns a short description of this command 51 | * 52 | * @return string A short description 53 | */ 54 | public function getShortDescription() { 55 | $commandMethodReflection = $this->getCommandReflection(); 56 | $lines = explode(chr(10), $commandMethodReflection->getDescription()); 57 | $shortDescription = ((count($lines) > 0) ? trim($lines[0]) : '') . ($this->isDeprecated() ? ' (DEPRECATED)' : ''); 58 | 59 | return $shortDescription; 60 | } 61 | 62 | /** 63 | * Returns a longer description of this command 64 | * This is the complete method description except for the first line which can be retrieved via getShortDescription() 65 | * If The command description only consists of one line, an empty string is returned 66 | * 67 | * @return string A longer description of this command 68 | */ 69 | public function getDescription() { 70 | $commandReflection = $this->getCommandReflection(); 71 | $lines = explode(chr(10), $commandReflection->getDescription()); 72 | array_shift($lines); 73 | $descriptionLines = array(); 74 | foreach ($lines as $line) { 75 | $trimmedLine = trim($line); 76 | if ($descriptionLines !== array() || $trimmedLine !== '') { 77 | $descriptionLines[] = $trimmedLine; 78 | } 79 | } 80 | $description = implode(chr(10), $descriptionLines); 81 | return $description; 82 | } 83 | 84 | /** 85 | * @return string 86 | */ 87 | public function hasArguments() { 88 | return TRUE; 89 | } 90 | 91 | /** 92 | * To get the argument definitions, the constructor parameters are used. 93 | * Then the necessary information is fetched through reflecting the commands 94 | * class properties. This makes it necessary that the constructor parameter 95 | * and the corresponding class properties are matched through naming. 96 | * 97 | * @return DomainCommandArgumentDefinition[] 98 | */ 99 | public function getArgumentDefinitions() { 100 | $commandReflection = $this->getCommandReflection(); 101 | $commandConstructor = $commandReflection->getConstructor(); 102 | $parameters = $commandConstructor->getParameters(); 103 | 104 | $argumentDefinitions = []; 105 | 106 | /** @var ParameterReflection $parameter */ 107 | foreach ($parameters as $parameter) { 108 | $parameterName = $parameter->getName(); 109 | 110 | if ($commandReflection->hasProperty($parameterName) === FALSE) { 111 | throw new \InvalidArgumentException('Unable to reflect property for parameter "' . $parameterName .'" in command "' . $this->controllerCommandName . '". Make sure constructor parameters and command property names match.', 1431083980); 112 | } 113 | 114 | $propertyReflection = $commandReflection->getProperty($parameterName); 115 | $argumentDescription = $propertyReflection->getDescription(); 116 | 117 | if ($propertyReflection->isTaggedWith('var') === FALSE) { 118 | throw new \InvalidArgumentException('The parameter type for command parameter "' . $parameterName . '" in command "' . $this->controllerCommandName . '" cannot be determined.', 1431081210); 119 | } 120 | 121 | $argumentType = current($propertyReflection->getTagValues('var')); 122 | $argumentDefinitions[] = new DomainCommandArgumentDefinition($parameterName, TRUE, $argumentDescription, $argumentType); 123 | } 124 | 125 | return $argumentDefinitions; 126 | } 127 | 128 | /** 129 | * @return boolean 130 | */ 131 | public function isInternal() { 132 | return $this->internal; 133 | } 134 | 135 | /** 136 | * @return boolean 137 | */ 138 | public function isDeprecated() { 139 | return FALSE; 140 | } 141 | 142 | /** 143 | * @return boolean 144 | */ 145 | public function isFlushingCaches() { 146 | return FALSE; 147 | } 148 | 149 | /** 150 | * @return array 151 | */ 152 | public function getRelatedCommandIdentifiers() { 153 | return []; 154 | } 155 | 156 | /** 157 | * @return ClassReflection 158 | */ 159 | protected function getCommandReflection() { 160 | if ($this->commandReflection === NULL) { 161 | $commandReflection = new ClassReflection($this->controllerCommandName); 162 | $this->commandReflection = $commandReflection->getParentClass(); 163 | } 164 | return $this->commandReflection; 165 | } 166 | 167 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Etg24.EventSourcing 2 | =================== 3 | 4 | This package provides basic CQRS/ES infrastructure for [TYPO3 Flow](http://flow.typo3.org). 5 | 6 | Its purpose is to provide inspiration for writing your own customized set of tools for working with CQRS/ES. *I do not recommend using this package without understanding the underlaying concepts first.* 7 | 8 | ## Installation 9 | 10 | ``` 11 | $ composer require etg24/eventsourcing dev-master 12 | ``` 13 | 14 | ## Commands 15 | 16 | Commands define the interface to your domain model. They enter the *[command bus](#command_bus)* and will then be handled (sync/async) by *[command handlers](#command_handlers)*. Commands do never return any data, thus the interface for handling commands is of type `void`. 17 | 18 | ### Defining commands 19 | 20 | You can define a command by extending from `Etg24\EventSourcing\Command\Command` (I will soonish update the code base to use the `CommandInterface` instead, but until then you need to inherit from `Command`). The command has a `commandId` that can be used for logging purposes, if so desired. 21 | 22 | ```php 23 | basketId = $basketId; 53 | $this->inventoryItemId = $inventoryItemId; 54 | } 55 | 56 | } 57 | 58 | ``` 59 | 60 | ### Command handlers 61 | 62 | Command handlers execute commands coming from the command bus. They are part of your application layer and only orchestrate the domain model. Their job is to resolve dependencies and pass them to the domain model. 63 | 64 | You can define a command handler by implementing the interface `Etg24\EventSourcing\Command\Handler\CommandHandlerInterface`. Alternatively, you can extend the `Etg24\EventSourcing\Command\CommandHandler` that will provide a default implementation for normal handlers. 65 | 66 | The command handler provided by Etg24.EventSourcing has a naming convention for handler methods: `handleCommand`. The command name is the simple class name. To avoid conflicts, it is furthermore necessary that the namespace for the command handler is `CommandHandler` and `Command` for the commands. See the example folder structure and implementation below. 67 | 68 | ``` 69 | - Vendor 70 | - Foo 71 | - CommandHandler 72 | - BasketCommandHandler.php 73 | - Command 74 | - AddInventoryItemToBasket.php 75 | ``` 76 | 77 | Here is the BasketCommandHandler implementation, handling the AddInventoryItemToBasket command. 78 | 79 | ```php 80 | basketRepository->find($command->basketId); 105 | $basket->addInventoryItem($command->inventoryItemId); 106 | $this->basketRepository->save($basket); 107 | } 108 | 109 | } 110 | 111 | ``` 112 | 113 | ### The command bus 114 | 115 | In order to get commands handled by their command handler, the command bus is used. You can inject the command bus in [event handlers](#event_handlers) or ActionControllers. The following example illustrates the usage within an ActionController. 116 | 117 | ```php 118 | commandBus->handle(new AddInventoryItemToBasket( 139 | $basketId, 140 | $inventoryItemId 141 | )); 142 | 143 | // redirect 144 | } 145 | 146 | } 147 | ``` 148 | 149 | Ideally, you would write a TypeConverter to automatically convert e. g. POST data to commands, handling them in a single ActionController. 150 | 151 | ### Working with the CLI 152 | 153 | By default, all commands are exposed via the Flow CLI. Once defined, you can use 154 | 155 | ``` 156 | $ ./flow help 157 | $ ./flow help foo:addinventoryitemtobasket 158 | $ ./flow foo:addinventoryitemtobasket --basket-id="2" --inventory-item-id="1" 159 | ``` 160 | 161 | to view the required parameters for any given command and execute them. 162 | 163 | To disable command CLI access, edit the `Settings.yaml` of your project like this: 164 | 165 | ```yaml 166 | Etg24: 167 | EventSourcing: 168 | Command: 169 | Controller: 170 | enabled: false 171 | ``` 172 | 173 | Alternatively, you can *hide* the commands from normal CLI users by marking them as internal. That way the commands are still accessible but no longer printed by `./flow help`. 174 | 175 | ```yaml 176 | Etg24: 177 | EventSourcing: 178 | Command: 179 | Controller: 180 | enabled: true 181 | markAsInternal: true 182 | ``` 183 | 184 | ## Events 185 | 186 | Domain events are created by [aggregates](#aggregates), retrieved by [repositories](#repositories), stored in an [event store](#event_store) and published through the [event bus](#event_bus). They are the single source of truth in an event sourced model and define your models state. 187 | 188 | ### Defining events 189 | 190 | An event is defined by extending from `Etg24\EventSourcing\Event\DomainEvent`. Please note that the constructor of the parent class must be called in order to generate the date when the event occurred (I'm still debating over doing this with e. g. AOP). 191 | 192 | ```php 193 | basketId = $basketId; 218 | $this->inventoryItemId = $inventoryItemId; 219 | } 220 | 221 | } 222 | 223 | ``` 224 | 225 | ### Repositories 226 | 227 | Repositories are used to save and retrieve aggregates. The naming convention from Flow also applies here. The `BasketRepository` for the aggregate `Basket` looks like this: 228 | 229 | ```php 230 | namespace Vendor\Foo\Domain\Repository; 231 | 232 | use Etg24\EventSourcing\Store\Repository; 233 | use TYPO3\Flow\Annotations as Flow; 234 | 235 | /** 236 | * @Flow\Scope("singleton") 237 | */ 238 | class BasketRepository extends Repository {} 239 | ``` 240 | 241 | The default event store backend is the `EventStoreBackend` for [EventStore](https://geteventstore.com). You can implement the `StoreBackendInterface` and use the `Objects.yaml` to use your own implementations. 242 | 243 | ### The event bus 244 | 245 | The event bus should normally only be used by the repository. I will add some interfaces soonish that enable the configuration for different queues, depending on whether events have to be handled asynchronously or synchronously. 246 | 247 | ## Event handlers 248 | 249 | Similar to [command handlers](#command_handlers), event handlers handle events that are published on the [event bus](#event_bus). Event handlers are subscribed to one or more [domain events](#events). To create an event handler and have it listen to events, implement the `EventHandlerInterface` (async) or `ImmediateEventHandlerInterface` (sync) interface. The [event bus](#event_bus) will then, depending on the interface, push events into a [queue](#queues) or directly pass them on for handling (will soon be done through different queues, API shouldn't change though). 250 | 251 | Usually you would use event handlers for dealing with eventual consistency or things like sending emails. The following example illustrates the usage of the `AbstractEventHandler`, and its conventions. 252 | 253 | ### Defining an event handler 254 | 255 | ```php 256 | flashMessageContainer->addFlashMessage('Hi! The inventory item "' . $event->inventoryItemId . '" has been added to your basket.'); 277 | } 278 | 279 | } 280 | 281 | ``` 282 | 283 | ## Aggregates 284 | 285 | In event sourced environments, aggregates are reconstructed using domain events only! You can create an aggregate class by implementing `Etg24\EventSourcing\AggregateRootInterface`. The trait `Etg24\EventSourcing\AggregateSourcing` contains behaviour that will satisfy the interface and provides a sane default implementation for your aggregates. 286 | 287 | ### Instantiation 288 | 289 | You can instantiate new aggregate instances like any other class in php. Please note that there is **NO** automatic identifier generation. The trait `AggregateSourcing` adds a property `$identifier` but does not fill it. When thinking about [commands](#commands), you will most likely want to generate the aggregates identifier before sending out an command. 290 | 291 | ```php 292 | $basket = new Basket('123'); 293 | ``` 294 | 295 | ### Publishing new domain events 296 | 297 | Once created, you can publish domain events. Domain events are only published from within an aggregate (or [entity](#entities)). 298 | 299 | ```php 300 | public function addInventoryItem($inventoryItemId) { 301 | // business logic 302 | // validation logic 303 | 304 | $this->applyNewEvent(new InventoryItemToBaskedAdded($this->identifier, $inventoryItemId)); 305 | } 306 | ``` 307 | 308 | Now when saving this aggregate in a repository, the new events are written to the [event store](#event_store). 309 | 310 | ### Loading aggregates from an event stream 311 | 312 | Normally, the [repository](#repositories) will handle the loading of existing aggregates for you. However, when testing you might want to load them manually. The trait `AggregateSourcing` implements the static method `loadFromEventStream` which will instantiate and apply events. 313 | 314 | ```php 315 | $existingBasket = Basket::loadFromEventStream([ 316 | new BasketCreated(123), 317 | new InventoryItemToBaskedAdded(123, 1) 318 | ]); 319 | ``` 320 | 321 | The trait will then instantiate a new instance of `Basket` without calling its constructor, then apply each event. To do so, you have to implement event applying methods. The convention for this is `on`. 322 | 323 | ```php 324 | /** 325 | * @param BasketCreated $event 326 | */ 327 | protected function onBasketCreated(BasketCreated $event) { 328 | $this->identifier = $event->basketId; 329 | } 330 | 331 | /** 332 | * @param InventoryItemToBaskedAdded $event 333 | */ 334 | protected function onInventoryItemToBaskedAdded(InventoryItemToBaskedAdded $event) { 335 | $this->inventoryItems[] = $event->inventoryItemId; 336 | } 337 | ``` 338 | 339 | The trait will also throw an exception, if an event cannot be applied due to a missing apply method. You might want to be less strict in this regard, when dealing with CRUD only parts of your model, where you have no business logic attached to certain properties. This way you can get the distilled business logic in your model, without any noise. 340 | 341 | ### The Basket aggregate 342 | 343 | ```php 344 | 355 | */ 356 | protected $inventoryItems = []; 357 | 358 | /** 359 | * @param string $basketId 360 | */ 361 | public function __construct($basketId) { 362 | $this->applyNewEvent(new BasketCreated($basketId)); 363 | } 364 | 365 | /** 366 | * @param string $inventoryItemId 367 | */ 368 | public function addInventoryItem($inventoryItemId) { 369 | // business logic 370 | // validation logic 371 | 372 | $this->applyNewEvent(new InventoryItemToBaskedAdded($this->identifier, $inventoryItemId)); 373 | } 374 | 375 | /** 376 | * @param BasketCreated $event 377 | */ 378 | protected function onBasketCreated(BasketCreated $event) { 379 | $this->identifier = $event->basketId; 380 | } 381 | 382 | /** 383 | * @param InventoryItemToBaskedAdded $event 384 | */ 385 | protected function onInventoryItemToBaskedAdded(InventoryItemToBaskedAdded $event) { 386 | $this->inventoryItems[] = $event->inventoryItemId; 387 | } 388 | 389 | } 390 | 391 | ``` 392 | 393 | ## Entities 394 | 395 | Entities are very similar to aggregates (technically), however they are maintained and owned by an aggregate. The lifecycle of entities is the responsibility of the aggregate and you must only ever access entities through the aggregate. 396 | 397 | Entities must implement the `Etg24\EventSourcing\EntityInterface`, the trait `Etg24\EventSourcing\EntitySourcing` provides behaviour. 398 | 399 | When creating an entity, you must register the entity with the aggregate: 400 | 401 | ```php 402 | public function onSomeThingsHappened(SomeThingsHappened $event) { 403 | $entity = new Entity(..); 404 | $this->entities[$entity->getIdentifier()] = $entity; 405 | $this->registerEntity($entity); 406 | } 407 | 408 | ``` 409 | 410 | Note how the aggregate is handling the event that creates the entity, not the entity. This is why there are no checks inside the method `Entity::__construct`, as this is like a method that applies events. 411 | 412 | ```php 413 | class Entity implements EntityInterface { 414 | 415 | use EntitySourcing; 416 | 417 | public function __construct($entityId) { 418 | $this->identifier = $entityId; 419 | } 420 | 421 | } 422 | ``` 423 | 424 | This ensures that events are also forwarded to each entity. Entities must then expose which events they are subscribed to by implementing `canApplyEvent`. 425 | 426 | ```php 427 | public function canApplyEvent(DomainEvent $event) { 428 | if ($event instanceof EntityEvent) { 429 | return ($event->entityId === $this->identifier); 430 | } 431 | 432 | return FALSE; 433 | } 434 | ``` 435 | 436 | Applying events works exactly like replaying events in aggregate roots. 437 | 438 | ## Store 439 | 440 | At the moment, the only store backend implemented is [EventStore](https://geteventstore.com). You can implement the `StoreBackendInterface` and use the `Objects.yaml` to use your own implementations. 441 | 442 | ## Projections 443 | 444 | Projections, also known as query model, are used to query data inside an event sourced environment. Basically, they are nothing but [event handlers](#event_handlers) that update some query optimized database. 445 | 446 | todo: add some example for the MysqlProjector 447 | 448 | ## Queues 449 | 450 | Message queues can be used to handle commands and events asynchronously or synchronously. At the moment, the only working queue is the `ImmediateQueue` that handles messages synchronously. My plan is to use TYPO3.Jobqueue in the future (no need to re-invent the wheel). 451 | 452 | ## Serialization 453 | 454 | Currently, there are two serializers implemented: 455 | 456 | * ArraySerializer: Converts messages into an array 457 | * JsonSerializer: Converts messages into a json string 458 | 459 | ## Testing 460 | 461 | todo: write about how event sourced models are tested (hint: it's not by using getters ;)) 462 | 463 | ## Todo 464 | 465 | * Finish this documentation 466 | * Exception handling in every handler (command/event/projection) 467 | * Implement snapshots 468 | * Write tests for the package (yes, this is entirely untested, but it works :o!) 469 | 470 | ## License 471 | 472 | Etg24.EventSourcing is released under the [MIT license](http://www.opensource.org/licenses/MIT). --------------------------------------------------------------------------------