From d859d26b27e35187c26a4a37bec7b3e457ebc6d2 Mon Sep 17 00:00:00 2001 From: LVoogd Date: Sun, 24 Nov 2024 19:17:11 +0100 Subject: [PATCH] Send events from the domain itself Currently, the application layer is responsible for sending events that are actually more related to the domain. For example, the `UpsertArticleHandler` publishes an event when an article is created. In this commit we move the publishing of domain events to the entity itself. By adding a doctrine listener we make sure the events are only emitted when the entity is committed to the database. --- .../Domain/EventPublishingAggregateRoot.php | 12 ++++ .../EventPublishingAggregateRootTrait.php | 26 +++++++++ .../EmitDomainEventsOnFlushListener.php | 57 +++++++++++++++++++ .../Article/Handler/UpsertArticleHandler.php | 11 ---- .../InvalidateArticleCacheListener.php | 2 +- ...alidateTotalArticlesCountCacheListener.php | 3 +- src/Feed/Domain/Article/Article.php | 12 +++- .../Event/Article/ArticleAddedEvent.php | 2 +- .../Event/Article/ArticleUpdatedEvent.php | 2 +- .../Handler/UpsertArticleHandlerTest.php | 19 +------ .../InvalidateArticleCacheListenerTest.php | 3 +- .../Unit/Feed/Domain/Article/ArticleTest.php | 12 ++++ 12 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 src/Common/Domain/EventPublishingAggregateRoot.php create mode 100644 src/Common/Domain/EventPublishingAggregateRootTrait.php create mode 100644 src/Common/Infrastructure/Persistence/Doctrine/EmitDomainEventsOnFlushListener.php rename src/Feed/{Application => Domain/Article}/Event/Article/ArticleAddedEvent.php (72%) rename src/Feed/{Application => Domain/Article}/Event/Article/ArticleUpdatedEvent.php (73%) diff --git a/src/Common/Domain/EventPublishingAggregateRoot.php b/src/Common/Domain/EventPublishingAggregateRoot.php new file mode 100644 index 0000000..f92682e --- /dev/null +++ b/src/Common/Domain/EventPublishingAggregateRoot.php @@ -0,0 +1,12 @@ + + */ + private array $domainEvents = []; + + public function recordEvent(object $event): void + { + $this->domainEvents[] = $event; + } + + public function shiftDomainEvent(): object + { + return array_shift($this->domainEvents) ?? throw new \LogicException('There are no domain events (left).'); + } + + public function hasDomainEvents(): bool + { + return $this->domainEvents !== []; + } +} diff --git a/src/Common/Infrastructure/Persistence/Doctrine/EmitDomainEventsOnFlushListener.php b/src/Common/Infrastructure/Persistence/Doctrine/EmitDomainEventsOnFlushListener.php new file mode 100644 index 0000000..3430119 --- /dev/null +++ b/src/Common/Infrastructure/Persistence/Doctrine/EmitDomainEventsOnFlushListener.php @@ -0,0 +1,57 @@ +getObjectManager()->getUnitOfWork(); + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + $this->emitRecordedEvents($entity); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + $this->emitRecordedEvents($entity); + } + + foreach ($uow->getScheduledEntityDeletions() as $entity) { + $this->emitRecordedEvents($entity); + } + + foreach ($uow->getScheduledCollectionDeletions() as $collection) { + foreach ($collection as $entity) { + $this->emitRecordedEvents($entity); + } + } + + foreach ($uow->getScheduledCollectionUpdates() as $collection) { + foreach ($collection as $entity) { + $this->emitRecordedEvents($entity); + } + } + } + + public function emitRecordedEvents(object $entity): void + { + if ($entity instanceof EventPublishingAggregateRoot === false) { + return; + } + + while ($entity->hasDomainEvents()) { + $this->eventBus->dispatch($entity->shiftDomainEvent()); + } + } +} diff --git a/src/Feed/Application/Command/Article/Handler/UpsertArticleHandler.php b/src/Feed/Application/Command/Article/Handler/UpsertArticleHandler.php index 5e205f9..3167a90 100644 --- a/src/Feed/Application/Command/Article/Handler/UpsertArticleHandler.php +++ b/src/Feed/Application/Command/Article/Handler/UpsertArticleHandler.php @@ -3,21 +3,15 @@ namespace App\Feed\Application\Command\Article\Handler; use App\Common\Infrastructure\Messenger\CommandBus\AsCommandHandler; -use App\Common\Infrastructure\Messenger\EventBus\EventBus; use App\Feed\Application\Command\Article\UpsertArticleCommand; -use App\Feed\Application\Event\Article\ArticleAddedEvent; -use App\Feed\Application\Event\Article\ArticleUpdatedEvent; use App\Feed\Domain\Article\Article; use App\Feed\Domain\Article\ArticleId; use App\Feed\Domain\Article\ArticleRepository; use App\Feed\Domain\Article\Url\Exception\MalformedUrlException; use App\Feed\Domain\Article\Url\Url; use App\Feed\Domain\Source\Exception\SourceNotFoundException; -use App\Feed\Domain\Source\SourceId; use App\Feed\Domain\Source\SourceRepository; -use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; -use Symfony\Component\Messenger\Attribute\AsMessageHandler; /** * Updates the article if the url is found in the article repository, otherwise it creates a new article. @@ -28,7 +22,6 @@ public function __construct( private ArticleRepository $articleRepository, private SourceRepository $sourceRepository, - private EventBus $eventBus, ) { } @@ -56,8 +49,6 @@ public function __invoke(UpsertArticleCommand $command): void $this->articleRepository->save($article); - $this->eventBus->dispatch(new ArticleAddedEvent($article->getId())); - return; } @@ -70,7 +61,5 @@ public function __invoke(UpsertArticleCommand $command): void ); $this->articleRepository->save($existingArticle); - - $this->eventBus->dispatch(new ArticleUpdatedEvent($existingArticle->getId())); } } diff --git a/src/Feed/Application/Listener/Article/InvalidateArticleCacheListener.php b/src/Feed/Application/Listener/Article/InvalidateArticleCacheListener.php index 5f33fdb..faddf26 100644 --- a/src/Feed/Application/Listener/Article/InvalidateArticleCacheListener.php +++ b/src/Feed/Application/Listener/Article/InvalidateArticleCacheListener.php @@ -3,7 +3,7 @@ namespace App\Feed\Application\Listener\Article; use App\Common\Infrastructure\Messenger\EventBus\AsEventSubscriber; -use App\Feed\Application\Event\Article\ArticleUpdatedEvent; +use App\Feed\Domain\Article\Event\Article\ArticleUpdatedEvent; use App\Feed\Infrastructure\Cache\FeedCacheKeys; use Symfony\Contracts\Cache\CacheInterface; diff --git a/src/Feed/Application/Listener/Article/InvalidateTotalArticlesCountCacheListener.php b/src/Feed/Application/Listener/Article/InvalidateTotalArticlesCountCacheListener.php index 8529cb4..b904ac1 100644 --- a/src/Feed/Application/Listener/Article/InvalidateTotalArticlesCountCacheListener.php +++ b/src/Feed/Application/Listener/Article/InvalidateTotalArticlesCountCacheListener.php @@ -3,8 +3,7 @@ namespace App\Feed\Application\Listener\Article; use App\Common\Infrastructure\Messenger\EventBus\AsEventSubscriber; -use App\Feed\Application\Event\Article\ArticleAddedEvent; -use App\Feed\Domain\Article\ArticleRepository; +use App\Feed\Domain\Article\Event\Article\ArticleAddedEvent; use App\Feed\Infrastructure\Cache\FeedCacheKeys; use Symfony\Contracts\Cache\CacheInterface; diff --git a/src/Feed/Domain/Article/Article.php b/src/Feed/Domain/Article/Article.php index 47fbec3..d06cb9f 100644 --- a/src/Feed/Domain/Article/Article.php +++ b/src/Feed/Domain/Article/Article.php @@ -2,6 +2,10 @@ namespace App\Feed\Domain\Article; +use App\Common\Domain\EventPublishingAggregateRoot; +use App\Common\Domain\EventPublishingAggregateRootTrait; +use App\Feed\Domain\Article\Event\Article\ArticleAddedEvent; +use App\Feed\Domain\Article\Event\Article\ArticleUpdatedEvent; use App\Feed\Domain\Article\Url\Url; use App\Feed\Domain\Source\Source; use DateTime; @@ -10,8 +14,10 @@ #[ORM\Entity] #[ORM\Table(name: 'articles')] #[ORM\Index(columns: ['url'], name: 'url_index')] -class Article +class Article implements EventPublishingAggregateRoot { + use EventPublishingAggregateRootTrait; + #[ORM\Id, ORM\Column(type: 'string')] private string $id; @@ -44,6 +50,8 @@ public function __construct( $this->url = (string) $url; $this->updated = $updated; $this->source = $source; + + $this->recordEvent(new ArticleAddedEvent($id)); } public function getId(): ArticleId @@ -91,5 +99,7 @@ public function updateArticle(string $newTitle, string $newSummary, Url $newUrl, $this->url = $newUrl; $this->updated = $newUpdated; $this->source = $newSource; + + $this->recordEvent(new ArticleUpdatedEvent($this->id)); } } diff --git a/src/Feed/Application/Event/Article/ArticleAddedEvent.php b/src/Feed/Domain/Article/Event/Article/ArticleAddedEvent.php similarity index 72% rename from src/Feed/Application/Event/Article/ArticleAddedEvent.php rename to src/Feed/Domain/Article/Event/Article/ArticleAddedEvent.php index 3757e54..53411b2 100644 --- a/src/Feed/Application/Event/Article/ArticleAddedEvent.php +++ b/src/Feed/Domain/Article/Event/Article/ArticleAddedEvent.php @@ -1,6 +1,6 @@ articleRepository = new InMemoryArticleRepository(); $this->sourceRepository = new InMemorySourceRepository(); - $this->eventBus = new RecordingEventBus(); $this->handler = new UpsertArticleHandler( $this->articleRepository, $this->sourceRepository, - $this->eventBus, ); } @@ -78,10 +69,6 @@ public function it_should_create_an_article(): void self::assertSame($url, (string) $article->getUrl()); self::assertSame($updated, $article->getUpdated()); self::assertSame($source, $article->getSource()); - - $event = $this->eventBus->shiftEvent(); - self::assertInstanceOf(ArticleAddedEvent::class, $event); - self::assertSame((string) $article->getId(), $event->articleId); } /** @@ -120,10 +107,6 @@ public function it_should_update_the_article(): void self::assertEquals($existingArticle->getUrl(), $article->getUrl()); self::assertSame($updated, $article->getUpdated()); self::assertSame($source, $article->getSource()); - - $event = $this->eventBus->shiftEvent(); - self::assertInstanceOf(ArticleUpdatedEvent::class, $event); - self::assertSame((string) $article->getId(), $event->articleId); } /** diff --git a/tests/Unit/Feed/Application/Listener/Article/InvalidateArticleCacheListenerTest.php b/tests/Unit/Feed/Application/Listener/Article/InvalidateArticleCacheListenerTest.php index cc115b5..28e560c 100644 --- a/tests/Unit/Feed/Application/Listener/Article/InvalidateArticleCacheListenerTest.php +++ b/tests/Unit/Feed/Application/Listener/Article/InvalidateArticleCacheListenerTest.php @@ -2,13 +2,12 @@ namespace Unit\Feed\Application\Listener\Article; -use App\Feed\Application\Event\Article\ArticleUpdatedEvent; use App\Feed\Application\Listener\Article\InvalidateArticleCacheListener; +use App\Feed\Domain\Article\Event\Article\ArticleUpdatedEvent; use App\Feed\Infrastructure\Cache\FeedCacheKeys; use Dev\Feed\Factory\ArticleFactory; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; final class InvalidateArticleCacheListenerTest extends TestCase diff --git a/tests/Unit/Feed/Domain/Article/ArticleTest.php b/tests/Unit/Feed/Domain/Article/ArticleTest.php index e37f57b..fccbe81 100644 --- a/tests/Unit/Feed/Domain/Article/ArticleTest.php +++ b/tests/Unit/Feed/Domain/Article/ArticleTest.php @@ -2,6 +2,8 @@ namespace Unit\Feed\Domain\Article; +use App\Feed\Domain\Article\Event\Article\ArticleAddedEvent; +use App\Feed\Domain\Article\Event\Article\ArticleUpdatedEvent; use Dev\Feed\Factory\ArticleFactory; use PHPUnit\Framework\TestCase; @@ -32,6 +34,9 @@ public function it_should_not_update_the_article_if_the_updates_are_older(): voi self::assertEquals('existing title', $article->getTitle()); self::assertEquals('existing summary', $article->getSummary()); self::assertEquals(new \DateTime('2022-03-03 00:00:00'), $article->getUpdated()); + + self::assertEquals(new ArticleAddedEvent($article->getId()), $article->shiftDomainEvent()); + self::assertFalse($article->hasDomainEvents()); } /** @@ -59,6 +64,9 @@ public function it_should_not_update_the_article_if_the_updates_happened_at_the_ self::assertEquals('existing title', $article->getTitle()); self::assertEquals('existing summary', $article->getSummary()); self::assertEquals(new \DateTime('2022-03-03 00:00:00'), $article->getUpdated()); + + self::assertEquals(new ArticleAddedEvent($article->getId()), $article->shiftDomainEvent()); + self::assertFalse($article->hasDomainEvents()); } /** @@ -86,5 +94,9 @@ public function it_should_update_the_article_if_the_updates_are_newer(): void self::assertEquals('updated title', $article->getTitle()); self::assertEquals('updated summary', $article->getSummary()); self::assertEquals(new \DateTime('2024-10-10 10:00:00'), $article->getUpdated()); + + self::assertEquals(new ArticleAddedEvent($article->getId()), $article->shiftDomainEvent()); + self::assertEquals(new ArticleUpdatedEvent($article->getId()), $article->shiftDomainEvent()); + self::assertFalse($article->hasDomainEvents()); } }