diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71e59e0..81df43a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,26 +3,44 @@ name: CI on: pull_request: +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read -env: - PHP_VERSION: '8.5' - jobs: + load-config: + name: Load config + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + build: name: Build + needs: load-config runs-on: ubuntu-latest - + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.load-config.outputs.php-version }} - name: Validate composer.json run: composer validate --no-interaction @@ -31,7 +49,7 @@ jobs: run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction - name: Upload vendor and composer.lock as artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: vendor-artifact path: | @@ -40,21 +58,21 @@ jobs: auto-review: name: Auto review + needs: [load-config, build] runs-on: ubuntu-latest - needs: build - + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.load-config.outputs.php-version }} - name: Download vendor artifact from build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v4 with: name: vendor-artifact path: . @@ -64,21 +82,21 @@ jobs: tests: name: Tests + needs: [load-config, auto-review] runs-on: ubuntu-latest - needs: auto-review - + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.load-config.outputs.php-version }} - name: Download vendor artifact from build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v4 with: name: vendor-artifact path: . diff --git a/README.md b/README.md index f63025b..5038821 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ + [Aggregate](#aggregate) + [Domain events with transactional outbox](#domain-events-with-transactional-outbox) + [Event sourcing](#event-sourcing) + + [Consuming events](#consuming-events) + [Snapshots](#snapshots) + - [Built-in conditions](#built-in-conditions) + [Upcasting](#upcasting) - [Defining an upcaster](#defining-an-upcaster) - [Upcasting an event](#upcasting-an-event) @@ -44,8 +46,9 @@ The library exposes three styles of aggregate modeling through sibling interface ### Entity -Every entity implements a protected `identityName()` method returning the name of the property that holds its -`Identity`. +Every entity exposes identity through `EntityBehavior`. The protected `identityName()` method returns the name of +the property that holds the `Identity` and defaults to `'id'`. Override it only when the property has a different +name. #### Single-field identity @@ -176,50 +179,69 @@ emitted as side effects and must be delivered at-least-once. #### Declaring events -* `DomainEvent`: empty marker interface. A domain event is a plain PHP object. +* `DomainEvent`: interface declaring `revision()`. A domain event is a plain PHP object. Use + `DomainEventBehavior` to get the default revision of 1; override `revision()` only when bumping schema. ```php use TinyBlocks\BuildingBlocks\Event\DomainEvent; + use TinyBlocks\BuildingBlocks\Event\DomainEventBehavior; final readonly class OrderPlaced implements DomainEvent { + use DomainEventBehavior; + public function __construct(public string $item) { } } ``` + When a schema change requires a new revision, override `revision()`: + + ```php + use TinyBlocks\BuildingBlocks\Event\DomainEvent; + use TinyBlocks\BuildingBlocks\Event\DomainEventBehavior; + use TinyBlocks\BuildingBlocks\Event\Revision; + + final readonly class OrderPlacedV2 implements DomainEvent + { + use DomainEventBehavior; + + public function __construct(public string $item, public string $currency) + { + } + + public function revision(): Revision + { + return Revision::of(value: 2); + } + } + ``` + #### Emitting events from the aggregate * `push`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a - fully-built `EventRecord` to the recorded buffer. The `Revision` is provided on the call site, so the event class - stays pure. + fully-built `EventRecord` to the recorded buffer. The `Revision` is read from the event via `revision()`. ```php use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior; - use TinyBlocks\BuildingBlocks\Event\Revision; final class Order implements EventualAggregateRoot { use EventualAggregateRootBehavior; - private function __construct(private OrderId $orderId) + private function __construct(private OrderId $id) { } public static function place(OrderId $orderId, string $item): Order { - $order = new Order(orderId: $orderId); - $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial()); + $order = new Order(id: $orderId); + $order->push(event: new OrderPlaced(item: $item)); return $order; } - - protected function identityName(): string - { - return 'orderId'; - } } ``` @@ -244,13 +266,13 @@ emitted as side effects and must be delivered at-least-once. #### Applying events to state -* `when`: protected method that records the event and immediately applies it to state by dispatching to a - `when` method by reflection. +* `when`: protected method that records the event and immediately applies it to state. By default, it dispatches + to a `when` method. Alternatively, register an explicit handler map via `eventHandlers()`. + Override `identityName()` only when the identity property is not named `id` (for example, `Cart` uses `cartId`). ```php use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior; - use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class Cart implements EventSourcingRoot @@ -262,7 +284,7 @@ emitted as side effects and must be delivered at-least-once. public function addProduct(string $productId): void { - $this->when(event: new ProductAdded(productId: $productId), revision: Revision::initial()); + $this->when(event: new ProductAdded(productId: $productId)); } public function applySnapshot(Snapshot $snapshot): void @@ -282,6 +304,48 @@ emitted as side effects and must be delivered at-least-once. } ``` + To register handlers explicitly instead of relying on the `when` convention, override + `eventHandlers()`. When the map is non-empty, only listed event classes are dispatched; any other event + causes a `LogicException`. + + ```php + use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; + use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior; + use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; + + final class Cart implements EventSourcingRoot + { + use EventSourcingRootBehavior; + + private CartId $cartId; + private array $productIds = []; + + public function addProduct(string $productId): void + { + $this->when(event: new ProductAdded(productId: $productId)); + } + + public function applySnapshot(Snapshot $snapshot): void + { + $this->productIds = $snapshot->getAggregateState()['productIds'] ?? []; + } + + public function eventHandlers(): array + { + return [ + ProductAdded::class => function (ProductAdded $event): void { + $this->productIds[] = $event->productId; + } + ]; + } + + protected function identityName(): string + { + return 'cartId'; + } + } + ``` + #### Creating a blank aggregate * `blank`: factory that instantiates the aggregate without invoking its constructor. All state must come from events @@ -308,6 +372,42 @@ emitted as side effects and must be delivered at-least-once. ); ``` +### Consuming events + +Domain events travel between services through whatever broker the consumer chooses (SQS, Kafka, RabbitMQ, etc.). +The library is intentionally silent about the transport: it produces and consumes `EventRecord` envelopes, +which the consumer is responsible for serializing and deserializing. + +A typical consumer integration deserializes the broker payload back into an `EventRecord` and dispatches +the wrapped `DomainEvent` to a handler. Sketch of the consumer side: + +```php +$record = new EventRecord( + id: Uuid::fromString($payload['event_id']), + type: EventType::fromString(value: $payload['event_type']), + event: $eventDeserializer->deserialize(type: $payload['event_type'], data: $payload['event_data']), + identity: $identityDeserializer->deserialize( + type: $payload['aggregate_type'], + value: $payload['aggregate_id'] + ), + revision: Revision::of(value: $payload['revision']), + occurredOn: Instant::fromString($payload['occurred_on']), + snapshotData: new SnapshotData(payload: json_decode($payload['snapshot'], true)), + aggregateType: $payload['aggregate_type'], + sequenceNumber: SequenceNumber::of(value: $payload['sequence_number']) +); + +$handler->handle(record: $record); +``` + +The aggregate identity, aggregate type, sequence number, and revision are all available on the envelope. +Handlers receive the full `EventRecord` rather than just the `DomainEvent`, so they can route or filter +based on envelope metadata without that metadata leaking into the event itself. + +The library does not ship deserializers because the format depends entirely on the consumer's transport +and storage choices. Consumers typically maintain a small registry mapping `EventType` values to concrete +`DomainEvent` classes, and a similar mapping for identity types. + ### Snapshots Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate. @@ -364,6 +464,28 @@ Snapshots let the event store skip replay of early events when reconstituting a } ``` +#### Built-in conditions + +Two ready-made implementations ship with the library: + +* `SnapshotEvery::events(count: N)` — returns `true` when the sequence number is a positive multiple of `N`. + Throws `InvalidArgumentException` when `N < 1`. + + ```php + use TinyBlocks\BuildingBlocks\Snapshot\SnapshotEvery; + + $condition = SnapshotEvery::events(count: 100); + $condition->shouldSnapshot(aggregate: $cart); # true at sequences 100, 200, 300, … + ``` + +* `SnapshotNever::create()` — always returns `false`. Useful in tests or to explicitly disable snapshotting. + + ```php + use TinyBlocks\BuildingBlocks\Snapshot\SnapshotNever; + + $condition = SnapshotNever::create(); + ``` + ### Upcasting Upcasters migrate serialized events across schema changes without touching the event classes. @@ -440,7 +562,7 @@ Upcasters migrate serialized events across schema changes without touching the e $event = IntermediateEvent::fromIterable(iterable: [ 'type' => EventType::fromString(value: 'ProductAdded'), 'revision' => Revision::of(value: 2), - 'serializedEvent' => ['productId' => 'prod-1', 'quantity' => 1], + 'serializedEvent' => ['productId' => 'prod-1', 'quantity' => 1] ]); ``` @@ -456,11 +578,13 @@ Upcasters migrate serialized events across schema changes without touching the e ## FAQ -### 01. Why is `DomainEvent` an empty marker interface? +### 01. Why does `DomainEvent` only declare `revision()`? -A domain event is a fact about something that happened in the domain. It has no technical contract beyond being that -fact. Persistence and transport concerns (type name, revision, aggregate identity) belong to `EventRecord`, not to -the event itself. Keeping the event pure prevents infrastructure concerns from leaking into the domain model. +`DomainEvent` declares one method, `revision()`, because schema versioning is an intrinsic property of the +event's structure: it tells consumers which fields the event carries and what semantics they have. +All other concerns — aggregate identity, aggregate type, sequence number, and serialization format — +belong to `EventRecord`, not to the event itself. Keeping those out of `DomainEvent` prevents +infrastructure from leaking into the domain model. ### 02. Why does `EventualAggregateRoot` store `EventRecord` instead of `DomainEvent`? @@ -474,32 +598,27 @@ Outbox and event sourcing are mutually exclusive persistence strategies. An aggr emits events as side effects, or persists only its events as the source of truth. A common base beyond `AggregateRoot` would imply the two patterns can coexist on the same aggregate, which they cannot. -### 04. Why does `Revision` live on the call site instead of on the event class? - -Keeping `Revision` on the `push` or `when` call site makes the aggregate the author of schema evolution. The -event class stays pure. Bumping the revision of an existing event does not require creating a new class. - -### 05. Why does `blank` skip the constructor? +### 04. Why does `blank` skip the constructor? `EventSourcingRootBehavior::blank` instantiates the aggregate via reflection without invoking its constructor because all aggregate state in an event-sourced model must come from events or from a snapshot. Any invariants established by the constructor would contradict that principle. Concrete aggregates should treat their constructor as private and reserved for internal use. -### 06. Why are `recordedEvents` and `sequenceNumber` excluded from `Snapshot::aggregateState`? +### 05. Why are `recordedEvents` and `sequenceNumber` excluded from `Snapshot::aggregateState`? `recordedEvents` belongs to the current unit of work, not to the aggregate's intrinsic state. `sequenceNumber` is already carried by the snapshot as a first-class field, so duplicating it inside `aggregateState` would force consumers to decide which copy is authoritative. -### 07. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace? +### 06. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace? Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, and `MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or `RuntimeException` from the PHP standard library, so consumers that catch the broad standard types continue to work; consumers that need precise handling can catch the specific classes. -### 08. Why did `IDENTITY` and `MODEL_VERSION` move from constants to methods? +### 07. Why did `IDENTITY` and `MODEL_VERSION` move from constants to methods? Class constants read by reflection inside traits are invisible to static analyzers such as PHPStan and Psalm. Every concrete aggregate had to annotate `@phpstan-ignore-next-line` or equivalent suppressions just to satisfy level-9 @@ -507,7 +626,7 @@ analysis. Replacing them with a protected `identityName(): string` method and a method makes the contract explicit in PHP's type system: the compiler enforces implementation, IDEs can navigate to it, and static analyzers raise no warnings — in the library or at consumer sites. -### 09. Why do `Revision`, `SequenceNumber`, and `EventType` now have private constructors? +### 08. Why do `Revision`, `SequenceNumber`, and `EventType` now have private constructors? These value objects have named static factories that carry semantic meaning: `Revision::initial()` communicates "first schema revision", `SequenceNumber::first()` communicates "first recorded event", and @@ -516,6 +635,22 @@ allowed `new Revision(value: 1)` at call sites, which bypasses the semantic inte factory conventions. A private constructor forces all creation through the factories, making the intent visible at every call site. The `of()` factory on `Revision` and `SequenceNumber` covers the loading-from-persistence path. +### 09. Should I add `identity()`, `aggregateType()`, or `toSnapshot()` to my `DomainEvent`? + +No. These three concerns live elsewhere: + +- **Identity and aggregate type** are envelope metadata. They are added by the aggregate when it builds + the `EventRecord` (see `AggregateRootBehavior::buildEventRecord`) and are accessed on the consumer + side through the envelope, not the event. +- **Serialization** is an infrastructure concern. The event remains a pure PHP object; serialization + happens in the outbox writer and the consumer deserializer, both of which live in the consumer + project. + +A `DomainEvent` that grows methods like `identity()`, `aggregateType()`, or `toSnapshot()` is duplicating +envelope data already on the `EventRecord` and pulling infrastructure into the domain layer. If you find +yourself reaching for these methods, the likely root cause is that consumer code is not unwrapping the +envelope correctly. See the *Consuming events* section above for the intended consumer-side pattern. + ## License Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE). diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php index 9f6ea06..6889985 100644 --- a/src/Aggregate/AggregateRootBehavior.php +++ b/src/Aggregate/AggregateRootBehavior.php @@ -9,8 +9,8 @@ use TinyBlocks\BuildingBlocks\Entity\EntityBehavior; use TinyBlocks\BuildingBlocks\Event\DomainEvent; use TinyBlocks\BuildingBlocks\Event\EventRecord; +use TinyBlocks\BuildingBlocks\Event\EventRecords; use TinyBlocks\BuildingBlocks\Event\EventType; -use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\BuildingBlocks\Event\SnapshotData; use TinyBlocks\Time\Instant; @@ -19,6 +19,8 @@ trait AggregateRootBehavior { use EntityBehavior; + private EventRecords $recordedEvents; + private SequenceNumber $sequenceNumber; public function getSequenceNumber(): SequenceNumber @@ -46,6 +48,13 @@ protected function nextSequenceNumber(): void $this->sequenceNumber = $this->getSequenceNumber()->next(); } + public function recordedEvents(): EventRecords + { + $records = $this->recordedEvents ?? EventRecords::createFromEmpty(); + + return EventRecords::createFrom(elements: $records); + } + protected function generateSnapshotData(): SnapshotData { $state = get_object_vars($this); @@ -54,14 +63,14 @@ protected function generateSnapshotData(): SnapshotData return new SnapshotData(payload: $state); } - protected function buildEventRecord(DomainEvent $event, Revision $revision): EventRecord + protected function buildEventRecord(DomainEvent $event): EventRecord { return new EventRecord( id: Uuid::uuid4(), type: EventType::fromEvent(event: $event), event: $event, identity: $this->getIdentity(), - revision: $revision, + revision: $event->revision(), occurredOn: Instant::now(), snapshotData: $this->generateSnapshotData(), aggregateType: $this->buildAggregateName(), diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php index 801b703..c22079e 100644 --- a/src/Aggregate/EventSourcingRoot.php +++ b/src/Aggregate/EventSourcingRoot.php @@ -5,6 +5,7 @@ namespace TinyBlocks\BuildingBlocks\Aggregate; use TinyBlocks\BuildingBlocks\Entity\Identity; +use TinyBlocks\BuildingBlocks\Event\DomainEvent; use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; @@ -27,6 +28,18 @@ */ interface EventSourcingRoot extends AggregateRoot { + /** + * Returns the explicit map of event class names to handler callables. + * + *

When the returned array is empty, the trait falls back to the implicit + * convention when<EventShortName>. When the array is + * non-empty, it is the authoritative source: only events whose class names + * appear as keys can be applied; absence triggers an exception.

+ * + * @return array, callable> + */ + public function eventHandlers(): array; + /** * Returns the events recorded during the current unit of work. * @@ -60,6 +73,18 @@ public static function blank(Identity $identity): static; */ public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static; + /** + * Returns the aggregate state to persist in a snapshot. + * + *

The default implementation provided by {@see EventSourcingRootBehavior} returns all object + * properties except recordedEvents (transient buffer) and sequenceNumber + * (already a first-class field on the snapshot). Override to exclude infrastructure properties + * (loggers, caches, etc.) or to include only a curated subset of state.

+ * + * @return array Keyed by property name. + */ + public function getSnapshotState(): array; + /** * Restores aggregate state from the given snapshot. * diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php index eb6ff6b..31bc602 100644 --- a/src/Aggregate/EventSourcingRootBehavior.php +++ b/src/Aggregate/EventSourcingRootBehavior.php @@ -4,30 +4,21 @@ namespace TinyBlocks\BuildingBlocks\Aggregate; -use LogicException; use ReflectionClass; use ReflectionProperty; use TinyBlocks\BuildingBlocks\Entity\Identity; use TinyBlocks\BuildingBlocks\Event\DomainEvent; use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; -use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Event\SequenceNumber; +use TinyBlocks\BuildingBlocks\Internal\Exceptions\EventHandlerMethodNotFound; +use TinyBlocks\BuildingBlocks\Internal\Exceptions\NoEventHandlerRegistered; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; trait EventSourcingRootBehavior { use AggregateRootBehavior; - private EventRecords $recordedEvents; - - public function recordedEvents(): EventRecords - { - $records = $this->recordedEvents ?? EventRecords::createFromEmpty(); - - return EventRecords::createFrom(elements: $records); - } - public static function blank(Identity $identity): static { $aggregate = new ReflectionClass(static::class)->newInstanceWithoutConstructor(); @@ -58,25 +49,47 @@ public static function reconstitute( return $aggregate; } - protected function when(DomainEvent $event, Revision $revision): void + public function eventHandlers(): array + { + return []; + } + + public function getSnapshotState(): array + { + $state = get_object_vars($this); + unset($state['recordedEvents'], $state['sequenceNumber']); + + return $state; + } + + protected function when(DomainEvent $event): void { $this->nextSequenceNumber(); - $record = $this->buildEventRecord(event: $event, revision: $revision); + $record = $this->buildEventRecord(event: $event); $this->applyEvent(record: $record); - $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())->add($record); + $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty()) + ->add(elements: $record); } protected function applyEvent(EventRecord $record): void { + $handlers = $this->eventHandlers(); $eventClass = $record->event::class; - $separatorPosition = strrpos($eventClass, '\\'); - $shortName = $separatorPosition === false ? $eventClass : substr($eventClass, $separatorPosition + 1); - $methodName = sprintf('when%s', $shortName); - if (!method_exists($this, $methodName)) { - $template = 'Handler method <%s> not found in aggregate <%s>.'; + if ($handlers !== []) { + if (!array_key_exists($eventClass, $handlers)) { + throw new NoEventHandlerRegistered(eventClass: $eventClass, aggregateClass: static::class); + } - throw new LogicException(sprintf($template, $methodName, static::class)); + $handlers[$eventClass]($record->event); + $this->sequenceNumber = $record->sequenceNumber; + return; + } + + $methodName = sprintf('when%s', $record->type->value); + + if (!method_exists($this, $methodName)) { + throw new EventHandlerMethodNotFound(methodName: $methodName, aggregateClass: static::class); } $this->{$methodName}($record->event); diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php index ce16abf..7f72cc6 100644 --- a/src/Aggregate/EventualAggregateRootBehavior.php +++ b/src/Aggregate/EventualAggregateRootBehavior.php @@ -6,30 +6,20 @@ use TinyBlocks\BuildingBlocks\Event\DomainEvent; use TinyBlocks\BuildingBlocks\Event\EventRecords; -use TinyBlocks\BuildingBlocks\Event\Revision; trait EventualAggregateRootBehavior { use AggregateRootBehavior; - private EventRecords $recordedEvents; - - public function recordedEvents(): EventRecords - { - $records = $this->recordedEvents ?? EventRecords::createFromEmpty(); - - return EventRecords::createFrom(elements: $records); - } - public function clearRecordedEvents(): void { $this->recordedEvents = EventRecords::createFromEmpty(); } - protected function push(DomainEvent $event, Revision $revision): void + protected function push(DomainEvent $event): void { $this->nextSequenceNumber(); $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty()) - ->add(elements: $this->buildEventRecord(event: $event, revision: $revision)); + ->add(elements: $this->buildEventRecord(event: $event)); } } diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php index 82407ce..0c46adb 100644 --- a/src/Entity/EntityBehavior.php +++ b/src/Entity/EntityBehavior.php @@ -8,7 +8,10 @@ trait EntityBehavior { - abstract protected function identityName(): string; + protected function identityName(): string + { + return 'id'; + } public function getIdentityName(): string { diff --git a/src/Event/DomainEvent.php b/src/Event/DomainEvent.php index 299ad52..a9ae71c 100644 --- a/src/Event/DomainEvent.php +++ b/src/Event/DomainEvent.php @@ -7,12 +7,24 @@ /** * Marker interface for records of something that happened in the domain. * - *

A Domain Event is a fact that domain experts care about. It has no technical contract beyond being - * that fact: persistence and transport concerns such as type name, revision, or envelope metadata belong - * to {@see EventRecord}, not here. Keeping the event pure prevents infrastructure concerns from leaking - * into the domain model.

+ *

A Domain Event carries only the data describing what happened in the domain. It is + * a fact that domain experts care about. The following concerns explicitly do not belong + * on a DomainEvent and must not be exposed as accessors on subtypes:

* - *

Being a plain PHP object, any implementation is compatible with PSR-14 + *

    + *
  • Aggregate identity — added by the aggregate when building the {@see EventRecord}.
  • + *
  • Aggregate type — derived from the aggregate's class name into the envelope.
  • + *
  • Sequence number — assigned by the aggregate into the envelope.
  • + *
  • Serialization to storage — responsibility of outbox writers and consumer + * deserializers, both infrastructure concerns outside this library.
  • + *
+ * + *

Adding accessors for any of the above to a DomainEvent subtype duplicates information + * already present on the {@see EventRecord} envelope and pulls infrastructure into the domain layer.

+ * + *

Each event declares its own schema {@see Revision} via the revision() method, defaulted + * to {@see Revision::initial} by {@see DomainEventBehavior}. Override only when bumping schema. Being a + * plain PHP object otherwise, any implementation remains compatible with PSR-14 * (Psr\EventDispatcher\EventDispatcherInterface) without additional adaptation.

* * @see Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8 @@ -20,4 +32,10 @@ */ interface DomainEvent { + /** + * Returns the schema revision of this event. + * + * @return Revision The current schema revision; defaults to {@see Revision::initial}. + */ + public function revision(): Revision; } diff --git a/src/Event/DomainEventBehavior.php b/src/Event/DomainEventBehavior.php new file mode 100644 index 0000000..78bf06e --- /dev/null +++ b/src/Event/DomainEventBehavior.php @@ -0,0 +1,13 @@ + not found in aggregate <%s>.'; + + parent::__construct(sprintf($template, $methodName, $aggregateClass)); + } +} diff --git a/src/Internal/Exceptions/InvalidEventType.php b/src/Internal/Exceptions/InvalidEventType.php index 34de069..a722cfc 100644 --- a/src/Internal/Exceptions/InvalidEventType.php +++ b/src/Internal/Exceptions/InvalidEventType.php @@ -10,8 +10,8 @@ final class InvalidEventType extends InvalidArgumentException { public function __construct(public readonly string $value, public readonly string $pattern) { - parent::__construct( - sprintf('Event type <%s> does not match the required pattern <%s>.', $value, $pattern) - ); + $template = 'Event type <%s> does not match the required pattern <%s>.'; + + parent::__construct(sprintf($template, $value, $pattern)); } } diff --git a/src/Internal/Exceptions/InvalidRevision.php b/src/Internal/Exceptions/InvalidRevision.php index 1583e13..ce0a082 100644 --- a/src/Internal/Exceptions/InvalidRevision.php +++ b/src/Internal/Exceptions/InvalidRevision.php @@ -10,8 +10,8 @@ final class InvalidRevision extends InvalidArgumentException { public function __construct(public readonly int $value) { - parent::__construct( - sprintf('Revision must be greater than or equal to 1, got <%d>.', $value) - ); + $template = 'Revision must be greater than or equal to 1, got <%d>.'; + + parent::__construct(sprintf($template, $value)); } } diff --git a/src/Internal/Exceptions/InvalidSequenceNumber.php b/src/Internal/Exceptions/InvalidSequenceNumber.php index 8035107..216595c 100644 --- a/src/Internal/Exceptions/InvalidSequenceNumber.php +++ b/src/Internal/Exceptions/InvalidSequenceNumber.php @@ -10,8 +10,8 @@ final class InvalidSequenceNumber extends InvalidArgumentException { public function __construct(public readonly int $value) { - parent::__construct( - sprintf('Sequence number must be greater than or equal to 0, got <%d>.', $value) - ); + $template = 'Sequence number must be greater than or equal to 0, got <%d>.'; + + parent::__construct(sprintf($template, $value)); } } diff --git a/src/Internal/Exceptions/InvalidSnapshotCount.php b/src/Internal/Exceptions/InvalidSnapshotCount.php new file mode 100644 index 0000000..d0996bd --- /dev/null +++ b/src/Internal/Exceptions/InvalidSnapshotCount.php @@ -0,0 +1,17 @@ +.'; + + parent::__construct(sprintf($template, $count)); + } +} diff --git a/src/Internal/Exceptions/MissingIdentityProperty.php b/src/Internal/Exceptions/MissingIdentityProperty.php index b5455c1..b2aa0a4 100644 --- a/src/Internal/Exceptions/MissingIdentityProperty.php +++ b/src/Internal/Exceptions/MissingIdentityProperty.php @@ -10,12 +10,8 @@ final class MissingIdentityProperty extends RuntimeException { public function __construct(public readonly string $className, public readonly string $propertyName) { - parent::__construct( - sprintf( - 'Property <%s> referenced by identityName() does not exist in <%s>.', - $propertyName, - $className - ) - ); + $template = 'Property <%s> referenced by identityName() does not exist in <%s>.'; + + parent::__construct(sprintf($template, $propertyName, $className)); } } diff --git a/src/Internal/Exceptions/NoEventHandlerRegistered.php b/src/Internal/Exceptions/NoEventHandlerRegistered.php new file mode 100644 index 0000000..be1bc89 --- /dev/null +++ b/src/Internal/Exceptions/NoEventHandlerRegistered.php @@ -0,0 +1,17 @@ + in aggregate <%s>.'; + + parent::__construct(sprintf($template, $eventClass, $aggregateClass)); + } +} diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php index 119204e..5c827ed 100644 --- a/src/Snapshot/Snapshot.php +++ b/src/Snapshot/Snapshot.php @@ -4,7 +4,6 @@ namespace TinyBlocks\BuildingBlocks\Snapshot; -use ReflectionObject; use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\Time\Instant; @@ -15,7 +14,7 @@ { use ValueObjectBehavior; - public function __construct( + private function __construct( private string $type, private Instant $createdAt, private mixed $aggregateId, @@ -24,22 +23,29 @@ public function __construct( ) { } + public static function restore( + string $type, + Instant $createdAt, + mixed $aggregateId, + array $aggregateState, + SequenceNumber $sequenceNumber + ): Snapshot { + return new Snapshot( + type: $type, + createdAt: $createdAt, + aggregateId: $aggregateId, + aggregateState: $aggregateState, + sequenceNumber: $sequenceNumber + ); + } + public static function fromAggregate(EventSourcingRoot $aggregate): Snapshot { - $reflection = new ReflectionObject($aggregate); - $aggregateState = []; - - foreach ($reflection->getProperties() as $property) { - if (!in_array($property->getName(), ['recordedEvents', 'sequenceNumber'], true)) { - $aggregateState[$property->getName()] = $property->getValue($aggregate); - } - } - return new Snapshot( type: $aggregate->buildAggregateName(), createdAt: Instant::now(), aggregateId: $aggregate->getIdentityValue(), - aggregateState: $aggregateState, + aggregateState: $aggregate->getSnapshotState(), sequenceNumber: $aggregate->getSequenceNumber() ); } diff --git a/src/Snapshot/SnapshotEvery.php b/src/Snapshot/SnapshotEvery.php new file mode 100644 index 0000000..2d1f64e --- /dev/null +++ b/src/Snapshot/SnapshotEvery.php @@ -0,0 +1,30 @@ +getSequenceNumber()->value; + + return $value > 0 && $value % $this->count === 0; + } +} diff --git a/src/Snapshot/SnapshotNever.php b/src/Snapshot/SnapshotNever.php new file mode 100644 index 0000000..5b5004c --- /dev/null +++ b/src/Snapshot/SnapshotNever.php @@ -0,0 +1,24 @@ +getModelVersion(); - /** @Then the version reflects the constant */ + /** @Then the version reflects the declared value */ self::assertSame(1, $version->value); } public function testGetModelVersionDefaultsToZeroWhenUndefined(): void { - /** @Given an aggregate without MODEL_VERSION constant */ + /** @Given an aggregate with the default model version */ $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen'); /** @When retrieving the model version */ diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Aggregate/EventSourcingRootBehaviorTest.php index 9492c33..ec4026c 100644 --- a/tests/Aggregate/EventSourcingRootBehaviorTest.php +++ b/tests/Aggregate/EventSourcingRootBehaviorTest.php @@ -9,6 +9,10 @@ use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; use Test\TinyBlocks\BuildingBlocks\Models\CartWithoutHandler; +use Test\TinyBlocks\BuildingBlocks\Models\ExplicitCart; +use Test\TinyBlocks\BuildingBlocks\Models\Order; +use Test\TinyBlocks\BuildingBlocks\Models\OrderId; +use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced; use Test\TinyBlocks\BuildingBlocks\Models\ProductAdded; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; @@ -79,15 +83,14 @@ public function testDomainOperationAdvancesSequenceNumber(): void /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-3')); - /** @And two products added in sequence */ + /** @When adding a product */ $cart->addProduct(productId: 'prod-1'); - $cart->addProduct(productId: 'prod-2'); - /** @When retrieving the sequence number */ - $sequenceNumber = $cart->getSequenceNumber(); + /** @And adding a second product */ + $cart->addProduct(productId: 'prod-2'); /** @Then the sequence number equals the number of events */ - self::assertSame(2, $sequenceNumber->value); + self::assertSame(2, $cart->getSequenceNumber()->value); } public function testDomainOperationAppendsToRecordedEvents(): void @@ -104,14 +107,16 @@ public function testDomainOperationAppendsToRecordedEvents(): void public function testFirstRecordedEventCarriesEnvelopeMetadata(): void { - /** @Given a blank cart with a known identity */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-5'); - /** @And a product added to the cart */ + /** @And a blank cart initialized */ $cart = Cart::blank(identity: $cartId); + + /** @And a product added to the cart */ $cart->addProduct(productId: 'prod-abc'); - /** @When inspecting the first recorded record */ + /** @When inspecting the first recorded event */ $record = $cart->recordedEvents()->first(); /** @Then the envelope carries the expected metadata */ @@ -126,11 +131,11 @@ public function testFirstRecordedEventCarriesEnvelopeMetadata(): void public function testReconstituteReplaysEventsInOrder(): void { - /** @Given a cart with two products added */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-6'); - $original = Cart::blank(identity: $cartId); - $original->addProduct(productId: 'prod-1'); - $original->addProduct(productId: 'prod-2'); + + /** @And a cart with two products added */ + $original = Cart::withProducts(cartId: $cartId, count: 2); /** @When reconstituting from the event stream */ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents()); @@ -141,11 +146,19 @@ public function testReconstituteReplaysEventsInOrder(): void public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream(): void { - /** @Given a cart that received products in a distinctive order */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-6b'); + + /** @And a blank cart */ $original = Cart::blank(identity: $cartId); + + /** @And a product added named zebra */ $original->addProduct(productId: 'zebra'); + + /** @And a product added named apple */ $original->addProduct(productId: 'apple'); + + /** @And a product added named mango */ $original->addProduct(productId: 'mango'); /** @When reconstituting from the event stream */ @@ -157,11 +170,11 @@ public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream public function testReconstituteAdvancesSequenceNumberToLastEvent(): void { - /** @Given a cart with two products added */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-6c'); - $original = Cart::blank(identity: $cartId); - $original->addProduct(productId: 'prod-1'); - $original->addProduct(productId: 'prod-2'); + + /** @And a cart with two products added */ + $original = Cart::withProducts(cartId: $cartId, count: 2); /** @When reconstituting from the event stream */ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents()); @@ -196,10 +209,16 @@ public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): vo public function testReconstituteFromSnapshotRestoresDomainState(): void { - /** @Given a cart with one product and a snapshot taken at that point */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-8'); + + /** @And a blank cart */ $cart = Cart::blank(identity: $cartId); + + /** @And a product added */ $cart->addProduct(productId: 'prod-snapshot'); + + /** @And a snapshot taken at that point */ $snapshot = Snapshot::fromAggregate(aggregate: $cart); /** @When reconstituting from the snapshot only */ @@ -211,10 +230,16 @@ public function testReconstituteFromSnapshotRestoresDomainState(): void public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): void { - /** @Given a cart with one product and a snapshot taken at that point */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-8b'); + + /** @And a blank cart */ $cart = Cart::blank(identity: $cartId); + + /** @And a product added */ $cart->addProduct(productId: 'prod-snapshot'); + + /** @And a snapshot taken at that point */ $snapshot = Snapshot::fromAggregate(aggregate: $cart); /** @When reconstituting from the snapshot only */ @@ -226,12 +251,22 @@ public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): public function testReconstituteCombinesSnapshotWithLaterEvents(): void { - /** @Given a cart snapshotted after one product, then more events after the snapshot */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-8c'); + + /** @And a blank cart */ $cart = Cart::blank(identity: $cartId); + + /** @And a first product added */ $cart->addProduct(productId: 'prod-1'); + + /** @And a snapshot taken after the first product */ $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @And a second product added after the snapshot */ $cart->addProduct(productId: 'prod-2'); + + /** @And the records after the snapshot filtered out */ $laterRecords = $cart->recordedEvents()->filter( predicates: static fn($record): bool => $record->sequenceNumber->isAfter( other: $snapshot->getSequenceNumber() @@ -247,12 +282,22 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequence(): void { - /** @Given a cart snapshotted after one product, then more events after the snapshot */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-8d'); + + /** @And a blank cart */ $cart = Cart::blank(identity: $cartId); + + /** @And a first product added */ $cart->addProduct(productId: 'prod-1'); + + /** @And a snapshot taken after the first product */ $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @And a second product added after the snapshot */ $cart->addProduct(productId: 'prod-2'); + + /** @And the records after the snapshot filtered out */ $laterRecords = $cart->recordedEvents()->filter( predicates: static fn($record): bool => $record->sequenceNumber->isAfter( other: $snapshot->getSequenceNumber() @@ -268,10 +313,11 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequen public function testReconstitutedAggregateHasNoRecordedEvents(): void { - /** @Given a cart with one recorded event */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-9'); - $original = Cart::blank(identity: $cartId); - $original->addProduct(productId: 'prod-1'); + + /** @And a cart with one product added */ + $original = Cart::withProducts(cartId: $cartId, count: 1); /** @When reconstituting from that event stream */ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents()); @@ -280,12 +326,59 @@ public function testReconstitutedAggregateHasNoRecordedEvents(): void self::assertTrue($reconstituted->recordedEvents()->isEmpty()); } + public function testExplicitHandlerIsInvokedForRegisteredEvent(): void + { + /** @Given a blank ExplicitCart */ + $cart = ExplicitCart::blank(identity: new CartId(value: 'cart-explicit-1')); + + /** @When adding a product via the explicit handler path */ + $cart->addProduct(productId: 'prod-explicit'); + + /** @Then the product appears in the aggregate state */ + self::assertSame(['prod-explicit'], $cart->getProductIds()); + } + + public function testRevisionOverrideIsCarriedOnEventRecord(): void + { + /** @Given a blank ExplicitCart */ + $cart = ExplicitCart::blank(identity: new CartId(value: 'cart-explicit-2')); + + /** @When adding a v2 product whose event overrides revision */ + $cart->addProductV2(productId: 'prod-v2', quantity: 3); + + /** @Then the recorded event carries revision 2 */ + self::assertSame(2, $cart->recordedEvents()->first()->revision->value); + } + + public function testExplicitCartThrowsForUnregisteredEvent(): void + { + /** @Given an ExplicitCart identity */ + $cartId = new CartId(value: 'cart-explicit-err'); + + /** @And an OrderPlaced record from a foreign aggregate */ + $orderRecords = Order::place(orderId: new OrderId(value: 'ord-err'), item: 'book')->recordedEvents(); + + /** @Then a LogicException naming the unregistered event should be thrown */ + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + sprintf( + 'No handler registered for event <%s> in aggregate <%s>.', + OrderPlaced::class, + ExplicitCart::class + ) + ); + + /** @When reconstituting ExplicitCart from the OrderPlaced records */ + ExplicitCart::reconstitute(identity: $cartId, records: $orderRecords); + } + public function testReconstituteThrowsWhenHandlerMethodIsMissing(): void { - /** @Given a recorded event whose aggregate has no matching when handler */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-10'); - $original = Cart::blank(identity: $cartId); - $original->addProduct(productId: 'prod-x'); + + /** @And a cart with one product added */ + $original = Cart::withProducts(cartId: $cartId, count: 1); /** @Then a LogicException pointing to the missing handler should be thrown */ $this->expectException(LogicException::class); diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Entity/EntityBehaviorTest.php index 7f26a39..83e7b96 100644 --- a/tests/Entity/EntityBehaviorTest.php +++ b/tests/Entity/EntityBehaviorTest.php @@ -6,6 +6,8 @@ use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\AppointmentId; +use Test\TinyBlocks\BuildingBlocks\Models\Cart; +use Test\TinyBlocks\BuildingBlocks\Models\CartId; use Test\TinyBlocks\BuildingBlocks\Models\Order; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; use Test\TinyBlocks\BuildingBlocks\Models\OrderWithMissingIdentityProperty; @@ -37,7 +39,19 @@ public function testGetIdentityNameReturnsPropertyName(): void $name = $order->getIdentityName(); /** @Then it matches the value returned by identityName() */ - self::assertSame('orderId', $name); + self::assertSame('id', $name); + } + + public function testGetIdentityNameReturnsOverriddenPropertyName(): void + { + /** @Given a blank Cart with an explicit identityName override */ + $cart = Cart::blank(identity: new CartId(value: 'cart-identity')); + + /** @When retrieving the identity property name */ + $name = $cart->getIdentityName(); + + /** @Then it matches the overridden value */ + self::assertSame('cartId', $name); } public function testGetIdentityValueReturnsScalarForSingleIdentity(): void diff --git a/tests/Event/DomainEventBehaviorTest.php b/tests/Event/DomainEventBehaviorTest.php new file mode 100644 index 0000000..ff7f40e --- /dev/null +++ b/tests/Event/DomainEventBehaviorTest.php @@ -0,0 +1,36 @@ +revision(); + + /** @Then the revision is the initial value */ + self::assertSame(1, $revision->value); + } + + public function testOverriddenRevisionIsReturned(): void + { + /** @Given an event that overrides revision() */ + $event = new ProductAddedV2(productId: 'prod-1', quantity: 2); + + /** @When retrieving its revision */ + $revision = $event->revision(); + + /** @Then the revision matches the override */ + self::assertSame(2, $revision->value); + } +} diff --git a/tests/Models/Cart.php b/tests/Models/Cart.php index 1017d48..b67f09d 100644 --- a/tests/Models/Cart.php +++ b/tests/Models/Cart.php @@ -6,7 +6,6 @@ use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior; -use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class Cart implements EventSourcingRoot @@ -18,9 +17,19 @@ final class Cart implements EventSourcingRoot /** @var list */ private array $productIds = []; + public static function withProducts(CartId $cartId, int $count): Cart + { + $cart = Cart::blank(identity: $cartId); + for ($index = 1; $index <= $count; $index++) { + $cart->addProduct(productId: sprintf('prod-%d', $index)); + } + + return $cart; + } + public function addProduct(string $productId): void { - $this->when(event: new ProductAdded(productId: $productId), revision: Revision::initial()); + $this->when(event: new ProductAdded(productId: $productId)); } public function applySnapshot(Snapshot $snapshot): void diff --git a/tests/Models/CartWithLogger.php b/tests/Models/CartWithLogger.php new file mode 100644 index 0000000..bf72e27 --- /dev/null +++ b/tests/Models/CartWithLogger.php @@ -0,0 +1,58 @@ + */ + private array $productIds = []; + + public function addProduct(string $productId): void + { + $this->logBuffer .= "Added: {$productId}"; + $this->when(event: new ProductAdded(productId: $productId)); + } + + public function applySnapshot(Snapshot $snapshot): void + { + $this->productIds = $snapshot->getAggregateState()['productIds'] ?? []; + } + + public function getSnapshotState(): array + { + $state = get_object_vars($this); + unset($state['recordedEvents'], $state['sequenceNumber'], $state['logBuffer']); + + return $state; + } + + /** + * @return list + */ + public function getProductIds(): array + { + return $this->productIds; + } + + protected function identityName(): string + { + return 'cartId'; + } + + protected function whenProductAdded(ProductAdded $event): void + { + $this->productIds[] = $event->productId; + } +} diff --git a/tests/Models/EveryTwoEvents.php b/tests/Models/EveryTwoEvents.php deleted file mode 100644 index 6cc4ff7..0000000 --- a/tests/Models/EveryTwoEvents.php +++ /dev/null @@ -1,16 +0,0 @@ -getSequenceNumber()->value % 2 === 0; - } -} diff --git a/tests/Models/ExplicitCart.php b/tests/Models/ExplicitCart.php new file mode 100644 index 0000000..8875743 --- /dev/null +++ b/tests/Models/ExplicitCart.php @@ -0,0 +1,59 @@ + */ + private array $productIds = []; + + public function addProduct(string $productId): void + { + $this->when(event: new ProductAdded(productId: $productId)); + } + + public function addProductV2(string $productId, int $quantity): void + { + $this->when(event: new ProductAddedV2(productId: $productId, quantity: $quantity)); + } + + public function applySnapshot(Snapshot $snapshot): void + { + $this->productIds = $snapshot->getAggregateState()['productIds'] ?? []; + } + + public function eventHandlers(): array + { + return [ + ProductAdded::class => function (ProductAdded $event): void { + $this->productIds[] = $event->productId; + }, + ProductAddedV2::class => function (ProductAddedV2 $event): void { + $this->productIds[] = $event->productId; + } + ]; + } + + /** + * @return list + */ + public function getProductIds(): array + { + return $this->productIds; + } + + protected function identityName(): string + { + return 'cartId'; + } +} diff --git a/tests/Models/Order.php b/tests/Models/Order.php index da919f5..4059585 100644 --- a/tests/Models/Order.php +++ b/tests/Models/Order.php @@ -6,7 +6,6 @@ use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior; -use TinyBlocks\BuildingBlocks\Event\Revision; final class Order implements EventualAggregateRoot { @@ -14,15 +13,15 @@ final class Order implements EventualAggregateRoot private string $status = 'draft'; - private function __construct(private OrderId $orderId) + private function __construct(private OrderId $id) { } public static function place(OrderId $orderId, string $item): Order { - $order = new Order(orderId: $orderId); + $order = new Order(id: $orderId); $order->status = 'placed'; - $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial()); + $order->push(event: new OrderPlaced(item: $item)); return $order; } @@ -30,16 +29,11 @@ public static function place(OrderId $orderId, string $item): Order public function ship(string $carrier): void { $this->status = 'shipped'; - $this->push(event: new OrderShipped(carrier: $carrier), revision: Revision::initial()); + $this->push(event: new OrderShipped(carrier: $carrier)); } public function getStatus(): string { return $this->status; } - - protected function identityName(): string - { - return 'orderId'; - } } diff --git a/tests/Models/OrderPlaced.php b/tests/Models/OrderPlaced.php index d52c62a..422f378 100644 --- a/tests/Models/OrderPlaced.php +++ b/tests/Models/OrderPlaced.php @@ -5,9 +5,12 @@ namespace Test\TinyBlocks\BuildingBlocks\Models; use TinyBlocks\BuildingBlocks\Event\DomainEvent; +use TinyBlocks\BuildingBlocks\Event\DomainEventBehavior; final readonly class OrderPlaced implements DomainEvent { + use DomainEventBehavior; + public function __construct(public string $item) { } diff --git a/tests/Models/OrderShipped.php b/tests/Models/OrderShipped.php index 6f422ea..33fb7ec 100644 --- a/tests/Models/OrderShipped.php +++ b/tests/Models/OrderShipped.php @@ -5,9 +5,12 @@ namespace Test\TinyBlocks\BuildingBlocks\Models; use TinyBlocks\BuildingBlocks\Event\DomainEvent; +use TinyBlocks\BuildingBlocks\Event\DomainEventBehavior; final readonly class OrderShipped implements DomainEvent { + use DomainEventBehavior; + public function __construct(public string $carrier) { } diff --git a/tests/Models/OrderWithMissingIdentityProperty.php b/tests/Models/OrderWithMissingIdentityProperty.php index 072aa86..04af2a5 100644 --- a/tests/Models/OrderWithMissingIdentityProperty.php +++ b/tests/Models/OrderWithMissingIdentityProperty.php @@ -6,7 +6,6 @@ use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior; -use TinyBlocks\BuildingBlocks\Event\Revision; final class OrderWithMissingIdentityProperty implements EventualAggregateRoot { @@ -14,7 +13,7 @@ final class OrderWithMissingIdentityProperty implements EventualAggregateRoot public function ship(): void { - $this->push(event: new OrderShipped(carrier: 'DHL'), revision: Revision::initial()); + $this->push(event: new OrderShipped(carrier: 'DHL')); } protected function identityName(): string diff --git a/tests/Models/ProductAdded.php b/tests/Models/ProductAdded.php index 073f9d5..5052eba 100644 --- a/tests/Models/ProductAdded.php +++ b/tests/Models/ProductAdded.php @@ -5,9 +5,12 @@ namespace Test\TinyBlocks\BuildingBlocks\Models; use TinyBlocks\BuildingBlocks\Event\DomainEvent; +use TinyBlocks\BuildingBlocks\Event\DomainEventBehavior; final readonly class ProductAdded implements DomainEvent { + use DomainEventBehavior; + public function __construct(public string $productId) { } diff --git a/tests/Models/ProductAddedV2.php b/tests/Models/ProductAddedV2.php new file mode 100644 index 0000000..bfba964 --- /dev/null +++ b/tests/Models/ProductAddedV2.php @@ -0,0 +1,23 @@ +shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 2)->shouldSnapshot(aggregate: $cart); - /** @Then the condition holds because zero is divisible by two */ - self::assertTrue($shouldSnapshot); + /** @Then the condition does not hold at zero */ + self::assertFalse($shouldSnapshot); } public function testConditionDoesNotHoldAfterOneEvent(): void @@ -32,13 +32,13 @@ public function testConditionDoesNotHoldAfterOneEvent(): void $cart->addProduct(productId: 'prod-1'); /** @When asking the condition whether to snapshot */ - $shouldSnapshot = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 2)->shouldSnapshot(aggregate: $cart); /** @Then the condition does not hold */ self::assertFalse($shouldSnapshot); } - public function testConditionHoldsAgainAfterTwoEvents(): void + public function testConditionHoldsAfterTwoEvents(): void { /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-3')); @@ -50,9 +50,9 @@ public function testConditionHoldsAgainAfterTwoEvents(): void $cart->addProduct(productId: 'prod-2'); /** @When asking the condition whether to snapshot */ - $shouldSnapshot = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 2)->shouldSnapshot(aggregate: $cart); - /** @Then the condition holds again at the next even step */ + /** @Then the condition holds at the first positive multiple */ self::assertTrue($shouldSnapshot); } @@ -71,7 +71,7 @@ public function testConditionDoesNotHoldAfterThreeEvents(): void $cart->addProduct(productId: 'prod-3'); /** @When asking the condition whether to snapshot */ - $shouldSnapshot = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 2)->shouldSnapshot(aggregate: $cart); /** @Then the condition does not hold at an odd step */ self::assertFalse($shouldSnapshot); diff --git a/tests/Snapshot/SnapshotEveryTest.php b/tests/Snapshot/SnapshotEveryTest.php new file mode 100644 index 0000000..3d7b1d3 --- /dev/null +++ b/tests/Snapshot/SnapshotEveryTest.php @@ -0,0 +1,154 @@ +shouldSnapshot(aggregate: $cart); + + /** @Then the result is false because sequence zero is excluded */ + self::assertFalse($result); + } + + public function testReturnsTrueAtSequenceHundred(): void + { + /** @Given a cart at sequence 100 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-2'), count: 100); + + /** @When asking a count-100 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is true */ + self::assertTrue($result); + } + + public function testReturnsTrueAtSequenceTwoHundred(): void + { + /** @Given a cart at sequence 200 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-3'), count: 200); + + /** @When asking a count-100 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is true */ + self::assertTrue($result); + } + + public function testReturnsTrueAtSequenceThreeHundred(): void + { + /** @Given a cart at sequence 300 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-4'), count: 300); + + /** @When asking a count-100 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is true */ + self::assertTrue($result); + } + + public function testReturnsFalseAtSequenceOne(): void + { + /** @Given a cart at sequence 1 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-5'), count: 1); + + /** @When asking a count-100 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is false */ + self::assertFalse($result); + } + + public function testReturnsFalseAtSequenceNinetyNine(): void + { + /** @Given a cart at sequence 99 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-6'), count: 99); + + /** @When asking a count-100 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is false */ + self::assertFalse($result); + } + + public function testReturnsFalseAtSequenceHundredOne(): void + { + /** @Given a cart at sequence 101 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-7'), count: 101); + + /** @When asking a count-100 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is false */ + self::assertFalse($result); + } + + public function testReturnsFalseAtSequenceOneNinetyNine(): void + { + /** @Given a cart at sequence 199 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-8'), count: 199); + + /** @When asking a count-100 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is false */ + self::assertFalse($result); + } + + public function testReturnsTrueForCountOneAtSequenceOne(): void + { + /** @Given a cart at sequence 1 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-9'), count: 1); + + /** @When asking a count-1 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is true */ + self::assertTrue($result); + } + + public function testReturnsTrueForCountOneAtSequenceTwo(): void + { + /** @Given a cart at sequence 2 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-10'), count: 2); + + /** @When asking a count-1 condition whether to snapshot */ + $result = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart); + + /** @Then the result is true */ + self::assertTrue($result); + } + + public function testThrowsWhenCountIsZero(): void + { + /** @Then an InvalidSnapshotCount exception with the correct message should be thrown */ + $this->expectException(InvalidSnapshotCount::class); + $this->expectExceptionMessage('Snapshot count must be at least 1, got <0>.'); + + /** @When creating a SnapshotEvery with count zero */ + SnapshotEvery::events(count: 0); + } + + public function testThrowsWhenCountIsNegative(): void + { + /** @Then an InvalidSnapshotCount exception with the correct message should be thrown */ + $this->expectException(InvalidSnapshotCount::class); + $this->expectExceptionMessage('Snapshot count must be at least 1, got <-5>.'); + + /** @When creating a SnapshotEvery with a negative count */ + SnapshotEvery::events(count: -5); + } +} diff --git a/tests/Snapshot/SnapshotNeverTest.php b/tests/Snapshot/SnapshotNeverTest.php new file mode 100644 index 0000000..b2e2f6d --- /dev/null +++ b/tests/Snapshot/SnapshotNeverTest.php @@ -0,0 +1,49 @@ +shouldSnapshot(aggregate: $cart); + + /** @Then the result is always false */ + self::assertFalse($result); + } + + public function testReturnsFalseForAggregateAtHighSequenceNumber(): void + { + /** @Given a cart at sequence 1000 */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-never-2'), count: 1000); + + /** @When asking the SnapshotNever condition whether to snapshot */ + $result = SnapshotNever::create()->shouldSnapshot(aggregate: $cart); + + /** @Then the result is always false */ + self::assertFalse($result); + } + + public function testTwoInstancesAreEqualUnderLooseComparison(): void + { + /** @Given two separate SnapshotNever instances */ + $first = SnapshotNever::create(); + + /** @And a second instance */ + $second = SnapshotNever::create(); + + /** @Then both instances are equal under loose comparison */ + self::assertEquals($first, $second); + } +} diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Snapshot/SnapshotTest.php index c8d7795..07d1535 100644 --- a/tests/Snapshot/SnapshotTest.php +++ b/tests/Snapshot/SnapshotTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; +use Test\TinyBlocks\BuildingBlocks\Models\CartWithLogger; use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; use TinyBlocks\Time\Instant; @@ -15,8 +16,10 @@ final class SnapshotTest extends TestCase { public function testFromAggregateCapturesAggregateType(): void { - /** @Given a cart with some state */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-1')); + + /** @And a product added */ $cart->addProduct(productId: 'prod-1'); /** @When taking a snapshot */ @@ -40,10 +43,8 @@ public function testFromAggregateCapturesAggregateId(): void public function testFromAggregateCapturesSequenceNumber(): void { - /** @Given a cart with two events applied */ - $cart = Cart::blank(identity: new CartId(value: 'cart-2')); - $cart->addProduct(productId: 'prod-1'); - $cart->addProduct(productId: 'prod-2'); + /** @Given a cart with two products added */ + $cart = Cart::withProducts(cartId: new CartId(value: 'cart-2'), count: 2); /** @When taking a snapshot */ $snapshot = Snapshot::fromAggregate(aggregate: $cart); @@ -66,8 +67,10 @@ public function testFromAggregateCapturesCreatedAt(): void public function testFromAggregateCarriesDomainFieldsInState(): void { - /** @Given a cart with a product added */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-4')); + + /** @And a product added */ $cart->addProduct(productId: 'prod-x'); /** @When taking a snapshot */ @@ -79,8 +82,10 @@ public function testFromAggregateCarriesDomainFieldsInState(): void public function testFromAggregateStateOmitsRecordedEventsBuffer(): void { - /** @Given a cart with a product added */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-5')); + + /** @And a product added */ $cart->addProduct(productId: 'prod-x'); /** @When taking a snapshot */ @@ -92,8 +97,10 @@ public function testFromAggregateStateOmitsRecordedEventsBuffer(): void public function testFromAggregateStateOmitsSequenceNumber(): void { - /** @Given a cart with a product added */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-6')); + + /** @And a product added */ $cart->addProduct(productId: 'prod-x'); /** @When taking a snapshot */ @@ -105,34 +112,80 @@ public function testFromAggregateStateOmitsSequenceNumber(): void public function testRoundTripThroughSnapshotRestoresDomainState(): void { - /** @Given a cart with a product added */ + /** @Given a cart identity */ $cartId = new CartId(value: 'cart-7'); + + /** @And a blank cart */ $original = Cart::blank(identity: $cartId); + + /** @And a product added */ $original->addProduct(productId: 'prod-roundtrip'); - /** @When taking a snapshot and reconstituting a fresh aggregate from it */ + /** @And a snapshot taken */ $snapshot = Snapshot::fromAggregate(aggregate: $original); + + /** @When reconstituting from the snapshot */ $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot); /** @Then the reconstituted aggregate carries the same domain state */ self::assertSame(['prod-roundtrip'], $reconstituted->getProductIds()); } + public function testGetSnapshotStateExcludesInfrastructureProperty(): void + { + /** @Given a blank cart with a logger */ + $cart = CartWithLogger::blank(identity: new CartId(value: 'cart-logger-1')); + + /** @When adding a product (which also writes to the log buffer) */ + $cart->addProduct(productId: 'prod-1'); + + /** @Then the snapshot state does not contain the log buffer */ + self::assertArrayNotHasKey('logBuffer', $cart->getSnapshotState()); + } + + public function testGetSnapshotStateIncludesDomainFields(): void + { + /** @Given a blank cart with a logger */ + $cart = CartWithLogger::blank(identity: new CartId(value: 'cart-logger-2')); + + /** @When adding a product */ + $cart->addProduct(productId: 'prod-snapshot'); + + /** @Then the snapshot state includes the domain fields */ + self::assertSame(['prod-snapshot'], $cart->getSnapshotState()['productIds']); + } + + public function testFromAggregateWithOverriddenSnapshotStateExcludesInfrastructureProperty(): void + { + /** @Given a blank cart with a logger */ + $cart = CartWithLogger::blank(identity: new CartId(value: 'cart-logger-3')); + + /** @When adding a product and taking a snapshot */ + $cart->addProduct(productId: 'prod-x'); + + /** @Then the snapshot does not carry the log buffer in the aggregate state */ + self::assertArrayNotHasKey('logBuffer', Snapshot::fromAggregate(aggregate: $cart)->getAggregateState()); + } + public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void { - /** @Given shared fields for two snapshots */ + /** @Given a sequence number at its first value */ $sequenceNumber = SequenceNumber::first(); + + /** @And a known creation timestamp */ $createdAt = Instant::now(); - /** @And two snapshots built from those identical fields */ - $first = new Snapshot( + /** @And the first snapshot built from those fields */ + $first = Snapshot::restore( type: 'Cart', createdAt: $createdAt, aggregateId: 'cart-1', aggregateState: ['productIds' => []], sequenceNumber: $sequenceNumber ); - $second = new Snapshot( + + /** @And the second snapshot built from the same fields */ + $second = Snapshot::restore( type: 'Cart', createdAt: $createdAt, aggregateId: 'cart-1', @@ -149,19 +202,23 @@ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void { - /** @Given two snapshots that differ only by type */ + /** @Given a sequence number at its first value */ $sequenceNumber = SequenceNumber::first(); + + /** @And a known creation timestamp */ $createdAt = Instant::now(); - /** @And the two snapshots constructed accordingly */ - $first = new Snapshot( + /** @And the first snapshot with type Cart */ + $first = Snapshot::restore( type: 'Cart', createdAt: $createdAt, aggregateId: 'cart-1', aggregateState: [], sequenceNumber: $sequenceNumber ); - $second = new Snapshot( + + /** @And the second snapshot with type Order */ + $second = Snapshot::restore( type: 'Order', createdAt: $createdAt, aggregateId: 'cart-1',