Skip to content

Commit

Permalink
Merge pull request #158 from patchlevel/attribute-apply-trait
Browse files Browse the repository at this point in the history
add attribute based apply methods
  • Loading branch information
DavidBadura authored Jan 7, 2022
2 parents f74dc8a + f6d7bd6 commit 573cc2d
Show file tree
Hide file tree
Showing 14 changed files with 724 additions and 67 deletions.
138 changes: 136 additions & 2 deletions docs/aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,144 @@ all newly recorded events are then fetched and written to the database.
The `apply` method must be implemented so that the events are processed on the aggregate.
This method can get quite big with some events.

To make things structured, you can use two different traits
To make things structured, you can use three different traits
that allow you to define different apply methods for each event.

### Attribute based apply method (since v1.2)

The first variant is the preferred one.
This uses [php attributes](https://www.php.net/manual/en/language.attributes.overview.php)
to find the right `apply` method.
You have to set the apply `attribute` to the appropriate method
and specify the event class for which it is responsible.

```php
use Patchlevel\EventSourcing\Aggregate\AggregateChanged;
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod;
use Patchlevel\EventSourcing\Attribute\Apply;

final class Profile extends AggregateRoot
{
use AttributeApplyMethod;

private string $id;
private string $name;

// ...

#[Apply(ProfileCreated::class)]
protected function applyProfileCreated(ProfileCreated $event): void
{
$this->id = $event->profileId();
$this->name = $event->name();
}

#[Apply(NameChanged::class)]
protected function applyNameChanged(NameChanged $event): void
{
$this->name = $event->name();
}
}
```

> :book: If no apply method has been defined for an event, then you get an exception.
> But you can control this with the attribute suppress.
You can also define several apply attributes with different events using the same method.

```php
use Patchlevel\EventSourcing\Aggregate\AggregateChanged;
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod;
use Patchlevel\EventSourcing\Attribute\Apply;

final class Profile extends AggregateRoot
{
use AttributeApplyMethod;

private string $id;
private string $name;

// ...

#[Apply(ProfileCreated::class)]
#[Apply(NameChanged::class)]
protected function applyProfileCreated(ProfileCreated|NameChanged $event): void
{
if ($event instanceof ProfileCreated) {
$this->id = $event->profileId();
}

$this->name = $event->name();
}
}
```

Sometimes you have events that do not change the state of the aggregate itself,
but are still recorded for the future, to listen on it or to create a projection.
So that you are not forced to write an apply method for it,
you can suppress the missing apply exceptions these events with the `SuppressMissingApply` attribute.

```php
use Patchlevel\EventSourcing\Aggregate\AggregateChanged;
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\SuppressMissingApply;

#[SuppressMissingApply([NameChanged::class])]
final class Profile extends AggregateRoot
{
use AttributeApplyMethod;

private string $id;
private string $name;

// ...

#[Apply(ProfileCreated::class)]
protected function applyProfileCreated(ProfileCreated $event): void
{
$this->id = $event->profileId();
$this->name = $event->name();
}
}
```

You can also completely deactivate the exceptions for missing apply methods.

```php
use Patchlevel\EventSourcing\Aggregate\AggregateChanged;
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\SuppressMissingApply;

#[SuppressMissingApply([SuppressMissingApply::ALL])]
final class Profile extends AggregateRoot
{
use AttributeApplyMethod;

private string $id;
private string $name;

// ...

#[Apply(ProfileCreated::class)]
protected function applyProfileCreated(ProfileCreated $event): void
{
$this->id = $event->profileId();
$this->name = $event->name();
}
}
```

> :warning: When all events are suppressed, debugging becomes more difficult if you forget an apply method.
### Strict apply method

The trait implements the apply method for you.
The event name is used here instead of attributes to find the right method.
It is looking for a suitable method for the event by using the short name of the event class
and prefixing it with an `apply`.

Expand Down Expand Up @@ -340,6 +472,8 @@ final class Profile extends AggregateRoot
}
```

> :warning: Problems can arise if the short name is the same. To get around this, use the attribute variant.
### Non strict apply method

The non-strict variant works in the same way as the strict one.
Expand Down
65 changes: 0 additions & 65 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,75 +1,10 @@
parameters:
ignoreErrors:
-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged\\:\\:serialize\\(\\) return type with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#"
count: 1
path: src/Aggregate/AggregateChanged.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:apply\\(\\) has parameter \\$event with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#"
count: 1
path: src/Aggregate/AggregateRoot.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:createFromEventStream\\(\\) has parameter \\$stream with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#"
count: 1
path: src/Aggregate/AggregateRoot.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:releaseEvents\\(\\) return type with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#"
count: 1
path: src/Aggregate/AggregateRoot.php

-
message: "#^Property Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:\\$uncommittedEvents with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#"
count: 1
path: src/Aggregate/AggregateRoot.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\ApplyMethodNotFound\\:\\:__construct\\(\\) has parameter \\$event with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#"
count: 1
path: src/Aggregate/ApplyMethodNotFound.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Console\\\\DoctrineHelper\\:\\:databaseName\\(\\) should return string but returns mixed\\.$#"
count: 2
path: src/Console/DoctrineHelper.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Console\\\\EventPrinter\\:\\:write\\(\\) has parameter \\$event with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#"
count: 1
path: src/Console/EventPrinter.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\ExcludeEventMiddleware\\:\\:__construct\\(\\) has parameter \\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#"
count: 1
path: src/Pipeline/Middleware/ExcludeEventMiddleware.php

-
message: "#^Property Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\ExcludeEventMiddleware\\:\\:\\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#"
count: 1
path: src/Pipeline/Middleware/ExcludeEventMiddleware.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\FilterEventMiddleware\\:\\:__construct\\(\\) has parameter \\$callable with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#"
count: 1
path: src/Pipeline/Middleware/FilterEventMiddleware.php

-
message: "#^Property Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\FilterEventMiddleware\\:\\:\\$callable with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#"
count: 1
path: src/Pipeline/Middleware/FilterEventMiddleware.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\IncludeEventMiddleware\\:\\:__construct\\(\\) has parameter \\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#"
count: 1
path: src/Pipeline/Middleware/IncludeEventMiddleware.php

-
message: "#^Property Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\IncludeEventMiddleware\\:\\:\\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#"
count: 1
path: src/Pipeline/Middleware/IncludeEventMiddleware.php

-
message: "#^Unable to resolve the template type E in call to method static method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged\\<array\\<string, mixed\\>\\>\\:\\:deserialize\\(\\)$#"
count: 1
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ parameters:
level: max
paths:
- src
checkGenericClassInNonGenericObjectType: false
21 changes: 21 additions & 0 deletions src/Aggregate/ApplyAttributeNotFound.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Aggregate;

use function sprintf;

final class ApplyAttributeNotFound extends AggregateException
{
public function __construct(AggregateRoot $aggregate, AggregateChanged $event)
{
parent::__construct(
sprintf(
'Apply method in "%s" could not be found for the event "%s"',
$aggregate::class,
$event::class
)
);
}
}
99 changes: 99 additions & 0 deletions src/Aggregate/AttributeApplyMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Aggregate;

use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\SuppressMissingApply;
use ReflectionClass;

use function array_key_exists;
use function method_exists;

/**
* @psalm-require-extends AggregateRoot
*/
trait AttributeApplyMethod
{
/** @var array<class-string<AggregateChanged>, true> */
private static array $suppressEvents = [];
private static bool $suppressAll = false;

/** @var array<class-string<AggregateChanged>, string>|null */
private static ?array $aggregateChangeMethodMap = null;

protected function apply(AggregateChanged $event): void
{
$map = self::aggregateChangeMethodMap();

if (!array_key_exists($event::class, $map)) {
if (!self::$suppressAll && !array_key_exists($event::class, self::$suppressEvents)) {
throw new ApplyAttributeNotFound($this, $event);
}

return;
}

$method = $map[$event::class];

if (!method_exists($this, $method)) {
return;
}

$this->$method($event);
}

/**
* @return array<class-string<AggregateChanged>, string>
*/
private static function aggregateChangeMethodMap(): array
{
if (self::$aggregateChangeMethodMap !== null) {
return self::$aggregateChangeMethodMap;
}

$reflector = new ReflectionClass(self::class);
$attributes = $reflector->getAttributes(SuppressMissingApply::class);

foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();

if ($instance->suppressAll()) {
self::$suppressAll = true;

continue;
}

foreach ($instance->suppressEvents() as $event) {
self::$suppressEvents[$event] = true;
}
}

$methods = $reflector->getMethods();

self::$aggregateChangeMethodMap = [];

foreach ($methods as $method) {
$attributes = $method->getAttributes(Apply::class);

foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$eventClass = $instance->aggregateChangedClass();

if (array_key_exists($eventClass, self::$aggregateChangeMethodMap)) {
throw new DuplicateApplyMethod(
self::class,
$eventClass,
self::$aggregateChangeMethodMap[$eventClass],
$method->getName()
);
}

self::$aggregateChangeMethodMap[$eventClass] = $method->getName();
}
}

return self::$aggregateChangeMethodMap;
}
}
27 changes: 27 additions & 0 deletions src/Aggregate/DuplicateApplyMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Aggregate;

use function sprintf;

final class DuplicateApplyMethod extends AggregateException
{
/**
* @param class-string<AggregateRoot> $aggregate
* @param class-string<AggregateChanged> $event
*/
public function __construct(string $aggregate, string $event, string $fistMethod, string $secondMethod)
{
parent::__construct(
sprintf(
'Two methods "%s" and "%s" on the aggregate "%s" want to apply the same event "%s".',
$fistMethod,
$secondMethod,
$aggregate,
$event
)
);
}
}
Loading

0 comments on commit 573cc2d

Please sign in to comment.