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
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/composer.json b/composer.json
index 9d30f1e..ea56d57 100644
--- a/composer.json
+++ b/composer.json
@@ -20,17 +20,17 @@
}
],
"require": {
- "php": ">=8.4",
+ "php": ">=8.5",
"webmozart/assert": "^1.11",
- "wwwision/dcb-eventstore": "^4",
- "wwwision/dcb-eventstore-doctrine": "^4"
+ "wwwision/dcb-eventstore": "^5",
+ "wwwision/dcb-eventstore-doctrine": "^6",
+ "wwwision/dcb-tools": "@dev"
},
"require-dev": {
"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": {
@@ -46,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 ca6c6c9..fccbca5 100644
--- a/index.php
+++ b/index.php
@@ -2,16 +2,30 @@
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\CommandHandler;
-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\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\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;
+use Wwwision\DCBTools\Serialization\SimpleEventSerializer;
+use Wwwision\DCBTools\StateProjector;
require __DIR__ . '/vendor/autoload.php';
@@ -27,27 +41,91 @@
/** 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();
-/** @var {@see CommandHandler} is the central authority to handle {@see Command}s */
-$commandHandler = new CommandHandler($eventStore);
+$eventSerializer = new SimpleEventSerializer([
+ CourseCapacityChanged::class,
+ CourseDefined::class,
+ CourseRenamed::class,
+ CourseRescheduled::class,
+ StudentRegistered::class,
+ StudentSubscribedToCourse::class,
+ StudentUnsubscribedFromCourse::class,
+]);
+
+$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, $stateProjector);
// Example:
-// 1. Create a course (c1)
-$commandHandler->handle(CreateCourse::create(courseId: 'c1', initialCapacity: 10, courseTitle: 'Course 02'));
+// 1. Define a course (c1)
+$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, 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('c1', 'Course 01 renamed');
// 3. register a student (s1) in the system
-$commandHandler->handle(RegisterStudent::create(studentId: 's1'));
+$app->registerStudent('s1');
+$app->registerStudent('s2');
// 4. subscribe student (s1) to course (s1)
-$commandHandler->handle(SubscribeStudentToCourse::create(courseId: 'c1', studentId: 's1'));
+#$eventStoreWithTracer->start('subscribeStudentToCourse s1 -> c1');
+$app->subscribeStudentToCourse('s1', 'c1');
+#echo $eventStoreWithTracer->end();
+$app->subscribeStudentToCourse('s1', 'c3');
+#$app->subscribeStudentToCourse('s2', 'c2');
+
+$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
-$commandHandler->handle(UpdateCourseCapacity::create(courseId: 'c1', newCapacity: 5));
+$app->changeCourseCapacity('c1', 5);
// 6. unsubscribe student (s1) from course (c1)
-$commandHandler->handle(UnsubscribeStudentFromCourse::create(courseId: 'c1', studentId: 's1'));
\ 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/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/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
new file mode 100644
index 0000000..fcfb7ac
--- /dev/null
+++ b/src/App.php
@@ -0,0 +1,76 @@
+domainEventAppender);
+ $handler(new DefineCourse($courseId, $initialCapacity, $courseTitle, $schedule));
+ }
+
+ 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 rescheduleCourse(string $courseId, CourseSchedule $newSchedule): void
+ {
+ $handler = new RescheduleCourseCommandHandler($this->domainEventAppender);
+ $handler(new RescheduleCourse($courseId, $newSchedule));
+ }
+
+ 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/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/DecisionModel/DecisionModel.php b/src/DecisionModel/DecisionModel.php
deleted file mode 100644
index 264ebe8..0000000
--- a/src/DecisionModel/DecisionModel.php
+++ /dev/null
@@ -1,22 +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/Event/CourseCreated.php b/src/Event/CourseCreated.php
deleted file mode 100644
index 1ba6bb7..0000000
--- a/src/Event/CourseCreated.php
+++ /dev/null
@@ -1,41 +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/Event/CourseEvent.php b/src/Event/CourseEvent.php
deleted file mode 100644
index 7893495..0000000
--- a/src/Event/CourseEvent.php
+++ /dev/null
@@ -1,17 +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/Event/DomainEvent.php b/src/Event/DomainEvent.php
deleted file mode 100644
index c7e8d73..0000000
--- a/src/Event/DomainEvent.php
+++ /dev/null
@@ -1,18 +0,0 @@
- $data
- */
- public static function fromArray(array $data): self;
-}
diff --git a/src/Event/DomainEvents.php b/src/Event/DomainEvents.php
deleted file mode 100644
index e10d2b6..0000000
--- a/src/Event/DomainEvents.php
+++ /dev/null
@@ -1,45 +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/Event/StudentEvent.php b/src/Event/StudentEvent.php
deleted file mode 100644
index bca3fa0..0000000
--- a/src/Event/StudentEvent.php
+++ /dev/null
@@ -1,17 +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/Event/StudentSubscribedToCourse.php b/src/Event/StudentSubscribedToCourse.php
deleted file mode 100644
index 702abd2..0000000
--- a/src/Event/StudentSubscribedToCourse.php
+++ /dev/null
@@ -1,38 +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/Event/StudentUnsubscribedFromCourse.php b/src/Event/StudentUnsubscribedFromCourse.php
deleted file mode 100644
index 87ae101..0000000
--- a/src/Event/StudentUnsubscribedFromCourse.php
+++ /dev/null
@@ -1,35 +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/EventSerializer.php b/src/EventSerializer.php
deleted file mode 100644
index d14c9b1..0000000
--- a/src/EventSerializer.php
+++ /dev/null
@@ -1,68 +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 = '\\Wwwision\\DCBExample\\Event\\' . $event->type->value;
- $domainEvent = $eventClassName::fromArray($payload);
- Assert::isInstanceOf($domainEvent, DomainEvent::class);
- return $domainEvent;
- }
-
- 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 = [];
- if ($domainEvent instanceof CourseEvent) {
- $tags[] = "course:{$domainEvent->courseId->value}";
- }
- if ($domainEvent instanceof StudentEvent) {
- $tags[] = "student:{$domainEvent->studentId->value}";
- }
- return Event::create(
- type: substr($domainEvent::class, strrpos($domainEvent::class, '\\') + 1),
- data: $eventData,
- tags: $tags,
- );
- }
-}
diff --git a/src/Exception/ConstraintException.php b/src/Exception/ConstraintException.php
deleted file mode 100644
index 39204c6..0000000
--- a/src/Exception/ConstraintException.php
+++ /dev/null
@@ -1,14 +0,0 @@
-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..709a7d6
--- /dev/null
+++ b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 0000000..ba5642b
--- /dev/null
+++ b/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php
@@ -0,0 +1,31 @@
+eventStore->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..18940e4
--- /dev/null
+++ b/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php
@@ -0,0 +1,32 @@
+eventStore->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..12e2a01
--- /dev/null
+++ b/src/Features/DefineCourse/Commands/DefineCourse.php
@@ -0,0 +1,45 @@
+courseId = $courseId;
+ $this->initialCapacity = $initialCapacity;
+ $this->courseTitle = $courseTitle;
+ $this->schedule = $schedule;
+ }
+}
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/Commands/RescheduleCourse.php b/src/Features/DefineCourse/Commands/RescheduleCourse.php
new file mode 100644
index 0000000..a8eb85f
--- /dev/null
+++ b/src/Features/DefineCourse/Commands/RescheduleCourse.php
@@ -0,0 +1,31 @@
+courseId = $courseId;
+ $this->newSchedule = $newSchedule;
+ }
+}
diff --git a/src/Features/DefineCourse/DefineCourseCommandHandler.php b/src/Features/DefineCourse/DefineCourseCommandHandler.php
new file mode 100644
index 0000000..30eb4c2
--- /dev/null
+++ b/src/Features/DefineCourse/DefineCourseCommandHandler.php
@@ -0,0 +1,30 @@
+eventStore->append(
+ constraints: [
+ not(Course::exists($command->courseId))
+ ],
+ onSuccess: static fn () => new CourseDefined($command->courseId, $command->initialCapacity, $command->courseTitle, $command->schedule),
+ );
+ }
+}
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..244ce33
--- /dev/null
+++ b/src/Features/DefineCourse/Events/CourseDefined.php
@@ -0,0 +1,49 @@
+courseId = $courseId;
+ $this->initialCapacity = $initialCapacity;
+ $this->courseTitle = $courseTitle;
+ $this->schedule = $schedule;
+ }
+}
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/Events/CourseRescheduled.php b/src/Features/DefineCourse/Events/CourseRescheduled.php
new file mode 100644
index 0000000..54830b0
--- /dev/null
+++ b/src/Features/DefineCourse/Events/CourseRescheduled.php
@@ -0,0 +1,35 @@
+courseId = $courseId;
+ $this->newSchedule = $newSchedule;
+ }
+}
diff --git a/src/Features/DefineCourse/RenameCourseCommandHandler.php b/src/Features/DefineCourse/RenameCourseCommandHandler.php
new file mode 100644
index 0000000..8632651
--- /dev/null
+++ b/src/Features/DefineCourse/RenameCourseCommandHandler.php
@@ -0,0 +1,31 @@
+eventStore->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/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/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..c03469c
--- /dev/null
+++ b/src/Features/RegisterStudent/RegisterStudentCommandHandler.php
@@ -0,0 +1,30 @@
+eventStore->append(
+ constraints: [
+ not(Student::isRegistered($command->studentId))
+ ],
+ onSuccess: fn () => new StudentRegistered($command->studentId),
+ );
+ }
+}
diff --git a/src/Model/Course/CourseDecisionModels.php b/src/Model/Course/CourseDecisionModels.php
new file mode 100644
index 0000000..e0e7840
--- /dev/null
+++ b/src/Model/Course/CourseDecisionModels.php
@@ -0,0 +1,105 @@
+ $state
+ );
+ }
+
+ public static function hasNoScheduleConflicts(CourseId $referenceCourseId, CourseIds $courseIds): Constraint
+ {
+ $projections = [CourseProjections::schedule($referenceCourseId)];
+ foreach ($courseIds as $courseId) {
+ $projections[] = CourseProjections::schedule($courseId);
+ }
+ // 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 function (array $schedules): bool {
+ $knownSchedules = array_values(array_filter($schedules, static fn ($schedule) => $schedule instanceof CourseSchedule));
+ return !CourseSchedules::create(...$knownSchedules)->hasOverlaps();
+ },
+ );
+ }
+
+ 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