From f61b1711dfbc69a6a25dc22b08bc1bfa4ca81cd8 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 10 Feb 2026 11:27:02 +0100 Subject: [PATCH 01/10] FEATURE: Major overhaul Introduce new high-level API to enforce constraints, require PHP 8.4+ and dcb-eventstore packages 5+ --- composer.json | 9 +- index.php | 6 +- phpstan.neon.dist | 9 +- src/Command/Command.php | 12 - src/Command/CreateCourse.php | 39 --- src/Command/RegisterStudent.php | 27 -- src/Command/RenameCourse.php | 33 -- src/Command/SubscribeStudentToCourse.php | 33 -- src/Command/UnsubscribeStudentFromCourse.php | 33 -- src/Command/UpdateCourseCapacity.php | 33 -- src/CommandHandler.php | 289 ------------------ src/Domain/App.php | 149 +++++++++ .../DecisionModel/CourseDecisionModels.php | 111 +++++++ .../DecisionModel/StudentDecisionModels.php | 66 ++++ .../Event/CourseCapacityChanged.php | 6 +- .../Event/CourseDefined.php} | 10 +- src/{ => Domain}/Event/CourseEvent.php | 5 +- src/{ => Domain}/Event/CourseRenamed.php | 6 +- src/{ => Domain}/Event/DomainEvents.php | 3 +- src/{ => Domain}/Event/StudentEvent.php | 5 +- src/{ => Domain}/Event/StudentRegistered.php | 4 +- .../Event/StudentSubscribedToCourse.php | 6 +- .../Event/StudentUnsubscribedFromCourse.php | 6 +- src/{ => Domain}/Types/CourseCapacity.php | 2 +- src/{ => Domain}/Types/CourseId.php | 9 +- src/{ => Domain}/Types/CourseIds.php | 2 +- src/{ => Domain}/Types/CourseTitle.php | 2 +- src/{ => Domain}/Types/StudentId.php | 9 +- .../DecisionModel/Constraint.php | 39 +++ .../DecisionModel/Constraints.php | 43 +++ .../DecisionModel/DecisionModel.php | 4 +- src/{Event => Infrastructure}/DomainEvent.php | 4 +- src/{ => Infrastructure}/EventSerializer.php | 26 +- .../Exception/ConstraintException.php | 2 +- .../Projection/AtomicProjection.php | 91 ++++++ .../Projection/CompositeProjection.php | 72 +++++ src/Infrastructure/Projection/Projection.php | 24 ++ src/Infrastructure/ProvidesTags.php | 13 + src/Infrastructure/functions.php | 16 + src/Projection/ClosureProjection.php | 75 ----- src/Projection/CompositeProjection.php | 72 ----- src/Projection/NullProjection.php | 25 -- src/Projection/Projection.php | 25 -- src/Projection/StreamCriteriaAware.php | 12 - src/Projection/TaggedProjection.php | 81 ----- tests/Behat/Bootstrap/FeatureContext.php | 128 ++++---- ...ateCourse.feature => DefineCourse.feature} | 14 +- tests/Behat/RegisterStudent.feature | 10 +- tests/Behat/RenameCourse.feature | 8 +- tests/Behat/SubscribeStudentToCourse.feature | 12 +- .../UnsubscribeStudentFromCourse.feature | 8 +- tests/Behat/UpdateCourseCapacity.feature | 21 +- .../PHPStan/DecisionModelPhpStanExtension.php | 43 --- 53 files changed, 792 insertions(+), 1000 deletions(-) delete mode 100644 src/Command/Command.php delete mode 100644 src/Command/CreateCourse.php delete mode 100644 src/Command/RegisterStudent.php delete mode 100644 src/Command/RenameCourse.php delete mode 100644 src/Command/SubscribeStudentToCourse.php delete mode 100644 src/Command/UnsubscribeStudentFromCourse.php delete mode 100644 src/Command/UpdateCourseCapacity.php delete mode 100644 src/CommandHandler.php create mode 100644 src/Domain/App.php create mode 100644 src/Domain/DecisionModel/CourseDecisionModels.php create mode 100644 src/Domain/DecisionModel/StudentDecisionModels.php rename src/{ => Domain}/Event/CourseCapacityChanged.php (84%) rename src/{Event/CourseCreated.php => Domain/Event/CourseDefined.php} (78%) rename src/{ => Domain}/Event/CourseEvent.php (62%) rename src/{ => Domain}/Event/CourseRenamed.php (84%) rename src/{ => Domain}/Event/DomainEvents.php (90%) rename src/{ => Domain}/Event/StudentEvent.php (63%) rename src/{ => Domain}/Event/StudentRegistered.php (86%) rename src/{ => Domain}/Event/StudentSubscribedToCourse.php (86%) rename src/{ => Domain}/Event/StudentUnsubscribedFromCourse.php (85%) rename src/{ => Domain}/Types/CourseCapacity.php (93%) rename src/{ => Domain}/Types/CourseId.php (70%) rename src/{ => Domain}/Types/CourseIds.php (97%) rename src/{ => Domain}/Types/CourseTitle.php (92%) rename src/{ => Domain}/Types/StudentId.php (66%) create mode 100644 src/Infrastructure/DecisionModel/Constraint.php create mode 100644 src/Infrastructure/DecisionModel/Constraints.php rename src/{ => Infrastructure}/DecisionModel/DecisionModel.php (69%) rename src/{Event => Infrastructure}/DomainEvent.php (66%) rename src/{ => Infrastructure}/EventSerializer.php (74%) rename src/{ => Infrastructure}/Exception/ConstraintException.php (80%) create mode 100644 src/Infrastructure/Projection/AtomicProjection.php create mode 100644 src/Infrastructure/Projection/CompositeProjection.php create mode 100644 src/Infrastructure/Projection/Projection.php create mode 100644 src/Infrastructure/ProvidesTags.php create mode 100644 src/Infrastructure/functions.php delete mode 100644 src/Projection/ClosureProjection.php delete mode 100644 src/Projection/CompositeProjection.php delete mode 100644 src/Projection/NullProjection.php delete mode 100644 src/Projection/Projection.php delete mode 100644 src/Projection/StreamCriteriaAware.php delete mode 100644 src/Projection/TaggedProjection.php rename tests/Behat/{CreateCourse.feature => DefineCourse.feature} (56%) delete mode 100644 tests/PHPStan/DecisionModelPhpStanExtension.php diff --git a/composer.json b/composer.json index 9d30f1e..7468d80 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,8 @@ "require": { "php": ">=8.4", "webmozart/assert": "^1.11", - "wwwision/dcb-eventstore": "^4", - "wwwision/dcb-eventstore-doctrine": "^4" + "wwwision/dcb-eventstore": "^5", + "wwwision/dcb-eventstore-doctrine": "^5" }, "require-dev": { "roave/security-advisories": "dev-latest", @@ -35,7 +35,10 @@ "autoload": { "psr-4": { "Wwwision\\DCBExample\\": "src/" - } + }, + "files": [ + "src/Infrastructure/functions.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/index.php b/index.php index ca6c6c9..c8e10cd 100644 --- a/index.php +++ b/index.php @@ -4,7 +4,6 @@ use Doctrine\DBAL\DriverManager; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; -use Wwwision\DCBExample\CommandHandler; use Wwwision\DCBExample\Command\Command; use Wwwision\DCBExample\Command\CreateCourse; use Wwwision\DCBExample\Command\RegisterStudent; @@ -12,6 +11,7 @@ use Wwwision\DCBExample\Command\SubscribeStudentToCourse; use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; use Wwwision\DCBExample\Command\UpdateCourseCapacity; +use Wwwision\DCBExample\Domain\App; require __DIR__ . '/vendor/autoload.php'; @@ -30,8 +30,8 @@ /** The {@see EventStore::setup()} method is used to make sure that the Events Store backend is set up (i.e. required tables are created and their schema up-to-date) **/ $eventStore->setup(); -/** @var {@see CommandHandler} is the central authority to handle {@see Command}s */ -$commandHandler = new CommandHandler($eventStore); +/** @var {@see App} is the central authority to handle {@see Command}s */ +$commandHandler = new App($eventStore); // Example: // 1. Create a course (c1) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 81d4af7..478362b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,9 +1,4 @@ parameters: - level: 9 + level: max paths: - - src -services: - - - class: Wwwision\DCBExample\Tests\PHPStan\DecisionModelPhpStanExtension - tags: - - phpstan.broker.dynamicMethodReturnTypeExtension \ No newline at end of file + - src \ No newline at end of file diff --git a/src/Command/Command.php b/src/Command/Command.php deleted file mode 100644 index 0e63966..0000000 --- a/src/Command/Command.php +++ /dev/null @@ -1,12 +0,0 @@ -eventSerializer = new EventSerializer(); - } - - public function handle(Command $command): void - { - match ($command::class) { - CreateCourse::class => $this->handleCreateCourse($command), - RenameCourse::class => $this->handleRenameCourse($command), - RegisterStudent::class => $this->handleRegisterStudent($command), - SubscribeStudentToCourse::class => $this->handleSubscribeStudentToCourse($command), - UnsubscribeStudentFromCourse::class => $this->handleUnsubscribeStudentFromCourse($command), - UpdateCourseCapacity::class => $this->handleUpdateCourseCapacity($command), - default => throw new RuntimeException(sprintf('Unsupported command %s', $command::class), 1684579212), - }; - } - - private function handleCreateCourse(CreateCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - ); - if ($decisionModel->state->courseExists) { - throw new ConstraintException(sprintf('Failed to create course with id "%s" because a course with that id already exists', $command->courseId->value), 1684593925); - } - $domainEvent = new CourseCreated($command->courseId, $command->initialCapacity, $command->courseTitle); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleRenameCourse(RenameCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - courseTitle: self::courseTitle($command->courseId), - ); - if (!$decisionModel->state->courseExists) { - throw new ConstraintException(sprintf('Failed to rename course with id "%s" because a course with that id does not exist', $command->courseId->value), 1684509782); - } - if ($decisionModel->state->courseTitle->equals($command->newCourseTitle)) { - throw new ConstraintException(sprintf('Failed to rename course with id "%s" to "%s" because this is already the title of this course', $command->courseId->value, $command->newCourseTitle->value), 1684509837); - } - $domainEvent = new CourseRenamed($command->courseId, $command->newCourseTitle); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleRegisterStudent(RegisterStudent $command): void - { - $decisionModel = $this->buildDecisionModel( - studentRegistered: self::studentRegistered($command->studentId), - ); - if ($decisionModel->state->studentRegistered) { - throw new ConstraintException(sprintf('Failed to register student with id "%s" because a student with that id already exists', $command->studentId->value), 1684579300); - } - $domainEvent = new StudentRegistered($command->studentId); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleSubscribeStudentToCourse(SubscribeStudentToCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - studentRegistered: self::studentRegistered($command->studentId), - courseCapacity: self::courseCapacity($command->courseId), - numberOfCourseSubscriptions: self::numberOfCourseSubscriptions($command->courseId), - studentSubscriptions: self::studentSubscriptions($command->studentId), - ); - if (!$decisionModel->state->courseExists) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1685266122); - } - if (!$decisionModel->state->studentRegistered) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1686914105); - } - if ($decisionModel->state->courseCapacity->value === $decisionModel->state->numberOfCourseSubscriptions) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because the course\'s capacity of %d is reached', $command->studentId->value, $command->courseId->value, $decisionModel->state->courseCapacity->value), 1684603201); - } - if ($decisionModel->state->studentSubscriptions->contains($command->courseId)) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed to this course', $command->studentId->value, $command->courseId->value), 1684510963); - } - $maximumSubscriptionsPerStudent = 10; - if ($decisionModel->state->studentSubscriptions->count() === $maximumSubscriptionsPerStudent) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed the maximum of %d courses', $command->studentId->value, $command->courseId->value, $maximumSubscriptionsPerStudent), 1684605232); - } - $domainEvent = new StudentSubscribedToCourse($command->courseId, $command->studentId); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleUnsubscribeStudentFromCourse(UnsubscribeStudentFromCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - studentRegistered: self::studentRegistered($command->studentId), - studentSubscriptions: self::studentSubscriptions($command->studentId), - ); - if (!$decisionModel->state->courseExists) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579448); - } - if (!$decisionModel->state->studentRegistered) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579463); - } - if (!$decisionModel->state->studentSubscriptions->contains($command->courseId)) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because that student is not subscribed to this course', $command->studentId->value, $command->courseId->value), 1684579464); - } - $domainEvent = new StudentUnsubscribedFromCourse($command->studentId, $command->courseId); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleUpdateCourseCapacity(UpdateCourseCapacity $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - courseCapacity: self::courseCapacity($command->courseId), - numberOfCourseSubscriptions: self::numberOfCourseSubscriptions($command->courseId), - ); - if (!$decisionModel->state->courseExists) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because a course with that id does not exist', $command->courseId->value, $command->newCapacity->value), 1684604283); - } - if ($decisionModel->state->courseCapacity->equals($command->newCapacity)) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because that is already the courses capacity', $command->courseId->value, $command->newCapacity->value), 1686819073); - } - if ($decisionModel->state->numberOfCourseSubscriptions > $command->newCapacity->value) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because it already has %d active subscriptions', $command->courseId->value, $command->newCapacity->value, $decisionModel->state->numberOfCourseSubscriptions), 1684604361); - } - $domainEvent = new CourseCapacityChanged($command->courseId, $command->newCapacity); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - // ----------------------------- - - /** - * @return Projection - */ - private static function courseExists(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: false, - onlyLastEvent: true, - ) - ->when(CourseCreated::class, fn() => true) - ); - } - - /** - * @return Projection - */ - private static function courseCapacity(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: CourseCapacity::fromInteger(0), - ) - ->when(CourseCreated::class, fn($_, CourseCreated $event) => $event->initialCapacity) - ->when(CourseCapacityChanged::class, fn($_, CourseCapacityChanged $event) => $event->newCapacity) - ); - } - - /** - * @return Projection - */ - private static function numberOfCourseSubscriptions(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: 0, - ) - ->when(StudentSubscribedToCourse::class, fn(int $state) => $state + 1) - ->when(StudentUnsubscribedFromCourse::class, fn(int $state) => $state - 1) - ); - } - - /** - * @return Projection - */ - private static function courseTitle(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: CourseTitle::fromString(''), - ) - ->when(CourseCreated::class, static fn ($_, CourseCreated $event) => $event->courseTitle) - ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle) - ); - } - - /** - * @return Projection - */ - private static function studentRegistered(StudentId $studentId): Projection - { - return TaggedProjection::create( - $studentId->toTag(), - ClosureProjection::create( - initialState: false, - onlyLastEvent: true, - ) - ->when(StudentRegistered::class, fn() => true) - ); - } - - /** - * @return Projection - */ - private static function studentSubscriptions(StudentId $studentId): Projection - { - return TaggedProjection::create( - $studentId->toTag(), - ClosureProjection::create( - initialState: CourseIds::none(), - ) - ->when(StudentSubscribedToCourse::class, static fn (CourseIds $state, StudentSubscribedToCourse $event) => $state->with($event->courseId)) - ->when(StudentUnsubscribedFromCourse::class, static fn (CourseIds $state, StudentUnsubscribedFromCourse $event) => $state->without($event->courseId)) - ); - } - - - // ------------------------------------ - - /** - * @phpstan-ignore-next-line - */ - private function buildDecisionModel(Projection ...$projections): DecisionModel - { - $query = StreamQuery::wildcard(); - Assert::isMap($projections); - $compositeProjection = CompositeProjection::create($projections); - $query = $query->withCriteria($compositeProjection->getCriteria()); - $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::none(); - $state = $compositeProjection->initialState(); - foreach ($this->eventStore->read($query) as $eventEnvelope) { - $domainEvent = $this->eventSerializer->convertEvent($eventEnvelope->event); - $state = $compositeProjection->apply($state, $domainEvent, $eventEnvelope); - $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::fromSequenceNumber($eventEnvelope->sequenceNumber); - } - return new DecisionModel($state, new AppendCondition($query, $expectedHighestSequenceNumber)); - } -} diff --git a/src/Domain/App.php b/src/Domain/App.php new file mode 100644 index 0000000..4cf1a7b --- /dev/null +++ b/src/Domain/App.php @@ -0,0 +1,149 @@ +eventSerializer = new EventSerializer('\\Wwwision\\DCBExample\\Domain\\Event'); + } + + public function defineCourse(CourseId $courseId, CourseTitle $courseTitle, CourseCapacity $initialCapacity): void + { + $this->appendEventConditionally( + Constraints::create( + not(Course::exists($courseId)) + ), + static fn () => new CourseDefined($courseId, $initialCapacity, $courseTitle), + ); + } + + public function renameCourse(CourseId $courseId, CourseTitle $newTitle): void + { + $this->appendEventConditionally( + Constraints::create( + Course::exists($courseId), + not(Course::titleEquals($courseId, $newTitle)), + ), + static fn () => new CourseRenamed($courseId, $newTitle), + ); + } + + public function registerStudent(StudentId $studentId): void + { + $this->appendEventConditionally( + Constraints::create( + not(Student::isRegistered($studentId)) + ), + fn () => new StudentRegistered($studentId), + ); + } + + public function subscribeStudentToCourse(StudentId $studentId, CourseId $courseId): void + { + $this->appendEventConditionally( + Constraints::create( + Course::exists($courseId), + Student::isRegistered($studentId), + Course::hasFreeSeats($courseId), + not(Student::isSubscribedToCourse($studentId, $courseId)), + Student::numberOfSubscriptionsIsBelow($studentId, self::MAX_SUBSCRIPTIONS_PER_STUDENT), + ), + fn () => new StudentSubscribedToCourse($courseId, $studentId), + ); + } + + public function unsubscribeStudentFromCourse(StudentId $studentId, CourseId $courseId): void + { + $this->appendEventConditionally( + Constraints::create( + Course::exists($courseId), + Student::isRegistered($studentId), + Student::isSubscribedToCourse($studentId, $courseId), + ), + static fn () => new StudentUnsubscribedFromCourse($studentId, $courseId), + ); + } + + public function changeCourseCapacity(CourseId $courseId, CourseCapacity $newCapacity): void + { + $this->appendEventConditionally( + Constraints::create( + Course::exists($courseId), + not(Course::capacityEquals($courseId, $newCapacity)), + Course::numberOfSubscriptionsIsBelowOrEqualTo($courseId, $newCapacity->value), + ), + static fn () => new CourseCapacityChanged($courseId, $newCapacity), + ); + } + + // ------------------------------------ + + /** + * @param Closure(): DomainEvent $eventProducer + */ + private function appendEventConditionally(Constraints $constraints, Closure $eventProducer): void + { + $projections = $constraints->map(static fn (Constraint $c) => $c->projection); + assert($projections !== []); + $compositeProjection = CompositeProjection::create($projections, stdClass::class); + $query = $compositeProjection->query(); + $highestMatchingPosition = null; + foreach ($this->eventStore->read($query) as $sequencedEvent) { + $domainEvent = $this->eventSerializer->convertEvent($sequencedEvent->event); + $compositeProjection->apply($domainEvent, $sequencedEvent); + $highestMatchingPosition = $sequencedEvent->position; + } + $constraintStates = (array)$compositeProjection->state(); + + $index = 0; + foreach ($constraints as $constraint) { + $constraintResult = $constraint->evaluate($constraintStates[$index] ?? null); + if ($constraintResult !== true) { + throw new ConstraintException(sprintf('Constraint "%s" failed', $constraint->key)); + } + $index++; + } + + $appendCondition = AppendCondition::create(failIfEventsMatch: $query, after: $highestMatchingPosition); + $domainEvent = ($eventProducer)(); + $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $appendCondition); + } +} diff --git a/src/Domain/DecisionModel/CourseDecisionModels.php b/src/Domain/DecisionModel/CourseDecisionModels.php new file mode 100644 index 0000000..cb1b8b2 --- /dev/null +++ b/src/Domain/DecisionModel/CourseDecisionModels.php @@ -0,0 +1,111 @@ +when(CourseDefined::class, true); + + return Constraint::create('courseExists', $projection, static fn (bool $state) => $state); + } + + /** + * @return Projection + */ + private static function capacity(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: CourseCapacity::fromInteger(0)) + ->when(CourseDefined::class, fn($_, CourseDefined $event) => $event->initialCapacity) + ->when(CourseCapacityChanged::class, fn($_, CourseCapacityChanged $event) => $event->newCapacity) + ; + } + + public static function capacityEquals(CourseId $courseId, CourseCapacity $candidate): Constraint + { + return Constraint::create( + 'courseCapacityEquals', + self::capacity($courseId), + static fn (CourseCapacity $currentCapacity) => $currentCapacity->equals($candidate) + ); + } + + public static function hasFreeSeats(CourseId $courseId): Constraint + { + /** @var CompositeProjection $projection */ + $projection = CompositeProjection::create([ + 'courseCapacity' => self::capacity($courseId), + 'numberOfCourseSubscriptions' => self::numberOfSubscriptions($courseId), + ], stdClass::class); + return Constraint::create( + 'courseHasCapacity', + $projection, + static fn (object $state) => $state->courseCapacity->value > $state->numberOfCourseSubscriptions + ); + } + + /** + * @return Projection + */ + private static function numberOfSubscriptions(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: 0) + ->when(StudentSubscribedToCourse::class, fn(int $state) => $state + 1) + ->when(StudentUnsubscribedFromCourse::class, fn(int $state) => $state - 1) + ; + } + + public static function numberOfSubscriptionsIsBelowOrEqualTo(CourseId $courseId, int $value): Constraint + { + return Constraint::create( + 'numberOfCourseSubscriptionsIsBelowLimit', + self::numberOfSubscriptions($courseId), + static fn (int $numberOfCourseSubscriptions) => $numberOfCourseSubscriptions <= $value + ); + } + + /** + * @return Projection + */ + private static function title(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: CourseTitle::fromString('')) + ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->courseTitle) + ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle) + ; + } + + public static function titleEquals(CourseId $courseId, CourseTitle $candidate): Constraint + { + return Constraint::create( + 'courseTitleEquals', + self::title($courseId), + static fn (CourseTitle $currentTitle) => $currentTitle->equals($candidate) + ); + } +} diff --git a/src/Domain/DecisionModel/StudentDecisionModels.php b/src/Domain/DecisionModel/StudentDecisionModels.php new file mode 100644 index 0000000..68513ff --- /dev/null +++ b/src/Domain/DecisionModel/StudentDecisionModels.php @@ -0,0 +1,66 @@ +when(StudentRegistered::class, fn() => true) + ; + return Constraint::create( + 'studentIsRegistered', + $projection, + static fn (bool $state) => $state + ); + } + + /** + * @return Projection + */ + private static function subscriptions(StudentId $studentId): Projection + { + return AtomicProjection::create($studentId, initialState: CourseIds::none()) + ->when(StudentSubscribedToCourse::class, static fn (CourseIds $state, StudentSubscribedToCourse $event) => $state->with($event->courseId)) + ->when(StudentUnsubscribedFromCourse::class, static fn (CourseIds $state, StudentUnsubscribedFromCourse $event) => $state->without($event->courseId)) + ; + } + + public static function isSubscribedToCourse(StudentId $studentId, CourseId $courseId): Constraint + { + return Constraint::create( + 'studentSubscribedToCourse', + self::subscriptions($studentId), + static fn (CourseIds $courseIds) => $courseIds->contains($courseId), + ); + } + + public static function numberOfSubscriptionsIsBelow(StudentId $studentId, int $value): Constraint + { + return Constraint::create( + 'numberOfStudentSubscriptionsIsBelowLimit', + self::subscriptions($studentId), + static fn (CourseIds $courseIds) => $courseIds->count() < $value + ); + } +} diff --git a/src/Event/CourseCapacityChanged.php b/src/Domain/Event/CourseCapacityChanged.php similarity index 84% rename from src/Event/CourseCapacityChanged.php rename to src/Domain/Event/CourseCapacityChanged.php index 51fd419..605827b 100644 --- a/src/Event/CourseCapacityChanged.php +++ b/src/Domain/Event/CourseCapacityChanged.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; use Webmozart\Assert\Assert; -use Wwwision\DCBExample\Types\CourseCapacity; -use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\CourseCapacity; +use Wwwision\DCBExample\Domain\Types\CourseId; /** * Domain Events that occurs when the total capacity of a course has changed diff --git a/src/Event/CourseCreated.php b/src/Domain/Event/CourseDefined.php similarity index 78% rename from src/Event/CourseCreated.php rename to src/Domain/Event/CourseDefined.php index 1ba6bb7..df4c551 100644 --- a/src/Event/CourseCreated.php +++ b/src/Domain/Event/CourseDefined.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; use Webmozart\Assert\Assert; -use Wwwision\DCBExample\Types\CourseCapacity; -use Wwwision\DCBExample\Types\CourseId; -use Wwwision\DCBExample\Types\CourseTitle; +use Wwwision\DCBExample\Domain\Types\CourseCapacity; +use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\CourseTitle; /** * Domain Events that occurs when a new course was created */ -final readonly class CourseCreated implements CourseEvent +final readonly class CourseDefined implements CourseEvent { public function __construct( public CourseId $courseId, diff --git a/src/Event/CourseEvent.php b/src/Domain/Event/CourseEvent.php similarity index 62% rename from src/Event/CourseEvent.php rename to src/Domain/Event/CourseEvent.php index 7893495..0e54e3b 100644 --- a/src/Event/CourseEvent.php +++ b/src/Domain/Event/CourseEvent.php @@ -4,9 +4,10 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; -use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Infrastructure\DomainEvent; /** * Contract for course related Domain Events diff --git a/src/Event/CourseRenamed.php b/src/Domain/Event/CourseRenamed.php similarity index 84% rename from src/Event/CourseRenamed.php rename to src/Domain/Event/CourseRenamed.php index 3356c21..35c2b10 100644 --- a/src/Event/CourseRenamed.php +++ b/src/Domain/Event/CourseRenamed.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; use Webmozart\Assert\Assert; -use Wwwision\DCBExample\Types\CourseId; -use Wwwision\DCBExample\Types\CourseTitle; +use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\CourseTitle; /** * Domain Events that occurs when the title of a course has changed diff --git a/src/Event/DomainEvents.php b/src/Domain/Event/DomainEvents.php similarity index 90% rename from src/Event/DomainEvents.php rename to src/Domain/Event/DomainEvents.php index e10d2b6..9938fff 100644 --- a/src/Event/DomainEvents.php +++ b/src/Domain/Event/DomainEvents.php @@ -4,12 +4,13 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; use ArrayIterator; use Closure; use IteratorAggregate; use Traversable; +use Wwwision\DCBExample\Infrastructure\DomainEvent; /** * @implements IteratorAggregate diff --git a/src/Event/StudentEvent.php b/src/Domain/Event/StudentEvent.php similarity index 63% rename from src/Event/StudentEvent.php rename to src/Domain/Event/StudentEvent.php index bca3fa0..b69cf63 100644 --- a/src/Event/StudentEvent.php +++ b/src/Domain/Event/StudentEvent.php @@ -4,9 +4,10 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; -use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBExample\Domain\Types\StudentId; +use Wwwision\DCBExample\Infrastructure\DomainEvent; /** * Contract for student related Domain Events diff --git a/src/Event/StudentRegistered.php b/src/Domain/Event/StudentRegistered.php similarity index 86% rename from src/Event/StudentRegistered.php rename to src/Domain/Event/StudentRegistered.php index b1e1aa8..c979f95 100644 --- a/src/Event/StudentRegistered.php +++ b/src/Domain/Event/StudentRegistered.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; use Webmozart\Assert\Assert; -use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBExample\Domain\Types\StudentId; /** * Domain Events that occurs when a new student was registered in the system diff --git a/src/Event/StudentSubscribedToCourse.php b/src/Domain/Event/StudentSubscribedToCourse.php similarity index 86% rename from src/Event/StudentSubscribedToCourse.php rename to src/Domain/Event/StudentSubscribedToCourse.php index 702abd2..c48bc8c 100644 --- a/src/Event/StudentSubscribedToCourse.php +++ b/src/Domain/Event/StudentSubscribedToCourse.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; use Webmozart\Assert\Assert; -use Wwwision\DCBExample\Types\CourseId; -use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\StudentId; /** * Domain Events that occurs when a student was subscribed to a course diff --git a/src/Event/StudentUnsubscribedFromCourse.php b/src/Domain/Event/StudentUnsubscribedFromCourse.php similarity index 85% rename from src/Event/StudentUnsubscribedFromCourse.php rename to src/Domain/Event/StudentUnsubscribedFromCourse.php index 87ae101..aebda1c 100644 --- a/src/Event/StudentUnsubscribedFromCourse.php +++ b/src/Domain/Event/StudentUnsubscribedFromCourse.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Domain\Event; use Webmozart\Assert\Assert; -use Wwwision\DCBExample\Types\CourseId; -use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\StudentId; /** * Domain Events that occurs when a student was unsubscribed from a course diff --git a/src/Types/CourseCapacity.php b/src/Domain/Types/CourseCapacity.php similarity index 93% rename from src/Types/CourseCapacity.php rename to src/Domain/Types/CourseCapacity.php index 816f460..dd2aafb 100644 --- a/src/Types/CourseCapacity.php +++ b/src/Domain/Types/CourseCapacity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Domain\Types; use JsonSerializable; use Webmozart\Assert\Assert; diff --git a/src/Types/CourseId.php b/src/Domain/Types/CourseId.php similarity index 70% rename from src/Types/CourseId.php rename to src/Domain/Types/CourseId.php index 3849174..1e09d2d 100644 --- a/src/Types/CourseId.php +++ b/src/Domain/Types/CourseId.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Domain\Types; use JsonSerializable; -use Wwwision\DCBEventStore\Types\Tag; +use Wwwision\DCBEventStore\Event\Tag; +use Wwwision\DCBExample\Infrastructure\ProvidesTags; /** * Globally unique identifier of a course (usually represented as a UUID v4) */ -final readonly class CourseId implements JsonSerializable +final readonly class CourseId implements ProvidesTags, JsonSerializable { private function __construct(public string $value) { @@ -31,7 +32,7 @@ public function equals(self $other): bool return $other->value === $this->value; } - public function toTag(): Tag + public function tags(): Tag { return Tag::fromString("course:$this->value"); } diff --git a/src/Types/CourseIds.php b/src/Domain/Types/CourseIds.php similarity index 97% rename from src/Types/CourseIds.php rename to src/Domain/Types/CourseIds.php index f0502ff..9bd85f7 100644 --- a/src/Types/CourseIds.php +++ b/src/Domain/Types/CourseIds.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Domain\Types; use ArrayIterator; use Countable; diff --git a/src/Types/CourseTitle.php b/src/Domain/Types/CourseTitle.php similarity index 92% rename from src/Types/CourseTitle.php rename to src/Domain/Types/CourseTitle.php index 1d83332..9ee406d 100644 --- a/src/Types/CourseTitle.php +++ b/src/Domain/Types/CourseTitle.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Domain\Types; use JsonSerializable; diff --git a/src/Types/StudentId.php b/src/Domain/Types/StudentId.php similarity index 66% rename from src/Types/StudentId.php rename to src/Domain/Types/StudentId.php index 31f2e3c..3a375bc 100644 --- a/src/Types/StudentId.php +++ b/src/Domain/Types/StudentId.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Domain\Types; use JsonSerializable; -use Wwwision\DCBEventStore\Types\Tag; +use Wwwision\DCBEventStore\Event\Tag; +use Wwwision\DCBExample\Infrastructure\ProvidesTags; /** * Globally unique identifier of a student (usually represented as a UUID v4) */ -final readonly class StudentId implements JsonSerializable +final readonly class StudentId implements ProvidesTags, JsonSerializable { private function __construct(public string $value) { @@ -26,7 +27,7 @@ public function jsonSerialize(): string return $this->value; } - public function toTag(): Tag + public function tags(): Tag { return Tag::fromString("student:$this->value"); } diff --git a/src/Infrastructure/DecisionModel/Constraint.php b/src/Infrastructure/DecisionModel/Constraint.php new file mode 100644 index 0000000..0df5d7c --- /dev/null +++ b/src/Infrastructure/DecisionModel/Constraint.php @@ -0,0 +1,39 @@ + $projection + * @param Closure(S): bool $transformer + */ + private function __construct( + public string $key, + public Projection $projection, + private Closure $transformer, + ) { + } + + /** + * @template PS + * + * @param Projection $wrappedProjection + * @param Closure(PS): bool $transformer + */ + public static function create(string $key, Projection $wrappedProjection, Closure $transformer): self + { + return new self($key, $wrappedProjection, $transformer); + } + + public function evaluate(mixed $state): bool + { + return ($this->transformer)($state); + } +} diff --git a/src/Infrastructure/DecisionModel/Constraints.php b/src/Infrastructure/DecisionModel/Constraints.php new file mode 100644 index 0000000..375229a --- /dev/null +++ b/src/Infrastructure/DecisionModel/Constraints.php @@ -0,0 +1,43 @@ + + */ +final readonly class Constraints implements IteratorAggregate +{ + /** + * @param list $constraints + */ + private function __construct( + private array $constraints, + ) { + } + + public static function create(Constraint ...$constraints): self + { + return new self(array_values($constraints)); + } + + public function getIterator(): Traversable + { + yield from $this->constraints; + } + + /** + * @template T + * @param Closure(Constraint): T $callback + * @return list + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->constraints); + } +} diff --git a/src/DecisionModel/DecisionModel.php b/src/Infrastructure/DecisionModel/DecisionModel.php similarity index 69% rename from src/DecisionModel/DecisionModel.php rename to src/Infrastructure/DecisionModel/DecisionModel.php index 264ebe8..b403c6c 100644 --- a/src/DecisionModel/DecisionModel.php +++ b/src/Infrastructure/DecisionModel/DecisionModel.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\DecisionModel; +namespace Wwwision\DCBExample\Infrastructure\DecisionModel; -use Wwwision\DCBEventStore\Types\AppendCondition; +use Wwwision\DCBEventStore\AppendCondition\AppendCondition; /** * @template S of object diff --git a/src/Event/DomainEvent.php b/src/Infrastructure/DomainEvent.php similarity index 66% rename from src/Event/DomainEvent.php rename to src/Infrastructure/DomainEvent.php index c7e8d73..0aa69b2 100644 --- a/src/Event/DomainEvent.php +++ b/src/Infrastructure/DomainEvent.php @@ -1,10 +1,8 @@ event; } try { @@ -39,10 +43,8 @@ public function convertEvent(Event|EventEnvelope $event): DomainEvent } Assert::isArray($payload); /** @var class-string $eventClassName */ - $eventClassName = '\\Wwwision\\DCBExample\\Event\\' . $event->type->value; - $domainEvent = $eventClassName::fromArray($payload); - Assert::isInstanceOf($domainEvent, DomainEvent::class); - return $domainEvent; + $eventClassName = $this->eventClassNamespace . '\\' . $event->type->value; + return $eventClassName::fromArray($payload); } public function convertDomainEvent(DomainEvent $domainEvent): Event diff --git a/src/Exception/ConstraintException.php b/src/Infrastructure/Exception/ConstraintException.php similarity index 80% rename from src/Exception/ConstraintException.php rename to src/Infrastructure/Exception/ConstraintException.php index 39204c6..4a5ce0b 100644 --- a/src/Exception/ConstraintException.php +++ b/src/Infrastructure/Exception/ConstraintException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Exception; +namespace Wwwision\DCBExample\Infrastructure\Exception; use InvalidArgumentException; diff --git a/src/Infrastructure/Projection/AtomicProjection.php b/src/Infrastructure/Projection/AtomicProjection.php new file mode 100644 index 0000000..f81fe51 --- /dev/null +++ b/src/Infrastructure/Projection/AtomicProjection.php @@ -0,0 +1,91 @@ + + */ +final class AtomicProjection implements Projection +{ + /** + * @var array, S|callable> + */ + private array $handlers = []; + + /** + * @param S $state + */ + private function __construct( + private readonly Tags $tags, + private mixed $state, + private readonly bool $onlyLastEvent, + ) { + } + + /** + * @template PS + * + * @param PS $initialState + * + * @return self + */ + public static function create(Tag|Tags|ProvidesTags $tags, mixed $initialState, bool $onlyLastEvent = false): self + { + if ($tags instanceof ProvidesTags) { + $tags = $tags->tags(); + } + if ($tags instanceof Tag) { + $tags = Tags::create($tags); + } + return new self($tags, $initialState, $onlyLastEvent); + } + + /** + * @template E of DomainEvent + * @template SN + * + * @param class-string $class + * @param (callable(S|SN, E, SequencedEvent): SN)|(S|SN) $cb + * + * @return self + */ + public function when(string $class, mixed $cb): self + { + $this->handlers[$class] = $cb; // @phpstan-ignore assign.propertyType + + return $this; + } + + public function apply(DomainEvent $event, SequencedEvent $sequencedEvent): void + { + if (!array_key_exists($event::class, $this->handlers) || !$sequencedEvent->event->tags->containEvery($this->tags)) { + return; + } + $this->state = is_callable($this->handlers[$event::class]) ? $this->handlers[$event::class]($this->state, $event, $sequencedEvent) : $this->handlers[$event::class]; + } + + public function state(): mixed + { + return $this->state; + } + + public function query(): Query + { + $eventTypes = array_map(static fn (string $domainEventClass) => substr($domainEventClass, strrpos($domainEventClass, '\\') + 1), array_keys($this->handlers)); + return Query::fromItems( + QueryItem::create(eventTypes: $eventTypes, tags: $this->tags, onlyLastEvent: $this->onlyLastEvent), + ); + } +} diff --git a/src/Infrastructure/Projection/CompositeProjection.php b/src/Infrastructure/Projection/CompositeProjection.php new file mode 100644 index 0000000..032957a --- /dev/null +++ b/src/Infrastructure/Projection/CompositeProjection.php @@ -0,0 +1,72 @@ + + */ +final readonly class CompositeProjection implements Projection +{ + /** + * @param non-empty-array> $projectors + */ + private function __construct( + private array $projectors, + private string $stateClassName, + ) { + } + + /** + * @template PS of object + * + * @param non-empty-array> $projectors + * @param class-string $stateClassName + * + * @return self + */ + public static function create(array $projectors, string $stateClassName): self + { + /** @var self */ + return new self($projectors, $stateClassName); + } + + public function apply(DomainEvent $event, SequencedEvent $sequencedEvent): void + { + foreach ($this->projectors as $projector) { + $projector->apply($event, $sequencedEvent); + } + } + + public function state(): object + { + $state = []; + foreach ($this->projectors as $projectorKey => $projector) { + $state[$projectorKey] = $projector->state(); + } + if ($this->stateClassName === stdClass::class) { + /** @var S */ // @phpstan-ignore varTag.nativeType + return (object)$state; + } + /** @var S */ + return new ($this->stateClassName)(...$state); + } + + public function query(): Query + { + /** @var Query|null $query */ + $query = null; + foreach ($this->projectors as $projector) { + $query = $query === null ? $projector->query() : $query->merge($projector->query()); + } + return $query; + } +} diff --git a/src/Infrastructure/Projection/Projection.php b/src/Infrastructure/Projection/Projection.php new file mode 100644 index 0000000..b1196b5 --- /dev/null +++ b/src/Infrastructure/Projection/Projection.php @@ -0,0 +1,24 @@ +key), + $constraint->projection, + static fn ($state) => !$constraint->evaluate($state), + ); +} diff --git a/src/Projection/ClosureProjection.php b/src/Projection/ClosureProjection.php deleted file mode 100644 index dac8e44..0000000 --- a/src/Projection/ClosureProjection.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ -final class ClosureProjection implements Projection, StreamCriteriaAware -{ - /** - * @param S $initialState - * @param array $handlers - */ - private function __construct( - private readonly mixed $initialState, - public readonly array $handlers, - public readonly bool $onlyLastEvent, - ) { - } - - /** - * @template PS - * @param PS $initialState - * @return self - */ - public static function create(mixed $initialState, bool $onlyLastEvent = false): self - { - return new self($initialState, [], $onlyLastEvent); - } - - /** - * @template E of DomainEvent - * @param class-string $class - * @param callable(S, E): S $cb - * @return self - */ - public function when(string $class, callable $cb): self - { - return new self($this->initialState, [...$this->handlers, $class => $cb], $this->onlyLastEvent); - } - - /** - * @return S - */ - public function initialState(): mixed - { - return $this->initialState; - } - - /** - * @param S $state - * @return S - */ - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): mixed - { - if (!array_key_exists($domainEvent::class, $this->handlers)) { - return $state; - } - return $this->handlers[$domainEvent::class]($state, $domainEvent, $eventEnvelope); - } - - public function getCriteria(): Criteria - { - $eventTypes = EventTypes::fromStrings(...array_map(static fn($domainEventClassName) => substr($domainEventClassName, strrpos($domainEventClassName, '\\') + 1), array_keys($this->handlers))); - return Criteria::create(Criteria\EventTypesAndTagsCriterion::create(eventTypes: $eventTypes, onlyLastEvent: $this->onlyLastEvent)); - } -} diff --git a/src/Projection/CompositeProjection.php b/src/Projection/CompositeProjection.php deleted file mode 100644 index 7f8ec20..0000000 --- a/src/Projection/CompositeProjection.php +++ /dev/null @@ -1,72 +0,0 @@ - - */ -final class CompositeProjection implements Projection, StreamCriteriaAware -{ - /** - * @param array> $projections - */ - private function __construct( - private readonly array $projections, - ) { - } - - /** - * @param array $projections - */ - public static function create(array $projections): self // @phpstan-ignore-line TODO fix - { - return new self($projections); - } - - - /** - * @return S - */ - public function initialState(): object - { - $state = new stdClass(); - foreach ($this->projections as $projectionKey => $projection) { - $state->{$projectionKey} = $projection->initialState(); - } - return $state; // @phpstan-ignore-line TODO fix - } - - /** - * @param S $state - * @return S - */ - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): object - { - foreach ($this->projections as $projectionKey => $projection) { - if ($projection instanceof StreamCriteriaAware && !$projection->getCriteria()->matchesEvent($eventEnvelope->event)) { - continue; - } - $state->{$projectionKey} = $projection->apply($state->{$projectionKey}, $domainEvent, $eventEnvelope); - } - return $state; - } - - public function getCriteria(): Criteria - { - $criteria = Criteria::create(); - foreach ($this->projections as $projection) { - if ($projection instanceof StreamCriteriaAware) { - $criteria = $criteria->merge($projection->getCriteria()); - } - } - return $criteria; - } -} diff --git a/src/Projection/NullProjection.php b/src/Projection/NullProjection.php deleted file mode 100644 index 700c0b1..0000000 --- a/src/Projection/NullProjection.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -final class NullProjection implements Projection -{ - public function initialState(): mixed - { - return null; - } - - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): mixed - { - return null; - } -} diff --git a/src/Projection/Projection.php b/src/Projection/Projection.php deleted file mode 100644 index 57e9495..0000000 --- a/src/Projection/Projection.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -final class TaggedProjection implements Projection, StreamCriteriaAware -{ - /** - * @param Projection $wrapped - */ - private function __construct( - private readonly Tags $tags, - private readonly Projection $wrapped, - ) { - } - - /** - * @template PS - * @param Tags|Tag $tags - * @param Projection $handlers - * @return self - */ - public static function create(Tags|Tag $tags, Projection $handlers): self - { - return new self( - $tags instanceof Tag ? Tags::create($tags) : $tags, - $handlers, - ); - } - - /** - * @return S - */ - public function initialState(): mixed - { - return $this->wrapped->initialState(); - } - - /** - * @param S $state - * @return S - */ - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): mixed - { - if (!$eventEnvelope->event->tags->containEvery($this->tags)) { - return $state; - } - return $this->wrapped->apply($state, $domainEvent, $eventEnvelope); - } - - public function getCriteria(): Criteria - { - $criteria = []; - if ($this->wrapped instanceof StreamCriteriaAware) { - foreach ($this->wrapped->getCriteria() as $criterion) { - $criteria[] = EventTypesAndTagsCriterion::create( - eventTypes: $criterion->eventTypes, - tags: $criterion->tags !== null ? $criterion->tags->merge($this->tags) : $this->tags, - ); - } - } else { - $criteria[] = EventTypesAndTagsCriterion::create( - tags: $this->tags, - ); - } - return Criteria::fromArray($criteria); - } -} diff --git a/tests/Behat/Bootstrap/FeatureContext.php b/tests/Behat/Bootstrap/FeatureContext.php index f25f4cb..07a9834 100644 --- a/tests/Behat/Bootstrap/FeatureContext.php +++ b/tests/Behat/Bootstrap/FeatureContext.php @@ -6,20 +6,20 @@ use Behat\Behat\Context\Context; use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; +use Closure; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use InvalidArgumentException; use PHPUnit\Framework\Assert; +use Wwwision\DCBEventStore\AppendCondition\AppendCondition; +use Wwwision\DCBEventStore\Event\Event; +use Wwwision\DCBEventStore\Event\Events; use Wwwision\DCBEventStore\EventStore; -use Wwwision\DCBEventStore\EventStream; -use Wwwision\DCBEventStore\Helpers\InMemoryEventStream; -use Wwwision\DCBEventStore\Types\AppendCondition; -use Wwwision\DCBEventStore\Types\Event; -use Wwwision\DCBEventStore\Types\Events; -use Wwwision\DCBEventStore\Types\ReadOptions; -use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; +use Wwwision\DCBEventStore\Query\Query; +use Wwwision\DCBEventStore\ReadOptions; +use Wwwision\DCBEventStore\SequencedEvent\SequencedEvents; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; use Wwwision\DCBExample\Command\Command; use Wwwision\DCBExample\Command\CreateCourse; @@ -28,18 +28,18 @@ use Wwwision\DCBExample\Command\SubscribeStudentToCourse; use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; use Wwwision\DCBExample\Command\UpdateCourseCapacity; -use Wwwision\DCBExample\CommandHandler; -use Wwwision\DCBExample\Event\CourseCreated; -use Wwwision\DCBExample\Event\DomainEvent; -use Wwwision\DCBExample\Event\StudentRegistered; -use Wwwision\DCBExample\Event\StudentSubscribedToCourse; -use Wwwision\DCBExample\Event\StudentUnsubscribedFromCourse; -use Wwwision\DCBExample\EventSerializer; -use Wwwision\DCBExample\Exception\ConstraintException; -use Wwwision\DCBExample\Types\CourseCapacity; -use Wwwision\DCBExample\Types\CourseId; -use Wwwision\DCBExample\Types\CourseTitle; -use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBExample\Domain\App; +use Wwwision\DCBExample\Domain\Event\CourseDefined; +use Wwwision\DCBExample\Domain\Event\StudentRegistered; +use Wwwision\DCBExample\Domain\Event\StudentSubscribedToCourse; +use Wwwision\DCBExample\Domain\Event\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Domain\Types\CourseCapacity; +use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\CourseTitle; +use Wwwision\DCBExample\Domain\Types\StudentId; +use Wwwision\DCBExample\Infrastructure\DomainEvent; +use Wwwision\DCBExample\Infrastructure\EventSerializer; +use Wwwision\DCBExample\Infrastructure\Exception\ConstraintException; use function array_diff; use function array_keys; @@ -56,7 +56,7 @@ final class FeatureContext implements Context private Connection $eventStoreConnection; private EventStore $eventStore; - private CommandHandler $commandHandler; + private App $app; private EventSerializer $eventSerializer; private ?ConstraintException $lastConstraintException = null; @@ -84,18 +84,18 @@ public function setup(): void $this->inner->setup(); } - public function read(StreamQuery $query, ReadOptions|null $options = null): EventStream + public function read(Query $query, ReadOptions|null $options = null): SequencedEvents { $innerStream = $this->inner->read($query, $options); - $eventEnvelopes = []; - foreach ($innerStream as $eventEnvelope) { - $this->readEvents = $this->readEvents->append($eventEnvelope->event); - $eventEnvelopes[] = $eventEnvelope; - } - return InMemoryEventStream::create(...$eventEnvelopes); + return SequencedEvents::create(function () use ($innerStream) { + foreach ($innerStream as $sequencedEvent) { + $this->readEvents = $this->readEvents->append($sequencedEvent->event); + yield $sequencedEvent; + } + }); } - public function append(Events|Event $events, AppendCondition $condition): void + public function append(Events|Event $events, AppendCondition|null $condition = null): void { $this->inner->append($events, $condition); if ($events instanceof Event) { @@ -104,8 +104,8 @@ public function append(Events|Event $events, AppendCondition $condition): void $this->appendedEvents = $events; } }; - $this->commandHandler = new CommandHandler($this->eventStore); - $this->eventSerializer = new EventSerializer(); + $this->app = new App($this->eventStore); + $this->eventSerializer = new EventSerializer('\\Wwwision\\DCBExample\\Domain\\Event'); } /** @@ -146,7 +146,7 @@ public function courseExists(string $courseIds, string|null $courseTitle = null, { $domainEvents = []; foreach (explode(',', $courseIds) as $courseId) { - $domainEvents[] = new CourseCreated( + $domainEvents[] = new CourseDefined( CourseId::fromString($courseId), CourseCapacity::fromInteger($initialCapacity ?? 10), courseTitle::fromString($courseTitle ?? ('course ' . $courseId)), @@ -201,19 +201,18 @@ public function studentIsUnsubscribedFromCourses(string $studentId, string $cour // -------------- COMMANDS ---------------------- /** - * @When a new course is created with id :courseId, title :courseTitle and capacity of :initialCapacity - * @When a new course is created with id :courseId and capacity of :initialCapacity - * @When a new course is created with id :courseId and title :courseTitle - * @When a new course is created with id :courseId + * @When a new course is defined with id :courseId, title :courseTitle and capacity of :initialCapacity + * @When a new course is defined with id :courseId and capacity of :initialCapacity + * @When a new course is defined with id :courseId and title :courseTitle + * @When a new course is defined with id :courseId */ - public function aNewCourseIsCreated(string $courseId, string|null $courseTitle = null, int|null $initialCapacity = null): void + public function aNewCourseIsDefined(string $courseId, string $courseTitle = 'Course Title', int $initialCapacity = 10): void { - $command = CreateCourse::create( - courseId: $courseId, - initialCapacity: $initialCapacity ?? 10, - courseTitle: $courseTitle ?? ('course ' . $courseId), - ); - $this->handleCommandAndCatchException($command); + $this->tryAndCatchException(fn () => $this->app->defineCourse( + CourseId::fromString($courseId), + CourseTitle::fromString($courseTitle), + CourseCapacity::fromInteger($initialCapacity) + )); } /** @@ -221,11 +220,10 @@ public function aNewCourseIsCreated(string $courseId, string|null $courseTitle = */ public function courseIsRenamed(string $courseId, string $newCourseTitle): void { - $command = RenameCourse::create( - courseId: $courseId, - newCourseTitle: $newCourseTitle, - ); - $this->handleCommandAndCatchException($command); + $this->tryAndCatchException(fn () => $this->app->renameCourse( + CourseId::fromString($courseId), + CourseTitle::fromString($newCourseTitle), + )); } /** @@ -233,22 +231,20 @@ public function courseIsRenamed(string $courseId, string $newCourseTitle): void */ public function courseCapacityIsChanged(string $courseId, int $newCapacity): void { - $command = UpdateCourseCapacity::create( - courseId: $courseId, - newCapacity: $newCapacity, - ); - $this->handleCommandAndCatchException($command); + $this->tryAndCatchException(fn () => $this->app->changeCourseCapacity( + CourseId::fromString($courseId), + CourseCapacity::fromInteger($newCapacity), + )); } /** * @When a new student is registered with id :studentId */ - public function aNewCourseIsRegistered(string $studentId): void + public function aNewStudentIsRegistered(string $studentId): void { - $command = RegisterStudent::create( + $this->tryAndCatchException(fn () => $this->app->registerStudent( StudentId::fromString($studentId), - ); - $this->handleCommandAndCatchException($command); + )); } /** @@ -256,11 +252,10 @@ public function aNewCourseIsRegistered(string $studentId): void */ public function studentSubscribesToCourse(string $studentId, string $courseId): void { - $command = SubscribeStudentToCourse::create( - CourseId::fromString($courseId), + $this->tryAndCatchException(fn () => $this->app->subscribeStudentToCourse( StudentId::fromString($studentId), - ); - $this->handleCommandAndCatchException($command); + CourseId::fromString($courseId), + )); } /** @@ -268,11 +263,10 @@ public function studentSubscribesToCourse(string $studentId, string $courseId): */ public function studentUnsubscribesFromCourse(string $studentId, string $courseId): void { - $command = UnsubscribeStudentFromCourse::create( - CourseId::fromString($courseId), + $this->tryAndCatchException(fn () => $this->app->unsubscribeStudentFromCourse( StudentId::fromString($studentId), - ); - $this->handleCommandAndCatchException($command); + CourseId::fromString($courseId), + )); } /** @@ -358,12 +352,12 @@ private static function eventToArray(array $keys, Event $event): array // ---------------------------- - private function handleCommandAndCatchException(Command $command): void + private function tryAndCatchException(Closure $handler): void { $this->eventStore->appendedEvents = Events::none(); $this->eventStore->readEvents = Events::none(); try { - $this->commandHandler->handle($command); + $handler(); } catch (ConstraintException $exception) { $this->lastConstraintException = $exception; } @@ -371,7 +365,7 @@ private function handleCommandAndCatchException(Command $command): void private function appendEvents(DomainEvent ...$domainEvents): void { - $this->eventStore->append(Events::fromArray(array_map($this->eventSerializer->convertDomainEvent(...), $domainEvents)), AppendCondition::noConstraints()); + $this->eventStore->append(Events::fromArray(array_map($this->eventSerializer->convertDomainEvent(...), $domainEvents))); } diff --git a/tests/Behat/CreateCourse.feature b/tests/Behat/DefineCourse.feature similarity index 56% rename from tests/Behat/CreateCourse.feature rename to tests/Behat/DefineCourse.feature index d5fe24f..f65bb00 100644 --- a/tests/Behat/CreateCourse.feature +++ b/tests/Behat/DefineCourse.feature @@ -1,19 +1,19 @@ -Feature: Creating courses +Feature: Defining courses - Scenario: Creating a new course with an ID that already exists + Scenario: Defining a new course with an ID that already exists Given course "c1" exists - When a new course is created with id "c1" + When a new course is defined with id "c1" Then the command should be rejected with the following message: """ - Failed to create course with id "c1" because a course with that id already exists + Constraint "notCourseExists" failed """ And no events should be appended - Scenario: Creating a new course + Scenario: Defining a new course Given course "c1" exists - When a new course is created with id "c2", title "course 02" and capacity of 10 + When a new course is defined with id "c2", title "course 02" and capacity of 10 Then no events should be read And the command should pass without errors And the following event should be appended: | Type | Data | Tags | - | "CourseCreated" | {"courseId": "c2", "initialCapacity": "10", "courseTitle": "course 02"} | ["course:c2"] | \ No newline at end of file + | "CourseDefined" | {"courseId": "c2", "initialCapacity": "10", "courseTitle": "course 02"} | ["course:c2"] | \ No newline at end of file diff --git a/tests/Behat/RegisterStudent.feature b/tests/Behat/RegisterStudent.feature index 12eeacc..930cac8 100644 --- a/tests/Behat/RegisterStudent.feature +++ b/tests/Behat/RegisterStudent.feature @@ -5,14 +5,14 @@ Feature: Registering students When a new student is registered with id "s1" Then the command should be rejected with the following message: """ - Failed to register student with id "s1" because a student with that id already exists + Constraint "notStudentIsRegistered" failed """ Scenario: Registering a new student Given student "s1" is registered When a new student is registered with id "s2" Then no events should be read -# And the command should pass without errors -# And the following event should be appended: -# | Type | Data | Tags | -# | "StudentRegistered" | {"studentId": "s2"} | ["student:s2"] | \ No newline at end of file + And the command should pass without errors + And the following event should be appended: + | Type | Data | Tags | + | "StudentRegistered" | {"studentId": "s2"} | ["student:s2"] | \ No newline at end of file diff --git a/tests/Behat/RenameCourse.feature b/tests/Behat/RenameCourse.feature index 16501a3..f9ac227 100644 --- a/tests/Behat/RenameCourse.feature +++ b/tests/Behat/RenameCourse.feature @@ -5,7 +5,7 @@ Feature: Renaming courses When course "non-existing" is renamed to "New course Title" Then the command should be rejected with the following message: """ - Failed to rename course with id "non-existing" because a course with that id does not exist + Constraint "courseExists" failed """ And no events should be appended @@ -14,7 +14,7 @@ Feature: Renaming courses When course "c1" is renamed to "course 01" Then the command should be rejected with the following message: """ - Failed to rename course with id "c1" to "course 01" because this is already the title of this course + Constraint "notCourseTitleEquals" failed """ And no events should be appended @@ -23,7 +23,7 @@ Feature: Renaming courses When course "c1" is renamed to "course 01 renamed" Then the following events should be read: | Type | Tags | - | "CourseCreated" | ["course:c1"] | + | "CourseDefined" | ["course:c1"] | And the command should pass without errors And the following event should be appended: | Type | Data | Tags | @@ -36,7 +36,7 @@ Feature: Renaming courses When course "c1" is renamed to "course 01 renamed 3" Then the following events should be read: | Type | Tags | - | "CourseCreated" | ["course:c1"] | + | "CourseDefined" | ["course:c1"] | | "CourseRenamed" | ["course:c1"] | | "CourseRenamed" | ["course:c1"] | And the command should pass without errors diff --git a/tests/Behat/SubscribeStudentToCourse.feature b/tests/Behat/SubscribeStudentToCourse.feature index 4c2b737..633d3ff 100644 --- a/tests/Behat/SubscribeStudentToCourse.feature +++ b/tests/Behat/SubscribeStudentToCourse.feature @@ -5,7 +5,7 @@ Feature: Subscribing students to courses When student "non-existing" subscribes to course "c1" Then the command should be rejected with the following message: """ - Failed to subscribe student with id "non-existing" to course with id "c1" because a student with that id does not exist + Constraint "studentIsRegistered" failed """ And no events should be appended @@ -15,7 +15,7 @@ Feature: Subscribing students to courses When student "s1" subscribes to course "non-existing" Then the command should be rejected with the following message: """ - Failed to subscribe student with id "s1" to course with id "non-existing" because a course with that id does not exist + Constraint "courseExists" failed """ And no events should be appended @@ -26,7 +26,7 @@ Feature: Subscribing students to courses When student "s1" subscribes to course "c1" Then the command should be rejected with the following message: """ - Failed to subscribe student with id "s1" to course with id "c1" because that student is already subscribed to this course + Constraint "notStudentSubscribedToCourse" failed """ And no events should be appended @@ -39,7 +39,7 @@ Feature: Subscribing students to courses And student "s4" subscribes to course "c1" Then the command should be rejected with the following message: """ - Failed to subscribe student with id "s4" to course with id "c1" because the course's capacity of 3 is reached + Constraint "courseHasCapacity" failed """ And no events should be appended @@ -50,7 +50,7 @@ Feature: Subscribing students to courses When student "s1" subscribes to course "c11" Then the command should be rejected with the following message: """ - Failed to subscribe student with id "s1" to course with id "c11" because that student is already subscribed the maximum of 10 courses + Constraint "numberOfStudentSubscriptionsIsBelowLimit" failed """ And no events should be appended @@ -61,7 +61,7 @@ Feature: Subscribing students to courses When student "s1" subscribes to course "c10" Then the following events should be read: | Type | Tags | - | "CourseCreated" | ["course:c10"] | + | "CourseDefined" | ["course:c10"] | | "StudentRegistered" | ["student:s1"] | | "StudentSubscribedToCourse" | ["course:c1", "student:s1"] | | "StudentSubscribedToCourse" | ["course:c2", "student:s1"] | diff --git a/tests/Behat/UnsubscribeStudentFromCourse.feature b/tests/Behat/UnsubscribeStudentFromCourse.feature index c6f24b8..a5a1a23 100644 --- a/tests/Behat/UnsubscribeStudentFromCourse.feature +++ b/tests/Behat/UnsubscribeStudentFromCourse.feature @@ -5,7 +5,7 @@ Feature: Unsubscribing students from courses When student "non-existing" unsubscribes from course "c1" Then the command should be rejected with the following message: """ - Failed to unsubscribe student with id "non-existing" from course with id "c1" because a student with that id does not exist + Constraint "studentIsRegistered" failed """ And no events should be appended @@ -15,7 +15,7 @@ Feature: Unsubscribing students from courses When student "s1" unsubscribes from course "non-existing" Then the command should be rejected with the following message: """ - Failed to unsubscribe student with id "s1" from course with id "non-existing" because a course with that id does not exist + Constraint "courseExists" failed """ And no events should be appended @@ -26,7 +26,7 @@ Feature: Unsubscribing students from courses When student "s1" unsubscribes from course "c2" Then the command should be rejected with the following message: """ - Failed to unsubscribe student with id "s1" from course with id "c2" because that student is not subscribed to this course + Constraint "studentSubscribedToCourse" failed """ And no events should be appended @@ -38,7 +38,7 @@ Feature: Unsubscribing students from courses And student "s2" unsubscribes from course "c1" Then the following events should be read: | Type | Tags | - | "CourseCreated" | ["course:c1"] | + | "CourseDefined" | ["course:c1"] | | "StudentRegistered" | ["student:s2"] | | "StudentSubscribedToCourse" | ["course:c1", "student:s2"] | And the command should pass without errors diff --git a/tests/Behat/UpdateCourseCapacity.feature b/tests/Behat/UpdateCourseCapacity.feature index 271350e..f989851 100644 --- a/tests/Behat/UpdateCourseCapacity.feature +++ b/tests/Behat/UpdateCourseCapacity.feature @@ -5,7 +5,7 @@ Feature: Updating the capacity of a course When course "c2" capacity is changed to 3 Then the command should be rejected with the following message: """ - Failed to change capacity of course with id "c2" to 3 because a course with that id does not exist + Constraint "courseExists" failed """ And no events should be appended @@ -14,7 +14,7 @@ Feature: Updating the capacity of a course When course "c1" capacity is changed to 3 Then the command should be rejected with the following message: """ - Failed to change capacity of course with id "c1" to 3 because that is already the courses capacity + Constraint "notCourseCapacityEquals" failed """ And no events should be appended @@ -30,7 +30,7 @@ Feature: Updating the capacity of a course And course "c1" capacity is changed to 3 Then the command should be rejected with the following message: """ - Failed to change capacity of course with id "c1" to 3 because it already has 4 active subscriptions + Constraint "numberOfCourseSubscriptionsIsBelowLimit" failed """ And no events should be appended @@ -38,8 +38,8 @@ Feature: Updating the capacity of a course Given course "c1" exists with a capacity of 3 When course "c1" capacity is changed to 4 Then the following events should be read: - | Type | Tags | - | "CourseCreated" | ["course:c1"] | + | Type | Tags | + | "CourseDefined" | ["course:c1"] | And the command should pass without errors And the following event should be appended: | Type | Data | Tags | @@ -47,11 +47,14 @@ Feature: Updating the capacity of a course Scenario: Changing capacity of a course to a lower value Given course "c1" exists with a capacity of 4 - When course "c1" capacity is changed to 3 + And student "s1" is registered + And student "s1" is subscribed to course "c1" + When course "c1" capacity is changed to 1 Then the following events should be read: - | Type | Tags | - | "CourseCreated" | ["course:c1"] | + | Type | Tags | + | "CourseDefined" | ["course:c1"] | + | "StudentSubscribedToCourse" | ["course:c1", "student:s1"] | And the command should pass without errors And the following event should be appended: | Type | Data | Tags | - | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 3} | ["course:c1"] | \ No newline at end of file + | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 1} | ["course:c1"] | \ No newline at end of file diff --git a/tests/PHPStan/DecisionModelPhpStanExtension.php b/tests/PHPStan/DecisionModelPhpStanExtension.php deleted file mode 100644 index 3487e86..0000000 --- a/tests/PHPStan/DecisionModelPhpStanExtension.php +++ /dev/null @@ -1,43 +0,0 @@ -getName() === 'buildDecisionModel'; - } - - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type - { - $args = $methodCall->getArgs(); - $properties = []; - foreach ($args as $argExpression) { - $nameOfParam = $argExpression->getAttributes()['originalArg']->name->name; - /** @var GenericObjectType $projectionObjectType */ - $projectionObjectType = $scope->getType($argExpression->value); - $properties[$nameOfParam] = $projectionObjectType->getTemplateType(Projection::class, 'S'); - } - return new GenericObjectType(DecisionModel::class, [new ObjectShapeType($properties, [])]); - } -} \ No newline at end of file From 1906ff927bcc68b39a4c7bd11137b640c6e781d8 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 20 Feb 2026 16:35:46 +0100 Subject: [PATCH 02/10] Update copyright year in license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 333c3fb..d3bab70 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Bastian Waidelich +Copyright (c) 2026 Bastian Waidelich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 03c66d95e1f6aac443a9c25b5b75575d7e85fcf7 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 21 Feb 2026 12:29:38 +0100 Subject: [PATCH 03/10] Remove unused `DomainEvents` --- src/Domain/Event/DomainEvents.php | 46 ------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 src/Domain/Event/DomainEvents.php diff --git a/src/Domain/Event/DomainEvents.php b/src/Domain/Event/DomainEvents.php deleted file mode 100644 index 9938fff..0000000 --- a/src/Domain/Event/DomainEvents.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ -final class DomainEvents implements IteratorAggregate -{ - /** - * @param array $domainEvents - */ - private function __construct(private readonly array $domainEvents) - { - } - - public static function create(DomainEvent ...$domainEvents): self - { - return new self($domainEvents); - } - - public function getIterator(): Traversable - { - return new ArrayIterator($this->domainEvents); - } - - /** - * @template S - * @param Closure(DomainEvent): S $callback - * @return array - */ - public function map(Closure $callback): array - { - return array_map($callback, $this->domainEvents); - } -} From b172909aeee4be750b9f1f1969fb84c71fa93b02 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 21 Feb 2026 12:29:46 +0100 Subject: [PATCH 04/10] Tweak doc comments in events --- src/Domain/Event/CourseCapacityChanged.php | 2 +- src/Domain/Event/CourseDefined.php | 2 +- src/Domain/Event/CourseRenamed.php | 2 +- src/Domain/Event/StudentRegistered.php | 2 +- src/Domain/Event/StudentSubscribedToCourse.php | 2 +- src/Domain/Event/StudentUnsubscribedFromCourse.php | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Domain/Event/CourseCapacityChanged.php b/src/Domain/Event/CourseCapacityChanged.php index 605827b..3f63287 100644 --- a/src/Domain/Event/CourseCapacityChanged.php +++ b/src/Domain/Event/CourseCapacityChanged.php @@ -9,7 +9,7 @@ use Wwwision\DCBExample\Domain\Types\CourseId; /** - * Domain Events that occurs when the total capacity of a course has changed + * Domain Event that occurs when the total capacity of a course has changed */ final readonly class CourseCapacityChanged implements CourseEvent { diff --git a/src/Domain/Event/CourseDefined.php b/src/Domain/Event/CourseDefined.php index df4c551..9a5abd7 100644 --- a/src/Domain/Event/CourseDefined.php +++ b/src/Domain/Event/CourseDefined.php @@ -10,7 +10,7 @@ use Wwwision\DCBExample\Domain\Types\CourseTitle; /** - * Domain Events that occurs when a new course was created + * Domain Event that occurs when a new course was created */ final readonly class CourseDefined implements CourseEvent { diff --git a/src/Domain/Event/CourseRenamed.php b/src/Domain/Event/CourseRenamed.php index 35c2b10..90f17ac 100644 --- a/src/Domain/Event/CourseRenamed.php +++ b/src/Domain/Event/CourseRenamed.php @@ -9,7 +9,7 @@ use Wwwision\DCBExample\Domain\Types\CourseTitle; /** - * Domain Events that occurs when the title of a course has changed + * Domain Event that occurs when the title of a course has changed */ final readonly class CourseRenamed implements CourseEvent { diff --git a/src/Domain/Event/StudentRegistered.php b/src/Domain/Event/StudentRegistered.php index c979f95..49bbefb 100644 --- a/src/Domain/Event/StudentRegistered.php +++ b/src/Domain/Event/StudentRegistered.php @@ -8,7 +8,7 @@ use Wwwision\DCBExample\Domain\Types\StudentId; /** - * Domain Events that occurs when a new student was registered in the system + * Domain Event that occurs when a new student was registered in the system */ final readonly class StudentRegistered implements StudentEvent { diff --git a/src/Domain/Event/StudentSubscribedToCourse.php b/src/Domain/Event/StudentSubscribedToCourse.php index c48bc8c..6755207 100644 --- a/src/Domain/Event/StudentSubscribedToCourse.php +++ b/src/Domain/Event/StudentSubscribedToCourse.php @@ -9,7 +9,7 @@ use Wwwision\DCBExample\Domain\Types\StudentId; /** - * Domain Events that occurs when a student was subscribed to a course + * Domain Event that occurs when a student was subscribed to a course * * Note: This event affects two entities (course and student)! */ diff --git a/src/Domain/Event/StudentUnsubscribedFromCourse.php b/src/Domain/Event/StudentUnsubscribedFromCourse.php index aebda1c..74ceb4b 100644 --- a/src/Domain/Event/StudentUnsubscribedFromCourse.php +++ b/src/Domain/Event/StudentUnsubscribedFromCourse.php @@ -9,7 +9,7 @@ use Wwwision\DCBExample\Domain\Types\StudentId; /** - * Domain Events that occurs when a student was unsubscribed from a course + * Domain Event that occurs when a student was unsubscribed from a course * * Note: This event affects two entities (course and student)! */ From 525024d5983b26f683215c855cc03fb85b24c217 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 21 Feb 2026 12:39:33 +0100 Subject: [PATCH 05/10] Extract atomic projections to dedicated class --- src/Domain/App.php | 6 +- .../DecisionModel/CourseDecisionModels.php | 73 ++++++------------- .../DecisionModel/StudentDecisionModels.php | 37 ++++------ src/Domain/Projection/CourseProjections.php | 69 ++++++++++++++++++ src/Domain/Projection/StudentProjections.php | 44 +++++++++++ tests/Behat/UpdateCourseCapacity.feature | 2 +- 6 files changed, 150 insertions(+), 81 deletions(-) create mode 100644 src/Domain/Projection/CourseProjections.php create mode 100644 src/Domain/Projection/StudentProjections.php diff --git a/src/Domain/App.php b/src/Domain/App.php index 4cf1a7b..c68a220 100644 --- a/src/Domain/App.php +++ b/src/Domain/App.php @@ -37,8 +37,6 @@ { private EventSerializer $eventSerializer; - private const int MAX_SUBSCRIPTIONS_PER_STUDENT = 10; - public function __construct( private EventStore $eventStore, ) { @@ -84,7 +82,7 @@ public function subscribeStudentToCourse(StudentId $studentId, CourseId $courseI Student::isRegistered($studentId), Course::hasFreeSeats($courseId), not(Student::isSubscribedToCourse($studentId, $courseId)), - Student::numberOfSubscriptionsIsBelow($studentId, self::MAX_SUBSCRIPTIONS_PER_STUDENT), + Student::numberOfSubscriptionsIsBelowLimit($studentId), ), fn () => new StudentSubscribedToCourse($courseId, $studentId), ); @@ -108,7 +106,7 @@ public function changeCourseCapacity(CourseId $courseId, CourseCapacity $newCapa Constraints::create( Course::exists($courseId), not(Course::capacityEquals($courseId, $newCapacity)), - Course::numberOfSubscriptionsIsBelowOrEqualTo($courseId, $newCapacity->value), + Course::numberOfSubscriptionsIsBelowCapacity($courseId, $newCapacity->value), ), static fn () => new CourseCapacityChanged($courseId, $newCapacity), ); diff --git a/src/Domain/DecisionModel/CourseDecisionModels.php b/src/Domain/DecisionModel/CourseDecisionModels.php index cb1b8b2..aa8f6a0 100644 --- a/src/Domain/DecisionModel/CourseDecisionModels.php +++ b/src/Domain/DecisionModel/CourseDecisionModels.php @@ -10,6 +10,7 @@ use Wwwision\DCBExample\Domain\Event\CourseRenamed; use Wwwision\DCBExample\Domain\Event\StudentSubscribedToCourse; use Wwwision\DCBExample\Domain\Event\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Domain\Projection\CourseProjections; use Wwwision\DCBExample\Domain\Types\CourseCapacity; use Wwwision\DCBExample\Domain\Types\CourseId; use Wwwision\DCBExample\Domain\Types\CourseTitle; @@ -29,29 +30,19 @@ private function __construct() public static function exists(CourseId $courseId): Constraint { - $projection = AtomicProjection::create($courseId, initialState: false) - ->when(CourseDefined::class, true); - - return Constraint::create('courseExists', $projection, static fn (bool $state) => $state); - } - - /** - * @return Projection - */ - private static function capacity(CourseId $courseId): Projection - { - return AtomicProjection::create($courseId, initialState: CourseCapacity::fromInteger(0)) - ->when(CourseDefined::class, fn($_, CourseDefined $event) => $event->initialCapacity) - ->when(CourseCapacityChanged::class, fn($_, CourseCapacityChanged $event) => $event->newCapacity) - ; + return Constraint::create( + key: 'courseExists', + wrappedProjection: CourseProjections::idIsUsed($courseId), + transformer: static fn (bool $state) => $state + ); } public static function capacityEquals(CourseId $courseId, CourseCapacity $candidate): Constraint { return Constraint::create( - 'courseCapacityEquals', - self::capacity($courseId), - static fn (CourseCapacity $currentCapacity) => $currentCapacity->equals($candidate) + key: 'courseCapacityEquals', + wrappedProjection: CourseProjections::capacity($courseId), + transformer: static fn (CourseCapacity $currentCapacity) => $currentCapacity->equals($candidate) ); } @@ -59,53 +50,31 @@ public static function hasFreeSeats(CourseId $courseId): Constraint { /** @var CompositeProjection $projection */ $projection = CompositeProjection::create([ - 'courseCapacity' => self::capacity($courseId), - 'numberOfCourseSubscriptions' => self::numberOfSubscriptions($courseId), + 'courseCapacity' => CourseProjections::capacity($courseId), + 'numberOfCourseSubscriptions' => CourseProjections::numberOfSubscriptions($courseId), ], stdClass::class); return Constraint::create( - 'courseHasCapacity', - $projection, - static fn (object $state) => $state->courseCapacity->value > $state->numberOfCourseSubscriptions + key: 'courseHasCapacity', + wrappedProjection: $projection, + transformer: static fn (object $state) => $state->courseCapacity->value > $state->numberOfCourseSubscriptions ); } - /** - * @return Projection - */ - private static function numberOfSubscriptions(CourseId $courseId): Projection - { - return AtomicProjection::create($courseId, initialState: 0) - ->when(StudentSubscribedToCourse::class, fn(int $state) => $state + 1) - ->when(StudentUnsubscribedFromCourse::class, fn(int $state) => $state - 1) - ; - } - - public static function numberOfSubscriptionsIsBelowOrEqualTo(CourseId $courseId, int $value): Constraint + public static function numberOfSubscriptionsIsBelowCapacity(CourseId $courseId, int $capacity): Constraint { return Constraint::create( - 'numberOfCourseSubscriptionsIsBelowLimit', - self::numberOfSubscriptions($courseId), - static fn (int $numberOfCourseSubscriptions) => $numberOfCourseSubscriptions <= $value + key: 'numberOfCourseSubscriptionsIsBelowCapacity', + wrappedProjection: CourseProjections::numberOfSubscriptions($courseId), + transformer: static fn (int $numberOfCourseSubscriptions) => $numberOfCourseSubscriptions <= $capacity ); } - /** - * @return Projection - */ - private static function title(CourseId $courseId): Projection - { - return AtomicProjection::create($courseId, initialState: CourseTitle::fromString('')) - ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->courseTitle) - ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle) - ; - } - public static function titleEquals(CourseId $courseId, CourseTitle $candidate): Constraint { return Constraint::create( - 'courseTitleEquals', - self::title($courseId), - static fn (CourseTitle $currentTitle) => $currentTitle->equals($candidate) + key: 'courseTitleEquals', + wrappedProjection: CourseProjections::title($courseId), + transformer: static fn (CourseTitle $currentTitle) => $currentTitle->equals($candidate) ); } } diff --git a/src/Domain/DecisionModel/StudentDecisionModels.php b/src/Domain/DecisionModel/StudentDecisionModels.php index 68513ff..d8112c1 100644 --- a/src/Domain/DecisionModel/StudentDecisionModels.php +++ b/src/Domain/DecisionModel/StudentDecisionModels.php @@ -7,6 +7,7 @@ use Wwwision\DCBExample\Domain\Event\StudentRegistered; use Wwwision\DCBExample\Domain\Event\StudentSubscribedToCourse; use Wwwision\DCBExample\Domain\Event\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Domain\Projection\StudentProjections; use Wwwision\DCBExample\Domain\Types\CourseId; use Wwwision\DCBExample\Domain\Types\CourseIds; use Wwwision\DCBExample\Domain\Types\StudentId; @@ -16,6 +17,8 @@ final readonly class StudentDecisionModels { + private const int MAX_SUBSCRIPTIONS_PER_STUDENT = 10; + /** * This class only contains static members and is not meant to be initialized */ @@ -25,42 +28,28 @@ private function __construct() public static function isRegistered(StudentId $studentId): Constraint { - $projection = AtomicProjection::create($studentId, initialState: false) - ->when(StudentRegistered::class, fn() => true) - ; return Constraint::create( - 'studentIsRegistered', - $projection, - static fn (bool $state) => $state + key: 'studentIsRegistered', + wrappedProjection: StudentProjections::idIsUsed($studentId), + transformer: static fn (bool $state) => $state ); } - /** - * @return Projection - */ - private static function subscriptions(StudentId $studentId): Projection - { - return AtomicProjection::create($studentId, initialState: CourseIds::none()) - ->when(StudentSubscribedToCourse::class, static fn (CourseIds $state, StudentSubscribedToCourse $event) => $state->with($event->courseId)) - ->when(StudentUnsubscribedFromCourse::class, static fn (CourseIds $state, StudentUnsubscribedFromCourse $event) => $state->without($event->courseId)) - ; - } - public static function isSubscribedToCourse(StudentId $studentId, CourseId $courseId): Constraint { return Constraint::create( - 'studentSubscribedToCourse', - self::subscriptions($studentId), - static fn (CourseIds $courseIds) => $courseIds->contains($courseId), + key: 'studentSubscribedToCourse', + wrappedProjection: StudentProjections::subscriptions($studentId), + transformer: static fn (CourseIds $courseIds) => $courseIds->contains($courseId), ); } - public static function numberOfSubscriptionsIsBelow(StudentId $studentId, int $value): Constraint + public static function numberOfSubscriptionsIsBelowLimit(StudentId $studentId): Constraint { return Constraint::create( - 'numberOfStudentSubscriptionsIsBelowLimit', - self::subscriptions($studentId), - static fn (CourseIds $courseIds) => $courseIds->count() < $value + key: 'numberOfStudentSubscriptionsIsBelowLimit', + wrappedProjection: StudentProjections::subscriptions($studentId), + transformer: static fn (CourseIds $courseIds) => $courseIds->count() < self::MAX_SUBSCRIPTIONS_PER_STUDENT, ); } } diff --git a/src/Domain/Projection/CourseProjections.php b/src/Domain/Projection/CourseProjections.php new file mode 100644 index 0000000..131e6e4 --- /dev/null +++ b/src/Domain/Projection/CourseProjections.php @@ -0,0 +1,69 @@ + + */ + public static function idIsUsed(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: false) + ->when(CourseDefined::class, true) + ; + } + + /** + * @return Projection + */ + public static function capacity(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: CourseCapacity::fromInteger(0)) + ->when(CourseDefined::class, fn($_, CourseDefined $event) => $event->initialCapacity) + ->when(CourseCapacityChanged::class, fn($_, CourseCapacityChanged $event) => $event->newCapacity) + ; + } + + /** + * @return Projection + */ + public static function numberOfSubscriptions(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: 0) + ->when(StudentSubscribedToCourse::class, fn(int $state) => $state + 1) + ->when(StudentUnsubscribedFromCourse::class, fn(int $state) => $state - 1) + ; + } + + /** + * @return Projection + */ + public static function title(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: CourseTitle::fromString('')) + ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->courseTitle) + ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle) + ; + } +} diff --git a/src/Domain/Projection/StudentProjections.php b/src/Domain/Projection/StudentProjections.php new file mode 100644 index 0000000..4e5d9d9 --- /dev/null +++ b/src/Domain/Projection/StudentProjections.php @@ -0,0 +1,44 @@ + + */ + public static function idIsUsed(StudentId $studentId): Projection + { + return AtomicProjection::create($studentId, initialState: false) + ->when(StudentRegistered::class, true) + ; + } + + /** + * @return Projection + */ + public static function subscriptions(StudentId $studentId): Projection + { + return AtomicProjection::create($studentId, initialState: CourseIds::none()) + ->when(StudentSubscribedToCourse::class, static fn (CourseIds $state, StudentSubscribedToCourse $event) => $state->with($event->courseId)) + ->when(StudentUnsubscribedFromCourse::class, static fn (CourseIds $state, StudentUnsubscribedFromCourse $event) => $state->without($event->courseId)) + ; + } +} diff --git a/tests/Behat/UpdateCourseCapacity.feature b/tests/Behat/UpdateCourseCapacity.feature index f989851..492e30f 100644 --- a/tests/Behat/UpdateCourseCapacity.feature +++ b/tests/Behat/UpdateCourseCapacity.feature @@ -30,7 +30,7 @@ Feature: Updating the capacity of a course And course "c1" capacity is changed to 3 Then the command should be rejected with the following message: """ - Constraint "numberOfCourseSubscriptionsIsBelowLimit" failed + Constraint "numberOfCourseSubscriptionsIsBelowCapacity" failed """ And no events should be appended From a735aed94aae1f72c016b48198da3b0d2cea4cbc Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 21 Feb 2026 13:02:26 +0100 Subject: [PATCH 06/10] Fix index.php and update README --- README.md | 32 ++++++++++---------------------- index.php | 29 +++++++++++++---------------- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 5c80089..9132b30 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dynamic Consistency Boundary Example -Simple example for the Dynamic Consistency Boundary pattern [described by Sara Pellegrini](https://sara.event-thinking.io/2023/04/kill-aggregate-chapter-1-I-am-here-to-kill-the-aggregate.html). +Simple example for the [Dynamic Consistency Boundary pattern](https://dcb.events). The purpose of this package is to explore the idea, find potential pitfalls and to spread the word. @@ -12,28 +12,16 @@ Dynamic Consistency Boundary (aka DCB) allow to enforce hard constraints in Even This facilitates focussing on the _behavior_ of the Domain Model rather than on its rigid structure. It also allows for simpler architecture and potential performance improvements as multiple projections can act on the same events without requiring synchronization. -Read all about this interesting approach in the blog post mentioned above or watch Saras talk on [YouTube](https://www.youtube.com/watch?v=DhhxKoOpJe0&t=150s) (Italian with English subtitles). -This package models the example of this presentation (with a few deviations) using the [wwwision/dcb-eventstore](https://github.com/bwaidelich/dcb-eventstore) package and the [wwwision/dcb-eventstore-doctrine](https://github.com/bwaidelich/dcb-eventstore-doctrine) database adapter. +Read all about this interesting approach at https://dcb.events. -### Important Classes / Concepts - -* [Commands](src%2FCommand) are just a concept of this example package. They implement the [Command Marker Interface](src%2FCommand%2FCommand.php) -* The [CommandHandler](src/CommandHandler.php) is the central authority, handling and verifying incoming Command -* It uses in-memory [Projections](src%2FProjection%2FProjection.php) to enforce hard constraints -* For each command handler a [DecisionModel](src%2FDecisionModel%2FDecisionModel.php) instance is built that contains the state of those in-memory projections and the [AppendCondition](https://github.com/bwaidelich/dcb-eventstore/blob/main/Specification.md#AppendCondition) for new events -* The [EventSerializer](src%2FEventSerializer.php) can convert [DomainEvent](src%2FEvent%2DDomainEvent.php) instances to writable events, vice versa -* *Note:* This package contains no Read Model (i.e. classic projections) yet +This package models the example of this presentation (with a few deviations) introducing some higher level concepts like "Decision Models" and "Atomic Projections" (see below) and using the [wwwision/dcb-eventstore](https://github.com/bwaidelich/dcb-eventstore) package and the [wwwision/dcb-eventstore-doctrine](https://github.com/bwaidelich/dcb-eventstore-doctrine) database adapter for persistence. -### Considerations / Findings - -I always had the feeling, that the focus on Event Streams is a distraction to Domain-driven design. So I was very happy to come across this concept. -In the meantime I have had the chance to test it in multiple real world scenarios, and it works really well for me and simplifies things (in spite of some minor caveats in the current implementation): +### Important Classes / Concepts -* It becomes trivial to enforce constraints involving multiple entities (like in this example). -* Global uniqueness (aka "the unique username problem") can easily be achieved with DCB -* Consecutive sequences (e.g. invoice number) can be done without reservation patterns and by only reading a single event per constraint check -* When using composition like in this example, phe in-memory projections are surprisingly small because they focus on a single responsibility -* ...and more +* The [App](src/Domain/App.php) is the central authority, handling and verifying incoming Commands (Note: Commands are simple method calls right now, but obviously command classes could be used as well) +* It uses in-memory [DecisionModels](src/Domain/DecisionModel) to enforce hard constraints via in-memory [Projections](src/Domain/Projection) +* The [EventSerializer](src/Infrastructure/EventSerializer.php) can convert [DomainEvent](src/Infrastructure/DomainEvent.php) instances to [Sequenced Events](https://dcb.events/specification/#sequenced-event), vice versa +* *Note:* This package contains no Read Model (i.e. classic persisted projections) yet ## Usage @@ -53,13 +41,13 @@ And you should get ...no output at all. That's because the example script curren Try changing the script to test, that the business rules are actually enforced, for example you could add the line: ```php -$commandHandler->handle(SubscribeStudentToCourse::create(courseId: 'c1', studentId: 's2')); +$app->subscribeStudentToCourse(StudentId::fromString('s2'), CourseId::fromString('c1')); ``` to the end of the file, which should lead to the following exception: ``` -Failed to subscribe student with id "s2" to course with id "c1" because a student with that id does not exist +Constraint "studentIsRegistered" failed ``` Alternatively, you could have a look at the [Behat Tests](tests/Behat): diff --git a/index.php b/index.php index c8e10cd..615a82b 100644 --- a/index.php +++ b/index.php @@ -4,14 +4,11 @@ use Doctrine\DBAL\DriverManager; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; -use Wwwision\DCBExample\Command\Command; -use Wwwision\DCBExample\Command\CreateCourse; -use Wwwision\DCBExample\Command\RegisterStudent; -use Wwwision\DCBExample\Command\RenameCourse; -use Wwwision\DCBExample\Command\SubscribeStudentToCourse; -use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; -use Wwwision\DCBExample\Command\UpdateCourseCapacity; use Wwwision\DCBExample\Domain\App; +use Wwwision\DCBExample\Domain\Types\CourseCapacity; +use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Domain\Types\CourseTitle; +use Wwwision\DCBExample\Domain\Types\StudentId; require __DIR__ . '/vendor/autoload.php'; @@ -31,23 +28,23 @@ $eventStore->setup(); /** @var {@see App} is the central authority to handle {@see Command}s */ -$commandHandler = new App($eventStore); +$app = new App($eventStore); // Example: -// 1. Create a course (c1) -$commandHandler->handle(CreateCourse::create(courseId: 'c1', initialCapacity: 10, courseTitle: 'Course 02')); +// 1. Define a course (c1) +$app->defineCourse(CourseId::fromString('c1'), CourseTitle::fromString('Course 01'), CourseCapacity::fromInteger(10)); -// 2. rename it, register a student (s1) and subscribe it to the course, change the course capacity, unregister the student -$commandHandler->handle(RenameCourse::create(courseId: 'c1', newCourseTitle: 'Course 01 renamed')); +// 2. rename it +$app->renameCourse(CourseId::fromString('c1'), CourseTitle::fromString('Course 01 renamed')); // 3. register a student (s1) in the system -$commandHandler->handle(RegisterStudent::create(studentId: 's1')); +$app->registerStudent(StudentId::fromString('s1')); // 4. subscribe student (s1) to course (s1) -$commandHandler->handle(SubscribeStudentToCourse::create(courseId: 'c1', studentId: 's1')); +$app->subscribeStudentToCourse(StudentId::fromString('s1'), CourseId::fromString('c1')); // 5. change capacity of course (c1) to 5 -$commandHandler->handle(UpdateCourseCapacity::create(courseId: 'c1', newCapacity: 5)); +$app->changeCourseCapacity(CourseId::fromString('c1'), CourseCapacity::fromInteger(5)); // 6. unsubscribe student (s1) from course (c1) -$commandHandler->handle(UnsubscribeStudentFromCourse::create(courseId: 'c1', studentId: 's1')); \ No newline at end of file +$app->unsubscribeStudentFromCourse(StudentId::fromString('s1'), CourseId::fromString('c1')); \ No newline at end of file From 562ff2d861fbce1c094072a33010d8a6dbc947e2 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 21 Feb 2026 15:41:54 +0100 Subject: [PATCH 07/10] Remove `CourseEvent` and `StudentEvent` interfaces and rely on the `ProvideTags` interface to extract tags instead --- src/Domain/Event/CourseCapacityChanged.php | 3 +- src/Domain/Event/CourseDefined.php | 3 +- src/Domain/Event/CourseEvent.php | 18 -------- src/Domain/Event/CourseRenamed.php | 3 +- src/Domain/Event/StudentEvent.php | 18 -------- src/Domain/Event/StudentRegistered.php | 3 +- .../Event/StudentSubscribedToCourse.php | 3 +- .../Event/StudentUnsubscribedFromCourse.php | 3 +- src/Infrastructure/DomainEvents.php | 43 +++++++++++++++++++ src/Infrastructure/EventSerializer.php | 16 +++---- 10 files changed, 63 insertions(+), 50 deletions(-) delete mode 100644 src/Domain/Event/CourseEvent.php delete mode 100644 src/Domain/Event/StudentEvent.php create mode 100644 src/Infrastructure/DomainEvents.php diff --git a/src/Domain/Event/CourseCapacityChanged.php b/src/Domain/Event/CourseCapacityChanged.php index 3f63287..840e170 100644 --- a/src/Domain/Event/CourseCapacityChanged.php +++ b/src/Domain/Event/CourseCapacityChanged.php @@ -7,11 +7,12 @@ use Webmozart\Assert\Assert; use Wwwision\DCBExample\Domain\Types\CourseCapacity; use Wwwision\DCBExample\Domain\Types\CourseId; +use Wwwision\DCBExample\Infrastructure\DomainEvent; /** * Domain Event that occurs when the total capacity of a course has changed */ -final readonly class CourseCapacityChanged implements CourseEvent +final readonly class CourseCapacityChanged implements DomainEvent { public function __construct( public CourseId $courseId, diff --git a/src/Domain/Event/CourseDefined.php b/src/Domain/Event/CourseDefined.php index 9a5abd7..bbdf98a 100644 --- a/src/Domain/Event/CourseDefined.php +++ b/src/Domain/Event/CourseDefined.php @@ -8,11 +8,12 @@ use Wwwision\DCBExample\Domain\Types\CourseCapacity; use Wwwision\DCBExample\Domain\Types\CourseId; use Wwwision\DCBExample\Domain\Types\CourseTitle; +use Wwwision\DCBExample\Infrastructure\DomainEvent; /** * Domain Event that occurs when a new course was created */ -final readonly class CourseDefined implements CourseEvent +final readonly class CourseDefined implements DomainEvent { public function __construct( public CourseId $courseId, diff --git a/src/Domain/Event/CourseEvent.php b/src/Domain/Event/CourseEvent.php deleted file mode 100644 index 0e54e3b..0000000 --- a/src/Domain/Event/CourseEvent.php +++ /dev/null @@ -1,18 +0,0 @@ - + */ +final class DomainEvents implements IteratorAggregate +{ + /** + * @param array $domainEvents + */ + private function __construct(private readonly array $domainEvents) + { + } + + public static function create(DomainEvent ...$domainEvents): self + { + return new self($domainEvents); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->domainEvents); + } + + /** + * @template S + * @param Closure(DomainEvent): S $callback + * @return array + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->domainEvents); + } +} diff --git a/src/Infrastructure/EventSerializer.php b/src/Infrastructure/EventSerializer.php index 8c5ec77..66fdce1 100644 --- a/src/Infrastructure/EventSerializer.php +++ b/src/Infrastructure/EventSerializer.php @@ -8,11 +8,12 @@ use RuntimeException; use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Event\Event; +use Wwwision\DCBEventStore\Event\Tags; use Wwwision\DCBEventStore\SequencedEvent\SequencedEvent; -use Wwwision\DCBExample\Domain\Event\CourseEvent; -use Wwwision\DCBExample\Domain\Event\StudentEvent; +use Wwwision\DCBExample\Infrastructure\ProvidesTags; use function get_debug_type; +use function get_object_vars; use function json_decode; use function json_encode; use function sprintf; @@ -54,12 +55,11 @@ public function convertDomainEvent(DomainEvent $domainEvent): Event } catch (JsonException $e) { throw new RuntimeException(sprintf('Failed to JSON encode payload of domain event %s: %s', get_debug_type($domainEvent), $e->getMessage()), 1685965020, $e); } - $tags = []; - if ($domainEvent instanceof CourseEvent) { - $tags[] = "course:{$domainEvent->courseId->value}"; - } - if ($domainEvent instanceof StudentEvent) { - $tags[] = "student:{$domainEvent->studentId->value}"; + $tags = Tags::create(); + foreach (get_object_vars($domainEvent) as $value) { + if ($value instanceof ProvidesTags) { + $tags = $tags->merge($value->tags()); + } } return Event::create( type: substr($domainEvent::class, strrpos($domainEvent::class, '\\') + 1), From 0ea26b441fdcd8572fdad720517086395d025b40 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 29 May 2026 10:33:08 +0200 Subject: [PATCH 08/10] WIP: Rewrite --- composer.json | 8 +- index.php | 43 +++-- src/App.php | 80 ++++++++++ src/Domain/App.php | 147 ------------------ .../DecisionModel/CourseDecisionModels.php | 80 ---------- .../DecisionModel/StudentDecisionModels.php | 55 ------- src/Domain/Event/CourseCapacityChanged.php | 37 ----- src/Domain/Event/CourseDefined.php | 42 ----- src/Domain/Event/CourseRenamed.php | 37 ----- src/Domain/Event/StudentRegistered.php | 32 ---- .../Event/StudentSubscribedToCourse.php | 39 ----- .../Event/StudentUnsubscribedFromCourse.php | 36 ----- .../Commands/SubscribeStudentToCourse.php | 28 ++++ .../Commands/UnsubscribeStudentFromCourse.php | 28 ++++ .../Events/StudentSubscribedToCourse.php | 34 ++++ .../Events/StudentUnsubscribedFromCourse.php | 34 ++++ ...SubscribeStudentToCourseCommandHandler.php | 35 +++++ ...bscribeStudentFromCourseCommandHandler.php | 31 ++++ .../ChangeCourseCapacityCommandHandler.php | 34 ++++ .../Commands/ChangeCourseCapacity.php | 28 ++++ .../DefineCourse/Commands/DefineCourse.php | 35 +++++ .../DefineCourse/Commands/RenameCourse.php | 28 ++++ .../DefineCourseCommandHandler.php | 30 ++++ .../Events/CourseCapacityChanged.php | 32 ++++ .../DefineCourse/Events/CourseDefined.php | 39 +++++ .../DefineCourse/Events/CourseRenamed.php | 32 ++++ .../RenameCourseCommandHandler.php | 31 ++++ .../Commands/RegisterStudent.php | 21 +++ .../Events/StudentRegistered.php | 25 +++ .../RegisterStudentCommandHandler.php | 30 ++++ .../DecisionModel/Constraint.php | 39 ----- .../DecisionModel/Constraints.php | 43 ----- .../DecisionModel/DecisionModel.php | 22 --- src/Infrastructure/DomainEvent.php | 16 -- src/Infrastructure/DomainEvents.php | 43 ----- src/Infrastructure/EventSerializer.php | 70 --------- .../Exception/ConstraintException.php | 14 -- .../Projection/AtomicProjection.php | 91 ----------- .../Projection/CompositeProjection.php | 72 --------- src/Infrastructure/Projection/Projection.php | 24 --- src/Infrastructure/ProvidesTags.php | 13 -- src/Infrastructure/functions.php | 16 -- src/Model/Course/CourseDecisionModels.php | 72 +++++++++ .../Course}/CourseProjections.php | 24 +-- .../Course/Dto}/CourseCapacity.php | 2 +- .../Types => Model/Course/Dto}/CourseId.php | 4 +- .../Types => Model/Course/Dto}/CourseIds.php | 2 +- .../Course/Dto}/CourseTitle.php | 2 +- .../Types => Model/Student/Dto}/StudentId.php | 4 +- src/Model/Student/StudentDecisionModels.php | 49 ++++++ .../Student}/StudentProjections.php | 16 +- tests/Behat/Bootstrap/FeatureContext.php | 18 +-- 52 files changed, 826 insertions(+), 1021 deletions(-) create mode 100644 src/App.php delete mode 100644 src/Domain/App.php delete mode 100644 src/Domain/DecisionModel/CourseDecisionModels.php delete mode 100644 src/Domain/DecisionModel/StudentDecisionModels.php delete mode 100644 src/Domain/Event/CourseCapacityChanged.php delete mode 100644 src/Domain/Event/CourseDefined.php delete mode 100644 src/Domain/Event/CourseRenamed.php delete mode 100644 src/Domain/Event/StudentRegistered.php delete mode 100644 src/Domain/Event/StudentSubscribedToCourse.php delete mode 100644 src/Domain/Event/StudentUnsubscribedFromCourse.php create mode 100644 src/Features/CourseSubscription/Commands/SubscribeStudentToCourse.php create mode 100644 src/Features/CourseSubscription/Commands/UnsubscribeStudentFromCourse.php create mode 100644 src/Features/CourseSubscription/Events/StudentSubscribedToCourse.php create mode 100644 src/Features/CourseSubscription/Events/StudentUnsubscribedFromCourse.php create mode 100644 src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php create mode 100644 src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php create mode 100644 src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php create mode 100644 src/Features/DefineCourse/Commands/ChangeCourseCapacity.php create mode 100644 src/Features/DefineCourse/Commands/DefineCourse.php create mode 100644 src/Features/DefineCourse/Commands/RenameCourse.php create mode 100644 src/Features/DefineCourse/DefineCourseCommandHandler.php create mode 100644 src/Features/DefineCourse/Events/CourseCapacityChanged.php create mode 100644 src/Features/DefineCourse/Events/CourseDefined.php create mode 100644 src/Features/DefineCourse/Events/CourseRenamed.php create mode 100644 src/Features/DefineCourse/RenameCourseCommandHandler.php create mode 100644 src/Features/RegisterStudent/Commands/RegisterStudent.php create mode 100644 src/Features/RegisterStudent/Events/StudentRegistered.php create mode 100644 src/Features/RegisterStudent/RegisterStudentCommandHandler.php delete mode 100644 src/Infrastructure/DecisionModel/Constraint.php delete mode 100644 src/Infrastructure/DecisionModel/Constraints.php delete mode 100644 src/Infrastructure/DecisionModel/DecisionModel.php delete mode 100644 src/Infrastructure/DomainEvent.php delete mode 100644 src/Infrastructure/DomainEvents.php delete mode 100644 src/Infrastructure/EventSerializer.php delete mode 100644 src/Infrastructure/Exception/ConstraintException.php delete mode 100644 src/Infrastructure/Projection/AtomicProjection.php delete mode 100644 src/Infrastructure/Projection/CompositeProjection.php delete mode 100644 src/Infrastructure/Projection/Projection.php delete mode 100644 src/Infrastructure/ProvidesTags.php delete mode 100644 src/Infrastructure/functions.php create mode 100644 src/Model/Course/CourseDecisionModels.php rename src/{Domain/Projection => Model/Course}/CourseProjections.php (70%) rename src/{Domain/Types => Model/Course/Dto}/CourseCapacity.php (93%) rename src/{Domain/Types => Model/Course/Dto}/CourseId.php (88%) rename src/{Domain/Types => Model/Course/Dto}/CourseIds.php (97%) rename src/{Domain/Types => Model/Course/Dto}/CourseTitle.php (91%) rename src/{Domain/Types => Model/Student/Dto}/StudentId.php (86%) create mode 100644 src/Model/Student/StudentDecisionModels.php rename src/{Domain/Projection => Model/Student}/StudentProjections.php (67%) diff --git a/composer.json b/composer.json index 7468d80..9ee4a3d 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "php": ">=8.4", "webmozart/assert": "^1.11", "wwwision/dcb-eventstore": "^5", - "wwwision/dcb-eventstore-doctrine": "^5" + "wwwision/dcb-eventstore-doctrine": "^5", + "wwwision/dcb-tools": "@dev" }, "require-dev": { "roave/security-advisories": "dev-latest", @@ -35,10 +36,7 @@ "autoload": { "psr-4": { "Wwwision\\DCBExample\\": "src/" - }, - "files": [ - "src/Infrastructure/functions.php" - ] + } }, "autoload-dev": { "psr-4": { diff --git a/index.php b/index.php index 615a82b..2c250ef 100644 --- a/index.php +++ b/index.php @@ -4,11 +4,19 @@ use Doctrine\DBAL\DriverManager; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; -use Wwwision\DCBExample\Domain\App; -use Wwwision\DCBExample\Domain\Types\CourseCapacity; -use Wwwision\DCBExample\Domain\Types\CourseId; -use Wwwision\DCBExample\Domain\Types\CourseTitle; -use Wwwision\DCBExample\Domain\Types\StudentId; +use Wwwision\DCBExample\App; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseCapacityChanged; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseDefined; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRenamed; +use Wwwision\DCBExample\Features\RegisterStudent\Events\StudentRegistered; +use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; +use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; +use Wwwision\DCBExample\Model\Student\Dto\StudentId; +use Wwwision\DCBTools\DomainEventAppender; +use Wwwision\DCBTools\Serialization\SimpleEventSerializer; require __DIR__ . '/vendor/autoload.php'; @@ -27,24 +35,35 @@ /** The {@see EventStore::setup()} method is used to make sure that the Events Store backend is set up (i.e. required tables are created and their schema up-to-date) **/ $eventStore->setup(); +$eventSerializer = new SimpleEventSerializer([ + CourseCapacityChanged::class, + CourseDefined::class, + CourseRenamed::class, + StudentRegistered::class, + StudentSubscribedToCourse::class, + StudentUnsubscribedFromCourse::class, +]); + +$domainEventAppender = new DomainEventAppender($eventStore, $eventSerializer); + /** @var {@see App} is the central authority to handle {@see Command}s */ -$app = new App($eventStore); +$app = new App($domainEventAppender); // Example: // 1. Define a course (c1) -$app->defineCourse(CourseId::fromString('c1'), CourseTitle::fromString('Course 01'), CourseCapacity::fromInteger(10)); +$app->defineCourse('c1', 'Course 01', 10); // 2. rename it -$app->renameCourse(CourseId::fromString('c1'), CourseTitle::fromString('Course 01 renamed')); +$app->renameCourse('c1', 'Course 01 renamed'); // 3. register a student (s1) in the system -$app->registerStudent(StudentId::fromString('s1')); +$app->registerStudent('s1'); // 4. subscribe student (s1) to course (s1) -$app->subscribeStudentToCourse(StudentId::fromString('s1'), CourseId::fromString('c1')); +$app->subscribeStudentToCourse('s1', 'c1'); // 5. change capacity of course (c1) to 5 -$app->changeCourseCapacity(CourseId::fromString('c1'), CourseCapacity::fromInteger(5)); +$app->changeCourseCapacity('c1', 5); // 6. unsubscribe student (s1) from course (c1) -$app->unsubscribeStudentFromCourse(StudentId::fromString('s1'), CourseId::fromString('c1')); \ No newline at end of file +$app->unsubscribeStudentFromCourse('s1', 'c1'); \ No newline at end of file diff --git a/src/App.php b/src/App.php new file mode 100644 index 0000000..535ef09 --- /dev/null +++ b/src/App.php @@ -0,0 +1,80 @@ +domainEventAppender); + $handler(new DefineCourse($courseId, $initialCapacity, $courseTitle)); + } + + public function renameCourse(string $courseId, string $newTitle): void + { + $handler = new RenameCourseCommandHandler($this->domainEventAppender); + $handler(new RenameCourse($courseId, $newTitle)); + } + + public function changeCourseCapacity(string $courseId, int $newCapacity): void + { + $handler = new ChangeCourseCapacityCommandHandler($this->domainEventAppender); + $handler(new ChangeCourseCapacity($courseId, $newCapacity)); + } + + public function registerStudent(string $studentId): void + { + $handler = new RegisterStudentCommandHandler($this->domainEventAppender); + $handler(new RegisterStudent($studentId)); + } + + public function subscribeStudentToCourse(string $studentId, string $courseId): void + { + $handler = new SubscribeStudentToCourseCommandHandler($this->domainEventAppender); + $handler(new SubscribeStudentToCourse($studentId, $courseId)); + } + + public function unsubscribeStudentFromCourse(string $studentId, string $courseId): void + { + $handler = new UnsubscribeStudentFromCourseCommandHandler($this->domainEventAppender); + $handler(new UnsubscribeStudentFromCourse($studentId, $courseId)); + } +} diff --git a/src/Domain/App.php b/src/Domain/App.php deleted file mode 100644 index c68a220..0000000 --- a/src/Domain/App.php +++ /dev/null @@ -1,147 +0,0 @@ -eventSerializer = new EventSerializer('\\Wwwision\\DCBExample\\Domain\\Event'); - } - - public function defineCourse(CourseId $courseId, CourseTitle $courseTitle, CourseCapacity $initialCapacity): void - { - $this->appendEventConditionally( - Constraints::create( - not(Course::exists($courseId)) - ), - static fn () => new CourseDefined($courseId, $initialCapacity, $courseTitle), - ); - } - - public function renameCourse(CourseId $courseId, CourseTitle $newTitle): void - { - $this->appendEventConditionally( - Constraints::create( - Course::exists($courseId), - not(Course::titleEquals($courseId, $newTitle)), - ), - static fn () => new CourseRenamed($courseId, $newTitle), - ); - } - - public function registerStudent(StudentId $studentId): void - { - $this->appendEventConditionally( - Constraints::create( - not(Student::isRegistered($studentId)) - ), - fn () => new StudentRegistered($studentId), - ); - } - - public function subscribeStudentToCourse(StudentId $studentId, CourseId $courseId): void - { - $this->appendEventConditionally( - Constraints::create( - Course::exists($courseId), - Student::isRegistered($studentId), - Course::hasFreeSeats($courseId), - not(Student::isSubscribedToCourse($studentId, $courseId)), - Student::numberOfSubscriptionsIsBelowLimit($studentId), - ), - fn () => new StudentSubscribedToCourse($courseId, $studentId), - ); - } - - public function unsubscribeStudentFromCourse(StudentId $studentId, CourseId $courseId): void - { - $this->appendEventConditionally( - Constraints::create( - Course::exists($courseId), - Student::isRegistered($studentId), - Student::isSubscribedToCourse($studentId, $courseId), - ), - static fn () => new StudentUnsubscribedFromCourse($studentId, $courseId), - ); - } - - public function changeCourseCapacity(CourseId $courseId, CourseCapacity $newCapacity): void - { - $this->appendEventConditionally( - Constraints::create( - Course::exists($courseId), - not(Course::capacityEquals($courseId, $newCapacity)), - Course::numberOfSubscriptionsIsBelowCapacity($courseId, $newCapacity->value), - ), - static fn () => new CourseCapacityChanged($courseId, $newCapacity), - ); - } - - // ------------------------------------ - - /** - * @param Closure(): DomainEvent $eventProducer - */ - private function appendEventConditionally(Constraints $constraints, Closure $eventProducer): void - { - $projections = $constraints->map(static fn (Constraint $c) => $c->projection); - assert($projections !== []); - $compositeProjection = CompositeProjection::create($projections, stdClass::class); - $query = $compositeProjection->query(); - $highestMatchingPosition = null; - foreach ($this->eventStore->read($query) as $sequencedEvent) { - $domainEvent = $this->eventSerializer->convertEvent($sequencedEvent->event); - $compositeProjection->apply($domainEvent, $sequencedEvent); - $highestMatchingPosition = $sequencedEvent->position; - } - $constraintStates = (array)$compositeProjection->state(); - - $index = 0; - foreach ($constraints as $constraint) { - $constraintResult = $constraint->evaluate($constraintStates[$index] ?? null); - if ($constraintResult !== true) { - throw new ConstraintException(sprintf('Constraint "%s" failed', $constraint->key)); - } - $index++; - } - - $appendCondition = AppendCondition::create(failIfEventsMatch: $query, after: $highestMatchingPosition); - $domainEvent = ($eventProducer)(); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $appendCondition); - } -} diff --git a/src/Domain/DecisionModel/CourseDecisionModels.php b/src/Domain/DecisionModel/CourseDecisionModels.php deleted file mode 100644 index aa8f6a0..0000000 --- a/src/Domain/DecisionModel/CourseDecisionModels.php +++ /dev/null @@ -1,80 +0,0 @@ - $state - ); - } - - public static function capacityEquals(CourseId $courseId, CourseCapacity $candidate): Constraint - { - return Constraint::create( - key: 'courseCapacityEquals', - wrappedProjection: CourseProjections::capacity($courseId), - transformer: static fn (CourseCapacity $currentCapacity) => $currentCapacity->equals($candidate) - ); - } - - public static function hasFreeSeats(CourseId $courseId): Constraint - { - /** @var CompositeProjection $projection */ - $projection = CompositeProjection::create([ - 'courseCapacity' => CourseProjections::capacity($courseId), - 'numberOfCourseSubscriptions' => CourseProjections::numberOfSubscriptions($courseId), - ], stdClass::class); - return Constraint::create( - key: 'courseHasCapacity', - wrappedProjection: $projection, - transformer: static fn (object $state) => $state->courseCapacity->value > $state->numberOfCourseSubscriptions - ); - } - - public static function numberOfSubscriptionsIsBelowCapacity(CourseId $courseId, int $capacity): Constraint - { - return Constraint::create( - key: 'numberOfCourseSubscriptionsIsBelowCapacity', - wrappedProjection: CourseProjections::numberOfSubscriptions($courseId), - transformer: static fn (int $numberOfCourseSubscriptions) => $numberOfCourseSubscriptions <= $capacity - ); - } - - public static function titleEquals(CourseId $courseId, CourseTitle $candidate): Constraint - { - return Constraint::create( - key: 'courseTitleEquals', - wrappedProjection: CourseProjections::title($courseId), - transformer: static fn (CourseTitle $currentTitle) => $currentTitle->equals($candidate) - ); - } -} diff --git a/src/Domain/DecisionModel/StudentDecisionModels.php b/src/Domain/DecisionModel/StudentDecisionModels.php deleted file mode 100644 index d8112c1..0000000 --- a/src/Domain/DecisionModel/StudentDecisionModels.php +++ /dev/null @@ -1,55 +0,0 @@ - $state - ); - } - - public static function isSubscribedToCourse(StudentId $studentId, CourseId $courseId): Constraint - { - return Constraint::create( - key: 'studentSubscribedToCourse', - wrappedProjection: StudentProjections::subscriptions($studentId), - transformer: static fn (CourseIds $courseIds) => $courseIds->contains($courseId), - ); - } - - public static function numberOfSubscriptionsIsBelowLimit(StudentId $studentId): Constraint - { - return Constraint::create( - key: 'numberOfStudentSubscriptionsIsBelowLimit', - wrappedProjection: StudentProjections::subscriptions($studentId), - transformer: static fn (CourseIds $courseIds) => $courseIds->count() < self::MAX_SUBSCRIPTIONS_PER_STUDENT, - ); - } -} diff --git a/src/Domain/Event/CourseCapacityChanged.php b/src/Domain/Event/CourseCapacityChanged.php deleted file mode 100644 index 840e170..0000000 --- a/src/Domain/Event/CourseCapacityChanged.php +++ /dev/null @@ -1,37 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'newCapacity'); - Assert::numeric($data['newCapacity']); - return new self( - CourseId::fromString($data['courseId']), - CourseCapacity::fromInteger((int)$data['newCapacity']), - ); - } -} diff --git a/src/Domain/Event/CourseDefined.php b/src/Domain/Event/CourseDefined.php deleted file mode 100644 index bbdf98a..0000000 --- a/src/Domain/Event/CourseDefined.php +++ /dev/null @@ -1,42 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'initialCapacity'); - Assert::numeric($data['initialCapacity']); - Assert::keyExists($data, 'courseTitle'); - Assert::string($data['courseTitle']); - return new self( - CourseId::fromString($data['courseId']), - CourseCapacity::fromInteger((int)$data['initialCapacity']), - CourseTitle::fromString($data['courseTitle']), - ); - } -} diff --git a/src/Domain/Event/CourseRenamed.php b/src/Domain/Event/CourseRenamed.php deleted file mode 100644 index 072e4be..0000000 --- a/src/Domain/Event/CourseRenamed.php +++ /dev/null @@ -1,37 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'newCourseTitle'); - Assert::string($data['newCourseTitle']); - return new self( - CourseId::fromString($data['courseId']), - CourseTitle::fromString($data['newCourseTitle']), - ); - } -} diff --git a/src/Domain/Event/StudentRegistered.php b/src/Domain/Event/StudentRegistered.php deleted file mode 100644 index c26a470..0000000 --- a/src/Domain/Event/StudentRegistered.php +++ /dev/null @@ -1,32 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'studentId'); - Assert::string($data['studentId']); - return new self( - StudentId::fromString($data['studentId']), - ); - } -} diff --git a/src/Domain/Event/StudentSubscribedToCourse.php b/src/Domain/Event/StudentSubscribedToCourse.php deleted file mode 100644 index cb1ea5a..0000000 --- a/src/Domain/Event/StudentSubscribedToCourse.php +++ /dev/null @@ -1,39 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'studentId'); - Assert::string($data['studentId']); - return new self( - CourseId::fromString($data['courseId']), - StudentId::fromString($data['studentId']), - ); - } -} diff --git a/src/Domain/Event/StudentUnsubscribedFromCourse.php b/src/Domain/Event/StudentUnsubscribedFromCourse.php deleted file mode 100644 index 5dbca99..0000000 --- a/src/Domain/Event/StudentUnsubscribedFromCourse.php +++ /dev/null @@ -1,36 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'studentId'); - Assert::string($data['studentId']); - return new self(StudentId::fromString($data['studentId']), CourseId::fromString($data['courseId']),); - } -} diff --git a/src/Features/CourseSubscription/Commands/SubscribeStudentToCourse.php b/src/Features/CourseSubscription/Commands/SubscribeStudentToCourse.php new file mode 100644 index 0000000..ae95e45 --- /dev/null +++ b/src/Features/CourseSubscription/Commands/SubscribeStudentToCourse.php @@ -0,0 +1,28 @@ +studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/Commands/UnsubscribeStudentFromCourse.php b/src/Features/CourseSubscription/Commands/UnsubscribeStudentFromCourse.php new file mode 100644 index 0000000..4f5d3ae --- /dev/null +++ b/src/Features/CourseSubscription/Commands/UnsubscribeStudentFromCourse.php @@ -0,0 +1,28 @@ +studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/Events/StudentSubscribedToCourse.php b/src/Features/CourseSubscription/Events/StudentSubscribedToCourse.php new file mode 100644 index 0000000..81f410b --- /dev/null +++ b/src/Features/CourseSubscription/Events/StudentSubscribedToCourse.php @@ -0,0 +1,34 @@ +studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/Events/StudentUnsubscribedFromCourse.php b/src/Features/CourseSubscription/Events/StudentUnsubscribedFromCourse.php new file mode 100644 index 0000000..b0233ea --- /dev/null +++ b/src/Features/CourseSubscription/Events/StudentUnsubscribedFromCourse.php @@ -0,0 +1,34 @@ +studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php new file mode 100644 index 0000000..06b3bfe --- /dev/null +++ b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php @@ -0,0 +1,35 @@ +domainEventAppender->append( + constraints: [ + Course::exists($command->courseId), + Student::isRegistered($command->studentId), + Course::hasFreeSeats($command->courseId), + not(Student::isSubscribedToCourse($command->studentId, $command->courseId)), + Student::numberOfSubscriptionsIsBelowLimit($command->studentId), + ], + onSuccess: fn () => new StudentSubscribedToCourse($command->studentId, $command->courseId), + ); + } +} diff --git a/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php b/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php new file mode 100644 index 0000000..706d0ab --- /dev/null +++ b/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php @@ -0,0 +1,31 @@ +domainEventAppender->append( + constraints: [ + Course::exists($command->courseId), + Student::isRegistered($command->studentId), + Student::isSubscribedToCourse($command->studentId, $command->courseId), + ], + onSuccess: static fn () => new StudentUnsubscribedFromCourse($command->studentId, $command->courseId), + ); + } +} diff --git a/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php b/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php new file mode 100644 index 0000000..25c5d9e --- /dev/null +++ b/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php @@ -0,0 +1,34 @@ +domainEventAppender->append( + constraints: [ + Course::exists($command->courseId), + not(Course::capacityEquals($command->courseId, $command->newCapacity)), + Course::numberOfSubscriptionsIsBelowCapacity($command->courseId, $command->newCapacity->value), + ], + onSuccess: static fn () => new CourseCapacityChanged($command->courseId, $command->newCapacity), + ); + } +} diff --git a/src/Features/DefineCourse/Commands/ChangeCourseCapacity.php b/src/Features/DefineCourse/Commands/ChangeCourseCapacity.php new file mode 100644 index 0000000..78ae7ba --- /dev/null +++ b/src/Features/DefineCourse/Commands/ChangeCourseCapacity.php @@ -0,0 +1,28 @@ +courseId = $courseId; + $this->newCapacity = $newCapacity; + } +} diff --git a/src/Features/DefineCourse/Commands/DefineCourse.php b/src/Features/DefineCourse/Commands/DefineCourse.php new file mode 100644 index 0000000..dd38f9a --- /dev/null +++ b/src/Features/DefineCourse/Commands/DefineCourse.php @@ -0,0 +1,35 @@ +courseId = $courseId; + $this->initialCapacity = $initialCapacity; + $this->courseTitle = $courseTitle; + } +} diff --git a/src/Features/DefineCourse/Commands/RenameCourse.php b/src/Features/DefineCourse/Commands/RenameCourse.php new file mode 100644 index 0000000..ffd962d --- /dev/null +++ b/src/Features/DefineCourse/Commands/RenameCourse.php @@ -0,0 +1,28 @@ +courseId = $courseId; + $this->newTitle = $newTitle; + } +} diff --git a/src/Features/DefineCourse/DefineCourseCommandHandler.php b/src/Features/DefineCourse/DefineCourseCommandHandler.php new file mode 100644 index 0000000..16f0cd8 --- /dev/null +++ b/src/Features/DefineCourse/DefineCourseCommandHandler.php @@ -0,0 +1,30 @@ +domainEventAppender->append( + constraints: [ + not(Course::exists($command->courseId)) + ], + onSuccess: static fn () => new CourseDefined($command->courseId, $command->initialCapacity, $command->courseTitle), + ); + } +} diff --git a/src/Features/DefineCourse/Events/CourseCapacityChanged.php b/src/Features/DefineCourse/Events/CourseCapacityChanged.php new file mode 100644 index 0000000..1d927c6 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseCapacityChanged.php @@ -0,0 +1,32 @@ +courseId = $courseId; + $this->newCapacity = $newCapacity; + } +} diff --git a/src/Features/DefineCourse/Events/CourseDefined.php b/src/Features/DefineCourse/Events/CourseDefined.php new file mode 100644 index 0000000..2aff2d9 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseDefined.php @@ -0,0 +1,39 @@ +courseId = $courseId; + $this->initialCapacity = $initialCapacity; + $this->courseTitle = $courseTitle; + } +} diff --git a/src/Features/DefineCourse/Events/CourseRenamed.php b/src/Features/DefineCourse/Events/CourseRenamed.php new file mode 100644 index 0000000..bd88cf7 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseRenamed.php @@ -0,0 +1,32 @@ +courseId = $courseId; + $this->newTitle = $newTitle; + } +} diff --git a/src/Features/DefineCourse/RenameCourseCommandHandler.php b/src/Features/DefineCourse/RenameCourseCommandHandler.php new file mode 100644 index 0000000..9f0923e --- /dev/null +++ b/src/Features/DefineCourse/RenameCourseCommandHandler.php @@ -0,0 +1,31 @@ +domainEventAppender->append( + constraints: [ + Course::exists($command->courseId), + not(Course::titleEquals($command->courseId, $command->newTitle)), + ], + onSuccess: static fn () => new CourseRenamed($command->courseId, $command->newTitle), + ); + } +} diff --git a/src/Features/RegisterStudent/Commands/RegisterStudent.php b/src/Features/RegisterStudent/Commands/RegisterStudent.php new file mode 100644 index 0000000..09bc760 --- /dev/null +++ b/src/Features/RegisterStudent/Commands/RegisterStudent.php @@ -0,0 +1,21 @@ +studentId = $studentId; + } +} diff --git a/src/Features/RegisterStudent/Events/StudentRegistered.php b/src/Features/RegisterStudent/Events/StudentRegistered.php new file mode 100644 index 0000000..46327c6 --- /dev/null +++ b/src/Features/RegisterStudent/Events/StudentRegistered.php @@ -0,0 +1,25 @@ +studentId = $studentId; + } +} diff --git a/src/Features/RegisterStudent/RegisterStudentCommandHandler.php b/src/Features/RegisterStudent/RegisterStudentCommandHandler.php new file mode 100644 index 0000000..90b2214 --- /dev/null +++ b/src/Features/RegisterStudent/RegisterStudentCommandHandler.php @@ -0,0 +1,30 @@ +domainEventAppender->append( + constraints: [ + not(Student::isRegistered($command->studentId)) + ], + onSuccess: fn () => new StudentRegistered($command->studentId), + ); + } +} diff --git a/src/Infrastructure/DecisionModel/Constraint.php b/src/Infrastructure/DecisionModel/Constraint.php deleted file mode 100644 index 0df5d7c..0000000 --- a/src/Infrastructure/DecisionModel/Constraint.php +++ /dev/null @@ -1,39 +0,0 @@ - $projection - * @param Closure(S): bool $transformer - */ - private function __construct( - public string $key, - public Projection $projection, - private Closure $transformer, - ) { - } - - /** - * @template PS - * - * @param Projection $wrappedProjection - * @param Closure(PS): bool $transformer - */ - public static function create(string $key, Projection $wrappedProjection, Closure $transformer): self - { - return new self($key, $wrappedProjection, $transformer); - } - - public function evaluate(mixed $state): bool - { - return ($this->transformer)($state); - } -} diff --git a/src/Infrastructure/DecisionModel/Constraints.php b/src/Infrastructure/DecisionModel/Constraints.php deleted file mode 100644 index 375229a..0000000 --- a/src/Infrastructure/DecisionModel/Constraints.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ -final readonly class Constraints implements IteratorAggregate -{ - /** - * @param list $constraints - */ - private function __construct( - private array $constraints, - ) { - } - - public static function create(Constraint ...$constraints): self - { - return new self(array_values($constraints)); - } - - public function getIterator(): Traversable - { - yield from $this->constraints; - } - - /** - * @template T - * @param Closure(Constraint): T $callback - * @return list - */ - public function map(Closure $callback): array - { - return array_map($callback, $this->constraints); - } -} diff --git a/src/Infrastructure/DecisionModel/DecisionModel.php b/src/Infrastructure/DecisionModel/DecisionModel.php deleted file mode 100644 index b403c6c..0000000 --- a/src/Infrastructure/DecisionModel/DecisionModel.php +++ /dev/null @@ -1,22 +0,0 @@ - $data - */ - public static function fromArray(array $data): self; -} diff --git a/src/Infrastructure/DomainEvents.php b/src/Infrastructure/DomainEvents.php deleted file mode 100644 index bfbfa8a..0000000 --- a/src/Infrastructure/DomainEvents.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ -final class DomainEvents implements IteratorAggregate -{ - /** - * @param array $domainEvents - */ - private function __construct(private readonly array $domainEvents) - { - } - - public static function create(DomainEvent ...$domainEvents): self - { - return new self($domainEvents); - } - - public function getIterator(): Traversable - { - return new ArrayIterator($this->domainEvents); - } - - /** - * @template S - * @param Closure(DomainEvent): S $callback - * @return array - */ - public function map(Closure $callback): array - { - return array_map($callback, $this->domainEvents); - } -} diff --git a/src/Infrastructure/EventSerializer.php b/src/Infrastructure/EventSerializer.php deleted file mode 100644 index 66fdce1..0000000 --- a/src/Infrastructure/EventSerializer.php +++ /dev/null @@ -1,70 +0,0 @@ -event; - } - try { - $payload = json_decode($event->data->value, true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new RuntimeException(sprintf('Failed to decode JSON: %s', $e->getMessage()), 1684510536, $e); - } - Assert::isArray($payload); - /** @var class-string $eventClassName */ - $eventClassName = $this->eventClassNamespace . '\\' . $event->type->value; - return $eventClassName::fromArray($payload); - } - - public function convertDomainEvent(DomainEvent $domainEvent): Event - { - try { - $eventData = json_encode($domainEvent, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new RuntimeException(sprintf('Failed to JSON encode payload of domain event %s: %s', get_debug_type($domainEvent), $e->getMessage()), 1685965020, $e); - } - $tags = Tags::create(); - foreach (get_object_vars($domainEvent) as $value) { - if ($value instanceof ProvidesTags) { - $tags = $tags->merge($value->tags()); - } - } - return Event::create( - type: substr($domainEvent::class, strrpos($domainEvent::class, '\\') + 1), - data: $eventData, - tags: $tags, - ); - } -} diff --git a/src/Infrastructure/Exception/ConstraintException.php b/src/Infrastructure/Exception/ConstraintException.php deleted file mode 100644 index 4a5ce0b..0000000 --- a/src/Infrastructure/Exception/ConstraintException.php +++ /dev/null @@ -1,14 +0,0 @@ - - */ -final class AtomicProjection implements Projection -{ - /** - * @var array, S|callable> - */ - private array $handlers = []; - - /** - * @param S $state - */ - private function __construct( - private readonly Tags $tags, - private mixed $state, - private readonly bool $onlyLastEvent, - ) { - } - - /** - * @template PS - * - * @param PS $initialState - * - * @return self - */ - public static function create(Tag|Tags|ProvidesTags $tags, mixed $initialState, bool $onlyLastEvent = false): self - { - if ($tags instanceof ProvidesTags) { - $tags = $tags->tags(); - } - if ($tags instanceof Tag) { - $tags = Tags::create($tags); - } - return new self($tags, $initialState, $onlyLastEvent); - } - - /** - * @template E of DomainEvent - * @template SN - * - * @param class-string $class - * @param (callable(S|SN, E, SequencedEvent): SN)|(S|SN) $cb - * - * @return self - */ - public function when(string $class, mixed $cb): self - { - $this->handlers[$class] = $cb; // @phpstan-ignore assign.propertyType - - return $this; - } - - public function apply(DomainEvent $event, SequencedEvent $sequencedEvent): void - { - if (!array_key_exists($event::class, $this->handlers) || !$sequencedEvent->event->tags->containEvery($this->tags)) { - return; - } - $this->state = is_callable($this->handlers[$event::class]) ? $this->handlers[$event::class]($this->state, $event, $sequencedEvent) : $this->handlers[$event::class]; - } - - public function state(): mixed - { - return $this->state; - } - - public function query(): Query - { - $eventTypes = array_map(static fn (string $domainEventClass) => substr($domainEventClass, strrpos($domainEventClass, '\\') + 1), array_keys($this->handlers)); - return Query::fromItems( - QueryItem::create(eventTypes: $eventTypes, tags: $this->tags, onlyLastEvent: $this->onlyLastEvent), - ); - } -} diff --git a/src/Infrastructure/Projection/CompositeProjection.php b/src/Infrastructure/Projection/CompositeProjection.php deleted file mode 100644 index 032957a..0000000 --- a/src/Infrastructure/Projection/CompositeProjection.php +++ /dev/null @@ -1,72 +0,0 @@ - - */ -final readonly class CompositeProjection implements Projection -{ - /** - * @param non-empty-array> $projectors - */ - private function __construct( - private array $projectors, - private string $stateClassName, - ) { - } - - /** - * @template PS of object - * - * @param non-empty-array> $projectors - * @param class-string $stateClassName - * - * @return self - */ - public static function create(array $projectors, string $stateClassName): self - { - /** @var self */ - return new self($projectors, $stateClassName); - } - - public function apply(DomainEvent $event, SequencedEvent $sequencedEvent): void - { - foreach ($this->projectors as $projector) { - $projector->apply($event, $sequencedEvent); - } - } - - public function state(): object - { - $state = []; - foreach ($this->projectors as $projectorKey => $projector) { - $state[$projectorKey] = $projector->state(); - } - if ($this->stateClassName === stdClass::class) { - /** @var S */ // @phpstan-ignore varTag.nativeType - return (object)$state; - } - /** @var S */ - return new ($this->stateClassName)(...$state); - } - - public function query(): Query - { - /** @var Query|null $query */ - $query = null; - foreach ($this->projectors as $projector) { - $query = $query === null ? $projector->query() : $query->merge($projector->query()); - } - return $query; - } -} diff --git a/src/Infrastructure/Projection/Projection.php b/src/Infrastructure/Projection/Projection.php deleted file mode 100644 index b1196b5..0000000 --- a/src/Infrastructure/Projection/Projection.php +++ /dev/null @@ -1,24 +0,0 @@ -key), - $constraint->projection, - static fn ($state) => !$constraint->evaluate($state), - ); -} diff --git a/src/Model/Course/CourseDecisionModels.php b/src/Model/Course/CourseDecisionModels.php new file mode 100644 index 0000000..a0bfee2 --- /dev/null +++ b/src/Model/Course/CourseDecisionModels.php @@ -0,0 +1,72 @@ + $state + ); + } + + public static function capacityEquals(CourseId $courseId, CourseCapacity $candidate): Constraint + { + return Constraint::create( + name: 'courseCapacityEquals', + projection: CourseProjections::capacity($courseId), + predicate: static fn (CourseCapacity $currentCapacity) => $currentCapacity->equals($candidate) + ); + } + + public static function hasFreeSeats(CourseId $courseId): Constraint + { + /** @var CompositeProjection $projection */ + $projection = CompositeProjection::create([ + 'courseCapacity' => CourseProjections::capacity($courseId), + 'numberOfCourseSubscriptions' => CourseProjections::numberOfSubscriptions($courseId), + ], stdClass::class); + return Constraint::create( + name: 'courseHasCapacity', + projection: $projection, + predicate: static fn (object $state) => $state->courseCapacity->value > $state->numberOfCourseSubscriptions + ); + } + + public static function numberOfSubscriptionsIsBelowCapacity(CourseId $courseId, int $capacity): Constraint + { + return Constraint::create( + name: 'numberOfCourseSubscriptionsIsBelowCapacity', + projection: CourseProjections::numberOfSubscriptions($courseId), + predicate: static fn (int $numberOfCourseSubscriptions) => $numberOfCourseSubscriptions <= $capacity + ); + } + + public static function titleEquals(CourseId $courseId, CourseTitle $candidate): Constraint + { + return Constraint::create( + name: 'courseTitleEquals', + projection: CourseProjections::title($courseId), + predicate: static fn (CourseTitle $currentTitle) => $currentTitle->equals($candidate) + ); + } +} diff --git a/src/Domain/Projection/CourseProjections.php b/src/Model/Course/CourseProjections.php similarity index 70% rename from src/Domain/Projection/CourseProjections.php rename to src/Model/Course/CourseProjections.php index 131e6e4..2e4a1b6 100644 --- a/src/Domain/Projection/CourseProjections.php +++ b/src/Model/Course/CourseProjections.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Domain\Projection; +namespace Wwwision\DCBExample\Model\Course; -use Wwwision\DCBExample\Domain\Event\CourseCapacityChanged; -use Wwwision\DCBExample\Domain\Event\CourseDefined; -use Wwwision\DCBExample\Domain\Event\CourseRenamed; -use Wwwision\DCBExample\Domain\Event\StudentSubscribedToCourse; -use Wwwision\DCBExample\Domain\Event\StudentUnsubscribedFromCourse; -use Wwwision\DCBExample\Domain\Types\CourseCapacity; -use Wwwision\DCBExample\Domain\Types\CourseId; -use Wwwision\DCBExample\Domain\Types\CourseTitle; -use Wwwision\DCBExample\Infrastructure\Projection\AtomicProjection; -use Wwwision\DCBExample\Infrastructure\Projection\Projection; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseCapacityChanged; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseDefined; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRenamed; +use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; +use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; +use Wwwision\DCBTools\Projection\AtomicProjection; +use Wwwision\DCBTools\Projection\Projection; final readonly class CourseProjections { @@ -63,7 +63,7 @@ public static function title(CourseId $courseId): Projection { return AtomicProjection::create($courseId, initialState: CourseTitle::fromString('')) ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->courseTitle) - ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle) + ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newTitle) ; } } diff --git a/src/Domain/Types/CourseCapacity.php b/src/Model/Course/Dto/CourseCapacity.php similarity index 93% rename from src/Domain/Types/CourseCapacity.php rename to src/Model/Course/Dto/CourseCapacity.php index dd2aafb..045c6eb 100644 --- a/src/Domain/Types/CourseCapacity.php +++ b/src/Model/Course/Dto/CourseCapacity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Domain\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use JsonSerializable; use Webmozart\Assert\Assert; diff --git a/src/Domain/Types/CourseId.php b/src/Model/Course/Dto/CourseId.php similarity index 88% rename from src/Domain/Types/CourseId.php rename to src/Model/Course/Dto/CourseId.php index 1e09d2d..947f86c 100644 --- a/src/Domain/Types/CourseId.php +++ b/src/Model/Course/Dto/CourseId.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Domain\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use JsonSerializable; use Wwwision\DCBEventStore\Event\Tag; -use Wwwision\DCBExample\Infrastructure\ProvidesTags; +use Wwwision\DCBTools\Event\ProvidesTags; /** * Globally unique identifier of a course (usually represented as a UUID v4) diff --git a/src/Domain/Types/CourseIds.php b/src/Model/Course/Dto/CourseIds.php similarity index 97% rename from src/Domain/Types/CourseIds.php rename to src/Model/Course/Dto/CourseIds.php index 9bd85f7..7a66109 100644 --- a/src/Domain/Types/CourseIds.php +++ b/src/Model/Course/Dto/CourseIds.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Domain\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use ArrayIterator; use Countable; diff --git a/src/Domain/Types/CourseTitle.php b/src/Model/Course/Dto/CourseTitle.php similarity index 91% rename from src/Domain/Types/CourseTitle.php rename to src/Model/Course/Dto/CourseTitle.php index 9ee406d..5e8c2de 100644 --- a/src/Domain/Types/CourseTitle.php +++ b/src/Model/Course/Dto/CourseTitle.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Domain\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use JsonSerializable; diff --git a/src/Domain/Types/StudentId.php b/src/Model/Student/Dto/StudentId.php similarity index 86% rename from src/Domain/Types/StudentId.php rename to src/Model/Student/Dto/StudentId.php index 3a375bc..9972818 100644 --- a/src/Domain/Types/StudentId.php +++ b/src/Model/Student/Dto/StudentId.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Domain\Types; +namespace Wwwision\DCBExample\Model\Student\Dto; use JsonSerializable; use Wwwision\DCBEventStore\Event\Tag; -use Wwwision\DCBExample\Infrastructure\ProvidesTags; +use Wwwision\DCBTools\Event\ProvidesTags; /** * Globally unique identifier of a student (usually represented as a UUID v4) diff --git a/src/Model/Student/StudentDecisionModels.php b/src/Model/Student/StudentDecisionModels.php new file mode 100644 index 0000000..2e5a20f --- /dev/null +++ b/src/Model/Student/StudentDecisionModels.php @@ -0,0 +1,49 @@ + $state + ); + } + + public static function isSubscribedToCourse(StudentId $studentId, CourseId $courseId): Constraint + { + return Constraint::create( + name: 'studentSubscribedToCourse', + projection: StudentProjections::subscriptions($studentId), + predicate: static fn (CourseIds $courseIds) => $courseIds->contains($courseId), + ); + } + + public static function numberOfSubscriptionsIsBelowLimit(StudentId $studentId): Constraint + { + return Constraint::create( + name: 'numberOfStudentSubscriptionsIsBelowLimit', + projection: StudentProjections::subscriptions($studentId), + predicate: static fn (CourseIds $courseIds) => $courseIds->count() < self::MAX_SUBSCRIPTIONS_PER_STUDENT, + ); + } +} diff --git a/src/Domain/Projection/StudentProjections.php b/src/Model/Student/StudentProjections.php similarity index 67% rename from src/Domain/Projection/StudentProjections.php rename to src/Model/Student/StudentProjections.php index 4e5d9d9..57fd423 100644 --- a/src/Domain/Projection/StudentProjections.php +++ b/src/Model/Student/StudentProjections.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Domain\Projection; +namespace Wwwision\DCBExample\Model\Student; -use Wwwision\DCBExample\Domain\Event\StudentRegistered; -use Wwwision\DCBExample\Domain\Event\StudentSubscribedToCourse; -use Wwwision\DCBExample\Domain\Event\StudentUnsubscribedFromCourse; -use Wwwision\DCBExample\Domain\Types\CourseIds; -use Wwwision\DCBExample\Domain\Types\StudentId; -use Wwwision\DCBExample\Infrastructure\Projection\AtomicProjection; -use Wwwision\DCBExample\Infrastructure\Projection\Projection; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Features\RegisterStudent\Events\StudentRegistered; +use Wwwision\DCBExample\Model\Course\Dto\CourseIds; +use Wwwision\DCBExample\Model\Student\Dto\StudentId; +use Wwwision\DCBTools\Projection\AtomicProjection; +use Wwwision\DCBTools\Projection\Projection; final readonly class StudentProjections { diff --git a/tests/Behat/Bootstrap/FeatureContext.php b/tests/Behat/Bootstrap/FeatureContext.php index 07a9834..fa17913 100644 --- a/tests/Behat/Bootstrap/FeatureContext.php +++ b/tests/Behat/Bootstrap/FeatureContext.php @@ -21,6 +21,7 @@ use Wwwision\DCBEventStore\ReadOptions; use Wwwision\DCBEventStore\SequencedEvent\SequencedEvents; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; +use Wwwision\DCBExample\App; use Wwwision\DCBExample\Command\Command; use Wwwision\DCBExample\Command\CreateCourse; use Wwwision\DCBExample\Command\RegisterStudent; @@ -28,18 +29,17 @@ use Wwwision\DCBExample\Command\SubscribeStudentToCourse; use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; use Wwwision\DCBExample\Command\UpdateCourseCapacity; -use Wwwision\DCBExample\Domain\App; -use Wwwision\DCBExample\Domain\Event\CourseDefined; -use Wwwision\DCBExample\Domain\Event\StudentRegistered; -use Wwwision\DCBExample\Domain\Event\StudentSubscribedToCourse; -use Wwwision\DCBExample\Domain\Event\StudentUnsubscribedFromCourse; -use Wwwision\DCBExample\Domain\Types\CourseCapacity; -use Wwwision\DCBExample\Domain\Types\CourseId; -use Wwwision\DCBExample\Domain\Types\CourseTitle; -use Wwwision\DCBExample\Domain\Types\StudentId; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseDefined; +use Wwwision\DCBExample\Features\RegisterStudent\Events\StudentRegistered; use Wwwision\DCBExample\Infrastructure\DomainEvent; use Wwwision\DCBExample\Infrastructure\EventSerializer; use Wwwision\DCBExample\Infrastructure\Exception\ConstraintException; +use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; +use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; +use Wwwision\DCBExample\Model\Student\Dto\StudentId; use function array_diff; use function array_keys; From b12991bfcb0282b7db8216b99b6a8cdfda3c7e19 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Thu, 11 Jun 2026 17:15:42 +0200 Subject: [PATCH 09/10] Major Rewrite --- composer.json | 4 +- index.php | 67 +++++++++- src/App.php | 33 +++-- ...SubscribeStudentToCourseCommandHandler.php | 10 +- ...bscribeStudentFromCourseCommandHandler.php | 4 +- .../ChangeCourseCapacityCommandHandler.php | 6 +- .../DefineCourse/Commands/DefineCourse.php | 7 ++ .../Commands/RescheduleCourse.php | 28 +++++ .../DefineCourseCommandHandler.php | 6 +- .../DefineCourse/Events/CourseDefined.php | 7 ++ .../DefineCourse/Events/CourseRescheduled.php | 32 +++++ .../RenameCourseCommandHandler.php | 4 +- .../RescheduleCourseCommandHandler.php | 39 ++++++ .../RegisterStudentCommandHandler.php | 4 +- src/Model/Course/CourseDecisionModels.php | 27 ++++ src/Model/Course/CourseProjections.php | 41 +++++++ src/Model/Course/Dto/CourseIds.php | 32 ++++- src/Model/Course/Dto/CourseSchedule.php | 44 +++++++ src/Model/Course/Dto/CourseSchedules.php | 62 ++++++++++ src/Model/Course/Dto/DateAndTime.php | 37 ++++++ src/Model/Student/Dto/StudentId.php | 5 + src/Model/Student/Dto/StudentIds.php | 116 ++++++++++++++++++ .../Student/Dto/StudentSubscriptions.php | 18 +++ 23 files changed, 592 insertions(+), 41 deletions(-) create mode 100644 src/Features/DefineCourse/Commands/RescheduleCourse.php create mode 100644 src/Features/DefineCourse/Events/CourseRescheduled.php create mode 100644 src/Features/DefineCourse/RescheduleCourseCommandHandler.php create mode 100644 src/Model/Course/Dto/CourseSchedule.php create mode 100644 src/Model/Course/Dto/CourseSchedules.php create mode 100644 src/Model/Course/Dto/DateAndTime.php create mode 100644 src/Model/Student/Dto/StudentIds.php create mode 100644 src/Model/Student/Dto/StudentSubscriptions.php diff --git a/composer.json b/composer.json index 9ee4a3d..3997738 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,10 @@ } ], "require": { - "php": ">=8.4", + "php": ">=8.5", "webmozart/assert": "^1.11", "wwwision/dcb-eventstore": "^5", - "wwwision/dcb-eventstore-doctrine": "^5", + "wwwision/dcb-eventstore-doctrine": "^6", "wwwision/dcb-tools": "@dev" }, "require-dev": { diff --git a/index.php b/index.php index 2c250ef..b07177c 100644 --- a/index.php +++ b/index.php @@ -2,7 +2,13 @@ declare(strict_types=1); use Doctrine\DBAL\DriverManager; +use Wwwision\DCBEventStore\AppendCondition\AppendCondition; +use Wwwision\DCBEventStore\Event\Event; +use Wwwision\DCBEventStore\Event\Events; use Wwwision\DCBEventStore\EventStore; +use Wwwision\DCBEventStore\Query\Query; +use Wwwision\DCBEventStore\ReadOptions; +use Wwwision\DCBEventStore\SequencedEvent\SequencedEvents; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; use Wwwision\DCBExample\App; use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; @@ -17,6 +23,7 @@ use Wwwision\DCBExample\Model\Student\Dto\StudentId; use Wwwision\DCBTools\DomainEventAppender; use Wwwision\DCBTools\Serialization\SimpleEventSerializer; +use Wwwision\DCBTools\StateProjector; require __DIR__ . '/vendor/autoload.php'; @@ -32,6 +39,42 @@ /** The second parameter is the table name to store the events in **/ $eventStore = DoctrineEventStore::create($connection, 'dcb_events'); +$eventStoreWithTracer = new class ($eventStore) implements EventStore { + + private bool $active = false; + + private array $messages = []; + + public function __construct(private EventStore $inner) {} + + public function start(string $message): void + { + $this->messages[] = $message; + $this->active = true; + } + + public function end(): string + { + $this->active = false; + $result = implode(PHP_EOL, $this->messages); + $this->messages = []; + return $result; + } + + public function read(Query $query, ?ReadOptions $options = null): SequencedEvents + { + return $this->inner->read($query, $options); + } + + public function append(Event|Events $events, ?AppendCondition $condition = null): void + { + if ($this->active) { + $this->messages[] = json_encode($condition->failIfEventsMatch, JSON_PRETTY_PRINT); + } + $this->inner->append($events, $condition); + } +}; + /** The {@see EventStore::setup()} method is used to make sure that the Events Store backend is set up (i.e. required tables are created and their schema up-to-date) **/ $eventStore->setup(); @@ -44,26 +87,42 @@ StudentUnsubscribedFromCourse::class, ]); -$domainEventAppender = new DomainEventAppender($eventStore, $eventSerializer); +$domainEventAppender = new DomainEventAppender($eventStoreWithTracer, $eventSerializer); +$stateProjector = new StateProjector($eventStoreWithTracer, $eventSerializer); /** @var {@see App} is the central authority to handle {@see Command}s */ -$app = new App($domainEventAppender); +$app = new App($domainEventAppender, $stateProjector); // Example: // 1. Define a course (c1) -$app->defineCourse('c1', 'Course 01', 10); +$app->defineCourse('c1', 'Course 01', 10, ['start' => '2026-08-01 15:30:00', 'end' => '2026-08-01 17:30:00']); +$app->defineCourse('c2', 'Course 02', 8, ['start' => '2026-08-01 17:00:00', 'end' => '2026-08-01 18:30:00']); +$app->defineCourse('c3', 'Course 03', 7, ['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 18:45:00']); // 2. rename it $app->renameCourse('c1', 'Course 01 renamed'); // 3. register a student (s1) in the system $app->registerStudent('s1'); +$app->registerStudent('s2'); // 4. subscribe student (s1) to course (s1) +#$eventStoreWithTracer->start('subscribeStudentToCourse s1 -> c1'); $app->subscribeStudentToCourse('s1', 'c1'); +#echo $eventStoreWithTracer->end(); +$app->subscribeStudentToCourse('s1', 'c3'); +#$app->subscribeStudentToCourse('s2', 'c2'); + +$app->rescheduleCourse('c3', ['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 15:30:00']); +#$app->rescheduleCourse('c3', ['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 19:45:00']); // 5. change capacity of course (c1) to 5 $app->changeCourseCapacity('c1', 5); // 6. unsubscribe student (s1) from course (c1) -$app->unsubscribeStudentFromCourse('s1', 'c1'); \ No newline at end of file +$app->unsubscribeStudentFromCourse('s1', 'c1'); + + +//foreach ($eventStore->read(Query::all()) as $eventEnvelope) { +// echo $eventEnvelope->event->type . ': ' . implode(', ', $eventEnvelope->event->tags->toStrings()). PHP_EOL; +//} \ No newline at end of file diff --git a/src/App.php b/src/App.php index 535ef09..49fdbff 100644 --- a/src/App.php +++ b/src/App.php @@ -6,31 +6,20 @@ use Wwwision\DCBExample\Features\CourseSubscription\Commands\SubscribeStudentToCourse; use Wwwision\DCBExample\Features\CourseSubscription\Commands\UnsubscribeStudentFromCourse; -use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; -use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentUnsubscribedFromCourse; use Wwwision\DCBExample\Features\CourseSubscription\SubscribeStudentToCourseCommandHandler; use Wwwision\DCBExample\Features\CourseSubscription\UnsubscribeStudentFromCourseCommandHandler; use Wwwision\DCBExample\Features\DefineCourse\ChangeCourseCapacityCommandHandler; use Wwwision\DCBExample\Features\DefineCourse\Commands\ChangeCourseCapacity; use Wwwision\DCBExample\Features\DefineCourse\Commands\DefineCourse; use Wwwision\DCBExample\Features\DefineCourse\Commands\RenameCourse; +use Wwwision\DCBExample\Features\DefineCourse\Commands\RescheduleCourse; use Wwwision\DCBExample\Features\DefineCourse\DefineCourseCommandHandler; -use Wwwision\DCBExample\Features\DefineCourse\Events\CourseCapacityChanged; -use Wwwision\DCBExample\Features\DefineCourse\Events\CourseDefined; -use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRenamed; use Wwwision\DCBExample\Features\DefineCourse\RenameCourseCommandHandler; +use Wwwision\DCBExample\Features\DefineCourse\RescheduleCourseCommandHandler; use Wwwision\DCBExample\Features\RegisterStudent\Commands\RegisterStudent; -use Wwwision\DCBExample\Features\RegisterStudent\Events\StudentRegistered; use Wwwision\DCBExample\Features\RegisterStudent\RegisterStudentCommandHandler; -use Wwwision\DCBExample\Model\Course\CourseDecisionModels as Course; -use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; -use Wwwision\DCBExample\Model\Course\Dto\CourseId; -use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; -use Wwwision\DCBExample\Model\Student\Dto\StudentId; -use Wwwision\DCBExample\Model\Student\StudentDecisionModels as Student; use Wwwision\DCBTools\DomainEventAppender; - -use function Wwwision\DCBTools\not; +use Wwwision\DCBTools\StateProjector; /** * Main authority of this package, responsible to handle incoming commands @@ -39,13 +28,17 @@ { public function __construct( private DomainEventAppender $domainEventAppender, + private StateProjector $stateProjector, ) { } - public function defineCourse(string $courseId, string $courseTitle, int $initialCapacity): void + /** + * @param array{start: string, end: string} $schedule + */ + public function defineCourse(string $courseId, string $courseTitle, int $initialCapacity, array $schedule): void { $handler = new DefineCourseCommandHandler($this->domainEventAppender); - $handler(new DefineCourse($courseId, $initialCapacity, $courseTitle)); + $handler(new DefineCourse($courseId, $initialCapacity, $courseTitle, $schedule)); } public function renameCourse(string $courseId, string $newTitle): void @@ -60,6 +53,12 @@ public function changeCourseCapacity(string $courseId, int $newCapacity): void $handler(new ChangeCourseCapacity($courseId, $newCapacity)); } + public function rescheduleCourse(string $courseId, array $newSchedule): void + { + $handler = new RescheduleCourseCommandHandler($this->domainEventAppender); + $handler(new RescheduleCourse($courseId, $newSchedule)); + } + public function registerStudent(string $studentId): void { $handler = new RegisterStudentCommandHandler($this->domainEventAppender); @@ -68,7 +67,7 @@ public function registerStudent(string $studentId): void public function subscribeStudentToCourse(string $studentId, string $courseId): void { - $handler = new SubscribeStudentToCourseCommandHandler($this->domainEventAppender); + $handler = new SubscribeStudentToCourseCommandHandler($this->domainEventAppender, $this->stateProjector); $handler(new SubscribeStudentToCourse($studentId, $courseId)); } diff --git a/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php index 06b3bfe..3b1f72d 100644 --- a/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php +++ b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php @@ -7,7 +7,9 @@ use Wwwision\DCBExample\Features\CourseSubscription\Commands\SubscribeStudentToCourse; use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; use Wwwision\DCBExample\Model\Course\CourseDecisionModels as Course; +use Wwwision\DCBExample\Model\Course\Dto\CourseIds; use Wwwision\DCBExample\Model\Student\StudentDecisionModels as Student; +use Wwwision\DCBExample\Model\Student\StudentProjections; use Wwwision\DCBTools\DomainEventAppender; use function Wwwision\DCBTools\not; @@ -15,19 +17,21 @@ final readonly class SubscribeStudentToCourseCommandHandler { public function __construct( - private DomainEventAppender $domainEventAppender, + private DomainEventAppender $eventStore, ) { } public function __invoke(SubscribeStudentToCourse $command): void { - $this->domainEventAppender->append( - constraints: [ + $this->eventStore->appendWithState( + stateProjection: StudentProjections::subscriptions($command->studentId), + constraints: fn (CourseIds $studentSubscriptions) => [ Course::exists($command->courseId), Student::isRegistered($command->studentId), Course::hasFreeSeats($command->courseId), not(Student::isSubscribedToCourse($command->studentId, $command->courseId)), Student::numberOfSubscriptionsIsBelowLimit($command->studentId), + Course::hasNoScheduleConflicts($command->courseId, $studentSubscriptions), ], onSuccess: fn () => new StudentSubscribedToCourse($command->studentId, $command->courseId), ); diff --git a/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php b/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php index 706d0ab..ba5642b 100644 --- a/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php +++ b/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php @@ -13,13 +13,13 @@ final readonly class UnsubscribeStudentFromCourseCommandHandler { public function __construct( - private DomainEventAppender $domainEventAppender, + private DomainEventAppender $eventStore, ) { } public function __invoke(UnsubscribeStudentFromCourse $command): void { - $this->domainEventAppender->append( + $this->eventStore->append( constraints: [ Course::exists($command->courseId), Student::isRegistered($command->studentId), diff --git a/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php b/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php index 25c5d9e..18940e4 100644 --- a/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php +++ b/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php @@ -5,9 +5,7 @@ namespace Wwwision\DCBExample\Features\DefineCourse; use Wwwision\DCBExample\Features\DefineCourse\Commands\ChangeCourseCapacity; -use Wwwision\DCBExample\Features\DefineCourse\Commands\RenameCourse; use Wwwision\DCBExample\Features\DefineCourse\Events\CourseCapacityChanged; -use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRenamed; use Wwwision\DCBExample\Model\Course\CourseDecisionModels as Course; use Wwwision\DCBTools\DomainEventAppender; @@ -16,13 +14,13 @@ final readonly class ChangeCourseCapacityCommandHandler { public function __construct( - private DomainEventAppender $domainEventAppender, + private DomainEventAppender $eventStore, ) { } public function __invoke(ChangeCourseCapacity $command): void { - $this->domainEventAppender->append( + $this->eventStore->append( constraints: [ Course::exists($command->courseId), not(Course::capacityEquals($command->courseId, $command->newCapacity)), diff --git a/src/Features/DefineCourse/Commands/DefineCourse.php b/src/Features/DefineCourse/Commands/DefineCourse.php index dd38f9a..5f2fca7 100644 --- a/src/Features/DefineCourse/Commands/DefineCourse.php +++ b/src/Features/DefineCourse/Commands/DefineCourse.php @@ -6,6 +6,7 @@ use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedule; use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; final readonly class DefineCourse @@ -13,11 +14,13 @@ public CourseId $courseId; public CourseCapacity $initialCapacity; public CourseTitle $courseTitle; + public CourseSchedule $schedule; public function __construct( CourseId|string $courseId, CourseCapacity|int $initialCapacity, CourseTitle|string $courseTitle, + CourseSchedule|array $schedule, ) { if (is_string($courseId)) { $courseId = CourseId::fromString($courseId); @@ -28,8 +31,12 @@ public function __construct( if (is_string($courseTitle)) { $courseTitle = CourseTitle::fromString($courseTitle); } + if (is_array($schedule)) { + $schedule = CourseSchedule::fromArray($schedule); + } $this->courseId = $courseId; $this->initialCapacity = $initialCapacity; $this->courseTitle = $courseTitle; + $this->schedule = $schedule; } } diff --git a/src/Features/DefineCourse/Commands/RescheduleCourse.php b/src/Features/DefineCourse/Commands/RescheduleCourse.php new file mode 100644 index 0000000..e57b186 --- /dev/null +++ b/src/Features/DefineCourse/Commands/RescheduleCourse.php @@ -0,0 +1,28 @@ +courseId = $courseId; + $this->newSchedule = $newSchedule; + } +} diff --git a/src/Features/DefineCourse/DefineCourseCommandHandler.php b/src/Features/DefineCourse/DefineCourseCommandHandler.php index 16f0cd8..30eb4c2 100644 --- a/src/Features/DefineCourse/DefineCourseCommandHandler.php +++ b/src/Features/DefineCourse/DefineCourseCommandHandler.php @@ -14,17 +14,17 @@ final readonly class DefineCourseCommandHandler { public function __construct( - private DomainEventAppender $domainEventAppender, + private DomainEventAppender $eventStore, ) { } public function __invoke(DefineCourse $command): void { - $this->domainEventAppender->append( + $this->eventStore->append( constraints: [ not(Course::exists($command->courseId)) ], - onSuccess: static fn () => new CourseDefined($command->courseId, $command->initialCapacity, $command->courseTitle), + onSuccess: static fn () => new CourseDefined($command->courseId, $command->initialCapacity, $command->courseTitle, $command->schedule), ); } } diff --git a/src/Features/DefineCourse/Events/CourseDefined.php b/src/Features/DefineCourse/Events/CourseDefined.php index 2aff2d9..0d09323 100644 --- a/src/Features/DefineCourse/Events/CourseDefined.php +++ b/src/Features/DefineCourse/Events/CourseDefined.php @@ -6,6 +6,7 @@ use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedule; use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; use Wwwision\DCBTools\Event\DomainEvent; @@ -17,11 +18,13 @@ public CourseId $courseId; public CourseCapacity $initialCapacity; public CourseTitle $courseTitle; + public CourseSchedule $schedule; public function __construct( CourseId|string $courseId, CourseCapacity|int $initialCapacity, CourseTitle|string $courseTitle, + CourseSchedule|array $schedule, ) { if (is_string($courseId)) { $courseId = CourseId::fromString($courseId); @@ -32,8 +35,12 @@ public function __construct( if (is_string($courseTitle)) { $courseTitle = CourseTitle::fromString($courseTitle); } + if (is_array($schedule)) { + $schedule = CourseSchedule::fromArray($schedule); + } $this->courseId = $courseId; $this->initialCapacity = $initialCapacity; $this->courseTitle = $courseTitle; + $this->schedule = $schedule; } } diff --git a/src/Features/DefineCourse/Events/CourseRescheduled.php b/src/Features/DefineCourse/Events/CourseRescheduled.php new file mode 100644 index 0000000..ef968f2 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseRescheduled.php @@ -0,0 +1,32 @@ +courseId = $courseId; + $this->newSchedule = $newSchedule; + } +} diff --git a/src/Features/DefineCourse/RenameCourseCommandHandler.php b/src/Features/DefineCourse/RenameCourseCommandHandler.php index 9f0923e..8632651 100644 --- a/src/Features/DefineCourse/RenameCourseCommandHandler.php +++ b/src/Features/DefineCourse/RenameCourseCommandHandler.php @@ -14,13 +14,13 @@ final readonly class RenameCourseCommandHandler { public function __construct( - private DomainEventAppender $domainEventAppender, + private DomainEventAppender $eventStore, ) { } public function __invoke(RenameCourse $command): void { - $this->domainEventAppender->append( + $this->eventStore->append( constraints: [ Course::exists($command->courseId), not(Course::titleEquals($command->courseId, $command->newTitle)), diff --git a/src/Features/DefineCourse/RescheduleCourseCommandHandler.php b/src/Features/DefineCourse/RescheduleCourseCommandHandler.php new file mode 100644 index 0000000..4b581cd --- /dev/null +++ b/src/Features/DefineCourse/RescheduleCourseCommandHandler.php @@ -0,0 +1,39 @@ +eventStore->appendWithState( + stateProjection: ProjectionChain::start( + CourseProjections::coursesWithConflictingSchedule($command->courseId, $command->newSchedule) + )->then( + CourseProjections::subscribedStudents(...) + ), + constraints: fn(StudentIds $affectedStudentIds) => [ + Course::exists($command->courseId), + not(Course::hasMatchingSubscriptions($command->courseId, $affectedStudentIds)), + ], + onSuccess: fn () => new CourseRescheduled($command->courseId, $command->newSchedule), + ); + } +} diff --git a/src/Features/RegisterStudent/RegisterStudentCommandHandler.php b/src/Features/RegisterStudent/RegisterStudentCommandHandler.php index 90b2214..c03469c 100644 --- a/src/Features/RegisterStudent/RegisterStudentCommandHandler.php +++ b/src/Features/RegisterStudent/RegisterStudentCommandHandler.php @@ -14,13 +14,13 @@ final readonly class RegisterStudentCommandHandler { public function __construct( - private DomainEventAppender $domainEventAppender, + private DomainEventAppender $eventStore, ) { } public function __invoke(RegisterStudent $command): void { - $this->domainEventAppender->append( + $this->eventStore->append( constraints: [ not(Student::isRegistered($command->studentId)) ], diff --git a/src/Model/Course/CourseDecisionModels.php b/src/Model/Course/CourseDecisionModels.php index a0bfee2..fe29589 100644 --- a/src/Model/Course/CourseDecisionModels.php +++ b/src/Model/Course/CourseDecisionModels.php @@ -7,7 +7,11 @@ use stdClass; use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseIds; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedules; use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; +use Wwwision\DCBExample\Model\Student\Dto\StudentId; +use Wwwision\DCBExample\Model\Student\Dto\StudentIds; use Wwwision\DCBTools\DecisionModel\Constraint; use Wwwision\DCBTools\Projection\CompositeProjection; @@ -29,6 +33,20 @@ public static function exists(CourseId $courseId): Constraint ); } + public static function hasNoScheduleConflicts(CourseId $referenceCourseId, CourseIds $courseIds): Constraint + { + $projections = [CourseProjections::schedule($referenceCourseId)]; + foreach ($courseIds as $courseId) { + $projections[] = CourseProjections::schedule($courseId); + } + $projection = CompositeProjection::create($projections, CourseSchedules::class); + return Constraint::create( + name: __FUNCTION__, + projection: $projection, + predicate: static fn (CourseSchedules $schedules) => !$schedules->hasOverlaps(), + ); + } + public static function capacityEquals(CourseId $courseId, CourseCapacity $candidate): Constraint { return Constraint::create( @@ -61,6 +79,15 @@ public static function numberOfSubscriptionsIsBelowCapacity(CourseId $courseId, ); } + public static function hasMatchingSubscriptions(CourseId $courseId, StudentIds $studentIds): Constraint + { + return Constraint::create( + name: __FUNCTION__, + projection: CourseProjections::subscribedStudents($courseId), + predicate: static fn (StudentIds $subscribedStudentIds) => $studentIds->intersects($subscribedStudentIds), + ); + } + public static function titleEquals(CourseId $courseId, CourseTitle $candidate): Constraint { return Constraint::create( diff --git a/src/Model/Course/CourseProjections.php b/src/Model/Course/CourseProjections.php index 2e4a1b6..8218858 100644 --- a/src/Model/Course/CourseProjections.php +++ b/src/Model/Course/CourseProjections.php @@ -4,14 +4,19 @@ namespace Wwwision\DCBExample\Model\Course; +use Wwwision\DCBEventStore\Event\Tags; use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentUnsubscribedFromCourse; use Wwwision\DCBExample\Features\DefineCourse\Events\CourseCapacityChanged; use Wwwision\DCBExample\Features\DefineCourse\Events\CourseDefined; use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRenamed; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRescheduled; use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseIds; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedule; use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; +use Wwwision\DCBExample\Model\Student\Dto\StudentIds; use Wwwision\DCBTools\Projection\AtomicProjection; use Wwwision\DCBTools\Projection\Projection; @@ -66,4 +71,40 @@ public static function title(CourseId $courseId): Projection ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newTitle) ; } + + /** + * @return Projection + */ + public static function schedule(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: null) + ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->schedule) + ; + } + + /** + * @return Projection + */ + public static function coursesWithConflictingSchedule(CourseId $courseIdToIgnore, CourseSchedule $schedule): Projection + { + // TODO introduce tags for time ranges to reduce the number of events to look up? + return AtomicProjection::create(Tags::create(), initialState: CourseIds::none()) + ->when(CourseDefined::class, static fn (CourseIds $state, CourseDefined $event) => !$event->courseId->equals($courseIdToIgnore) && $event->schedule->overlaps($schedule) ? $state->with($event->courseId) : $state) + ->when(CourseRescheduled::class, static fn (CourseIds $state, CourseRescheduled $event) => !$event->courseId->equals($courseIdToIgnore) && $event->newSchedule->overlaps($schedule) ? $state->with($event->courseId) : $state) + ; + } + + /** + * @return Projection + */ + public static function subscribedStudents(CourseId|CourseIds $courseIds): Projection + { + if ($courseIds instanceof CourseId) { + $courseIds = CourseIds::create($courseIds); + } + return AtomicProjection::create($courseIds, initialState: StudentIds::none()) + ->when(StudentSubscribedToCourse::class, static fn (StudentIds $state, StudentSubscribedToCourse $event) => $courseIds->isEmpty() ? $state : $state->with($event->studentId)) + ->when(StudentUnsubscribedFromCourse::class, static fn (StudentIds $state, StudentUnsubscribedFromCourse $event) => $courseIds->isEmpty() ? $state : $state->without($event->studentId)) + ; + } } diff --git a/src/Model/Course/Dto/CourseIds.php b/src/Model/Course/Dto/CourseIds.php index 7a66109..6f4903a 100644 --- a/src/Model/Course/Dto/CourseIds.php +++ b/src/Model/Course/Dto/CourseIds.php @@ -5,9 +5,13 @@ namespace Wwwision\DCBExample\Model\Course\Dto; use ArrayIterator; +use Closure; use Countable; use IteratorAggregate; use Traversable; +use Wwwision\DCBEventStore\Event\Tag; +use Wwwision\DCBEventStore\Event\Tags; +use Wwwision\DCBTools\Event\ProvidesTags; use function array_filter; use function array_map; @@ -17,13 +21,13 @@ * * @implements IteratorAggregate */ -final class CourseIds implements IteratorAggregate, Countable +final class CourseIds implements IteratorAggregate, Countable, ProvidesTags { /** * @param CourseId[] $ids */ private function __construct( - public readonly array $ids, + private readonly array $ids, ) { //Assert::notEmpty($this->ids, 'CourseIds must not be empty'); } @@ -78,4 +82,28 @@ public function count(): int { return count($this->ids); } + + public function isEmpty(): bool + { + return $this->ids === []; + } + + /** + * @template T + * @param Closure(CourseId): T $callback + * @return array + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->ids); + } + + public function tags(): Tags + { + $tags = Tags::create(); + foreach ($this->ids as $id) { + $tags = $tags->merge($id->tags()); + } + return $tags; + } } diff --git a/src/Model/Course/Dto/CourseSchedule.php b/src/Model/Course/Dto/CourseSchedule.php new file mode 100644 index 0000000..af6408d --- /dev/null +++ b/src/Model/Course/Dto/CourseSchedule.php @@ -0,0 +1,44 @@ + $schedule + */ + public static function fromArray(array $schedule): self + { + Assert::isMap($schedule); + Assert::keyExists($schedule, 'start'); + Assert::string($schedule['start']); + Assert::keyExists($schedule, 'end'); + Assert::string($schedule['end']); + return new self( + DateAndTime::fromString($schedule['start']), + DateAndTime::fromString($schedule['end']), + ); + } + + public function overlaps(self $other): bool + { + return $this->start < $other->end + && $other->start < $this->end; + } +} diff --git a/src/Model/Course/Dto/CourseSchedules.php b/src/Model/Course/Dto/CourseSchedules.php new file mode 100644 index 0000000..da5f9fe --- /dev/null +++ b/src/Model/Course/Dto/CourseSchedules.php @@ -0,0 +1,62 @@ + + */ +final class CourseSchedules implements IteratorAggregate +{ + /** + * @var array + */ + private array $schedules; + + public function __construct( + CourseSchedule ...$schedules, + ) { + $this->schedules = array_values($schedules); + } + + public static function create(CourseSchedule ...$schedules): self + { + return new self(...$schedules); + } + + public static function none(): self + { + return new self(...[]); + } + + public static function fromArray(array $schedules): self + { + return self::create(...array_map(static fn (array|CourseSchedule $schedule) => is_array($schedule) ? CourseSchedule::fromArray($schedule) : $schedule, $schedules)); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->schedules); + } + + public function hasOverlaps(): bool + { + $sorted = $this->schedules; + usort($sorted, static fn (CourseSchedule $a, CourseSchedule $b) => $a->start->value <=> $b->start->value); + for ($i = 1, $count = count($sorted); $i < $count; $i++) { + if ($sorted[$i]->start->value < $sorted[$i - 1]->end->value) { + return true; + } + } + return false; + } +} diff --git a/src/Model/Course/Dto/DateAndTime.php b/src/Model/Course/Dto/DateAndTime.php new file mode 100644 index 0000000..53bd1f2 --- /dev/null +++ b/src/Model/Course/Dto/DateAndTime.php @@ -0,0 +1,37 @@ +setTimezone(new \DateTimeZone('UTC'))->format(self::FORMAT)); + } + + public function toPhpDateTime(): DateTimeImmutable + { + return DateTimeImmutable::createFromFormat(self::FORMAT, $this->value, new \DateTimeZone('UTC')); + } + + public function jsonSerialize(): string + { + return $this->value; + } +} diff --git a/src/Model/Student/Dto/StudentId.php b/src/Model/Student/Dto/StudentId.php index 9972818..0697eca 100644 --- a/src/Model/Student/Dto/StudentId.php +++ b/src/Model/Student/Dto/StudentId.php @@ -27,6 +27,11 @@ public function jsonSerialize(): string return $this->value; } + public function equals(self $other): bool + { + return $other->value === $this->value; + } + public function tags(): Tag { return Tag::fromString("student:$this->value"); diff --git a/src/Model/Student/Dto/StudentIds.php b/src/Model/Student/Dto/StudentIds.php new file mode 100644 index 0000000..5845079 --- /dev/null +++ b/src/Model/Student/Dto/StudentIds.php @@ -0,0 +1,116 @@ + + */ +final class StudentIds implements IteratorAggregate, Countable, ProvidesTags +{ + /** + * @param StudentId[] $ids + */ + private function __construct( + private readonly array $ids, + ) { + //Assert::notEmpty($this->ids, 'StudentIds must not be empty'); + } + + public static function create(StudentId ...$ids): self + { + return new self($ids); + } + + public static function none(): self + { + return new self([]); + } + + public static function fromStrings(string ...$ids): self + { + return new self(array_map(static fn (string $type) => StudentId::fromString($type), $ids)); + } + + public function contains(StudentId $id): bool + { + foreach ($this->ids as $existingId) { + if ($existingId->equals($id)) { + return true; + } + } + return false; + } + + /** + * Returns true if this set intersects with the given set + */ + public function intersects(self $other): bool + { + foreach ($other as $id) { + if ($this->contains($id)) { + return true; + } + } + return false; + } + + public function with(StudentId $studentId): self + { + if ($this->contains($studentId)) { + return $this; + } + return new self([...$this->ids, $studentId]); + } + + public function without(StudentId $studentId): self + { + if (!$this->contains($studentId)) { + return $this; + } + return new self(array_filter($this->ids, static fn (StudentId $id) => !$id->equals($studentId))); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->ids); + } + + public function count(): int + { + return count($this->ids); + } + + /** + * @template T + * @param Closure(StudentId): T $callback + * @return array + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->ids); + } + + public function tags(): Tags + { + $tags = Tags::create(); + foreach ($this->ids as $id) { + $tags = $tags->merge($id->tags()); + } + return $tags; + } +} diff --git a/src/Model/Student/Dto/StudentSubscriptions.php b/src/Model/Student/Dto/StudentSubscriptions.php new file mode 100644 index 0000000..b8e1045 --- /dev/null +++ b/src/Model/Student/Dto/StudentSubscriptions.php @@ -0,0 +1,18 @@ + Date: Wed, 24 Jun 2026 11:47:05 +0200 Subject: [PATCH 10/10] WIP: TASK: Major overhaul (version 2) --- composer.json | 7 +- index.php | 13 +- phpunit.xml | 5 +- src/App.php | 11 +- ...SubscribeStudentToCourseCommandHandler.php | 1 + .../DefineCourse/Commands/DefineCourse.php | 3 + .../Commands/RescheduleCourse.php | 3 + .../DefineCourse/Events/CourseDefined.php | 3 + .../DefineCourse/Events/CourseRescheduled.php | 3 + src/Model/Course/CourseDecisionModels.php | 10 +- src/Model/Course/CourseProjections.php | 6 +- src/Model/Course/Dto/CourseSchedules.php | 3 + src/Model/Course/Dto/DateAndTime.php | 5 +- .../Course/SubscribedStudentsProjection.php | 69 ++++ tests/Behat/Bootstrap/FeatureContext.php | 372 ------------------ tests/Behat/DefineCourse.feature | 19 - tests/Behat/RegisterStudent.feature | 18 - tests/Behat/RenameCourse.feature | 45 --- tests/Behat/SubscribeStudentToCourse.feature | 78 ---- .../UnsubscribeStudentFromCourse.feature | 47 --- tests/Behat/UpdateCourseCapacity.feature | 60 --- tests/Consistency/ArchitectureTest.php | 90 +++++ .../ChangeCourseCapacityConsistencyTest.php | 64 +++ tests/Consistency/ConsistencyTestCase.php | 23 ++ tests/Consistency/CourseTestSupport.php | 50 +++ .../DefineCourseConsistencyTest.php | 51 +++ tests/Consistency/HandlerBoundariesTest.php | 61 +++ .../RegisterStudentConsistencyTest.php | 52 +++ .../RenameCourseConsistencyTest.php | 53 +++ .../RescheduleCourseConsistencyTest.php | 98 +++++ ...ubscribeStudentToCourseConsistencyTest.php | 99 +++++ ...scribeStudentFromCourseConsistencyTest.php | 60 +++ 32 files changed, 818 insertions(+), 664 deletions(-) create mode 100644 src/Model/Course/SubscribedStudentsProjection.php delete mode 100644 tests/Behat/Bootstrap/FeatureContext.php delete mode 100644 tests/Behat/DefineCourse.feature delete mode 100644 tests/Behat/RegisterStudent.feature delete mode 100644 tests/Behat/RenameCourse.feature delete mode 100644 tests/Behat/SubscribeStudentToCourse.feature delete mode 100644 tests/Behat/UnsubscribeStudentFromCourse.feature delete mode 100644 tests/Behat/UpdateCourseCapacity.feature create mode 100644 tests/Consistency/ArchitectureTest.php create mode 100644 tests/Consistency/ChangeCourseCapacityConsistencyTest.php create mode 100644 tests/Consistency/ConsistencyTestCase.php create mode 100644 tests/Consistency/CourseTestSupport.php create mode 100644 tests/Consistency/DefineCourseConsistencyTest.php create mode 100644 tests/Consistency/HandlerBoundariesTest.php create mode 100644 tests/Consistency/RegisterStudentConsistencyTest.php create mode 100644 tests/Consistency/RenameCourseConsistencyTest.php create mode 100644 tests/Consistency/RescheduleCourseConsistencyTest.php create mode 100644 tests/Consistency/SubscribeStudentToCourseConsistencyTest.php create mode 100644 tests/Consistency/UnsubscribeStudentFromCourseConsistencyTest.php diff --git a/composer.json b/composer.json index 3997738..ea56d57 100644 --- a/composer.json +++ b/composer.json @@ -30,8 +30,7 @@ "roave/security-advisories": "dev-latest", "phpstan/phpstan": "^2", "squizlabs/php_codesniffer": "^4.0.x-dev", - "phpunit/phpunit": "^11", - "behat/behat": "^3" + "phpunit/phpunit": "^12" }, "autoload": { "psr-4": { @@ -47,11 +46,11 @@ "test:phpstan": "phpstan", "test:cs": "phpcs --colors --standard=PSR12 --exclude=Generic.Files.LineLength src", "test:cs:fix": "phpcbf --colors --standard=PSR12 --exclude=Generic.Files.LineLength src", - "test:behat": "behat", + "test:phpunit": "phpunit", "test": [ "@test:phpstan", "@test:cs", - "@test:behat" + "@test:phpunit" ] } } diff --git a/index.php b/index.php index b07177c..fccbca5 100644 --- a/index.php +++ b/index.php @@ -16,9 +16,11 @@ use Wwwision\DCBExample\Features\DefineCourse\Events\CourseCapacityChanged; use Wwwision\DCBExample\Features\DefineCourse\Events\CourseDefined; use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRenamed; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRescheduled; use Wwwision\DCBExample\Features\RegisterStudent\Events\StudentRegistered; use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedule; use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; use Wwwision\DCBExample\Model\Student\Dto\StudentId; use Wwwision\DCBTools\DomainEventAppender; @@ -82,6 +84,7 @@ public function append(Event|Events $events, ?AppendCondition $condition = null) CourseCapacityChanged::class, CourseDefined::class, CourseRenamed::class, + CourseRescheduled::class, StudentRegistered::class, StudentSubscribedToCourse::class, StudentUnsubscribedFromCourse::class, @@ -95,9 +98,9 @@ public function append(Event|Events $events, ?AppendCondition $condition = null) // Example: // 1. Define a course (c1) -$app->defineCourse('c1', 'Course 01', 10, ['start' => '2026-08-01 15:30:00', 'end' => '2026-08-01 17:30:00']); -$app->defineCourse('c2', 'Course 02', 8, ['start' => '2026-08-01 17:00:00', 'end' => '2026-08-01 18:30:00']); -$app->defineCourse('c3', 'Course 03', 7, ['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 18:45:00']); +$app->defineCourse('c1', 10, 'Course 01', CourseSchedule::fromArray(['start' => '2026-08-01 15:30:00', 'end' => '2026-08-01 17:30:00'])); +$app->defineCourse('c2', 8, 'Course 02', CourseSchedule::fromArray(['start' => '2026-08-01 17:00:00', 'end' => '2026-08-01 18:30:00'])); +$app->defineCourse('c3', 7, 'Course 03', CourseSchedule::fromArray(['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 18:45:00'])); // 2. rename it $app->renameCourse('c1', 'Course 01 renamed'); @@ -113,8 +116,8 @@ public function append(Event|Events $events, ?AppendCondition $condition = null) $app->subscribeStudentToCourse('s1', 'c3'); #$app->subscribeStudentToCourse('s2', 'c2'); -$app->rescheduleCourse('c3', ['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 15:30:00']); -#$app->rescheduleCourse('c3', ['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 19:45:00']); +$app->rescheduleCourse('c3', CourseSchedule::fromArray(['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 15:30:00'])); +#$app->rescheduleCourse('c3', CourseSchedule::fromArray(['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 19:45:00'])); // 5. change capacity of course (c1) to 5 $app->changeCourseCapacity('c1', 5); diff --git a/phpunit.xml b/phpunit.xml index 4d35423..7f470ac 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,11 +7,12 @@ requireCoverageMetadata="true" beStrictAboutCoverageMetadata="true" beStrictAboutOutputDuringTests="true" + beStrictAboutTestsThatDoNotTestAnything="false" failOnRisky="true" failOnWarning="true"> - - tests/unit + + tests/Consistency diff --git a/src/App.php b/src/App.php index 49fdbff..fcfb7ac 100644 --- a/src/App.php +++ b/src/App.php @@ -18,6 +18,7 @@ use Wwwision\DCBExample\Features\DefineCourse\RescheduleCourseCommandHandler; use Wwwision\DCBExample\Features\RegisterStudent\Commands\RegisterStudent; use Wwwision\DCBExample\Features\RegisterStudent\RegisterStudentCommandHandler; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedule; use Wwwision\DCBTools\DomainEventAppender; use Wwwision\DCBTools\StateProjector; @@ -28,14 +29,10 @@ { public function __construct( private DomainEventAppender $domainEventAppender, - private StateProjector $stateProjector, ) { } - /** - * @param array{start: string, end: string} $schedule - */ - public function defineCourse(string $courseId, string $courseTitle, int $initialCapacity, array $schedule): void + public function defineCourse(string $courseId, int $initialCapacity, string $courseTitle, CourseSchedule $schedule): void { $handler = new DefineCourseCommandHandler($this->domainEventAppender); $handler(new DefineCourse($courseId, $initialCapacity, $courseTitle, $schedule)); @@ -53,7 +50,7 @@ public function changeCourseCapacity(string $courseId, int $newCapacity): void $handler(new ChangeCourseCapacity($courseId, $newCapacity)); } - public function rescheduleCourse(string $courseId, array $newSchedule): void + public function rescheduleCourse(string $courseId, CourseSchedule $newSchedule): void { $handler = new RescheduleCourseCommandHandler($this->domainEventAppender); $handler(new RescheduleCourse($courseId, $newSchedule)); @@ -67,7 +64,7 @@ public function registerStudent(string $studentId): void public function subscribeStudentToCourse(string $studentId, string $courseId): void { - $handler = new SubscribeStudentToCourseCommandHandler($this->domainEventAppender, $this->stateProjector); + $handler = new SubscribeStudentToCourseCommandHandler($this->domainEventAppender); $handler(new SubscribeStudentToCourse($studentId, $courseId)); } diff --git a/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php index 3b1f72d..709a7d6 100644 --- a/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php +++ b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php @@ -8,6 +8,7 @@ use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; use Wwwision\DCBExample\Model\Course\CourseDecisionModels as Course; use Wwwision\DCBExample\Model\Course\Dto\CourseIds; +use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; use Wwwision\DCBExample\Model\Student\StudentDecisionModels as Student; use Wwwision\DCBExample\Model\Student\StudentProjections; use Wwwision\DCBTools\DomainEventAppender; diff --git a/src/Features/DefineCourse/Commands/DefineCourse.php b/src/Features/DefineCourse/Commands/DefineCourse.php index 5f2fca7..12e2a01 100644 --- a/src/Features/DefineCourse/Commands/DefineCourse.php +++ b/src/Features/DefineCourse/Commands/DefineCourse.php @@ -16,6 +16,9 @@ public CourseTitle $courseTitle; public CourseSchedule $schedule; + /** + * @param CourseSchedule|array{start:string,end:string} $schedule + */ public function __construct( CourseId|string $courseId, CourseCapacity|int $initialCapacity, diff --git a/src/Features/DefineCourse/Commands/RescheduleCourse.php b/src/Features/DefineCourse/Commands/RescheduleCourse.php index e57b186..a8eb85f 100644 --- a/src/Features/DefineCourse/Commands/RescheduleCourse.php +++ b/src/Features/DefineCourse/Commands/RescheduleCourse.php @@ -12,6 +12,9 @@ public CourseId $courseId; public CourseSchedule $newSchedule; + /** + * @param CourseSchedule|array{start:string,end:string} $newSchedule + */ public function __construct( CourseId|string $courseId, CourseSchedule|array $newSchedule, diff --git a/src/Features/DefineCourse/Events/CourseDefined.php b/src/Features/DefineCourse/Events/CourseDefined.php index 0d09323..244ce33 100644 --- a/src/Features/DefineCourse/Events/CourseDefined.php +++ b/src/Features/DefineCourse/Events/CourseDefined.php @@ -20,6 +20,9 @@ public CourseTitle $courseTitle; public CourseSchedule $schedule; + /** + * @param CourseSchedule|array{start:string,end:string} $schedule + */ public function __construct( CourseId|string $courseId, CourseCapacity|int $initialCapacity, diff --git a/src/Features/DefineCourse/Events/CourseRescheduled.php b/src/Features/DefineCourse/Events/CourseRescheduled.php index ef968f2..54830b0 100644 --- a/src/Features/DefineCourse/Events/CourseRescheduled.php +++ b/src/Features/DefineCourse/Events/CourseRescheduled.php @@ -16,6 +16,9 @@ public CourseId $courseId; public CourseSchedule $newSchedule; + /** + * @param CourseSchedule|array{start:string,end:string} $newSchedule + */ public function __construct( CourseId|string $courseId, CourseSchedule|array $newSchedule, diff --git a/src/Model/Course/CourseDecisionModels.php b/src/Model/Course/CourseDecisionModels.php index fe29589..e0e7840 100644 --- a/src/Model/Course/CourseDecisionModels.php +++ b/src/Model/Course/CourseDecisionModels.php @@ -8,6 +8,7 @@ use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; use Wwwision\DCBExample\Model\Course\Dto\CourseId; use Wwwision\DCBExample\Model\Course\Dto\CourseIds; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedule; use Wwwision\DCBExample\Model\Course\Dto\CourseSchedules; use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; use Wwwision\DCBExample\Model\Student\Dto\StudentId; @@ -39,11 +40,16 @@ public static function hasNoScheduleConflicts(CourseId $referenceCourseId, Cours foreach ($courseIds as $courseId) { $projections[] = CourseProjections::schedule($courseId); } - $projection = CompositeProjection::create($projections, CourseSchedules::class); + // A plain (array) composite rather than hydrating CourseSchedules directly: schedule() yields null for a course + // that was never defined, and CourseSchedules' constructor does not accept null. Null schedules are skipped here. + $projection = CompositeProjection::create($projections); return Constraint::create( name: __FUNCTION__, projection: $projection, - predicate: static fn (CourseSchedules $schedules) => !$schedules->hasOverlaps(), + predicate: static function (array $schedules): bool { + $knownSchedules = array_values(array_filter($schedules, static fn ($schedule) => $schedule instanceof CourseSchedule)); + return !CourseSchedules::create(...$knownSchedules)->hasOverlaps(); + }, ); } diff --git a/src/Model/Course/CourseProjections.php b/src/Model/Course/CourseProjections.php index 8218858..e97bb35 100644 --- a/src/Model/Course/CourseProjections.php +++ b/src/Model/Course/CourseProjections.php @@ -79,6 +79,7 @@ public static function schedule(CourseId $courseId): Projection { return AtomicProjection::create($courseId, initialState: null) ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->schedule) + ->when(CourseRescheduled::class, static fn ($_, CourseRescheduled $event) => $event->newSchedule) ; } @@ -102,9 +103,6 @@ public static function subscribedStudents(CourseId|CourseIds $courseIds): Projec if ($courseIds instanceof CourseId) { $courseIds = CourseIds::create($courseIds); } - return AtomicProjection::create($courseIds, initialState: StudentIds::none()) - ->when(StudentSubscribedToCourse::class, static fn (StudentIds $state, StudentSubscribedToCourse $event) => $courseIds->isEmpty() ? $state : $state->with($event->studentId)) - ->when(StudentUnsubscribedFromCourse::class, static fn (StudentIds $state, StudentUnsubscribedFromCourse $event) => $courseIds->isEmpty() ? $state : $state->without($event->studentId)) - ; + return new SubscribedStudentsProjection($courseIds); } } diff --git a/src/Model/Course/Dto/CourseSchedules.php b/src/Model/Course/Dto/CourseSchedules.php index da5f9fe..e6e4946 100644 --- a/src/Model/Course/Dto/CourseSchedules.php +++ b/src/Model/Course/Dto/CourseSchedules.php @@ -38,6 +38,9 @@ public static function none(): self return new self(...[]); } + /** + * @param array $schedules + */ public static function fromArray(array $schedules): self { return self::create(...array_map(static fn (array|CourseSchedule $schedule) => is_array($schedule) ? CourseSchedule::fromArray($schedule) : $schedule, $schedules)); diff --git a/src/Model/Course/Dto/DateAndTime.php b/src/Model/Course/Dto/DateAndTime.php index 53bd1f2..0af0c30 100644 --- a/src/Model/Course/Dto/DateAndTime.php +++ b/src/Model/Course/Dto/DateAndTime.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use JsonSerializable; +use Webmozart\Assert\Assert; final readonly class DateAndTime implements JsonSerializable { @@ -27,7 +28,9 @@ public static function fromPhpDateTime(DateTimeImmutable $phpDateTime): self public function toPhpDateTime(): DateTimeImmutable { - return DateTimeImmutable::createFromFormat(self::FORMAT, $this->value, new \DateTimeZone('UTC')); + $result = DateTimeImmutable::createFromFormat(self::FORMAT, $this->value, new \DateTimeZone('UTC')); + Assert::isInstanceOf($result, DateTimeImmutable::class); + return $result; } public function jsonSerialize(): string diff --git a/src/Model/Course/SubscribedStudentsProjection.php b/src/Model/Course/SubscribedStudentsProjection.php new file mode 100644 index 0000000..1852341 --- /dev/null +++ b/src/Model/Course/SubscribedStudentsProjection.php @@ -0,0 +1,69 @@ + + */ +final class SubscribedStudentsProjection implements Projection +{ + private StudentIds $state; + + public function __construct( + private readonly CourseIds $courseIds, + ) { + $this->state = StudentIds::none(); + } + + public function apply(DomainEvent $event, SequencedEvent $envelope): void + { + if ($event instanceof StudentSubscribedToCourse && $this->courseIds->contains($event->courseId)) { + $this->state = $this->state->with($event->studentId); + } elseif ($event instanceof StudentUnsubscribedFromCourse && $this->courseIds->contains($event->courseId)) { + $this->state = $this->state->without($event->studentId); + } + } + + public function state(): StudentIds + { + return $this->state; + } + + public function query(EventSerializer $eventSerializer): Query + { + $eventTypes = [ + $eventSerializer->resolveEventType(StudentSubscribedToCourse::class), + $eventSerializer->resolveEventType(StudentUnsubscribedFromCourse::class), + ]; + $items = []; + foreach ($this->courseIds as $courseId) { + $items[] = QueryItem::create(eventTypes: $eventTypes, tags: [$courseId->tags()]); + } + if ($items === []) { + // No courses to consider: a bounded query that matches no event (rather than scanning all subscriptions). + $items[] = QueryItem::create(eventTypes: $eventTypes, tags: [Tag::fromString('none:none')]); + } + return Query::fromItems(...$items); + } +} diff --git a/tests/Behat/Bootstrap/FeatureContext.php b/tests/Behat/Bootstrap/FeatureContext.php deleted file mode 100644 index fa17913..0000000 --- a/tests/Behat/Bootstrap/FeatureContext.php +++ /dev/null @@ -1,372 +0,0 @@ -eventStoreConnection = DriverManager::getConnection(['url' => $eventStoreDsn ?? 'pdo-sqlite://:memory:']); - - /** The second parameter is the table name to store the events in **/ - $innerEventStore = DoctrineEventStore::create($this->eventStoreConnection, $eventTableName); - $innerEventStore->setup(); - $this->resetEventStore(); - $this->eventStore = new class ($innerEventStore) implements EventStore { - - public Events $appendedEvents; - public Events $readEvents; - - public function __construct(private EventStore $inner) { - $this->appendedEvents = Events::none(); - $this->readEvents = Events::none(); - } - - public function setup(): void - { - $this->inner->setup(); - } - - public function read(Query $query, ReadOptions|null $options = null): SequencedEvents - { - $innerStream = $this->inner->read($query, $options); - return SequencedEvents::create(function () use ($innerStream) { - foreach ($innerStream as $sequencedEvent) { - $this->readEvents = $this->readEvents->append($sequencedEvent->event); - yield $sequencedEvent; - } - }); - } - - public function append(Events|Event $events, AppendCondition|null $condition = null): void - { - $this->inner->append($events, $condition); - if ($events instanceof Event) { - $events = Events::fromArray([$events]); - } - $this->appendedEvents = $events; - } - }; - $this->app = new App($this->eventStore); - $this->eventSerializer = new EventSerializer('\\Wwwision\\DCBExample\\Domain\\Event'); - } - - /** - * @AfterScenario - */ - public function throwConstraintException(): void - { - if ($this->lastConstraintException !== null) { - throw $this->lastConstraintException; - } - } - - /** - * AfterScenario - */ - public function resetEventStore(): void - { - if ($this->eventStoreConnection->getDatabasePlatform() instanceof PostgreSQLPlatform) { - $this->eventStoreConnection->executeStatement('TRUNCATE TABLE ' . $this->eventTableName . ' RESTART IDENTITY'); - } elseif ($this->eventStoreConnection->getDatabasePlatform() instanceof SqlitePlatform) { - /** @noinspection SqlWithoutWhere */ - $this->eventStoreConnection->executeStatement('DELETE FROM ' . $this->eventTableName); - $this->eventStoreConnection->executeStatement('DELETE FROM sqlite_sequence WHERE name =\'' . $this->eventTableName . '\''); - } else { - $this->eventStoreConnection->executeStatement('TRUNCATE TABLE ' . $this->eventTableName); - } - } - - // -------------- EVENTS ---------------------- - - /** - * @Given course :courseIds exists with the title :courseTitle and a capacity of :initialCapacity - * @Given course :courseIds exists with a capacity of :initialCapacity - * @Given course :courseIds exists with the title :courseTitle - * @Given course(s) :courseIds exist(s) - */ - public function courseExists(string $courseIds, string|null $courseTitle = null, int|null $initialCapacity = null): void - { - $domainEvents = []; - foreach (explode(',', $courseIds) as $courseId) { - $domainEvents[] = new CourseDefined( - CourseId::fromString($courseId), - CourseCapacity::fromInteger($initialCapacity ?? 10), - courseTitle::fromString($courseTitle ?? ('course ' . $courseId)), - ); - } - $this->appendEvents(...$domainEvents); - } - - /** - * @Given student :studentIds is registered - * @Given students :studentIds are registered - */ - public function studentIsRegistered(string $studentIds): void - { - $domainEvents = []; - foreach (explode(',', $studentIds) as $studentId) { - $domainEvents[] = new StudentRegistered( - StudentId::fromString($studentId), - ); - } - $this->appendEvents(...$domainEvents); - } - - /** - * @Given student :studentId is subscribed to course(s) :courseIds - */ - public function studentIsSubscribedToCourses(string $studentId, string $courseIds): void - { - $domainEvents = []; - foreach (explode(',', $courseIds) as $courseId) { - $domainEvents[] = new StudentSubscribedToCourse( - CourseId::fromString($courseId), - StudentId::fromString($studentId), - ); - } - $this->appendEvents(...$domainEvents); - } - - /** - * @Given student :studentId is unsubscribed from course(s) :courseIds - */ - public function studentIsUnsubscribedFromCourses(string $studentId, string $courseIds): void - { - $domainEvents = []; - foreach (explode(',', $courseIds) as $courseId) { - $domainEvents[] = new StudentUnsubscribedFromCourse(StudentId::fromString($studentId), CourseId::fromString($courseId), - ); - } - $this->appendEvents(...$domainEvents); - } - - // -------------- COMMANDS ---------------------- - - /** - * @When a new course is defined with id :courseId, title :courseTitle and capacity of :initialCapacity - * @When a new course is defined with id :courseId and capacity of :initialCapacity - * @When a new course is defined with id :courseId and title :courseTitle - * @When a new course is defined with id :courseId - */ - public function aNewCourseIsDefined(string $courseId, string $courseTitle = 'Course Title', int $initialCapacity = 10): void - { - $this->tryAndCatchException(fn () => $this->app->defineCourse( - CourseId::fromString($courseId), - CourseTitle::fromString($courseTitle), - CourseCapacity::fromInteger($initialCapacity) - )); - } - - /** - * @When course :courseId is renamed to :newCourseTitle - */ - public function courseIsRenamed(string $courseId, string $newCourseTitle): void - { - $this->tryAndCatchException(fn () => $this->app->renameCourse( - CourseId::fromString($courseId), - CourseTitle::fromString($newCourseTitle), - )); - } - - /** - * @When course :courseId capacity is changed to :newCapacity - */ - public function courseCapacityIsChanged(string $courseId, int $newCapacity): void - { - $this->tryAndCatchException(fn () => $this->app->changeCourseCapacity( - CourseId::fromString($courseId), - CourseCapacity::fromInteger($newCapacity), - )); - } - - /** - * @When a new student is registered with id :studentId - */ - public function aNewStudentIsRegistered(string $studentId): void - { - $this->tryAndCatchException(fn () => $this->app->registerStudent( - StudentId::fromString($studentId), - )); - } - - /** - * @When student :studentId subscribes to course :courseId - */ - public function studentSubscribesToCourse(string $studentId, string $courseId): void - { - $this->tryAndCatchException(fn () => $this->app->subscribeStudentToCourse( - StudentId::fromString($studentId), - CourseId::fromString($courseId), - )); - } - - /** - * @When student :studentId unsubscribes from course :courseId - */ - public function studentUnsubscribesFromCourse(string $studentId, string $courseId): void - { - $this->tryAndCatchException(fn () => $this->app->unsubscribeStudentFromCourse( - StudentId::fromString($studentId), - CourseId::fromString($courseId), - )); - } - - /** - * @Then the command should be rejected with the following message: - */ - public function theCommandShouldBeRejectedWithTheFollowingMessage(PyStringNode $expectedErrorMessage): void - { - Assert::assertNotNull($this->lastConstraintException, 'Expected an error, but none was thrown'); - Assert::assertSame($expectedErrorMessage->getRaw(), $this->lastConstraintException->getMessage(), 'Error message did not match the expected'); - $this->lastConstraintException = null; - } - - /** - * @Then the command should pass without errors - */ - public function theCommandShouldPassWithoutErrors(): void - { - Assert::assertNull($this->lastConstraintException, 'Expected no error, but one was thrown'); - } - - /** - * @Then no events should be read - */ - public function noEventsShouldBeRead(): void - { - Assert::assertCount(0, $this->eventStore->readEvents); - } - - /** - * @Then the following event(s) should be read: - */ - public function theFollowingEventsShouldBeRead(TableNode $expectedEventsTable): void - { - self::asserEvents($expectedEventsTable, $this->eventStore->readEvents); - } - - - /** - * @Then no events should be appended - */ - public function noEventsShouldBeAppended(): void - { - Assert::assertSame(0, $this->eventStore->appendedEvents->count(), 'Expected no events to be appended'); - } - - /** - * @Then the following event(s) should be appended: - */ - public function theFollowingEventsShouldBeAppended(TableNode $expectedEventsTable): void - { - self::asserEvents($expectedEventsTable, $this->eventStore->appendedEvents); - } - - private static function asserEvents(TableNode $expectedEventsTable, Events $events): void - { - $expectedEvents = array_map(static fn (array $col) => array_map(static fn(string $val) => json_decode($val, true, 512, JSON_THROW_ON_ERROR), $col), $expectedEventsTable->getColumnsHash()); - $actualEvents = []; - $index = 0; - foreach ($events as $event) { - $actualEvents[] = self::eventToArray(isset($expectedEvents[$index]) ? array_keys($expectedEvents[$index]) : ['Id', 'Type', 'Data', 'Tags'], $event); - $index ++; - } - Assert::assertEquals($expectedEvents, $actualEvents); - } - - private static function eventToArray(array $keys, Event $event): array - { - $supportedKeys = ['Type', 'Data', 'Tags']; - $unsupportedKeys = array_diff($keys, $supportedKeys); - if ($unsupportedKeys !== []) { - throw new InvalidArgumentException(sprintf('Invalid key(s) "%s" for expected event. Allowed keys are: "%s"', implode('", "', $unsupportedKeys), implode('", "', $supportedKeys)), 1686128517); - } - $actualAsArray = [ - 'Type' => $event->type->value, - 'Data' => json_decode($event->data->value, true, 512, JSON_THROW_ON_ERROR), - 'Tags' => $event->tags->toStrings(), - ]; - foreach (array_diff($supportedKeys, $keys) as $unusedKey) { - unset($actualAsArray[$unusedKey]); - } - return $actualAsArray; - } - - // ---------------------------- - - private function tryAndCatchException(Closure $handler): void - { - $this->eventStore->appendedEvents = Events::none(); - $this->eventStore->readEvents = Events::none(); - try { - $handler(); - } catch (ConstraintException $exception) { - $this->lastConstraintException = $exception; - } - } - - private function appendEvents(DomainEvent ...$domainEvents): void - { - $this->eventStore->append(Events::fromArray(array_map($this->eventSerializer->convertDomainEvent(...), $domainEvents))); - } - - -} diff --git a/tests/Behat/DefineCourse.feature b/tests/Behat/DefineCourse.feature deleted file mode 100644 index f65bb00..0000000 --- a/tests/Behat/DefineCourse.feature +++ /dev/null @@ -1,19 +0,0 @@ -Feature: Defining courses - - Scenario: Defining a new course with an ID that already exists - Given course "c1" exists - When a new course is defined with id "c1" - Then the command should be rejected with the following message: - """ - Constraint "notCourseExists" failed - """ - And no events should be appended - - Scenario: Defining a new course - Given course "c1" exists - When a new course is defined with id "c2", title "course 02" and capacity of 10 - Then no events should be read - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseDefined" | {"courseId": "c2", "initialCapacity": "10", "courseTitle": "course 02"} | ["course:c2"] | \ No newline at end of file diff --git a/tests/Behat/RegisterStudent.feature b/tests/Behat/RegisterStudent.feature deleted file mode 100644 index 930cac8..0000000 --- a/tests/Behat/RegisterStudent.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: Registering students - - Scenario: Registering a new student with an ID that already exists - Given student "s1" is registered - When a new student is registered with id "s1" - Then the command should be rejected with the following message: - """ - Constraint "notStudentIsRegistered" failed - """ - - Scenario: Registering a new student - Given student "s1" is registered - When a new student is registered with id "s2" - Then no events should be read - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "StudentRegistered" | {"studentId": "s2"} | ["student:s2"] | \ No newline at end of file diff --git a/tests/Behat/RenameCourse.feature b/tests/Behat/RenameCourse.feature deleted file mode 100644 index f9ac227..0000000 --- a/tests/Behat/RenameCourse.feature +++ /dev/null @@ -1,45 +0,0 @@ -Feature: Renaming courses - - Scenario: Renaming non-existing course - Given course "c1" exists - When course "non-existing" is renamed to "New course Title" - Then the command should be rejected with the following message: - """ - Constraint "courseExists" failed - """ - And no events should be appended - - Scenario: Renaming non-existing course without actually changing the name - Given course "c1" exists with the title "course 01" - When course "c1" is renamed to "course 01" - Then the command should be rejected with the following message: - """ - Constraint "notCourseTitleEquals" failed - """ - And no events should be appended - - Scenario: Renaming course - Given course "c1" exists with the title "course 01" - When course "c1" is renamed to "course 01 renamed" - Then the following events should be read: - | Type | Tags | - | "CourseDefined" | ["course:c1"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseRenamed" | {"courseId": "c1", "newCourseTitle": "course 01 renamed"} | ["course:c1"] | - - Scenario: Renaming course - Given course "c1" exists with the title "course 01" - When course "c1" is renamed to "course 01 renamed 1" - When course "c1" is renamed to "course 01 renamed 2" - When course "c1" is renamed to "course 01 renamed 3" - Then the following events should be read: - | Type | Tags | - | "CourseDefined" | ["course:c1"] | - | "CourseRenamed" | ["course:c1"] | - | "CourseRenamed" | ["course:c1"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseRenamed" | {"courseId": "c1", "newCourseTitle": "course 01 renamed 3"} | ["course:c1"] | \ No newline at end of file diff --git a/tests/Behat/SubscribeStudentToCourse.feature b/tests/Behat/SubscribeStudentToCourse.feature deleted file mode 100644 index 633d3ff..0000000 --- a/tests/Behat/SubscribeStudentToCourse.feature +++ /dev/null @@ -1,78 +0,0 @@ -Feature: Subscribing students to courses - - Scenario: Subscribing non-existing student to course - Given course "c1" exists - When student "non-existing" subscribes to course "c1" - Then the command should be rejected with the following message: - """ - Constraint "studentIsRegistered" failed - """ - And no events should be appended - - Scenario: Subscribing student to non-existing course - Given course "c1" exists - And student "s1" is registered - When student "s1" subscribes to course "non-existing" - Then the command should be rejected with the following message: - """ - Constraint "courseExists" failed - """ - And no events should be appended - - Scenario: Subscribing student to course that the student is already subscribed to - Given course "c1" exists - And student "s1" is registered - And student "s1" is subscribed to course "c1" - When student "s1" subscribes to course "c1" - Then the command should be rejected with the following message: - """ - Constraint "notStudentSubscribedToCourse" failed - """ - And no events should be appended - - Scenario: Subscribing student to course that has already reached its capacity - Given course "c1" exists with a capacity of 3 - And students "s1,s2,s3,s4" are registered - And student "s1" is subscribed to course "c1" - And student "s2" is subscribed to course "c1" - And student "s3" is subscribed to course "c1" - And student "s4" subscribes to course "c1" - Then the command should be rejected with the following message: - """ - Constraint "courseHasCapacity" failed - """ - And no events should be appended - - Scenario: Subscribing student to 11 courses - Given courses "c1,c2,c3,c4,c5,c6,c7,c8,c9,c10,c11" exist - And student "s1" is registered - And student "s1" is subscribed to courses "c1,c2,c3,c4,c5,c6,c7,c8,c9,c10" - When student "s1" subscribes to course "c11" - Then the command should be rejected with the following message: - """ - Constraint "numberOfStudentSubscriptionsIsBelowLimit" failed - """ - And no events should be appended - - Scenario: Subscribing student to 10 courses - Given courses "c1,c2,c3,c4,c5,c6,c7,c8,c9,c10" exist - And student "s1" is registered - And student "s1" is subscribed to courses "c1,c2,c3,c4,c5,c6,c7,c8,c9" - When student "s1" subscribes to course "c10" - Then the following events should be read: - | Type | Tags | - | "CourseDefined" | ["course:c10"] | - | "StudentRegistered" | ["student:s1"] | - | "StudentSubscribedToCourse" | ["course:c1", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c2", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c3", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c4", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c5", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c6", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c7", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c8", "student:s1"] | - | "StudentSubscribedToCourse" | ["course:c9", "student:s1"] | - Then the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "StudentSubscribedToCourse" | {"courseId": "c10", "studentId": "s1"} | ["course:c10", "student:s1"] | \ No newline at end of file diff --git a/tests/Behat/UnsubscribeStudentFromCourse.feature b/tests/Behat/UnsubscribeStudentFromCourse.feature deleted file mode 100644 index a5a1a23..0000000 --- a/tests/Behat/UnsubscribeStudentFromCourse.feature +++ /dev/null @@ -1,47 +0,0 @@ -Feature: Unsubscribing students from courses - - Scenario: Unsubscribing non-existing student from course - Given course "c1" exists - When student "non-existing" unsubscribes from course "c1" - Then the command should be rejected with the following message: - """ - Constraint "studentIsRegistered" failed - """ - And no events should be appended - - Scenario: Unsubscribing student from non-existing course - Given course "c1" exists - And student "s1" is registered - When student "s1" unsubscribes from course "non-existing" - Then the command should be rejected with the following message: - """ - Constraint "courseExists" failed - """ - And no events should be appended - - Scenario: Unsubscribing student from course that the student is not subscribed to - Given courses "c1,c2" exists - And student "s1" is registered - And student "s1" is subscribed to course "c1" - When student "s1" unsubscribes from course "c2" - Then the command should be rejected with the following message: - """ - Constraint "studentSubscribedToCourse" failed - """ - And no events should be appended - - Scenario: Unsubscribing student from course - Given courses "c1,c2,c3,c4" exist - And students "s1,s2,s3,s4" are registered - And student "s1" is subscribed to course "c1" - And student "s2" is subscribed to course "c1" - And student "s2" unsubscribes from course "c1" - Then the following events should be read: - | Type | Tags | - | "CourseDefined" | ["course:c1"] | - | "StudentRegistered" | ["student:s2"] | - | "StudentSubscribedToCourse" | ["course:c1", "student:s2"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "StudentUnsubscribedFromCourse" | {"courseId": "c1", "studentId": "s2"} | ["course:c1", "student:s2"] | diff --git a/tests/Behat/UpdateCourseCapacity.feature b/tests/Behat/UpdateCourseCapacity.feature deleted file mode 100644 index 492e30f..0000000 --- a/tests/Behat/UpdateCourseCapacity.feature +++ /dev/null @@ -1,60 +0,0 @@ -Feature: Updating the capacity of a course - - Scenario: Changing capacity of a non-existing course - Given course "c1" exists - When course "c2" capacity is changed to 3 - Then the command should be rejected with the following message: - """ - Constraint "courseExists" failed - """ - And no events should be appended - - Scenario: Changing capacity of a course to a value that is not different - Given course "c1" exists with a capacity of 3 - When course "c1" capacity is changed to 3 - Then the command should be rejected with the following message: - """ - Constraint "notCourseCapacityEquals" failed - """ - And no events should be appended - - # NOTE: The following behavior actually deviates from https://sara.event-thinking.io/2023/04/kill-aggregate-chapter-6-the-aggregate-could-cause-unecessary-complexity.html - # "the course Capacity, can change at any time to any positive integer different from the current one (even if the number of currently subscribed students is larger than the new value)" - Scenario: Changing capacity of a course to a value that is lower than the currently active subscriptions - Given course "c1" exists with a capacity of 4 - And students "s1,s2,s3,s4" are registered - And student "s1" is subscribed to course "c1" - And student "s2" is subscribed to course "c1" - And student "s3" is subscribed to course "c1" - And student "s4" subscribes to course "c1" - And course "c1" capacity is changed to 3 - Then the command should be rejected with the following message: - """ - Constraint "numberOfCourseSubscriptionsIsBelowCapacity" failed - """ - And no events should be appended - - Scenario: Changing capacity of a course to a higher value - Given course "c1" exists with a capacity of 3 - When course "c1" capacity is changed to 4 - Then the following events should be read: - | Type | Tags | - | "CourseDefined" | ["course:c1"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 4} | ["course:c1"] | - - Scenario: Changing capacity of a course to a lower value - Given course "c1" exists with a capacity of 4 - And student "s1" is registered - And student "s1" is subscribed to course "c1" - When course "c1" capacity is changed to 1 - Then the following events should be read: - | Type | Tags | - | "CourseDefined" | ["course:c1"] | - | "StudentSubscribedToCourse" | ["course:c1", "student:s1"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 1} | ["course:c1"] | \ No newline at end of file diff --git a/tests/Consistency/ArchitectureTest.php b/tests/Consistency/ArchitectureTest.php new file mode 100644 index 0000000..59cafc1 --- /dev/null +++ b/tests/Consistency/ArchitectureTest.php @@ -0,0 +1,90 @@ +analyze( + self::HANDLERS, + scenarios: [ + RenameCourseCommandHandler::class => HandlerScenario::create(command: new RenameCourse('c1', 'Another Title')), + ChangeCourseCapacityCommandHandler::class => HandlerScenario::create(command: new ChangeCourseCapacity('c1', 5)), + ], + ); + } + + public function testBoundaryBreadthStaysWithinLimits(): void + { + // Current high-water mark: SubscribeStudentToCourse reads 6 event types across courses and students (via a + // 2-pass projection chain) – the most entangled decision of the model. Tighten if a refactoring shrinks it. + $this->analyze()->assertBoundaryBreadthAtMost(maxEventTypes: 6, maxEntityTypes: 2, maxReadPasses: 3); + } + + public function testAllFeaturesFormASingleBoundedContext(): void + { + // CourseSubscription reads DefineCourse's and RegisterStudent's events, so none of the three features could be + // split off without importing the others' events – the model is (deliberately) one bounded context. + $coupling = $this->analyze()->coupling(); + + self::assertSame([['CourseSubscription', 'DefineCourse', 'RegisterStudent']], $coupling->clusters); + + $seams = array_map(static fn($shared) => $shared->eventType, $coupling->sharedEventTypes); + self::assertContains('CourseDefined', $seams); + self::assertContains('StudentRegistered', $seams); + } + + public function testEveryHandlerConflictsWithAConcurrentDuplicateOfItself(): void + { + $report = $this->analyze(); + + self::assertSame([], $report->withSelfRaceStatus(SelfRaceStatus::Inconclusive)); + $report->assertNoneVulnerable(); + } + + public function testContentionFanInStaysWithinLimits(): void + { + // Current high-water mark: SubscribeStudentToCourse can be conflicted by 5 of the 6 other handlers – the + // contention hotspot of the model (its decision model touches courses, students and subscriptions). + $this->analyze()->contention()->assertFanInAtMost(5); + } +} diff --git a/tests/Consistency/ChangeCourseCapacityConsistencyTest.php b/tests/Consistency/ChangeCourseCapacityConsistencyTest.php new file mode 100644 index 0000000..41a45e7 --- /dev/null +++ b/tests/Consistency/ChangeCourseCapacityConsistencyTest.php @@ -0,0 +1,64 @@ +handler = new ChangeCourseCapacityCommandHandler($this->tester->appender()); + } + + public function testChangingCapacityOfAnExistingCourseIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) // default capacity 10 + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 5))) + ->then($this->tester->build(CourseCapacityChanged::class, newCapacity: 5)) + ->thenBoundaryIsBounded(); + } + + public function testChangingCapacityToTheCurrentValueIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) // default capacity 10 + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 10))) + ->thenFails('notCourseCapacityEquals'); + } + + public function testShrinkingCapacityBelowTheNumberOfSubscriptionsIsRejected(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class), // default capacity 10 + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's1'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's2'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's3'), + ) + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 2))) + ->thenFails('numberOfCourseSubscriptionsIsBelowCapacity'); + } + + public function testConcurrentSubscriptionWhileShrinkingCapacityConflicts(): void + { + // Safety: shrinking capacity to exactly the current headcount must be rejected if a seat is concurrently taken. + $this->tester->given( + $this->tester->build(CourseDefined::class, initialCapacity: 3), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's1'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's2'), + ) + ->race($this->tester->build(StudentSubscribedToCourse::class, studentId: 's3')) // a third seat taken between read and append + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 2))) + ->thenConflicts(); + } +} diff --git a/tests/Consistency/ConsistencyTestCase.php b/tests/Consistency/ConsistencyTestCase.php new file mode 100644 index 0000000..2184942 --- /dev/null +++ b/tests/Consistency/ConsistencyTestCase.php @@ -0,0 +1,23 @@ +tester->given(...)->when(...)->then(...)`; each concrete + * test wires its handler in `setUp()`. + */ +abstract class ConsistencyTestCase extends TestCase +{ + protected ConcurrencyTester $tester; + + protected function setUp(): void + { + $this->tester = ConcurrencyTester::inMemory(CourseTestSupport::serializer(), CourseTestSupport::defaults()); + } +} diff --git a/tests/Consistency/CourseTestSupport.php b/tests/Consistency/CourseTestSupport.php new file mode 100644 index 0000000..dcbabfd --- /dev/null +++ b/tests/Consistency/CourseTestSupport.php @@ -0,0 +1,50 @@ +with(CourseId::class, static fn() => CourseId::fromString('c1')) + ->with(StudentId::class, static fn() => StudentId::fromString('s1')) + ->with(CourseSchedule::class, static fn() => CourseSchedule::fromArray(['start' => '2026-01-01 10:00:00', 'end' => '2026-01-01 11:00:00'])) + ->with(CourseCapacity::class, static fn() => CourseCapacity::fromInteger(10)) + ->with(CourseTitle::class, static fn() => CourseTitle::fromString('Course Title')); + } +} diff --git a/tests/Consistency/DefineCourseConsistencyTest.php b/tests/Consistency/DefineCourseConsistencyTest.php new file mode 100644 index 0000000..0c2fb07 --- /dev/null +++ b/tests/Consistency/DefineCourseConsistencyTest.php @@ -0,0 +1,51 @@ +handler = new DefineCourseCommandHandler($this->tester->appender()); + } + + public function testDefiningANewCourseIsAccepted(): void + { + $this->tester->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->then($this->tester->build(CourseDefined::class)) + ->thenBoundaryIsBounded(); + } + + public function testDefiningACourseWithAnExistingIdIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->thenFails('notCourseExists'); + } + + public function testConcurrentDefinitionOfTheSameCourseConflicts(): void + { + $this->tester->race($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->thenConflicts(); + } + + public function testConcurrentDefinitionOfADifferentCourseIsAccepted(): void + { + // No false contention: the boundary is scoped to course:c1. + $this->tester->race($this->tester->build(CourseDefined::class, courseId: 'c2')) + ->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->then($this->tester->build(CourseDefined::class)); + } +} diff --git a/tests/Consistency/HandlerBoundariesTest.php b/tests/Consistency/HandlerBoundariesTest.php new file mode 100644 index 0000000..2e6bdd6 --- /dev/null +++ b/tests/Consistency/HandlerBoundariesTest.php @@ -0,0 +1,61 @@ +assertAllBounded();`. + */ + public function testRescheduleIsTheOnlyHandlerWithAnUnboundedBoundary(): void + { + $report = HandlerBoundaryScanner::create(CourseTestSupport::serializer(), CourseTestSupport::defaults()) + ->scan(self::HANDLERS); + + $unbounded = $report->withStatus(BoundaryStatus::Unbounded); + self::assertCount(1, $unbounded); + self::assertSame(RescheduleCourseCommandHandler::class, $unbounded[0]->handlerClass); + self::assertContains('CourseDefined', $unbounded[0]->unboundedEventTypes); + self::assertContains('CourseRescheduled', $unbounded[0]->unboundedEventTypes); + } + + public function testEveryOtherHandlerIsBounded(): void + { + $report = HandlerBoundaryScanner::create(CourseTestSupport::serializer(), CourseTestSupport::defaults()) + ->scan(self::HANDLERS); + + $bounded = array_map(static fn($finding) => $finding->handlerClass, $report->withStatus(BoundaryStatus::Bounded)); + self::assertCount(6, $bounded); + self::assertNotContains(RescheduleCourseCommandHandler::class, $bounded); + } +} diff --git a/tests/Consistency/RegisterStudentConsistencyTest.php b/tests/Consistency/RegisterStudentConsistencyTest.php new file mode 100644 index 0000000..fdb77ef --- /dev/null +++ b/tests/Consistency/RegisterStudentConsistencyTest.php @@ -0,0 +1,52 @@ +handler = new RegisterStudentCommandHandler($this->tester->appender()); + } + + public function testRegisteringNewStudentIsAccepted(): void + { + $this->tester->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->then($this->tester->build(StudentRegistered::class)) + ->thenBoundaryIsBounded(); + } + + public function testRegisteringAnAlreadyRegisteredStudentIsRejected(): void + { + $this->tester->given($this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->thenFails('notStudentIsRegistered'); + } + + public function testConcurrentRegistrationOfTheSameStudentConflicts(): void + { + // Safety: a competing registration of the same student must reject this one. + $this->tester->race($this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->thenConflicts(); + } + + public function testConcurrentRegistrationOfADifferentStudentIsAccepted(): void + { + // No false contention: the boundary is scoped to student:s1, so registering s2 concurrently is irrelevant. + $this->tester->race($this->tester->build(StudentRegistered::class, studentId: 's2')) + ->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->then($this->tester->build(StudentRegistered::class)); + } +} diff --git a/tests/Consistency/RenameCourseConsistencyTest.php b/tests/Consistency/RenameCourseConsistencyTest.php new file mode 100644 index 0000000..d5600f0 --- /dev/null +++ b/tests/Consistency/RenameCourseConsistencyTest.php @@ -0,0 +1,53 @@ +handler = new RenameCourseCommandHandler($this->tester->appender()); + } + + public function testRenamingAnExistingCourseIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'New title'))) + ->then($this->tester->build(CourseRenamed::class, newTitle: 'New title')) + ->thenBoundaryIsBounded(); + } + + public function testRenamingANonExistentCourseIsRejected(): void + { + $this->tester->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'New title'))) + ->thenFails('courseExists'); + } + + public function testRenamingToTheCurrentTitleIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, courseTitle: 'Same title')) + ->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'Same title'))) + ->thenFails('notCourseTitleEquals'); + } + + public function testConcurrentRenameOfTheSameCourseConflicts(): void + { + // Safety: two renames of the same course must not both succeed. + $this->tester->given($this->tester->build(CourseDefined::class)) + ->race($this->tester->build(CourseRenamed::class, newTitle: 'Concurrent title')) + ->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'New title'))) + ->thenConflicts(); + } +} diff --git a/tests/Consistency/RescheduleCourseConsistencyTest.php b/tests/Consistency/RescheduleCourseConsistencyTest.php new file mode 100644 index 0000000..0d1c37f --- /dev/null +++ b/tests/Consistency/RescheduleCourseConsistencyTest.php @@ -0,0 +1,98 @@ + '2026-08-01 10:00:00', 'end' => '2026-08-01 12:00:00']; + private const array EARLY = ['start' => '2026-08-01 08:00:00', 'end' => '2026-08-01 09:00:00']; + private const array FAR = ['start' => '2026-08-01 20:00:00', 'end' => '2026-08-01 21:00:00']; + private const array AFTERNOON = ['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 16:00:00']; + private const array LATE_AFTERNOON = ['start' => '2026-08-01 14:30:00', 'end' => '2026-08-01 16:30:00']; + private const array OVERLAPS_AFTERNOON = ['start' => '2026-08-01 15:00:00', 'end' => '2026-08-01 17:00:00']; + + private RescheduleCourseCommandHandler $handler; + + protected function setUp(): void + { + parent::setUp(); + $this->handler = new RescheduleCourseCommandHandler($this->tester->appender()); + } + + public function testReschedulingACourseWithoutConflictsIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, schedule: self::MORNING)) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::EARLY))) + ->then($this->tester->build(CourseRescheduled::class, newSchedule: self::EARLY)); + } + + public function testReschedulingIntoASlotThatClashesForASharedStudentIsRejected(): void + { + // s1 attends c1 and c2; moving c1 onto c2's slot would clash for s1 → rejected (single conflicting course). + $this->tester->given( + $this->tester->build(CourseDefined::class, schedule: self::MORNING), + $this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::AFTERNOON), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + $this->tester->build(StudentSubscribedToCourse::class, courseId: 'c2'), + ) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::OVERLAPS_AFTERNOON))) + ->thenFails('notHasMatchingSubscriptions'); + } + + /** + * ⚠️ KNOWN BUG (detected by the harness): the reschedule consistency boundary is UNBOUNDED. + * + * `coursesWithConflictingSchedule` projects over empty tags, so its query — and therefore the AppendCondition — + * matches every `CourseDefined`/`CourseRescheduled` event of every course. A correct bounded fix needs a + * candidate-by-time-bucket-then-verify redesign (schedules are mutable, so naive slot tagging over-rejects). + */ + public function testRescheduleConsistencyBoundaryIsUnbounded(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, schedule: self::MORNING)) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::EARLY))) + ->then($this->tester->build(CourseRescheduled::class, newSchedule: self::EARLY)) + ->thenBoundaryIsUnbounded(); + } + + /** + * ⚠️ KNOWN BUG (characterization test): because the boundary is unbounded, defining a completely unrelated, + * non-overlapping course concurrently FALSELY conflicts with this reschedule. + * + * Correct behaviour would be `then($this->tester->build(CourseRescheduled::class, newSchedule: self::EARLY))`. + */ + public function testUnrelatedConcurrentCourseDefinitionFalselyConflicts(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, schedule: self::MORNING)) + ->race($this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::FAR)) // unrelated, non-overlapping + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::EARLY))) + ->thenConflicts(); // BUG: should append CourseRescheduled; the global boundary turns an unrelated write into a conflict + } + + public function testClashWithMultipleConflictingCoursesIsDetected(): void + { + // The new slot overlaps both c2 and c3; s1 attends c1 and c2, so the reschedule must be rejected even though + // there is more than one conflicting course (regression test for the multi-course union of subscribed students). + $this->tester->given( + $this->tester->build(CourseDefined::class, schedule: self::MORNING), + $this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::AFTERNOON), + $this->tester->build(CourseDefined::class, courseId: 'c3', schedule: self::LATE_AFTERNOON), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + $this->tester->build(StudentSubscribedToCourse::class, courseId: 'c2'), + ) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::OVERLAPS_AFTERNOON))) + ->thenFails('notHasMatchingSubscriptions'); + } +} diff --git a/tests/Consistency/SubscribeStudentToCourseConsistencyTest.php b/tests/Consistency/SubscribeStudentToCourseConsistencyTest.php new file mode 100644 index 0000000..80a6557 --- /dev/null +++ b/tests/Consistency/SubscribeStudentToCourseConsistencyTest.php @@ -0,0 +1,99 @@ + '2026-08-01 10:00:00', 'end' => '2026-08-01 12:00:00']; // c1 + private const array AFTERNOON = ['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 16:00:00']; // c2 (no overlap with morning) + private const array MIDDAY = ['start' => '2026-08-01 11:00:00', 'end' => '2026-08-01 13:00:00']; // overlaps morning + + private SubscribeStudentToCourseCommandHandler $handler; + + protected function setUp(): void + { + parent::setUp(); + $this->handler = new SubscribeStudentToCourseCommandHandler($this->tester->appender()); + } + + public function testSubscribingARegisteredStudentToAnExistingCourseIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class), $this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->then($this->tester->build(StudentSubscribedToCourse::class)) + ->thenBoundaryIsBounded(); + } + + public function testSubscribingToANonExistentCourseIsRejected(): void + { + $this->tester->given($this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenFails('courseExists'); + } + + public function testSubscribingAnUnregisteredStudentIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenFails('studentIsRegistered'); + } + + public function testSubscribingToAFullCourseIsRejected(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class, initialCapacity: 1), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentRegistered::class, studentId: 's2'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's2'), // the only seat is taken + ) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenFails('courseHasCapacity'); + } + + public function testConcurrentSubscriptionFillingTheLastSeatConflicts(): void + { + // Safety: capacity must not be exceeded when the last seat is taken concurrently. + $this->tester->given( + $this->tester->build(CourseDefined::class, initialCapacity: 1), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentRegistered::class, studentId: 's2'), + ) + ->race($this->tester->build(StudentSubscribedToCourse::class, studentId: 's2')) // fills the only seat between read and append + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenConflicts(); + } + + public function testConcurrentDuplicateSubscriptionConflicts(): void + { + // Safety: the same student must not be subscribed to the same course twice concurrently. + $this->tester->given($this->tester->build(CourseDefined::class), $this->tester->build(StudentRegistered::class)) + ->race($this->tester->build(StudentSubscribedToCourse::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenConflicts(); + } + + public function testScheduleConflictIntroducedByARescheduleIsDetected(): void + { + // c2 is rescheduled to overlap c1 (which s1 attends), so subscribing s1 to c2 must be rejected. + $this->tester->given( + $this->tester->build(CourseDefined::class, schedule: self::MORNING), + $this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::AFTERNOON), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), // s1 -> c1 + $this->tester->build(CourseRescheduled::class, courseId: 'c2', newSchedule: self::MIDDAY), // c2 now overlaps c1 + ) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class, courseId: 'c2'))) + ->thenFails('hasNoScheduleConflicts'); + } +} diff --git a/tests/Consistency/UnsubscribeStudentFromCourseConsistencyTest.php b/tests/Consistency/UnsubscribeStudentFromCourseConsistencyTest.php new file mode 100644 index 0000000..449b1ee --- /dev/null +++ b/tests/Consistency/UnsubscribeStudentFromCourseConsistencyTest.php @@ -0,0 +1,60 @@ +handler = new UnsubscribeStudentFromCourseCommandHandler($this->tester->appender()); + } + + public function testUnsubscribingASubscribedStudentIsAccepted(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + ) + ->when(fn() => ($this->handler)($this->tester->build(UnsubscribeStudentFromCourse::class))) + ->then($this->tester->build(StudentUnsubscribedFromCourse::class)) + ->thenBoundaryIsBounded(); + } + + public function testUnsubscribingAStudentThatIsNotSubscribedIsRejected(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class), + $this->tester->build(StudentRegistered::class), + ) + ->when(fn() => ($this->handler)($this->tester->build(UnsubscribeStudentFromCourse::class))) + ->thenFails('studentSubscribedToCourse'); + } + + public function testConcurrentUnsubscribeOfTheSameSubscriptionConflicts(): void + { + // Safety: the same subscription must not be removed twice concurrently. + $this->tester->given( + $this->tester->build(CourseDefined::class), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + ) + ->race($this->tester->build(StudentUnsubscribedFromCourse::class)) + ->when(fn() => ($this->handler)($this->tester->build(UnsubscribeStudentFromCourse::class))) + ->thenConflicts(); + } +}