diff --git a/docs/pages/UPGRADE-4.0.md b/docs/pages/UPGRADE-4.0.md index 25749dcd7..28ea17508 100644 --- a/docs/pages/UPGRADE-4.0.md +++ b/docs/pages/UPGRADE-4.0.md @@ -1,5 +1,13 @@ # Upgrade 4.0 +## Aggregates + +### Child Aggregate + +We removed our experimental feature of child aggregates. +This was our first attempt to split aggregates into smaller parts, +but we found a better way to do this with the `Micro Aggregate` feature. + ## Subscription The constructor of the `DefaultSubscriptionEngine` class has been changed. diff --git a/docs/pages/aggregate.md b/docs/pages/aggregate.md index a04efcde4..96c5ba687 100644 --- a/docs/pages/aggregate.md +++ b/docs/pages/aggregate.md @@ -653,7 +653,7 @@ Or for test purposes the `FrozenClock`, which always returns the same time. In some cases, it makes sense to split an aggregate into several smaller aggregates. This can be the case if the aggregate becomes too large or if the aggregate is used in different contexts. -We currently support two patterns for this: Micro Aggregates and Child Aggregates (experimental). +For these cases you can use Micro Aggregates. ### Micro Aggregates @@ -740,96 +740,7 @@ final class Shipping extends BasicAggregateRoot } } ``` -### Child Aggregates -??? example "Experimental" - - This feature is still experimental and may change in the future. - Use it with caution. - -Another way to split an aggregate is to use child aggregates. -The difference to Micro Aggregates, child aggregates can only be accessed by the root aggregate -and are not separate aggregates. - -In the following example, we have an `Order` aggregate that has a `Shipping` child aggregate. - -```php -use Patchlevel\EventSourcing\Aggregate\BasicChildAggregate; -use Patchlevel\EventSourcing\Attribute\Apply; - -final class Shipping extends BasicChildAggregate -{ - private bool $arrived = false; - - public function __construct( - private string $trackingId, - ) { - } - - public function arrive(): void - { - $this->recordThat(new Arrived()); - } - - #[Apply] - public function applyArrived(Arrived $event): void - { - $this->arrived = true; - } - - public function isArrived(): bool - { - return $this->arrived; - } -} -``` -!!! warning - - The apply method must be public, otherwise the root aggregate cannot call it. - -!!! note - - Supress missing apply methods need to be defined in the root aggregate. - -And the `Order` aggregate root looks like this: - -```php -use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; -use Patchlevel\EventSourcing\Attribute\Aggregate; -use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\ChildAggregate; -use Patchlevel\EventSourcing\Attribute\Id; - -#[Aggregate('order')] -final class Order extends BasicAggregateRoot -{ - #[Id] - private Uuid $id; - - #[ChildAggregate] - private Shipping $shipping; - - public static function create(Uuid $id, string $trackingId): static - { - $self = new static(); - $self->recordThat(new OrderCreated($id, $trackingId)); - - return $self; - } - - #[Apply] - public function applyOrderCreated(OrderCreated $event): void - { - $this->shipping = new Shipping($event->trackingId); - } - - public function arrive(): void - { - $this->shipping->arrive(); - } -} -``` ## Aggregate Root Registry The library needs to know about all aggregates so that the correct aggregate class is used to load from the database. diff --git a/src/Aggregate/AggregateRootAttributeBehaviour.php b/src/Aggregate/AggregateRootAttributeBehaviour.php index 075c2bd0f..c8be85875 100644 --- a/src/Aggregate/AggregateRootAttributeBehaviour.php +++ b/src/Aggregate/AggregateRootAttributeBehaviour.php @@ -5,12 +5,9 @@ namespace Patchlevel\EventSourcing\Aggregate; use Patchlevel\Hydrator\Attribute\Ignore; -use Patchlevel\Hydrator\Attribute\PostHydrate; use ReflectionProperty; use function array_key_exists; -use function count; -use function explode; trait AggregateRootAttributeBehaviour { @@ -20,10 +17,6 @@ trait AggregateRootAttributeBehaviour #[Ignore] private AggregateRootId|null $cachedAggregateRootId = null; - /** @var (callable(object $event): void)|null */ - #[Ignore] - private $recorder = null; - protected function apply(object $event): void { $metadata = static::metadata(); @@ -38,58 +31,7 @@ protected function apply(object $event): void $method = $metadata->applyMethods[$event::class]; - if ($metadata->childAggregates === []) { - $this->$method($event); - - return; - } - - $parts = explode('.', $method); - - if (count($parts) === 2) { - [$property, $method] = $parts; - - $child = $this->getChildAggregateByPropertyName($property); - - if ($child !== null) { - $child->$method($event); - } - } else { - $this->$method($event); - } - - $this->passRecorderToChildAggregates(); - } - - #[PostHydrate] - private function passRecorderToChildAggregates(): void - { - $metadata = static::metadata(); - $this->recorder ??= $this->recordThat(...); - - foreach ($metadata->childAggregates as $propertyName) { - $child = $this->getChildAggregateByPropertyName($propertyName); - - if ($child === null) { - continue; - } - - $child->setRecorder($this->recorder); - } - } - - private function getChildAggregateByPropertyName(string $propertyName): ChildAggregate|null - { - $reflectionProperty = new ReflectionProperty($this::class, $propertyName); - - if (!$reflectionProperty->isInitialized($this)) { - return null; - } - - /** @var ChildAggregate|null $child */ - $child = $reflectionProperty->getValue($this); - - return $child; + $this->$method($event); } public function aggregateRootId(): AggregateRootId diff --git a/src/Aggregate/BasicChildAggregate.php b/src/Aggregate/BasicChildAggregate.php deleted file mode 100644 index 526db4473..000000000 --- a/src/Aggregate/BasicChildAggregate.php +++ /dev/null @@ -1,14 +0,0 @@ -recorder)($event); - } - - /** @param callable(object $event): void $recorder */ - public function setRecorder(callable $recorder): void - { - $this->recorder = $recorder; - } -} diff --git a/src/Attribute/ChildAggregate.php b/src/Attribute/ChildAggregate.php deleted file mode 100644 index 6d3f68775..000000000 --- a/src/Attribute/ChildAggregate.php +++ /dev/null @@ -1,13 +0,0 @@ - */ - public readonly array $childAggregates = [], string|null $streamName = null, ) { $this->streamName = $streamName ?? $this->name . '-{id}'; diff --git a/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php b/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php index e622b4910..ffb33075f 100644 --- a/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php +++ b/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php @@ -7,7 +7,6 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\ChildAggregate; use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\EventSourcing\Attribute\Snapshot as AttributeSnapshot; use Patchlevel\EventSourcing\Attribute\Stream; @@ -17,7 +16,6 @@ use ReflectionMethod; use ReflectionNamedType; use ReflectionUnionType; -use RuntimeException; use function array_key_exists; use function array_map; @@ -47,9 +45,8 @@ public function metadata(string $aggregate): AggregateRootMetadata $aggregateName = $this->findAggregateName($reflectionClass); $idProperty = $this->findIdProperty($reflectionClass); - $childAggregates = $this->findChildAggregates($reflectionClass); [$suppressEvents, $suppressAll] = $this->findSuppressMissingApply($reflectionClass); - $applyMethods = $this->findApplyMethods($reflectionClass, $aggregate, $childAggregates); + $applyMethods = $this->findApplyMethods($reflectionClass, $aggregate); $snapshot = $this->findSnapshot($reflectionClass); $metadata = new AggregateRootMetadata( @@ -60,7 +57,6 @@ public function metadata(string $aggregate): AggregateRootMetadata $suppressEvents, $suppressAll, $snapshot, - array_map(static fn (array $list) => $list[0], $childAggregates), $this->findStreamName($reflectionClass), ); @@ -158,60 +154,17 @@ private function findStreamName(ReflectionClass $reflector): string|null return $attributes[0]->newInstance()->name; } - /** @return list */ - private function findChildAggregates(ReflectionClass $reflector): array - { - $properties = $reflector->getProperties(); - $childAggregates = []; - - foreach ($properties as $property) { - $attributes = $property->getAttributes(ChildAggregate::class); - - if ($attributes === []) { - continue; - } - - $reflectionType = $property->getType(); - - if (!$reflectionType instanceof ReflectionNamedType) { - throw new RuntimeException('no intersection / union supported'); - } - - if (!is_a($reflectionType->getName(), \Patchlevel\EventSourcing\Aggregate\ChildAggregate::class, true)) { - throw new RuntimeException('no child'); - } - - $childAggregates[] = [$property->getName(), new ReflectionClass($reflectionType->getName())]; - } - - return $childAggregates; - } - /** - * @param class-string $aggregate - * @param list $childAggregates + * @param class-string $aggregate * * @return array */ - private function findApplyMethods(ReflectionClass $reflector, string $aggregate, array $childAggregates): array + private function findApplyMethods(ReflectionClass $reflector, string $aggregate): array { $applyMethods = []; - /** @var list $methodList */ - $methodList = []; - - foreach ($reflector->getMethods() as $method) { - $methodList[] = [$method->getName(), $method]; - } - - foreach ($childAggregates as [$propertyName, $childReflector]) { - foreach ($childReflector->getMethods() as $method) { - $methodList[] = [$propertyName . '.' . $method->getName(), $method]; - } - } - // process apply methods - foreach ($methodList as [$path, $method]) { + foreach ($reflector->getMethods() as $method) { $attributes = $method->getAttributes(Apply::class); if ($attributes === []) { @@ -234,7 +187,7 @@ private function findApplyMethods(ReflectionClass $reflector, string $aggregate, } if ($hasOneEmptyApply) { - throw new DuplicateEmptyApplyAttribute($path); + throw new DuplicateEmptyApplyAttribute($method->getName()); } $hasOneEmptyApply = true; @@ -242,12 +195,12 @@ private function findApplyMethods(ReflectionClass $reflector, string $aggregate, } if ($hasOneEmptyApply && $hasOneNonEmptyApply) { - throw new MixedApplyAttributeUsage($path); + throw new MixedApplyAttributeUsage($method->getName()); } foreach ($eventClasses as $eventClass) { if (!class_exists($eventClass)) { - throw new ArgumentTypeIsNotAClass($path, $eventClass); + throw new ArgumentTypeIsNotAClass($method->getName(), $eventClass); } if (array_key_exists($eventClass, $applyMethods)) { @@ -255,11 +208,11 @@ private function findApplyMethods(ReflectionClass $reflector, string $aggregate, $aggregate, $eventClass, $applyMethods[$eventClass], - $path, + $method->getName(), ); } - $applyMethods[$eventClass] = $path; + $applyMethods[$eventClass] = $method->getName(); } } diff --git a/tests/Integration/ChildAggregate/ChildAggregateIntegrationTest.php b/tests/Integration/ChildAggregate/ChildAggregateIntegrationTest.php deleted file mode 100644 index df6ddce68..000000000 --- a/tests/Integration/ChildAggregate/ChildAggregateIntegrationTest.php +++ /dev/null @@ -1,172 +0,0 @@ -connection = DbalManager::createConnection(); - } - - public function tearDown(): void - { - $this->connection->close(); - SendEmailMock::reset(); - } - - public function testSuccessful(): void - { - $store = new DoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - ); - - $profileProjector = new ProfileProjector($this->connection); - - $engine = new ThrowOnErrorSubscriptionEngine(new DefaultSubscriptionEngine( - new StoreMessageLoader($store), - new InMemorySubscriptionStore(), - new MetadataSubscriberAccessorRepository([$profileProjector]), - )); - - $manager = new RunSubscriptionEngineRepositoryManager( - new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - null, - null, - ), - $engine, - ); - - $repository = $manager->get(Profile::class); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); - - $schemaDirector->create(); - $engine->setup(skipBooting: true); - - $profileId = ProfileId::generate(); - $profile = Profile::create($profileId, 'John'); - $profile->changeName('Snow'); - $profile->trackView(); - $repository->save($profile); - - $result = $this->connection->fetchAssociative( - 'SELECT * FROM projection_profile WHERE id = ?', - [$profileId->toString()], - ); - - self::assertIsArray($result); - self::assertArrayHasKey('id', $result); - self::assertSame($profileId->toString(), $result['id']); - self::assertSame('Snow', $result['name']); - - $repository = $manager->get(Profile::class); - $profile = $repository->load($profileId); - - self::assertInstanceOf(Profile::class, $profile); - self::assertEquals($profileId, $profile->aggregateRootId()); - self::assertSame(3, $profile->playhead()); - self::assertSame('Snow', $profile->name()); - self::assertSame(1, $profile->views()); - } - - public function testSnapshot(): void - { - $store = new DoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - ); - - $profileProjection = new ProfileProjector($this->connection); - - $engine = new DefaultSubscriptionEngine( - new StoreMessageLoader($store), - new InMemorySubscriptionStore(), - new MetadataSubscriberAccessorRepository([$profileProjection]), - ); - - $manager = new RunSubscriptionEngineRepositoryManager( - new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), - ), - $engine, - ); - - $repository = $manager->get(Profile::class); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); - - $schemaDirector->create(); - $engine->setup(skipBooting: true); - - $profileId = ProfileId::generate(); - $profile = Profile::create($profileId, 'John'); - $repository->save($profile); - - $result = $this->connection->fetchAssociative( - 'SELECT * FROM projection_profile WHERE id = ?', - [$profileId->toString()], - ); - - self::assertIsArray($result); - self::assertArrayHasKey('id', $result); - self::assertSame($profileId->toString(), $result['id']); - self::assertSame('John', $result['name']); - - $repository = $manager->get(Profile::class); - - // create snapshot - $repository->load($profileId); - - // load from snapshot - $profile = $repository->load($profileId); - - $profile->changeName('Snow'); - $profile->trackView(); - $repository->save($profile); - - $profile = $repository->load($profileId); - - self::assertInstanceOf(Profile::class, $profile); - self::assertEquals($profileId, $profile->aggregateRootId()); - self::assertSame(3, $profile->playhead()); - self::assertSame('Snow', $profile->name()); - self::assertSame(1, $profile->views()); - } -} diff --git a/tests/Integration/ChildAggregate/Events/NameChanged.php b/tests/Integration/ChildAggregate/Events/NameChanged.php deleted file mode 100644 index 75c85e6f5..000000000 --- a/tests/Integration/ChildAggregate/Events/NameChanged.php +++ /dev/null @@ -1,16 +0,0 @@ -name = $event->name; - } - - public function name(): string - { - return $this->name; - } - - public function changeName(string $name): void - { - $this->recordThat(new NameChanged($name)); - } -} diff --git a/tests/Integration/ChildAggregate/Profile.php b/tests/Integration/ChildAggregate/Profile.php deleted file mode 100644 index c14279fd2..000000000 --- a/tests/Integration/ChildAggregate/Profile.php +++ /dev/null @@ -1,63 +0,0 @@ -recordThat(new ProfileCreated($id, $name)); - - return $self; - } - - #[Apply(ProfileCreated::class)] - protected function applyProfileCreated(ProfileCreated $event): void - { - $this->id = $event->profileId; - $this->personalInformation = new PersonalInformation($event->name); - $this->views = new Views(); - } - - public function name(): string - { - return $this->personalInformation->name(); - } - - public function views(): int - { - return $this->views->views(); - } - - public function changeName(string $name): void - { - $this->personalInformation->changeName($name); - } - - public function trackView(): void - { - $this->views->trackView(); - } -} diff --git a/tests/Integration/ChildAggregate/ProfileId.php b/tests/Integration/ChildAggregate/ProfileId.php deleted file mode 100644 index 8cbe08456..000000000 --- a/tests/Integration/ChildAggregate/ProfileId.php +++ /dev/null @@ -1,13 +0,0 @@ -addColumn('id', 'string')->setLength(36); - $table->addColumn('name', 'string')->setLength(255); - $table->setPrimaryKey(['id']); - - $this->connection->createSchemaManager()->createTable($table); - } - - #[Teardown] - public function drop(): void - { - $this->connection->createSchemaManager()->dropTable('projection_profile'); - } - - #[Subscribe(ProfileCreated::class)] - public function handleProfileCreated(ProfileCreated $profileCreated): void - { - $this->connection->executeStatement( - 'INSERT INTO projection_profile (id, name) VALUES(:id, :name);', - [ - 'id' => $profileCreated->profileId->toString(), - 'name' => $profileCreated->name, - ], - ); - } - - #[Subscribe(NameChanged::class)] - public function handleNameChanged(NameChanged $nameChanged, ProfileId $profileId): void - { - $this->connection->update( - 'projection_profile', - ['name' => $nameChanged->name], - ['id' => $profileId->toString()], - ); - } -} diff --git a/tests/Integration/ChildAggregate/SendEmailMock.php b/tests/Integration/ChildAggregate/SendEmailMock.php deleted file mode 100644 index 7c6047be1..000000000 --- a/tests/Integration/ChildAggregate/SendEmailMock.php +++ /dev/null @@ -1,25 +0,0 @@ -views++; - } - - public function views(): int - { - return $this->views; - } - - public function trackView(): void - { - $this->recordThat(new ViewTracked()); - } -} diff --git a/tests/Unit/Aggregate/AggregateRootTest.php b/tests/Unit/Aggregate/AggregateRootTest.php index e6e414b56..aea151bb1 100644 --- a/tests/Unit/Aggregate/AggregateRootTest.php +++ b/tests/Unit/Aggregate/AggregateRootTest.php @@ -15,13 +15,8 @@ use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\DuplicateApplyMethod; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; -use Patchlevel\EventSourcing\Tests\Unit\Fixture\ItemAdded; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Message; use Patchlevel\EventSourcing\Tests\Unit\Fixture\MessageId; -use Patchlevel\EventSourcing\Tests\Unit\Fixture\Order; -use Patchlevel\EventSourcing\Tests\Unit\Fixture\OrderCreated; -use Patchlevel\EventSourcing\Tests\Unit\Fixture\OrderId; -use Patchlevel\EventSourcing\Tests\Unit\Fixture\PaymentConfirmed; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; @@ -209,7 +204,7 @@ public function metadata(string $aggregate): AggregateRootMetadata { $this->called = true; - return new AggregateRootMetadata($aggregate, 'name', 'id', [], [], false, null, []); + return new AggregateRootMetadata($aggregate, 'name', 'id', [], [], false, null); } }; @@ -218,47 +213,4 @@ public function metadata(string $aggregate): AggregateRootMetadata Profile::getMetadata(); self::assertTrue($newFactory->called); } - - public function testApplyMethodWithMultipleChildren(): void - { - $id = OrderId::fromString('1'); - $order = Order::create($id); - - self::assertSame($id, $order->aggregateRootId()); - self::assertSame(1, $order->playhead()); - self::assertEquals($id, $order->id()); - self::assertEquals(0, $order->countItem('foo')); - self::assertEquals(0, $order->countItem('bar')); - self::assertEquals(0, $order->countAll()); - self::assertFalse($order->isPayed()); - - $events = $order->releaseEvents(); - self::assertCount(1, $events); - - $event = $events[0]; - - self::assertInstanceOf(OrderCreated::class, $event); - self::assertEquals($id, $event->orderId); - - $order->addItem('foo', 2); - self::assertEquals(2, $order->countItem('foo')); - self::assertEquals(0, $order->countItem('bar')); - self::assertEquals(2, $order->countAll()); - - $order->confirmPayment(); - self::assertTrue($order->isPayed()); - - $events = $order->releaseEvents(); - self::assertCount(2, $events); - - $event = $events[0]; - - self::assertInstanceOf(ItemAdded::class, $event); - self::assertEquals('foo', $event->productId); - self::assertEquals(2, $event->quantity); - - $event = $events[1]; - - self::assertInstanceOf(PaymentConfirmed::class, $event); - } } diff --git a/tests/Unit/Fixture/EmptyEvent.php b/tests/Unit/Fixture/EmptyEvent.php deleted file mode 100644 index 789046af9..000000000 --- a/tests/Unit/Fixture/EmptyEvent.php +++ /dev/null @@ -1,9 +0,0 @@ -id; - } - - public static function create(OrderId $id): self - { - $self = new self(); - $self->recordThat(new OrderCreated($id)); - - return $self; - } - - public function confirmPayment(): void - { - $this->payment->confirmPayment(); - } - - public function addItem(string $productId, int $quantity): void - { - if ($this->isPayed()) { - throw new RuntimeException('No adding possible, already paid!'); - } - - $this->orderItems->addItem($productId, $quantity); - } - - public function isPayed(): bool - { - return $this->payment->isPayed(); - } - - public function countItem(string $productId): int - { - return $this->orderItems->countItem($productId); - } - - public function countAll(): int - { - return $this->orderItems->countAll(); - } - - #[Apply(OrderCreated::class)] - protected function applyOrderCreated(OrderCreated $event): void - { - $this->id = $event->orderId; - $this->orderItems = new OrderItems(); - $this->payment = new Payment(); - } -} diff --git a/tests/Unit/Fixture/OrderCreated.php b/tests/Unit/Fixture/OrderCreated.php deleted file mode 100644 index b94cfc7ab..000000000 --- a/tests/Unit/Fixture/OrderCreated.php +++ /dev/null @@ -1,18 +0,0 @@ -id; - } -} diff --git a/tests/Unit/Fixture/OrderItems.php b/tests/Unit/Fixture/OrderItems.php deleted file mode 100644 index 2f6948e7b..000000000 --- a/tests/Unit/Fixture/OrderItems.php +++ /dev/null @@ -1,41 +0,0 @@ - */ - private array $items = []; - - public function addItem(string $productId, int $quantity): void - { - $this->recordThat(new ItemAdded($productId, $quantity)); - } - - #[Apply] - public function applyItemAdded(ItemAdded $event): void - { - $this->items[$event->productId] = $event->quantity; - } - - public function countItem(string $productId): int - { - return $this->items[$productId] ?? 0; - } - - public function countAll(): int - { - $count = 0; - - foreach ($this->items as $quantity) { - $count += $quantity; - } - - return $count; - } -} diff --git a/tests/Unit/Fixture/Payment.php b/tests/Unit/Fixture/Payment.php deleted file mode 100644 index ce987868e..000000000 --- a/tests/Unit/Fixture/Payment.php +++ /dev/null @@ -1,29 +0,0 @@ -recordThat(new PaymentConfirmed()); - } - - #[Apply(PaymentConfirmed::class)] - public function applyItemAdded(PaymentConfirmed $event): void - { - $this->payed = true; - } - - public function isPayed(): bool - { - return $this->payed; - } -} diff --git a/tests/Unit/Fixture/PaymentConfirmed.php b/tests/Unit/Fixture/PaymentConfirmed.php deleted file mode 100644 index 020188d9d..000000000 --- a/tests/Unit/Fixture/PaymentConfirmed.php +++ /dev/null @@ -1,15 +0,0 @@ -