Skip to content

Commit 1f7d8fb

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

19 files changed

Lines changed: 449 additions & 2 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 Initialize
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.

docs/pages/command_bus.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ final class Profile extends BasicAggregateRoot
191191
// ... apply methods
192192
}
193193
```
194+
!!! tip
195+
196+
If you want to automatically initialize an aggregate if it cannot be found in the store,
197+
you can use the [Auto Initialize](aggregate.md#auto-initialize) feature.
198+
194199
#### Inject Service
195200

196201
You can inject services into aggregate handler methods.

docs/pages/repository.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,17 @@ $profile = $repository->load($id);
198198
!!! warning
199199

200200
When the method is called, the aggregate is always reloaded and rebuilt from the database.
201-
201+
202202
!!! note
203203

204204
You can only fetch one aggregate at a time and don't do any complex queries either.
205205
Projections are used for this purpose.
206-
206+
207+
!!! tip
208+
209+
If you want to automatically initialize an aggregate if it cannot be found in the store,
210+
you can use the [Auto Initialize](aggregate.md#auto-initialize) feature.
211+
207212
### Has an aggregate
208213

209214
You can also check whether an `aggregate` with a certain id exists.

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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
/** @experimental */
11+
final class InvalidAggregate extends RepositoryException
12+
{
13+
public function __construct(
14+
string $autoInitializeMethod,
15+
string $aggregateRootClass,
16+
mixed $return,
17+
) {
18+
parent::__construct(sprintf(
19+
'The method "%s" in "%s" returned "%s". Expected an instance of "%s".',
20+
$autoInitializeMethod,
21+
$aggregateRootClass,
22+
get_debug_type($return),
23+
$aggregateRootClass,
24+
));
25+
}
26+
}

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+
}

0 commit comments

Comments
 (0)