├── .gitignore ├── src ├── Testing │ ├── GivenWhenThen │ │ ├── Outcome.php │ │ ├── Then.php │ │ ├── When.php │ │ ├── ThenNothing.php │ │ ├── ThenCommand.php │ │ ├── ThenEvent.php │ │ ├── ThenEvents.php │ │ ├── Given.php │ │ └── Scenario.php │ ├── EventSourcedAggregateRootTestingBehavior.php │ ├── TestObject.php │ └── AggregateRootTestingBehavior.php ├── Serialization │ ├── SerializablePayloadEdgeKey.php │ ├── SerializablePayloadEdge.php │ ├── SerializablePayloadEdgeValue.php │ ├── EmptyPayloadSerializing.php │ ├── CustomizedSerializablePayloadEdgeValue.php │ ├── SupportsAwareCustomizedSerializablePayloadEdgeValues.php │ └── EdgeAwareReflectionSerializing.php ├── Transaction │ ├── Transaction.php │ └── NoTransaction.php ├── MessagePayloadConsumption │ ├── MessagePayloadConsumer.php │ ├── SupportsAwareMessagePayloadConsumer.php │ └── MessagePayloadConsumerMessageConsumer.php ├── Identity │ ├── IdentityGeneration.php │ └── IdentityInflection.php ├── AggregateRoot │ ├── AggregateRootIdGeneration.php │ ├── AggregateRootAware.php │ ├── EventSourcedAggregateRoot.php │ ├── AggregateRootIdAware.php │ ├── EventedAggregateRoot.php │ ├── AsNotAnAggregateRootBehavior.php │ ├── EventSourcedAggregateRootBehaviour.php │ ├── EventedAggregateRootBehaviour.php │ └── EventSourcedAggregateRootRepository.php ├── MessagePreparation │ ├── MessagePreparation.php │ └── DefaultMessagePreparation.php ├── functions.php ├── LazyMessageDispatching │ └── LazyMessageDispatcher.php ├── LazyMessageConsumption │ ├── LazilySupportsAwareMessagePayloads.php │ └── LazyMessageConsumer.php └── Messages │ └── MessageInflection.php ├── phpstan.neon.dist ├── .editorconfig ├── .php-cs-fixer.dist.php ├── .github └── workflows │ ├── phpstan.yml │ └── php-cs-fixer.yml ├── LICENSE └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .php-cs-fixer.cache 3 | composer.lock 4 | -------------------------------------------------------------------------------- /src/Testing/GivenWhenThen/Outcome.php: -------------------------------------------------------------------------------- 1 | */ 10 | public array $events; 11 | 12 | public function __construct(object ...$events) 13 | { 14 | $this->events = $events; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Identity/IdentityGeneration.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public static function aggregateRootClassName(): string; 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | 17 | [*.{neon,neon.dist}] 18 | indent_style = tab 19 | 20 | [docker-compose.yml] 21 | indent_size = 4 22 | 23 | [*.{js,ts,cjs,vue,tsx}] 24 | indent_size = 2 25 | -------------------------------------------------------------------------------- /src/AggregateRoot/EventSourcedAggregateRoot.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | interface EventSourcedAggregateRoot extends EventedAggregateRoot 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Serialization/EmptyPayloadSerializing.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public array $events; 13 | 14 | /** 15 | * @template T of object 16 | * 17 | * @param T ...$events 18 | */ 19 | public function __construct(object ...$events) 20 | { 21 | $this->events = $events; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/AggregateRoot/AggregateRootIdAware.php: -------------------------------------------------------------------------------- 1 | */ 20 | public static function aggregateRootIdClassName(): string; 21 | } 22 | -------------------------------------------------------------------------------- /src/MessagePreparation/MessagePreparation.php: -------------------------------------------------------------------------------- 1 | |array 11 | */ 12 | public static function supportedMessages(): array; 13 | 14 | /** 15 | * @param object|class-string $payloadClassName 16 | */ 17 | public static function supportsMessage(object|string $payloadClassName): bool; 18 | } 19 | -------------------------------------------------------------------------------- /src/AggregateRoot/EventedAggregateRoot.php: -------------------------------------------------------------------------------- 1 | 16 | * @extends AggregateRootIdAware 17 | * @extends AggregateRootIdGeneration 18 | */ 19 | interface EventedAggregateRoot extends AggregateRoot, AggregateRootIdAware, AggregateRootIdGeneration, EventSourcedAggregate 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/MessagePayloadConsumption/MessagePayloadConsumerMessageConsumer.php: -------------------------------------------------------------------------------- 1 | messagePayloadHandler->handleMessagePayload($message->payload(), $message); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | save(); 17 | 18 | use Ergebnis\PhpCsFixer\Config; 19 | 20 | $config = Config\Factory::fromRuleSet(new Dflydev\PhpCsFixer\Config\RuleSet\Dflydev()); 21 | 22 | $config->getFinder()->in(__DIR__); 23 | $config->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); 24 | 25 | return $config; 26 | -------------------------------------------------------------------------------- /src/Identity/IdentityInflection.php: -------------------------------------------------------------------------------- 1 | $identityClass 15 | * 16 | * @return T|null $value 17 | */ 18 | public static function toObject(string $identityClass, mixed $value): ?AggregateRootId 19 | { 20 | if (is_null($value)) { 21 | return null; 22 | } 23 | 24 | if ($value instanceof $identityClass) { 25 | return $value; 26 | } 27 | 28 | assert(is_string($value), 'Expected $value to be a string if it is not null'); 29 | 30 | return $identityClass::fromString($value); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Serialization/CustomizedSerializablePayloadEdgeValue.php: -------------------------------------------------------------------------------- 1 | $type 23 | * 24 | * @return TType 25 | */ 26 | public static function fromCustomSerializablePayloadEdgeValue(string $type, string $name, string|array|null $value = null): mixed; 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: PHPStan 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: phpstan-${{ github.ref_name || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | 15 | phpstan: 16 | name: PHPStan PHP ${{ matrix.php }} 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | php: ['8.2'] 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | with: 27 | fetch-depth: 1 28 | 29 | - name: Set up PHP 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php }} 33 | 34 | - name: Install Composer dependencies 35 | uses: ramsey/composer-install@v2 36 | with: 37 | composer-options: "--no-progress --prefer-dist --optimize-autoloader" 38 | 39 | - name: PHPStan 40 | run: php ./vendor/bin/phpstan analyse --memory-limit=2G --error-format=github 41 | -------------------------------------------------------------------------------- /src/AggregateRoot/AsNotAnAggregateRootBehavior.php: -------------------------------------------------------------------------------- 1 | |null $throwableType 12 | * 13 | * @throws \RuntimeException 14 | */ 15 | function guard(bool $assertion, string|callable $message, ?string $throwableType = null): void 16 | { 17 | if ($assertion) { 18 | return; 19 | } 20 | 21 | if (is_callable($message)) { 22 | $message = $message(); 23 | } 24 | 25 | if ($message instanceof Throwable) { 26 | throw $message; 27 | } 28 | 29 | $throwableType ??= \RuntimeException::class; 30 | 31 | throw new $throwableType($message); 32 | } 33 | 34 | /** 35 | * @template T of Throwable 36 | * 37 | * @param class-string $throwableType 38 | * @param string|callable():string $message 39 | * 40 | * @return callable():T 41 | */ 42 | function lazyThrow(string $throwableType, string|callable $message = '', int $code = 0, ?Throwable $previous = null): callable 43 | { 44 | return fn () => new $throwableType(is_callable($message) ? $message() : $message, $code, $previous); 45 | } 46 | -------------------------------------------------------------------------------- /src/Testing/EventSourcedAggregateRootTestingBehavior.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | use AggregateRootTestingBehavior; 22 | 23 | /** 24 | * @return AggregateRootRepository 25 | */ 26 | protected function eventSourcedAggregateRootRepository(): AggregateRootRepository 27 | { 28 | return new EventSourcedAggregateRootRepository( 29 | $this->aggregateRootType(), 30 | $this->transaction(), 31 | $this->messageRepository(), 32 | $this->messagePreparation() 33 | ); 34 | } 35 | 36 | public static function configureForEventSourcedAggregateRootType(string $aggregateRootType): void 37 | { 38 | self::setAggregateRootType($aggregateRootType); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/LazyMessageDispatching/LazyMessageDispatcher.php: -------------------------------------------------------------------------------- 1 | messageConsumerClassNames = $messageConsumerClassNames; 21 | } 22 | 23 | public function dispatch(Message ...$messages): void 24 | { 25 | if (!isset($this->messageDispatcher)) { 26 | $messageConsumers = array_map(fn ($className) => new LazyMessageConsumer($this->container, $className), $this->messageConsumerClassNames); 27 | 28 | $this->messageDispatcher = new SynchronousMessageDispatcher(...$messageConsumers); 29 | } 30 | 31 | $this->messageDispatcher->dispatch(...$messages); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dflydev/eventsauce-support", 3 | "license": "MIT", 4 | "type": "library", 5 | "require": { 6 | "php": "^8.1 || ^8.2", 7 | "eventsauce/eventsauce": "^3", 8 | "eventsauce/message-outbox": "^0.3 || ^0.4" 9 | }, 10 | "require-dev": { 11 | "dflydev/php-coding-standards": "dev-main", 12 | "ergebnis/composer-normalize": "^2.31", 13 | "ergebnis/license": "^2.1", 14 | "phpstan/phpstan": "^1.10", 15 | "phpunit/phpunit": "^9 || ^10" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "Dflydev\\EventSauce\\Support\\": "src/" 20 | }, 21 | "files": [ 22 | "src/functions.php" 23 | ] 24 | }, 25 | "config": { 26 | "allow-plugins": { 27 | "ergebnis/composer-normalize": true 28 | }, 29 | "preferred-install": "dist", 30 | "sort-packages": true 31 | }, 32 | "extra": { 33 | "branch-alias": { 34 | "dev-main": "0.x-dev" 35 | } 36 | }, 37 | "scripts": { 38 | "lint": "@phpstan", 39 | "phpstan": "@php ./vendor/bin/phpstan analyze", 40 | "style:check": "@php ./vendor/bin/php-cs-fixer fix --dry-run", 41 | "style:fix": "@php ./vendor/bin/php-cs-fixer fix" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/AggregateRoot/EventSourcedAggregateRootBehaviour.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | use EventedAggregateRootBehaviour; 23 | 24 | /** 25 | * @phpstan-param T $aggregateRootId 26 | */ 27 | final private function __construct(AggregateRootId $aggregateRootId) 28 | { 29 | $this->aggregateRootId = $aggregateRootId; 30 | } 31 | 32 | /** 33 | * @phpstan-param T $aggregateRootId 34 | */ 35 | public static function reconstituteFromEvents(AggregateRootId $aggregateRootId, Generator $events): static 36 | { 37 | $aggregateRoot = self::createNewInstance($aggregateRootId); 38 | 39 | /** @var object $event */ 40 | foreach ($events as $event) { 41 | $aggregateRoot->apply($event); 42 | } 43 | 44 | $aggregateRootVersion = $events->getReturn(); 45 | 46 | $aggregateRoot->aggregateRootVersion = (is_int($aggregateRootVersion) && $aggregateRootVersion >= 0) 47 | ? $aggregateRootVersion 48 | : 0; 49 | 50 | return $aggregateRoot; 51 | } 52 | 53 | /** 54 | * @phpstan-param T $aggregateRootId 55 | */ 56 | private static function createNewInstance(AggregateRootId $aggregateRootId): static 57 | { 58 | return new static($aggregateRootId); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/LazyMessageConsumption/LazilySupportsAwareMessagePayloads.php: -------------------------------------------------------------------------------- 1 | {$method}($payload, $message); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/MessagePreparation/DefaultMessagePreparation.php: -------------------------------------------------------------------------------- 1 | messageDecorator = $messageDecorator ?? new DefaultHeadersDecorator(); 25 | $this->classNameInflector = $classNameInflector ?? new DotSeparatedSnakeCaseInflector(); 26 | } 27 | 28 | public function prepareMessages( 29 | string $aggregateRootClassName, 30 | AggregateRootId $aggregateRootId, 31 | int $aggregateRootVersion, 32 | object ...$events 33 | ): array { 34 | if (count($events) === 0) { 35 | return []; 36 | } 37 | 38 | // decrease the aggregate root version by the number of raised events 39 | // so the version of each message represents the version at the time 40 | // of recording. 41 | $aggregateRootVersion = $aggregateRootVersion - count($events); 42 | $metadata = [ 43 | Header::AGGREGATE_ROOT_ID => $aggregateRootId, 44 | Header::AGGREGATE_ROOT_TYPE => $this->classNameInflector->classNameToType($aggregateRootClassName), 45 | ]; 46 | 47 | return array_map(function (object $event) use ($metadata, &$aggregateRootVersion) { 48 | return $this->messageDecorator->decorate(new Message( 49 | $event, 50 | $metadata + [Header::AGGREGATE_ROOT_VERSION => ++$aggregateRootVersion] 51 | )); 52 | }, $events); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/AggregateRoot/EventedAggregateRootBehaviour.php: -------------------------------------------------------------------------------- 1 | aggregateRootId; 40 | } 41 | 42 | public function aggregateRootVersion(): int 43 | { 44 | return $this->aggregateRootVersion; 45 | } 46 | 47 | protected function recordThat(object $event): void 48 | { 49 | $this->apply($event); 50 | $this->recordedEvents[] = $event; 51 | } 52 | 53 | /** 54 | * @phpstan-return T 55 | */ 56 | public static function generateAggregateRootId(): AggregateRootId 57 | { 58 | /** @phpstan-var class-string $thisClass */ 59 | $thisClass = static::class; 60 | 61 | assert(in_array(AggregateRootIdAware::class, class_implements($thisClass)), "Aggregate root \"$thisClass\" must implement AggregateRootIdAware."); 62 | 63 | /** @var class-string $identityClassName */ 64 | $identityClassName = $thisClass::aggregateRootIdClassName(); 65 | 66 | assert(in_array(IdentityGeneration::class, class_implements($identityClassName)), "Aggregate root identity \"$identityClassName\" must implement IdentityGeneration."); 67 | 68 | return $identityClassName::generate(); 69 | } 70 | 71 | /** 72 | * @return object[] 73 | */ 74 | public function releaseEvents(): array 75 | { 76 | $releasedEvents = $this->recordedEvents; 77 | $this->recordedEvents = []; 78 | 79 | return $releasedEvents; 80 | } 81 | 82 | /** 83 | * @phpstan-param T $aggregateRootId 84 | */ 85 | public static function reconstituteFromEvents(AggregateRootId $aggregateRootId, Generator $events): static 86 | { 87 | throw new \LogicException('Evented aggregate roots cannot be reconstituted from events.'); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/LazyMessageConsumption/LazyMessageConsumer.php: -------------------------------------------------------------------------------- 1 | $actualMessageConsumerClass 24 | */ 25 | public function __construct( 26 | private ContainerInterface $container, 27 | private string $actualMessageConsumerClass 28 | ) { 29 | } 30 | 31 | public function actualConsumerClassName(): string 32 | { 33 | return $this->actualMessageConsumerClass; 34 | } 35 | 36 | /** 37 | * @throws ContainerExceptionInterface 38 | * @throws NotFoundExceptionInterface 39 | */ 40 | public function handle(Message $message): void 41 | { 42 | $actualMessageConsumerClass = $this->actualMessageConsumerClass; 43 | $payload = $message->payload(); 44 | 45 | if (in_array(SupportsAwareMessagePayloadConsumer::class, class_implements($actualMessageConsumerClass))) { 46 | /** @var SupportsAwareMessagePayloadConsumer $actualMessageConsumerClass */ 47 | if (!$actualMessageConsumerClass::supportsMessage($payload)) { 48 | return; 49 | } 50 | } 51 | 52 | if (in_array(MessagePayloadConsumer::class, class_implements($actualMessageConsumerClass))) { 53 | /** @var MessagePayloadConsumer $actualConsumer */ 54 | $actualConsumer = $this->container->get($this->actualMessageConsumerClass); 55 | 56 | $actualConsumer->handleMessagePayload($payload, $message); 57 | 58 | return; 59 | } 60 | 61 | if (in_array(MessageConsumer::class, class_implements($actualMessageConsumerClass))) { 62 | /** @var MessageConsumer $actualConsumer */ 63 | $actualConsumer = $this->container->get($this->actualMessageConsumerClass); 64 | 65 | $actualConsumer->handle($message); 66 | 67 | return; 68 | } 69 | 70 | $parts = explode('\\', get_class($payload)); 71 | $method = 'handle'.end($parts); 72 | 73 | if (method_exists($this, $method)) { 74 | $this->{$method}($payload, $message); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Messages/MessageInflection.php: -------------------------------------------------------------------------------- 1 | |null $identityType 23 | * 24 | * @phpstan-return T|AggregateRootId $value 25 | */ 26 | public function extractAggregateRootId(Message $message, ?string $identityType = null): AggregateRootId 27 | { 28 | /** @var class-string>|null $aggregateRootTypeString */ 29 | $aggregateRootTypeString = $message->aggregateRootType(); 30 | 31 | !is_null($aggregateRootTypeString) || throw new \LogicException('Expected $message to have an aggregate root type.'); 32 | 33 | /** @phpstan-var class-string> $aggregateRootType */ 34 | $aggregateRootType = $this->classNameInflector->typeToClassName($aggregateRootTypeString); 35 | 36 | if (!in_array(AggregateRootIdAware::class, class_implements($aggregateRootType) ?: [])) { 37 | throw new \RuntimeException("Cannot extract Aggregate Root ID from \"$aggregateRootType\" because it does not implement AggregateRootIdAware."); 38 | } 39 | 40 | $aggregateRootIdType = $aggregateRootType::aggregateRootIdClassName(); 41 | 42 | $value = IdentityInflection::toObject( 43 | $identityType ?? $aggregateRootIdType, 44 | $message->aggregateRootId() 45 | ); 46 | 47 | if (is_null($value)) { 48 | throw new \RuntimeException('Expected $message to have an aggregate root id'); 49 | } 50 | 51 | return $value; 52 | } 53 | 54 | /** 55 | * @template T of AggregateRootId 56 | * 57 | * @param class-string $identityType 58 | * 59 | * @phpstan-return T|AggregateRootId|null $value 60 | */ 61 | public function extractOptionalAggregateRootIdFromHeader(Message $message, string $identityType, string $headerName): ?AggregateRootId 62 | { 63 | return IdentityInflection::toObject( 64 | $identityType, 65 | $message->header($headerName) 66 | ); 67 | } 68 | 69 | /** 70 | * @template T of AggregateRootId 71 | * 72 | * @param class-string $identityType 73 | * 74 | * @phpstan-return T|AggregateRootId $value 75 | */ 76 | public function extractAggregateRootIdFromHeader(Message $message, string $identityType, string $headerName): AggregateRootId 77 | { 78 | $value = $this->extractOptionalAggregateRootIdFromHeader($message, $identityType, $headerName); 79 | 80 | if (is_null($value)) { 81 | throw new \RuntimeException('Expected $message to have an identity'); 82 | } 83 | 84 | return $value; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Testing/TestObject.php: -------------------------------------------------------------------------------- 1 | */ 13 | private array $arguments = []; 14 | 15 | /** @var list|callable> */ 16 | private mixed $defaults = []; 17 | 18 | /** 19 | * @param class-string $className 20 | */ 21 | final private function __construct( 22 | private readonly string $className, 23 | ) { 24 | } 25 | 26 | /** 27 | * @param class-string $className 28 | */ 29 | public static function ofType(string $className): static 30 | { 31 | return (new static($className)) 32 | ->withDefaults(static::defaultDefinition(...)); 33 | } 34 | 35 | /** 36 | * @param array $arguments 37 | * 38 | * @throws \ReflectionException 39 | */ 40 | public function fromPartial(array $arguments): static 41 | { 42 | $instance = clone $this; 43 | 44 | $reflectionClas = new \ReflectionClass($instance->className); 45 | 46 | $constructor = $reflectionClas->getConstructor(); 47 | 48 | if (is_null($constructor)) { 49 | throw new \RuntimeException('No constructor found for class "'.$instance->className.'".'); 50 | } 51 | 52 | foreach ($constructor->getParameters() as $index => $parameter) { 53 | if (!array_key_exists($index, $arguments)) { 54 | break; 55 | } 56 | 57 | if (is_null($arguments[$index]) && !$parameter->isOptional()) { 58 | continue; 59 | } 60 | 61 | $instance->arguments[$parameter->getName()] = $arguments[$index]; 62 | } 63 | 64 | return $instance; 65 | } 66 | 67 | /** 68 | * @param array|callable $definition 69 | * 70 | * @return $this 71 | */ 72 | public function withDefaults(mixed $definition): static 73 | { 74 | $instance = clone $this; 75 | $instance->defaults[] = $definition; 76 | 77 | return $instance; 78 | } 79 | 80 | /** 81 | * @return T 82 | */ 83 | public function build(): object 84 | { 85 | $className = $this->className; 86 | 87 | $arguments = array_map(fn ($argument) => is_callable($argument) ? $argument() : $argument, $this->arguments); 88 | 89 | /** @var array $definition */ 90 | $definition = call_user_func_array( 91 | 'array_merge', 92 | array_map(fn ($defaults) => is_callable($defaults) ? ($defaults)() : $defaults, $this->defaults) 93 | ); 94 | 95 | foreach ($definition as $argumentName => $argumentValue) { 96 | if (array_key_exists($argumentName, $arguments)) { 97 | continue; 98 | } 99 | 100 | $arguments[$argumentName] = is_callable($argumentValue) ? $argumentValue() : $argumentValue; 101 | } 102 | 103 | return new $className(...$arguments); 104 | } 105 | 106 | public static function defaultDefinition(): array 107 | { 108 | return []; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/AggregateRoot/EventSourcedAggregateRootRepository.php: -------------------------------------------------------------------------------- 1 | 28 | * 29 | * @property class-string $aggregateRootClassName 30 | */ 31 | final readonly class EventSourcedAggregateRootRepository implements AggregateRootRepository 32 | { 33 | private readonly MessagePreparation $messagePreparation; 34 | 35 | /** 36 | * @param class-string $aggregateRootClassName 37 | */ 38 | public function __construct( 39 | private string $aggregateRootClassName, 40 | private Transaction $transaction, 41 | private MessageRepository $messageRepository, 42 | ?MessagePreparation $messagePreparation = null, 43 | private ?MessageDispatcher $transactionalMessageDispatcher = null, 44 | private ?MessageDispatcher $synchronousMessageDispatcher = null, 45 | private ?OutboxRepository $outboxRepository = null, 46 | ) { 47 | $this->messagePreparation = $messagePreparation ?? new DefaultMessagePreparation(); 48 | } 49 | 50 | private function retrieveAllEvents(AggregateRootId $aggregateRootId): Generator 51 | { 52 | $messages = $this->messageRepository->retrieveAll($aggregateRootId); 53 | 54 | foreach ($messages as $message) { 55 | yield $message->payload(); 56 | } 57 | 58 | return $messages->getReturn(); 59 | } 60 | 61 | /** 62 | * @return T 63 | */ 64 | public function retrieve(AggregateRootId $aggregateRootId): object 65 | { 66 | try { 67 | $className = $this->aggregateRootClassName; 68 | $events = $this->retrieveAllEvents($aggregateRootId); 69 | 70 | return $className::reconstituteFromEvents($aggregateRootId, $events); 71 | } catch (Throwable $throwable) { 72 | throw UnableToReconstituteAggregateRoot::becauseOf($throwable->getMessage(), $throwable); 73 | } 74 | } 75 | 76 | public function persist(object $aggregateRoot): void 77 | { 78 | assert($aggregateRoot instanceof AggregateRoot, 'Expected $aggregateRoot to be an instance of '.AggregateRoot::class); 79 | 80 | $this->persistEvents( 81 | $aggregateRoot->aggregateRootId(), 82 | $aggregateRoot->aggregateRootVersion(), 83 | ...$aggregateRoot->releaseEvents() 84 | ); 85 | } 86 | 87 | public function persistEvents(AggregateRootId $aggregateRootId, int $aggregateRootVersion, object ...$events): void 88 | { 89 | $messages = $this->messagePreparation->prepareMessages( 90 | $this->aggregateRootClassName, 91 | $aggregateRootId, 92 | $aggregateRootVersion, 93 | ...$events 94 | ); 95 | 96 | if (count($messages) === 0) { 97 | return; 98 | } 99 | 100 | try { 101 | $this->transaction->begin(); 102 | 103 | try { 104 | $this->messageRepository->persist(...$messages); 105 | $this->outboxRepository?->persist(...$messages); 106 | $this->transactionalMessageDispatcher?->dispatch(...$messages); 107 | 108 | $this->transaction->commit(); 109 | } catch (Throwable $exception) { 110 | $this->transaction->rollBack(); 111 | throw $exception; 112 | } 113 | } catch (Throwable $exception) { 114 | throw UnableToPersistMessages::dueTo('', $exception); 115 | } 116 | 117 | $this->synchronousMessageDispatcher?->dispatch(...$messages); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Testing/AggregateRootTestingBehavior.php: -------------------------------------------------------------------------------- 1 | */ 31 | private static string $aggregateRootType; 32 | 33 | public Scenario $scenario; 34 | 35 | /** 36 | * @phpstan-var T2 37 | */ 38 | private AggregateRootId $aggregateRootId; 39 | 40 | private InMemoryMessageRepository $messageRepository; 41 | 42 | private MessagePreparation $messagePreparation; 43 | 44 | private Transaction $transaction; 45 | 46 | private MessageDecorator $messageDecorator; 47 | 48 | private ClassNameInflector $classNameInflector; 49 | 50 | private mixed $scenarioConfiguration; 51 | 52 | /** 53 | * @return T2 54 | */ 55 | protected static function newAggregateRootId(): AggregateRootId 56 | { 57 | /** @phpstan-var class-string> $aggregateRootType */ 58 | $aggregateRootType = self::aggregateRootType(); 59 | 60 | if (!in_array(AggregateRootIdGeneration::class, class_implements($aggregateRootType), true)) { 61 | throw new \RuntimeException(sprintf( 62 | 'Aggregate root type "%s" must implement "%s" or define "%s::newAggregateRootId()" directly.', 63 | $aggregateRootType, 64 | AggregateRootIdAware::class, 65 | static::class, 66 | )); 67 | } 68 | 69 | return $aggregateRootType::generateAggregateRootId(); 70 | } 71 | 72 | /** 73 | * @param class-string $aggregateRootType 74 | */ 75 | protected static function setAggregateRootType(string $aggregateRootType): void 76 | { 77 | self::$aggregateRootType = $aggregateRootType; 78 | } 79 | 80 | /** 81 | * @return class-string|class-string> 82 | */ 83 | protected static function aggregateRootType(): string 84 | { 85 | if (!isset(self::$aggregateRootType)) { 86 | throw new \LogicException('No aggregate root type specified. Did you forget to call self::setAggregateRootType() from setUpScenario()?'); 87 | } 88 | 89 | return self::$aggregateRootType; 90 | } 91 | 92 | /** 93 | * @return list 94 | */ 95 | protected function messageDecorators(): array 96 | { 97 | return []; 98 | } 99 | 100 | /** 101 | * @return T2 102 | */ 103 | protected function aggregateRootId(): AggregateRootId 104 | { 105 | if (!isset($this->aggregateRootId)) { 106 | $this->aggregateRootId = self::newAggregateRootId(); 107 | } 108 | 109 | return $this->aggregateRootId; 110 | } 111 | 112 | protected function messageRepository(): InMemoryMessageRepository 113 | { 114 | if (!isset($this->messageRepository)) { 115 | $this->messageRepository = new InMemoryMessageRepository(); 116 | } 117 | 118 | return $this->messageRepository; 119 | } 120 | 121 | protected function messagePreparation(): MessagePreparation 122 | { 123 | if (!isset($this->messagePreparation)) { 124 | $this->messagePreparation = new DefaultMessagePreparation( 125 | $this->messageDecorator(), 126 | $this->classNameInflector() 127 | ); 128 | } 129 | 130 | return $this->messagePreparation; 131 | } 132 | 133 | protected function transaction(): Transaction 134 | { 135 | if (!isset($this->transaction)) { 136 | $this->transaction = new NoTransaction(); 137 | } 138 | 139 | return $this->transaction; 140 | } 141 | 142 | protected function messageDecorator(): MessageDecorator 143 | { 144 | if (!isset($this->messageDecorator)) { 145 | $messageDecorators = array_merge( 146 | [new DefaultHeadersDecorator()], 147 | $this->messageDecorators() 148 | ); 149 | 150 | $this->messageDecorator = new MessageDecoratorChain(...$messageDecorators); 151 | } 152 | 153 | return $this->messageDecorator; 154 | } 155 | 156 | protected function classNameInflector(): ClassNameInflector 157 | { 158 | if (!isset($this->classNameInflector)) { 159 | $this->classNameInflector = new DotSeparatedSnakeCaseInflector(); 160 | } 161 | 162 | return $this->classNameInflector; 163 | } 164 | 165 | abstract protected function setUpScenario(): void; 166 | 167 | /** 168 | * @before 169 | */ 170 | protected function setUpScenarioBeforeHook(): void 171 | { 172 | $this->setUpScenario(); 173 | 174 | $this->scenario = $this->applyScenarioConfiguration(Scenario::new() 175 | ->usingMessageRepository($this->messageRepository()) 176 | ->usingMessagePreparation($this->messagePreparation()) 177 | ->withAggregateRootId($this->aggregateRootId())); 178 | } 179 | 180 | protected function applyScenarioConfiguration(Scenario $scenario): Scenario 181 | { 182 | if (!isset($this->scenarioConfiguration)) { 183 | return $scenario; 184 | } 185 | 186 | $scenarioConfiguration = $this->scenarioConfiguration; 187 | 188 | return $scenarioConfiguration($scenario); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Serialization/EdgeAwareReflectionSerializing.php: -------------------------------------------------------------------------------- 1 | getProperties() as $property) { 20 | $key = $property->getName(); 21 | $value = $property->getValue($this); 22 | 23 | $type = $property->getType(); 24 | 25 | if (!is_null($value) && $type instanceof ReflectionNamedType && !$type->isBuiltin()) { 26 | /** @phpstan-var class-string|object $value */ 27 | $targetClass = new ReflectionClass($value); 28 | 29 | if ($targetClass->implementsInterface(SerializablePayloadEdgeKey::class)) { 30 | /** @phpstan-var class-string $value */ 31 | $key = $value::getPayloadKey(); 32 | } 33 | 34 | $factoryMethodName = sprintf('%sEdgeValueToPayload', $key); 35 | 36 | if (method_exists($this, $factoryMethodName)) { 37 | $convertedValue = $this->$factoryMethodName($value); 38 | 39 | $value = $convertedValue; 40 | } elseif ($object->implementsInterface(CustomizedSerializablePayloadEdgeValue::class) && $this->hasCustomSerializablePayloadEdgeValue($targetClass->getName(), $key)) { 41 | $value = $this->toCustomSerializablePayloadEdgeValue($targetClass->getName(), $key, $value); 42 | } elseif ($targetClass->implementsInterface(SerializablePayloadEdgeValue::class)) { 43 | /** @var SerializablePayloadEdgeValue $value */ 44 | $convertedValue = $value->toPayloadValue(); 45 | 46 | $value = $convertedValue; 47 | } elseif ($targetClass->implementsInterface(SerializablePayload::class)) { 48 | /** @var SerializablePayload $value */ 49 | $convertedValue = $value->toPayload(); 50 | 51 | $value = $convertedValue; 52 | } 53 | 54 | if ($targetClass->implementsInterface(DateTimeInterface::class) && $value) { 55 | /** @var DateTimeInterface $value */ 56 | $convertedValue = $value->format('Y-m-d\TH:i:s.uP'); 57 | 58 | $value = $convertedValue; 59 | } 60 | } 61 | 62 | $payload[$key] = $value; 63 | } 64 | 65 | return $payload; 66 | } 67 | 68 | /** 69 | * @throws \ReflectionException 70 | */ 71 | public static function fromPayload(array $payload): static 72 | { 73 | $class = new ReflectionClass(static::class); 74 | 75 | if (!$class->getConstructor()) { 76 | return $class->newInstance(); 77 | } 78 | 79 | $args = []; 80 | 81 | foreach ($class->getConstructor()->getParameters() as $parameter) { 82 | $constructorKey = $key = $parameter->getName(); 83 | 84 | $type = $parameter->getType(); 85 | 86 | if ($type instanceof \ReflectionUnionType || in_array($type->getName(), ['mixed', 'object'])) { 87 | if ($class->hasProperty($key)) { 88 | $type = $class->getProperty($key)?->getType(); 89 | } 90 | } 91 | 92 | if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { 93 | $targetClassName = $type->getName(); 94 | 95 | $targetClass = new ReflectionClass($targetClassName); 96 | 97 | if ($targetClass->implementsInterface(SerializablePayloadEdgeKey::class)) { 98 | /** @var SerializablePayloadEdgeKey $targetClassName */ 99 | $key = $targetClassName::getPayloadKey(); 100 | } 101 | 102 | if (!array_key_exists($key, $payload)) { 103 | continue; 104 | } 105 | 106 | if (!is_null($payload[$key]) && $targetClass->implementsInterface(DateTimeInterface::class)) { 107 | $payload[$key] = $targetClass->newInstance($payload[$key]); 108 | } 109 | 110 | $factoryMethodName = sprintf('%sEdgeValueFromPayload', $key); 111 | 112 | if ($class->hasMethod($factoryMethodName)) { 113 | $payload[$key] = static::$factoryMethodName($payload[$key]); 114 | } elseif ($class->implementsInterface(CustomizedSerializablePayloadEdgeValue::class) && static::hasCustomSerializablePayloadEdgeValue($targetClass->getName(), $key)) { 115 | $payload[$key] = static::fromCustomSerializablePayloadEdgeValue($targetClass->getName(), $key, $payload[$key]); 116 | } elseif (!is_null($payload[$key]) && $targetClass->implementsInterface(SerializablePayloadEdgeValue::class)) { 117 | $method = $targetClass->hasMethod('fromPayloadValue') 118 | ? $targetClass->getMethod('fromPayloadValue') 119 | : null; 120 | 121 | if (!$method || $method->isAbstract()) { 122 | throw new \RuntimeException(sprintf('Needed to call `%s::fromPayloadValue($payload[\'%s\'])` to unserialize %s, but the `$%s->%s` property is likely typed to an interface or abstract class. Consider using `CustomizedSerializablePayloadEdgeValue` to define a custom serialization strategy for this property.', $targetClass->getShortName(), $key, $class->getShortName(), lcfirst($class->getShortName()), $key)); 123 | } 124 | 125 | /** @var SerializablePayloadEdgeValue $targetClassName */ 126 | $payload[$key] = $targetClassName::fromPayloadValue($payload[$key]); 127 | } elseif (!is_null($payload[$key]) && $targetClass->implementsInterface(SerializablePayload::class)) { 128 | $method = $targetClass->hasMethod('fromPayload') 129 | ? $targetClass->getMethod('fromPayload') 130 | : null; 131 | 132 | if (!$method || $method->isAbstract()) { 133 | throw new \RuntimeException(sprintf('Needed to call `%s::fromPayload($payload[\'%s\'])` to unserialize %s, but the `$%s->%s` property is likely typed to an interface or abstract class. Consider using `CustomizedSerializablePayloadEdgeValue` to define a custom serialization strategy for this property.', $targetClass->getShortName(), $key, $class->getShortName(), lcfirst($class->getShortName()), $key)); 134 | } 135 | 136 | /** @var SerializablePayload $targetClassName */ 137 | $payload[$key] = $targetClassName::fromPayload($payload[$key]); 138 | } 139 | } 140 | 141 | if (array_key_exists($key, $payload)) { 142 | $args[$constructorKey] = $payload[$key]; 143 | } 144 | } 145 | 146 | return $class->newInstance(...$args); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Testing/GivenWhenThen/Scenario.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private string $aggregateRootType; 24 | private AggregateRootId $aggregateRootId; 25 | private Given $given; 26 | private When $when; 27 | private Outcome $outcome; 28 | private mixed $handler; 29 | private InMemoryMessageRepository $messageRepository; 30 | private MessagePreparation $messagePreparation; 31 | private PayloadSerializer $payloadSerializer; 32 | private \Closure $visitMessages; 33 | private \Closure $visitEvents; 34 | 35 | /** 36 | * @var \Throwable|class-string<\Throwable> 37 | */ 38 | private \Throwable|string $expect; 39 | 40 | private function __construct( 41 | ) { 42 | } 43 | 44 | public static function new(): self 45 | { 46 | return new self(); 47 | } 48 | 49 | public function withAggregateRootId(AggregateRootId $aggregateRootId): self 50 | { 51 | $instance = clone $this; 52 | $instance->aggregateRootId = $aggregateRootId; 53 | 54 | if ($aggregateRootId instanceof AggregateRootAware) { 55 | $instance->aggregateRootType = $aggregateRootId->aggregateRootClassName(); 56 | } 57 | 58 | return $instance; 59 | } 60 | 61 | /** 62 | * @template T of object 63 | * 64 | * @param T ...$events 65 | */ 66 | public function given(object ...$events): self 67 | { 68 | $instance = clone $this; 69 | $instance->given = new Given(...$events); 70 | 71 | return $instance; 72 | } 73 | 74 | public function when(object $command): self 75 | { 76 | $instance = clone $this; 77 | $instance->when = new When($command); 78 | 79 | return $instance; 80 | } 81 | 82 | public function then(object ...$event): self 83 | { 84 | $instance = clone $this; 85 | $instance->outcome = count($event) > 1 ? new ThenEvents(...$event) : new ThenEvent($event[0]); 86 | 87 | return $instance; 88 | } 89 | 90 | public function thenNothing(): self 91 | { 92 | $instance = clone $this; 93 | $instance->outcome = new ThenNothing(); 94 | 95 | return $instance; 96 | } 97 | 98 | public function handledBy(mixed $handler): self 99 | { 100 | $instance = clone $this; 101 | $instance->handler = $handler; 102 | 103 | return $instance; 104 | } 105 | 106 | /** 107 | * @param \Throwable|class-string<\Throwable> $expect 108 | */ 109 | public function expect(\Throwable|string $expect): self 110 | { 111 | $instance = clone $this; 112 | $instance->expect = $expect; 113 | 114 | return $instance->thenNothing(); 115 | } 116 | 117 | public function visitMessages(\Closure $visitMessages): self 118 | { 119 | $instance = clone $this; 120 | $instance->visitMessages = $visitMessages; 121 | 122 | return $instance; 123 | } 124 | 125 | public function visitEvents(\Closure $visitEvents): self 126 | { 127 | $instance = clone $this; 128 | $instance->visitEvents = $visitEvents; 129 | 130 | return $instance; 131 | } 132 | 133 | public function assert(): self 134 | { 135 | $instance = clone $this; 136 | 137 | $messageRepository = $this->messageRepository ?? new InMemoryMessageRepository(); 138 | 139 | if (isset($instance->given)) { 140 | $messagePreparation = $this->messagePreparation ?? new DefaultMessagePreparation(); 141 | 142 | $messages = $messagePreparation->prepareMessages( 143 | $instance->aggregateRootType, 144 | $instance->aggregateRootId, 145 | count($instance->given->events), 146 | ...array_map(fn ($event) => $event instanceof TestObject ? $event->build() : $event, $instance->given->events) 147 | ); 148 | 149 | $messageRepository->persist(...$messages); 150 | 151 | $messageRepository->purgeLastCommit(); 152 | 153 | if (isset($instance->visitMessages)) { 154 | ($instance->visitMessages)($messages); 155 | } 156 | 157 | if (isset($instance->visitEvents)) { 158 | ($instance->visitEvents)(...array_map(fn (Message $item) => $item->payload(), $messages)); 159 | } 160 | } 161 | 162 | $handler = $instance->handler; 163 | 164 | if (!is_callable($handler)) { 165 | if (!is_object($handler)) { 166 | throw new \InvalidArgumentException('Handler must be an object'); 167 | } 168 | 169 | if (!method_exists($handler, 'handle')) { 170 | throw new \InvalidArgumentException('Handler must have a "handle" method'); 171 | } 172 | 173 | $handler = [$handler, 'handle']; 174 | } 175 | 176 | $caughtException = false; 177 | 178 | try { 179 | ($handler)($instance->when->command); 180 | } catch (\Throwable $throwable) { 181 | if (isset($instance->expect)) { 182 | if (is_string($instance->expect)) { 183 | Assert::assertInstanceOf($instance->expect, $throwable); 184 | } else { 185 | Assert::assertEquals($instance->expect, $throwable); 186 | } 187 | 188 | $caughtException = true; 189 | } else { 190 | throw $throwable; 191 | } 192 | } 193 | 194 | $recordedEvents = $messageRepository->lastCommit(); 195 | 196 | if (isset($instance->expect) && !$caughtException) { 197 | Assert::fail('Expected exception not thrown.'); 198 | } 199 | 200 | if (!isset($instance->outcome)) { 201 | Assert::fail('Expected an outcome.'); 202 | } 203 | 204 | if ($instance->outcome instanceof ThenNothing) { 205 | Assert::assertCount(0, $recordedEvents, 'Expected no events.'); 206 | } 207 | 208 | if ($instance->outcome instanceof ThenEvent) { 209 | Assert::assertCount(1, $recordedEvents, 'Expected exactly one event.'); 210 | 211 | $recordedEvent = $recordedEvents[0]; 212 | $expectedEvent = $instance->outcome->event; 213 | 214 | if ($expectedEvent instanceof TestObject) { 215 | $expectedEvent = $expectedEvent->build(); 216 | } 217 | 218 | Assert::assertEquals($expectedEvent, $recordedEvent); 219 | 220 | $this->assertObjectSurvivesSerializationRoundTrip($recordedEvent); 221 | } 222 | 223 | if ($instance->outcome instanceof ThenEvents) { 224 | $expectedEvents = array_values($instance->outcome->events); 225 | 226 | Assert::assertGreaterThan( 227 | 1, 228 | count($expectedEvents), 229 | 'Should expect at least two events.' 230 | ); 231 | 232 | Assert::assertCount( 233 | count($expectedEvents), 234 | $recordedEvents, 235 | sprintf('Expected exactly %d events.', count($expectedEvents)) 236 | ); 237 | 238 | while (count($recordedEvents) > 0) { 239 | $recordedEvent = array_shift($recordedEvents); 240 | $expectedEvent = array_shift($expectedEvents); 241 | 242 | if ($expectedEvent instanceof TestObject) { 243 | $expectedEvent = $expectedEvent->build(); 244 | } 245 | 246 | Assert::assertEquals($expectedEvent, $recordedEvent); 247 | 248 | $this->assertObjectSurvivesSerializationRoundTrip($recordedEvent); 249 | } 250 | } 251 | 252 | $this->assertObjectSurvivesSerializationRoundTrip($instance->when->command); 253 | 254 | return $instance; 255 | } 256 | 257 | public function usingMessagePreparation(MessagePreparation $messagePreparation): self 258 | { 259 | $instance = clone $this; 260 | $instance->messagePreparation = $messagePreparation; 261 | 262 | return $instance; 263 | } 264 | 265 | public function usingMessageRepository(InMemoryMessageRepository $messageRepository): self 266 | { 267 | $instance = clone $this; 268 | $instance->messageRepository = $messageRepository; 269 | 270 | return $instance; 271 | } 272 | 273 | public function assertObjectSurvivesSerializationRoundTrip(mixed $input): void 274 | { 275 | if (!isset($this->payloadSerializer)) { 276 | $this->payloadSerializer = DefaultPayloadSerializer::resolve(); 277 | } 278 | 279 | assert(is_object($input), 'Input must be an object.'); 280 | 281 | $serializedPayload = $this->payloadSerializer->serializePayload($input); 282 | $jsonEncodedSerializedPayload = json_encode($serializedPayload); 283 | 284 | assert(is_string($jsonEncodedSerializedPayload), 'JSON encoding failed.'); 285 | 286 | $jsonDecodedSerializedPayload = json_decode($jsonEncodedSerializedPayload, true); 287 | 288 | assert(is_array($jsonDecodedSerializedPayload), 'JSON decoding failed.'); 289 | 290 | $unserializedPayload = $this->payloadSerializer->unserializePayload(get_class($input), $jsonDecodedSerializedPayload); 291 | 292 | Assert::assertEquals($input, $unserializedPayload, 'Payload serialization failed round trip.'); 293 | } 294 | } 295 | --------------------------------------------------------------------------------