diff --git a/docs/inheritance.md b/docs/inheritance.md index 1e45591..61cfe2a 100644 --- a/docs/inheritance.md +++ b/docs/inheritance.md @@ -127,3 +127,29 @@ then you can also just change the `aggregate_translator` key in your config to p and register the `UserAggregateTranslator` in your container. see also: http://www.sasaprolic.com/2016/02/inheritance-with-aggregate-roots-in.html + +## Alternative to AggregateRoot inheritance + +Abstract `Prooph\EventSourcing\AggregateRoot` class provides a solid basis for +your aggregate roots, however, it is not mandatory. Two traits, +`Prooph\EventSourcing\Aggregate\EventProducerTrait` and +`Prooph\EventSourcing\Aggregate\EventSourcedTrait`, together provide exactly +the same functionality. + +- `EventProducerTrait` is responsible for event producing side of Event + Sourcing and might be used independently of `EventSourcedTrait` when you are + not ready to start with full event sourcing but still want to get the benefit + of design validation and audit trail provided by Event Sourcing. Forcing all + changes to be applied internally via event sourcing will ensure events data + consistency with the state and will make it easier to switch to full event + sourcing later on. + +- `EventSourcedTrait` is responsible for restoring state from event stream, it + should be used together with `EventProducerTrait` as normally you will not be + applying events not produced by that aggregate root. + +Default aggregate translator uses `AggregateRootDecorator` to access protected +methods of `Prooph\EventSourcing\AggregateRoot` descendants, you will need to +switch to +`Prooph\EventSourcing\EventStoreIntegration\ClosureAggregateTranslator` for +aggregate roots using traits. diff --git a/src/Aggregate/EventProducerTrait.php b/src/Aggregate/EventProducerTrait.php new file mode 100644 index 0000000..141c6c0 --- /dev/null +++ b/src/Aggregate/EventProducerTrait.php @@ -0,0 +1,65 @@ + + * (c) 2015-2017 Sascha-Oliver Prolic + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Prooph\EventSourcing\Aggregate; + +use Prooph\EventSourcing\AggregateChanged; + +trait EventProducerTrait +{ + /** + * Current version + * + * @var int + */ + protected $version = 0; + + /** + * List of events that are not committed to the EventStore + * + * @var AggregateChanged[] + */ + protected $recordedEvents = []; + + /** + * Get pending events and reset stack + * + * @return AggregateChanged[] + */ + protected function popRecordedEvents(): array + { + $pendingEvents = $this->recordedEvents; + + $this->recordedEvents = []; + + return $pendingEvents; + } + + /** + * Record an aggregate changed event + */ + protected function recordThat(AggregateChanged $event): void + { + $this->version += 1; + + $this->recordedEvents[] = $event->withVersion($this->version); + + $this->apply($event); + } + + abstract protected function aggregateId(): string; + + /** + * Apply given event + */ + abstract protected function apply(AggregateChanged $event): void; +} diff --git a/src/Aggregate/EventSourcedTrait.php b/src/Aggregate/EventSourcedTrait.php new file mode 100644 index 0000000..57e12ed --- /dev/null +++ b/src/Aggregate/EventSourcedTrait.php @@ -0,0 +1,60 @@ + + * (c) 2015-2017 Sascha-Oliver Prolic + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Prooph\EventSourcing\Aggregate; + +use Iterator; +use Prooph\EventSourcing\AggregateChanged; +use RuntimeException; + +trait EventSourcedTrait +{ + /** + * Current version + * + * @var int + */ + protected $version = 0; + + /** + * @throws RuntimeException + */ + protected static function reconstituteFromHistory(Iterator $historyEvents): self + { + $instance = new static(); + $instance->replay($historyEvents); + + return $instance; + } + + /** + * Replay past events + * + * @throws RuntimeException + */ + protected function replay(Iterator $historyEvents): void + { + foreach ($historyEvents as $pastEvent) { + /** @var AggregateChanged $pastEvent */ + $this->version = $pastEvent->version(); + + $this->apply($pastEvent); + } + } + + abstract protected function aggregateId(): string; + + /** + * Apply given event + */ + abstract protected function apply(AggregateChanged $event): void; +} diff --git a/src/AggregateRoot.php b/src/AggregateRoot.php index fd6669f..b9e7dfa 100644 --- a/src/AggregateRoot.php +++ b/src/AggregateRoot.php @@ -12,35 +12,13 @@ namespace Prooph\EventSourcing; -use Iterator; -use RuntimeException; +use Prooph\EventSourcing\Aggregate\EventProducerTrait; +use Prooph\EventSourcing\Aggregate\EventSourcedTrait; abstract class AggregateRoot { - /** - * Current version - * - * @var int - */ - protected $version = 0; - - /** - * List of events that are not committed to the EventStore - * - * @var AggregateChanged[] - */ - protected $recordedEvents = []; - - /** - * @throws RuntimeException - */ - protected static function reconstituteFromHistory(Iterator $historyEvents): self - { - $instance = new static(); - $instance->replay($historyEvents); - - return $instance; - } + use EventProducerTrait; + use EventSourcedTrait; /** * We do not allow public access to __construct, this way we make sure that an aggregate root can only @@ -49,52 +27,4 @@ protected static function reconstituteFromHistory(Iterator $historyEvents): self protected function __construct() { } - - abstract protected function aggregateId(): string; - - /** - * Get pending events and reset stack - * - * @return AggregateChanged[] - */ - protected function popRecordedEvents(): array - { - $pendingEvents = $this->recordedEvents; - - $this->recordedEvents = []; - - return $pendingEvents; - } - - /** - * Record an aggregate changed event - */ - protected function recordThat(AggregateChanged $event): void - { - $this->version += 1; - - $this->recordedEvents[] = $event->withVersion($this->version); - - $this->apply($event); - } - - /** - * Replay past events - * - * @throws RuntimeException - */ - protected function replay(Iterator $historyEvents): void - { - foreach ($historyEvents as $pastEvent) { - /** @var AggregateChanged $pastEvent */ - $this->version = $pastEvent->version(); - - $this->apply($pastEvent); - } - } - - /** - * Apply given event - */ - abstract protected function apply(AggregateChanged $event): void; } diff --git a/src/EventStoreIntegration/ClosureAggregateTranslator.php b/src/EventStoreIntegration/ClosureAggregateTranslator.php new file mode 100644 index 0000000..3c8b8d0 --- /dev/null +++ b/src/EventStoreIntegration/ClosureAggregateTranslator.php @@ -0,0 +1,117 @@ + + * (c) 2015-2017 Sascha-Oliver Prolic + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Prooph\EventSourcing\EventStoreIntegration; + +use Iterator; +use Prooph\Common\Messaging\Message; +use Prooph\EventSourcing\Aggregate\AggregateTranslator as EventStoreAggregateTranslator; +use Prooph\EventSourcing\Aggregate\AggregateType; +use RuntimeException; + +final class ClosureAggregateTranslator implements EventStoreAggregateTranslator +{ + protected $aggregateIdExtractor; + protected $aggregateReconstructor; + protected $pendingEventsExtractor; + protected $replayStreamEvents; + protected $versionExtractor; + + /** + * @param object $eventSourcedAggregateRoot + * + * @return int + */ + public function extractAggregateVersion($eventSourcedAggregateRoot): int + { + if (null === $this->versionExtractor) { + $this->versionExtractor = function (): int { + return $this->version; + }; + } + + return $this->versionExtractor->call($eventSourcedAggregateRoot); + } + + /** + * @param object $anEventSourcedAggregateRoot + * + * @return string + */ + public function extractAggregateId($anEventSourcedAggregateRoot): string + { + if (null === $this->aggregateIdExtractor) { + $this->aggregateIdExtractor = function (): string { + return $this->aggregateId(); + }; + } + + return $this->aggregateIdExtractor->call($anEventSourcedAggregateRoot); + } + + /** + * @param AggregateType $aggregateType + * @param Iterator $historyEvents + * + * @return object reconstructed AggregateRoot + */ + public function reconstituteAggregateFromHistory(AggregateType $aggregateType, Iterator $historyEvents) + { + if (null === $this->aggregateReconstructor) { + $this->aggregateReconstructor = function ($historyEvents) { + return static::reconstituteFromHistory($historyEvents); + }; + } + + $arClass = $aggregateType->toString(); + + if (! class_exists($arClass)) { + throw new RuntimeException( + sprintf('Aggregate root class %s cannot be found', $arClass) + ); + } + + return ($this->aggregateReconstructor->bindTo(null, $arClass))($historyEvents); + } + + /** + * @param object $anEventSourcedAggregateRoot + * + * @return Message[] + */ + public function extractPendingStreamEvents($anEventSourcedAggregateRoot): array + { + if (null === $this->pendingEventsExtractor) { + $this->pendingEventsExtractor = function (): array { + return $this->popRecordedEvents(); + }; + } + + return $this->pendingEventsExtractor->call($anEventSourcedAggregateRoot); + } + + /** + * @param object $anEventSourcedAggregateRoot + * @param Iterator $events + * + * @return void + */ + public function replayStreamEvents($anEventSourcedAggregateRoot, Iterator $events): void + { + if (null === $this->replayStreamEvents) { + $this->replayStreamEvents = function ($events): void { + $this->replay($events); + }; + } + $this->replayStreamEvents->call($anEventSourcedAggregateRoot, $events); + } +} diff --git a/tests/EventStoreIntegration/AggregateTranslatorTest.php b/tests/EventStoreIntegration/AggregateTranslatorTest.php index bcaa229..8e97083 100644 --- a/tests/EventStoreIntegration/AggregateTranslatorTest.php +++ b/tests/EventStoreIntegration/AggregateTranslatorTest.php @@ -129,7 +129,7 @@ protected function resetRepository() { $this->repository = new AggregateRepository( $this->eventStore, - AggregateType::fromAggregateRootClass('ProophTest\EventSourcing\Mock\User'), + AggregateType::fromAggregateRootClass(User::class), new AggregateTranslator() ); } diff --git a/tests/EventStoreIntegration/ClosureAggregateTranslatorTest.php b/tests/EventStoreIntegration/ClosureAggregateTranslatorTest.php new file mode 100644 index 0000000..09b64b4 --- /dev/null +++ b/tests/EventStoreIntegration/ClosureAggregateTranslatorTest.php @@ -0,0 +1,138 @@ + + * (c) 2015-2017 Sascha-Oliver Prolic + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ProophTest\EventSourcing\EventStoreIntegration; + +use ArrayIterator; +use PHPUnit\Framework\TestCase; +use Prooph\Common\Event\ProophActionEventEmitter; +use Prooph\EventSourcing\Aggregate\AggregateRepository; +use Prooph\EventSourcing\Aggregate\AggregateType; +use Prooph\EventSourcing\EventStoreIntegration\ClosureAggregateTranslator as AggregateTranslator; +use Prooph\EventStore\EventStore; +use Prooph\EventStore\InMemoryEventStore; +use Prooph\EventStore\Stream; +use Prooph\EventStore\StreamName; +use ProophTest\EventSourcing\Mock\User; +use ProophTest\EventSourcing\Mock\UserNameChanged; +use RuntimeException; + +class ClosureAggregateTranslatorTest extends TestCase +{ + /** + * @var InMemoryEventStore + */ + protected $eventStore; + + /** + * @var AggregateRepository + */ + protected $repository; + + protected function setUp() + { + $this->eventStore = new InMemoryEventStore(new ProophActionEventEmitter()); + + $this->eventStore->beginTransaction(); + + $this->eventStore->create(new Stream(new StreamName('event_stream'), new ArrayIterator([]))); + + $this->eventStore->commit(); + + $this->resetRepository(); + } + + /** + * @test + */ + public function it_translates_aggregate_back_and_forth() + { + $this->eventStore->beginTransaction(); + + $user = User::nameNew('John Doe'); + + $this->repository->saveAggregateRoot($user); + + $this->eventStore->commit(); + + $this->eventStore->beginTransaction(); + + //Simulate a normal program flow by fetching the AR before modifying it + $user = $this->repository->getAggregateRoot($user->id()); + + $user->changeName('Max Mustermann'); + + $this->repository->saveAggregateRoot($user); + + $this->eventStore->commit(); + + $this->resetRepository(); + + $loadedUser = $this->repository->getAggregateRoot($user->id()); + + $this->assertEquals('Max Mustermann', $loadedUser->name()); + + return $loadedUser; + } + + /** + * @test + * @depends it_translates_aggregate_back_and_forth + * @param User $loadedUser + */ + public function it_extracts_version(User $loadedUser) + { + $translator = new AggregateTranslator(); + $this->assertEquals(2, $translator->extractAggregateVersion($loadedUser)); + } + + /** + * @test + * @depends it_translates_aggregate_back_and_forth + * @param User $loadedUser + */ + public function it_applies_stream_events(User $loadedUser) + { + $newName = 'Jane Doe'; + + $translator = new AggregateTranslator(); + $translator->replayStreamEvents($loadedUser, new ArrayIterator([UserNameChanged::occur($loadedUser->id(), [ + 'username' => $newName, + ])])); + + $this->assertEquals($newName, $loadedUser->name()); + } + + /** + * @test + */ + public function it_throws_exception_when_reconstitute_from_history_with_invalid_class() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Aggregate root class UnknownClass cannot be found'); + + $translator = new AggregateTranslator(); + $translator->reconstituteAggregateFromHistory( + AggregateType::fromString('UnknownClass'), + new ArrayIterator([]) + ); + } + + protected function resetRepository() + { + $this->repository = new AggregateRepository( + $this->eventStore, + AggregateType::fromAggregateRootClass(User::class), + new AggregateTranslator() + ); + } +}