diff --git a/docs/pages/aggregate.md b/docs/pages/aggregate.md index 3e27093fd..d58483f21 100644 --- a/docs/pages/aggregate.md +++ b/docs/pages/aggregate.md @@ -1,38 +1,44 @@ # Aggregate -!!! abstract +The linchpin of event-sourcing is the aggregate. These aggregates can be imagined like entities in ORM. +One main difference is that we don't save the current state, but only the individual events that led to the state. +This means it is always possible to build the state again from the events. - Aggregate is a pattern in Domain-Driven Design. A DDD aggregate is a cluster of domain objects - that can be treated as a single unit. [...] +!!! note + + The term aggregate itself comes from DDD and has nothing to do with event sourcing and can be used independently as a pattern. + You can find out more about Aggregates [here](https://martinfowler.com/bliki/DDD_Aggregate.html). - [DDD Aggregate - Martin Flower](https://martinfowler.com/bliki/DDD_Aggregate.html) +An aggregate must fulfill a few points so that we can use it in event-sourcing: -An Aggregate has to inherit from `AggregateRoot` and need to implement the method `aggregateRootId`. -`aggregateRootId` is the identifier from `AggregateRoot` like a primary key for an entity. -The events will be added later, but the following is enough to make it executable: +* It must implement the `AggregateRoot` interface. +* It needs a unique identifier. +* It needs to provide the current playhead. +* It must make changes to his state available as events. +* And rebuild/catchup its state from the events. -To register an aggregate you have to set the `Aggregate` attribute over the class, -otherwise it will not be recognized as an aggregate. -There you also have to give the aggregate a name. +We can implement this ourselves, or use the `BasicAggregateRoot` implementation that already brings everything with it. +This basic implementation uses attributes to configure the aggregate and to specify how it should handle events. +We are building a minimal aggregate class here which only has an ID and mark this with the `AggregateId` attribute. +To make it easy to register with a name, we also add the `Aggregate` attribute. This is what it looks like: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\AggregateId; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; + #[AggregateId] + private UuidAggregateRootId $id; - public function aggregateRootId(): string - { - return $this->id; - } - - public static function register(string $id): self + public static function register(UuidAggregateRootId $id): self { $self = new self(); - // todo: record create event + + $self->id = $id; // we need to set the id temporary here for the basic example and will be replaced later. return $self; } @@ -45,7 +51,7 @@ final class Profile extends BasicAggregateRoot !!! tip - An aggregateId can be an **uuid**, you can find more about this [here](./uuid.md). + Find out more about aggregate IDs [here](./aggregate_id.md). We use a so-called named constructor here to create an object of the AggregateRoot. The constructor itself is protected and cannot be called from outside. @@ -77,7 +83,7 @@ final class CreateProfileHandler This is because only events are stored in the database and as long as no events exist, nothing happens. -!!! note +!!! tip A **command bus** system is not necessary, only recommended. The interaction can also easily take place in a controller or service. @@ -85,16 +91,22 @@ final class CreateProfileHandler ## Create a new aggregate In order that an aggregate is actually saved, at least one event must exist in the DB. -For our aggregate we create the Event `ProfileRegistered`: +For our aggregate we create the Event `ProfileRegistered` with an ID and a name. +Since the ID is a complex data type and cannot be easily serialized, we need to define a normalizer for the ID. +We do this with the `UuidAggregateIdNormalizer` attribute. +We also give the event a unique name using the `Event` attribute. ```php +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Serializer\Normalizer\UuidAggregateIdNormalizer; #[Event('profile.registered')] final class ProfileRegistered { public function __construct( - public readonly string $profileId, + #[UuidAggregateIdNormalizer] + public readonly UuidAggregateRootId $profileId, public readonly string $name ) {} } @@ -104,30 +116,28 @@ final class ProfileRegistered You can find out more about events [here](./events.md). -After we have defined the event, we have to adapt the creation of the profile: +After we have defined the event, we have to adapt the profile aggregate: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\AggregateId; use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; + #[AggregateId] + private UuidAggregateRootId $id; private string $name; - - public function aggregateRootId(): string - { - return $this->id; - } public function name(): string { return $this->name; } - public static function register(string $id, string $name): self + public static function register(UuidAggregateRootId $id, string $name): self { $self = new self(); $self->recordThat(new ProfileRegistered($id, $name)); @@ -150,12 +160,15 @@ final class Profile extends BasicAggregateRoot In our named constructor `register` we have now created the event and recorded it with the method `recordThat`. The aggregate remembers all new recorded events in order to save them later. -At the same time, a defined apply method is executed directly so that we can change our state. +At the same time, a defined `apply` method is executed directly so that we can change our state. So that the AggregateRoot also knows which method it should call, -we have to mark it with the `Apply` [attributes](https://www.php.net/manual/en/language.attributes.overview.php). -We did that in the `applyProfileRegistered` method. -In this method we change the `Profile` properties `id` and `name` with the transferred values. +we have to mark it with the `Apply` attribute. We did that in the `applyProfileRegistered` method. +In there we then change the state of the aggregate by filling the properties with the values from the event. + +!!! success + + The aggregate is now ready to be saved! ### Modify an aggregate @@ -184,26 +197,24 @@ This method then creates the event `NameChanged` and records it: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\AggregateId; use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; + #[AggregateId] + private UuidAggregateRootId $id; private string $name; - public function aggregateRootId(): string - { - return $this->id; - } - public function name(): string { return $this->name; } - public static function register(string $id, string $name): static + public static function register(UuidAggregateRootId $id, string $name): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name)); @@ -287,9 +298,6 @@ use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; - private string $name; - // ... #[Apply(ProfileCreated::class)] @@ -322,9 +330,6 @@ use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; #[SuppressMissingApply([NameChanged::class])] final class Profile extends BasicAggregateRoot { - private string $id; - private string $name; - // ... #[Apply] @@ -350,9 +355,6 @@ use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; #[SuppressMissingApply(SuppressMissingApply::ALL)] final class Profile extends BasicAggregateRoot { - private string $id; - private string $name; - // ... #[Apply] @@ -388,16 +390,8 @@ use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; - private string $name; - // ... - public function name(): string - { - return $this->name; - } - public function changeName(string $name): void { if (strlen($name) < 3) { @@ -417,7 +411,7 @@ final class Profile extends BasicAggregateRoot !!! danger - Disregarding this can break the rebuilding of the state! + Validations during "apply" can brake the rebuilding of the aggregate. We have now ensured that this rule takes effect when a name is changed with the method `changeName`. But when we create a new profile this rule does not currently apply. @@ -450,16 +444,19 @@ We can now use the value object `Name` in our aggregate: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\AggregateId; use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; + #[AggregateId] + private UuidAggregateRootId $id; private Name $name; - public static function register(string $id, Name $name): static + public static function register(UuidAggregateRootId $id, Name $name): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name)); @@ -566,17 +563,20 @@ But you can pass this information by yourself. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\AggregateId; use Patchlevel\EventSourcing\Attribute\Apply; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; + #[AggregateId] + private UuidAggregateRootId $id; private Name $name; private DateTimeImmutable $registeredAt; - public static function register(string $id, string $name, DateTimeImmutable $registeredAt): static + public static function register(UuidAggregateRootId $id, string $name, DateTimeImmutable $registeredAt): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name, $registeredAt)); @@ -592,18 +592,21 @@ But if you still want to make sure that the time is "now" and not in the past or ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\AggregateId; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Clock\Clock; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot { - private string $id; + #[AggregateId] + private UuidAggregateRootId $id; private Name $name; private DateTimeImmutable $registeredAt; - public static function register(string $id, string $name, Clock $clock): static + public static function register(UuidAggregateRootId $id, string $name, Clock $clock): static { $self = new static(); $self->recordThat(new ProfileRegistered($id, $name, $clock->now())); diff --git a/docs/pages/uuid.md b/docs/pages/aggregate_id.md similarity index 100% rename from docs/pages/uuid.md rename to docs/pages/aggregate_id.md diff --git a/docs/pages/events.md b/docs/pages/events.md index ee3a8045a..fc611d24e 100644 --- a/docs/pages/events.md +++ b/docs/pages/events.md @@ -67,12 +67,14 @@ so that the library knows how to write this data to the database and load it aga ```php use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; +use Patchlevel\Hydrator\Normalizer\UuidAggregateIdNormalizer; #[Event('profile.name_changed')] final class NameChanged { public function __construct( - public readonly string $name, + #[UuidAggregateIdNormalizer] + public readonly UuidAggregateRootId $name, #[DateTimeImmutableNormalizer] public readonly DateTimeImmutable $changedAt ) {} diff --git a/docs/pages/normalizer.md b/docs/pages/normalizer.md index 6d7924c4f..27ffe9987 100644 --- a/docs/pages/normalizer.md +++ b/docs/pages/normalizer.md @@ -200,6 +200,48 @@ final class DTO { } ``` +### UuidAggregateId + +To normalize a `UuidAggregateRootId` one can use the `UuidAggregateIdNormalizer`. + +```php +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; +use Patchlevel\Hydrator\Normalizer\UuidAggregateIdNormalizer; + +final class DTO { + #[UuidAggregateIdNormalizer] + public UuidAggregateRootId $id; +} +``` + +### ValueAggregateId + +To normalize a `ValueAggregateRootId` one can use the `ValeAggregateIdNormalizer`. + +```php +use Patchlevel\EventSourcing\Aggregate\ValueAggregateRootId; +use Patchlevel\Hydrator\Normalizer\ValeAggregateIdNormalizer; + +final class DTO { + #[ValeAggregateIdNormalizer] + public ValueAggregateRootId $id; +} +``` + +### Own AggregateId + +If you have your own AggregateId, you can use the `AggregateIdNormalizer` base normalizer. +the `AggregateIdNormalizer` needs the FQCN of the AggregateId as a parameter. + +```php +use Patchlevel\Hydrator\Normalizer\AggregateIdNormalizer; + +final class DTO { + #[AggregateIdNormalizer(Id::class)] + public Id $id; +} +``` + ## Custom Normalizer Since we only offer normalizers for PHP native things, @@ -235,6 +277,7 @@ Finally, you have to allow the normalizer to be used as an attribute. ```php use Patchlevel\Hydrator\Normalizer\Normalizer; +use Patchlevel\Hydrator\Normalizer\InvalidArgument; #[Attribute(Attribute::TARGET_PROPERTY)] class NameNormalizer implements Normalizer @@ -242,7 +285,7 @@ class NameNormalizer implements Normalizer public function normalize(mixed $value): string { if (!$value instanceof Name) { - throw new InvalidArgumentException(); + throw InvalidArgument::withWrongType(Name::class, $value); } return $value->toString(); @@ -255,7 +298,7 @@ class NameNormalizer implements Normalizer } if (!is_string($value)) { - throw new InvalidArgumentException(); + throw InvalidArgument::withWrongType('string', $value); } return new Name($value); diff --git a/docs/pages/pipeline.md b/docs/pages/pipeline.md index 7c7c207b7..96af29bd4 100644 --- a/docs/pages/pipeline.md +++ b/docs/pages/pipeline.md @@ -111,7 +111,7 @@ $source = new class implements Source { public function count(): int { - reutrn 1; + return 1; } } ``` diff --git a/docs/pages/repository.md b/docs/pages/repository.md index 75c7bd19f..00dbfee28 100644 --- a/docs/pages/repository.md +++ b/docs/pages/repository.md @@ -67,13 +67,12 @@ $repository = $repositoryManager->get(Profile::class); ### Decorator -If you want to add more metadata to the message, like e.g. an application id, then you can use decorator. +If you want to add more metadata to the message, like e.g. an application id, then you can use decorators. ```php -use Patchlevel\EventSourcing\EventBus\Decorator\RecordedOnDecorator; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; -$decorator = new RecordedOnDecorator($clock); +$decorator = new ApplicationIdDecorator(); $repositoryManager = new DefaultRepositoryManager( $aggregateRootRegistry, @@ -104,7 +103,10 @@ After the events have been written, the new events are dispatched on the [event bus](./event_bus.md). ```php -$profile = Profile::create('david.badura@patchlevel.de'); +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; + +$id = UuidAggregateRootId::generate(); +$profile = Profile::create($id, 'david.badura@patchlevel.de'); $repository->save($profile); ``` @@ -124,12 +126,15 @@ An `aggregate` can be loaded using the `load` method. All events for the aggregate are loaded from the database and the current state is rebuilt. ```php -$profile = $repository->load('229286ff-6f95-4df6-bc72-0a239fe7b284'); +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; + +$id = UuidAggregateRootId::fromString('229286ff-6f95-4df6-bc72-0a239fe7b284'); +$profile = $repository->load($id); ``` !!! warning - When the method is called, the aggregate is always reloaded from the database and rebuilt. + When the method is called, the aggregate is always reloaded and rebuilt from the database. !!! note @@ -142,7 +147,9 @@ You can also check whether an `aggregate` with a certain id exists. It is checked whether any event with this id exists in the database. ```php -if($repository->has('229286ff-6f95-4df6-bc72-0a239fe7b284')) { +$id = UuidAggregateRootId::fromString('229286ff-6f95-4df6-bc72-0a239fe7b284'); + +if($repository->has($id)) { // ... } ``` @@ -178,7 +185,7 @@ class ProfileRepository public function load(ProfileId $id): Profile { - return $this->repository->load($id->toString()); + return $this->repository->load($id); } public function save(Profile $profile): void @@ -188,7 +195,7 @@ class ProfileRepository public function has(ProfileId $id): bool { - return $this->repository->has($id->toString()); + return $this->repository->has($id); } } ``` diff --git a/docs/pages/snapshots.md b/docs/pages/snapshots.md index a9ed4a0e2..aa963aa92 100644 --- a/docs/pages/snapshots.md +++ b/docs/pages/snapshots.md @@ -72,14 +72,18 @@ You can define normalizers to bring the properties into the correct format. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\AggregateId; use Patchlevel\EventSourcing\Attribute\Snapshot; #[Aggregate('profile')] #[Snapshot('default')] final class Profile extends BasicAggregateRoot { - public string $id; + #[AggregateId] + #[UuidAggregateRootIdNormalizer] + public UuidAggregateRootId $id; public string $name, #[Normalize(new DateTimeImmutableNormalizer())] public DateTimeImmutable $createdAt; @@ -95,7 +99,7 @@ final class Profile extends BasicAggregateRoot !!! warning - In the end it has to be possible to serialize it as json. + In the end it has to be possible to serialize it as json. Also the aggregate ID. !!! note @@ -193,3 +197,41 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; $adapter = new InMemorySnapshotAdapter(); ``` + +## Usage + +The snapshot store is automatically used by the repository and takes care of saving and loading. +But you can also use the snapshot store yourself. + +### Save + +This allows you to save the aggregate as a snapshot: + +```php +$snapshotStore->save($aggregate); +``` + +!!! danger + + If the state of an aggregate is saved as a snapshot without being saved to the event store (database), + it can lead to data loss or broken aggregates! + +### Load + +You can also load an aggregate from the snapshot store: + +```php +use Patchlevel\EventSourcing\Aggregate\UuidAggregateRootId; + +$id = UuidAggregateRootId::fromString('229286ff-6f95-4df6-bc72-0a239fe7b284'); +$aggregate = $snapshotStore->load(Profile::class, $id); +``` + +The method returns the Aggregate if it was loaded successfully. +If the aggregate was not found, then a `SnapshotNotFound` is thrown. +And if the version is no longer correct and the snapshot is therefore invalid, then a `SnapshotVersionInvalid` is thrown. + +!!! warning + + The aggregate may be in an old state as the snapshot may lag behind. + You still have to bring the aggregate up to date by loading the missing events from the event store. \ No newline at end of file