Skip to content

Commit e24cdd9

Browse files
committed
add auto initializable aggregate feature
1 parent 8516bba commit e24cdd9

17 files changed

Lines changed: 436 additions & 0 deletions

docs/pages/aggregate.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,53 @@ final class Order extends BasicAggregateRoot
876876
}
877877
}
878878
```
879+
880+
## Auto Initializable Aggregate
881+
882+
??? example "Experimental"
883+
884+
This feature is still experimental and may change in the future.
885+
Use it with caution.
886+
887+
Sometimes you want to be able to access an aggregate even if it has not yet been created in the system.
888+
In this case, the aggregate should be automatically initialized if it cannot be found in the store.
889+
To achieve this, the aggregate must mark the initialization method with the `AutoInitialize` attribute.
890+
The method must be static, receives the aggregate ID as an argument and must return an instance of the aggregate.
891+
892+
```php
893+
use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
894+
use Patchlevel\EventSourcing\Aggregate\Uuid;
895+
use Patchlevel\EventSourcing\Attribute\Aggregate;
896+
use Patchlevel\EventSourcing\Attribute\Apply;
897+
use Patchlevel\EventSourcing\Attribute\AutoInitialize;
898+
use Patchlevel\EventSourcing\Attribute\Id;
899+
900+
#[Aggregate('profile')]
901+
final class Profile extends BasicAggregateRoot
902+
{
903+
#[Id]
904+
private Uuid $id;
905+
906+
#[AutoInitialize]
907+
public static function initialize(Uuid $id): static
908+
{
909+
$self = new static();
910+
$self->recordThat(new ProfileCreated($id));
911+
912+
return $self;
913+
}
914+
915+
#[Apply]
916+
public function applyProfileCreated(ProfileCreated $event): void
917+
{
918+
$this->id = $event->id;
919+
}
920+
}
921+
```
922+
!!! note
923+
924+
Recording events in the `initialize` method is optional but recommended.
925+
879926
## Aggregate Root Registry
880927

881928
The library needs to know about all aggregates so that the correct aggregate class is used to load from the database.

src/Attribute/AutoInitialize.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Attribute;
6+
7+
use Attribute;
8+
9+
/** @experimental */
10+
#[Attribute(Attribute::TARGET_METHOD)]
11+
final class AutoInitialize
12+
{
13+
}

src/Metadata/AggregateRoot/AggregateRootMetadata.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public function __construct(
2828
/** @var list<string> */
2929
public readonly array $childAggregates = [],
3030
string|null $streamName = null,
31+
public readonly string|null $autoInitializeMethod = null,
3132
) {
3233
$this->streamName = $streamName ?? $this->name . '-{id}';
3334
}

src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
88
use Patchlevel\EventSourcing\Attribute\Aggregate;
99
use Patchlevel\EventSourcing\Attribute\Apply;
10+
use Patchlevel\EventSourcing\Attribute\AutoInitialize;
1011
use Patchlevel\EventSourcing\Attribute\ChildAggregate;
1112
use Patchlevel\EventSourcing\Attribute\Id;
1213
use Patchlevel\EventSourcing\Attribute\SharedApplyContext;
@@ -52,6 +53,7 @@ public function metadata(string $aggregate): AggregateRootMetadata
5253
[$suppressEvents, $suppressAll] = $this->findSuppressMissingApply($reflectionClass);
5354
$applyMethods = $this->findApplyMethods($reflectionClass, $aggregate, $childAggregates);
5455
$snapshot = $this->findSnapshot($reflectionClass);
56+
$autoInitializeMethod = $this->findAutoInitializeMethod($reflectionClass);
5557

5658
$metadata = new AggregateRootMetadata(
5759
$aggregate,
@@ -63,6 +65,7 @@ public function metadata(string $aggregate): AggregateRootMetadata
6365
$snapshot,
6466
array_map(static fn (array $list) => $list[0], $childAggregates),
6567
$this->findStreamName($reflectionClass),
68+
$autoInitializeMethod,
6669
);
6770

6871
$this->aggregateMetadata[$aggregate] = $metadata;
@@ -176,6 +179,19 @@ private function findStreamName(ReflectionClass $reflector): string|null
176179
return $attributes[0]->newInstance()->name;
177180
}
178181

182+
private function findAutoInitializeMethod(ReflectionClass $reflector): string|null
183+
{
184+
foreach ($reflector->getMethods() as $method) {
185+
$attributes = $method->getAttributes(AutoInitialize::class);
186+
187+
if ($attributes !== []) {
188+
return $method->getName();
189+
}
190+
}
191+
192+
return null;
193+
}
194+
179195
/** @return list<array{string, ReflectionClass}> */
180196
private function findChildAggregates(ReflectionClass $reflector): array
181197
{

src/Repository/DefaultRepository.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
use function array_map;
4141
use function assert;
4242
use function count;
43+
use function is_a;
44+
use function is_object;
4345
use function sprintf;
4446

4547
/**
@@ -138,6 +140,30 @@ public function load(AggregateRootId $id): AggregateRoot
138140
$firstMessage = $stream->current();
139141

140142
if ($firstMessage === null) {
143+
if ($this->metadata->autoInitializeMethod) {
144+
$aggregate = $this->metadata->className::{$this->metadata->autoInitializeMethod}($id);
145+
146+
if (!is_object($aggregate) || !is_a($aggregate, $this->metadata->className, true)) {
147+
throw new InvalidAggregate(
148+
$this->metadata->autoInitializeMethod,
149+
$this->metadata->className,
150+
$aggregate,
151+
);
152+
}
153+
154+
$this->logger->debug(
155+
sprintf(
156+
'Repository: Auto initialize aggregate "%s" with the id "%s".',
157+
$this->metadata->name,
158+
$id->toString(),
159+
),
160+
);
161+
162+
$this->aggregateIsValid[$aggregate] = true;
163+
164+
return $aggregate;
165+
}
166+
141167
$this->logger->debug(
142168
sprintf(
143169
'Repository: Aggregate "%s" with the id "%s" not found.',
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Repository;
6+
7+
use function get_debug_type;
8+
use function sprintf;
9+
10+
final class InvalidAggregate extends RepositoryException
11+
{
12+
public function __construct(
13+
string $autoInitializeMethod,
14+
string $aggregateRootClass,
15+
mixed $return,
16+
) {
17+
parent::__construct(sprintf(
18+
'The method "%s" in "%s" returned "%s". Expected an instance of "%s".',
19+
$autoInitializeMethod,
20+
$aggregateRootClass,
21+
get_debug_type($return),
22+
$aggregateRootClass,
23+
));
24+
}
25+
}

tests/Integration/BasicImplementation/BasicIntegrationTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@
3131
use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore;
3232
use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository;
3333
use Patchlevel\EventSourcing\Tests\DbalManager;
34+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command\AdjustStockForProduct;
3435
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command\ChangeProfileName;
3536
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command\CreateProfile;
37+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command\DecreaseStockForProduct;
3638
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\NameChanged;
3739
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\ProfileCreated;
3840
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\MessageDecorator\FooMessageDecorator;
@@ -392,4 +394,50 @@ public function testQueryBus(): void
392394

393395
self::assertSame('John Doe', $result);
394396
}
397+
398+
public function testAggregateInitialization(): void
399+
{
400+
$store = new DoctrineDbalStore(
401+
$this->connection,
402+
DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']),
403+
DefaultHeadersSerializer::createFromPaths([
404+
__DIR__ . '/Header',
405+
]),
406+
);
407+
408+
$aggregateRootRegistry = new AggregateRootRegistry(['stock' => Stock::class]);
409+
410+
$manager = new DefaultRepositoryManager(
411+
$aggregateRootRegistry,
412+
$store,
413+
null,
414+
new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]),
415+
new FooMessageDecorator(),
416+
);
417+
418+
$commandBus = SyncCommandBus::createForAggregateHandlers(
419+
$aggregateRootRegistry,
420+
$manager,
421+
);
422+
423+
$schemaDirector = new DoctrineSchemaDirector(
424+
$this->connection,
425+
$store,
426+
);
427+
428+
$schemaDirector->create();
429+
430+
$stockId = StockId::create();
431+
$productId = ProductId::generate();
432+
433+
$commandBus->dispatch(new AdjustStockForProduct($stockId, $productId, 5));
434+
$commandBus->dispatch(new DecreaseStockForProduct($stockId, $productId, 3));
435+
436+
$repository = $manager->get(Stock::class);
437+
$stock = $repository->load($stockId);
438+
439+
self::assertEquals($stockId, $stock->aggregateRootId());
440+
self::assertSame(3, $stock->playhead());
441+
self::assertSame(2, $stock->stockFor($productId));
442+
}
395443
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command;
6+
7+
use Patchlevel\EventSourcing\Attribute\Id;
8+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\ProductId;
9+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\StockId;
10+
11+
final readonly class AdjustStockForProduct
12+
{
13+
public function __construct(
14+
#[Id]
15+
public StockId $stockId,
16+
public ProductId $productId,
17+
public int $quantity,
18+
) {
19+
}
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Command;
6+
7+
use Patchlevel\EventSourcing\Attribute\Id;
8+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\ProductId;
9+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\StockId;
10+
11+
final readonly class DecreaseStockForProduct
12+
{
13+
public function __construct(
14+
#[Id]
15+
public StockId $stockId,
16+
public ProductId $productId,
17+
public int $quantity,
18+
) {
19+
}
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events;
6+
7+
use Patchlevel\EventSourcing\Attribute\Event;
8+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\ProductId;
9+
use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\StockId;
10+
11+
#[Event('stock.adjusted')]
12+
final readonly class StockAdjusted
13+
{
14+
public function __construct(
15+
public StockId $stockId,
16+
public ProductId $productId,
17+
public int $quantity,
18+
) {
19+
}
20+
}

0 commit comments

Comments
 (0)