diff --git a/README.md b/README.md index 036b9d6..9ab48cf 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ + [Event sourcing](#event-sourcing) + [Snapshots](#snapshots) + [Upcasting](#upcasting) + - [Defining an upcaster](#defining-an-upcaster) + - [Upcasting an event](#upcasting-an-event) + - [Chaining upcasters](#chaining-upcasters) + - [Reconstituting from an iterable](#reconstituting-from-an-iterable) + - [Default values for new fields](#default-values-for-new-fields) * [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -19,10 +24,7 @@ The `Building Blocks` library provides the tactical design building blocks of Domain-Driven Design: `Entity`, `Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox -or an event-sourced store. - -It is persistence-agnostic and framework-agnostic. It depends only on the other `tiny-blocks` primitives -(`immutable-object`, `value-object`, `collection`, `time`) and `ramsey/uuid` for event identifiers. +or an event-sourced store. It is persistence-agnostic and framework-agnostic. Domain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not replace PSR-14; it defines what flows through it. @@ -44,7 +46,8 @@ The library exposes three styles of aggregate modeling through sibling interface ### Entity -Every entity declares an `IDENTITY` constant pointing to the property that holds its `Identity`. +Every entity implements a protected `identityName()` method returning the name of the property that holds its +`Identity`. #### Single-field identity @@ -93,7 +96,7 @@ Every entity declares an `IDENTITY` constant pointing to the property that holds #### Identity access * `getIdentity`, `getIdentityValue`, `sameIdentityOf`, `identityEquals`: provided by `EntityBehavior` for any entity - that declares the `IDENTITY` constant. + that implements `identityName()`. ```php use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; @@ -103,11 +106,14 @@ Every entity declares an `IDENTITY` constant pointing to the property that holds { use AggregateRootBehavior; - private const string IDENTITY = 'userId'; - private function __construct(private UserId $userId, private string $email) { } + + protected function identityName(): string + { + return 'userId'; + } } $user->sameIdentityOf(other: $otherUser); @@ -129,21 +135,31 @@ control and a `ModelVersion` for schema evolution of the aggregate type itself. { use AggregateRootBehavior; - private const string IDENTITY = 'userId'; + protected function identityName(): string + { + return 'userId'; + } } $user->getSequenceNumber(); ``` -* `getModelVersion`: resolved from the optional `MODEL_VERSION` class constant, defaults to zero when absent. +* `getModelVersion`: resolved from the protected `modelVersion()` method, defaults to zero when not overridden. ```php final class Cart implements AggregateRoot { use AggregateRootBehavior; - private const string IDENTITY = 'cartId'; - private const int MODEL_VERSION = 1; + protected function identityName(): string + { + return 'cartId'; + } + + protected function modelVersion(): int + { + return 1; + } } $cart->getModelVersion(); @@ -177,7 +193,7 @@ emitted as side effects and must be delivered at-least-once. #### Emitting events from the aggregate -* `pushEvent`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a +* `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. @@ -190,8 +206,6 @@ emitted as side effects and must be delivered at-least-once. { use EventualAggregateRootBehavior; - private const string IDENTITY = 'orderId'; - private function __construct(private OrderId $orderId) { } @@ -199,10 +213,15 @@ emitted as side effects and must be delivered at-least-once. public static function place(OrderId $orderId, string $item): Order { $order = new Order(orderId: $orderId); - $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1)); + $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial()); return $order; } + + protected function identityName(): string + { + return 'orderId'; + } } ``` @@ -240,14 +259,12 @@ emitted as side effects and must be delivered at-least-once. { use EventSourcingRootBehavior; - private const string IDENTITY = 'cartId'; - private CartId $cartId; private array $productIds = []; public function addProduct(string $productId): void { - $this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1)); + $this->when(event: new ProductAdded(productId: $productId), revision: Revision::initial()); } public function applySnapshot(Snapshot $snapshot): void @@ -255,6 +272,11 @@ emitted as side effects and must be delivered at-least-once. $this->productIds = $snapshot->getAggregateState()['productIds'] ?? []; } + protected function identityName(): string + { + return 'cartId'; + } + protected function whenProductAdded(ProductAdded $event): void { $this->productIds[] = $event->productId; @@ -383,13 +405,47 @@ Upcasters migrate serialized events across schema changes without touching the e $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); $upcasted = new ProductV1Upcaster()->upcast(event: $event); ``` +#### Chaining upcasters + +* `Upcasters`: ordered collection of `Upcaster` instances. `chain` folds them left-to-right over an + `IntermediateEvent`, applying each upcaster in sequence. Upcasters that do not match the current `(type, revision)` + pair pass the event through unchanged. + + ```php + use TinyBlocks\BuildingBlocks\Upcast\Upcasters; + + $upcasters = Upcasters::createFrom(elements: [ + new ProductV1Upcaster(), + new ProductV2Upcaster(), + ]); + + $upcasted = $upcasters->chain(event: $event); + ``` + +#### Reconstituting from an iterable + +* `IntermediateEvent` implements `ObjectMapper`, so it can be reconstituted from an iterable of typed field values. + Pass already-constructed `EventType` and `Revision` instances — the mapper maps each field by name. + + ```php + use TinyBlocks\BuildingBlocks\Event\EventType; + use TinyBlocks\BuildingBlocks\Event\Revision; + use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; + + $event = IntermediateEvent::fromIterable(iterable: [ + 'type' => EventType::fromString(value: 'ProductAdded'), + 'revision' => Revision::of(value: 2), + 'serializedEvent' => ['productId' => 'prod-1', 'quantity' => 1], + ]); + ``` + #### Default values for new fields * `DefaultValues`: type-to-default-value map for common primitive types, used when an upcast introduces a new field. @@ -412,7 +468,7 @@ the event itself. Keeping the event pure prevents infrastructure concerns from l Only the aggregate has the context needed to build the complete envelope: identity, sequence number, aggregate type name. Storing raw events and wrapping them later would either duplicate that context or require a second pass. -`pushEvent` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation. +`push` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation. ### 03. Why are `EventualAggregateRoot` and `EventSourcingRoot` siblings instead of a hierarchy? @@ -422,7 +478,7 @@ would imply the two patterns can coexist on the same aggregate, which they canno ### 04. Why does `Revision` live on the call site instead of on the event class? -Keeping `Revision` on the `pushEvent` or `when` call site makes the aggregate the author of schema evolution. The +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? @@ -440,11 +496,28 @@ consumers to decide which copy is authoritative. ### 07. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace? -Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, `MissingIdentityConstant` -and `MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or +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? + +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 +analysis. Replacing them with a protected `identityName(): string` method and a protected `modelVersion(): int` +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? + +These value objects have named static factories that carry semantic meaning: `Revision::initial()` communicates +"first schema revision", `SequenceNumber::first()` communicates "first recorded event", and +`EventType::fromEvent($event)` communicates "derive the type name from this event". Leaving the constructor public +allowed `new Revision(value: 1)` at call sites, which bypasses the semantic intent and mixes raw construction with +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. + ## License Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE). diff --git a/composer.json b/composer.json index a343f8e..076c563 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "ramsey/uuid": "^4.9", "tiny-blocks/collection": "^2.3", "tiny-blocks/immutable-object": "^1.1", + "tiny-blocks/mapper": "^2.0", "tiny-blocks/time": "^1.5", "tiny-blocks/value-object": "^3.2" }, diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 6115fe6..6e9089c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,4 +11,6 @@ parameters: - '#destructuring on mixed#' - identifier: trait.unused - '#expects int<1, max>#' + - message: '#Upcasters::chain\(\) should return#' + path: src/Upcast/Upcasters.php reportUnmatchedIgnoredErrors: false diff --git a/src/Aggregate/AggregateRoot.php b/src/Aggregate/AggregateRoot.php index ddbc59c..0118b16 100644 --- a/src/Aggregate/AggregateRoot.php +++ b/src/Aggregate/AggregateRoot.php @@ -44,11 +44,11 @@ public function getSequenceNumber(): SequenceNumber; /** * Returns the schema version of this aggregate type. * - *
Resolved from the optional MODEL_VERSION class constant, defaults to 0
- * when the constant is not declared. Used by consumers to migrate aggregate schemas when loading older
+ *
Resolved from the protected modelVersion() method, defaults to 0
+ * when the method is not overridden. Used by consumers to migrate aggregate schemas when loading older
* persisted state.
IDENTITY class constant is not defined.
+ * @throws MissingIdentityProperty When the property referenced by identityName() does not exist.
*/
public static function blank(Identity $identity): static;
@@ -56,7 +56,7 @@ public static function blank(Identity $identity): static;
* @param iterableIDENTITY class constant is not defined.
+ * @throws MissingIdentityProperty When the property referenced by identityName() does not exist.
*/
public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static;
diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php
index fa02b38..2503bbd 100644
--- a/src/Aggregate/EventSourcingRootBehavior.php
+++ b/src/Aggregate/EventSourcingRootBehavior.php
@@ -12,7 +12,6 @@
use TinyBlocks\BuildingBlocks\Event\EventRecords;
use TinyBlocks\BuildingBlocks\Event\Revision;
use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
trait EventSourcingRootBehavior
@@ -30,12 +29,9 @@ public function recordedEvents(): EventRecords
public static function blank(Identity $identity): static
{
- if (!defined('static::IDENTITY')) {
- throw new MissingIdentityConstant(className: static::class);
- }
-
$aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor();
- new ReflectionProperty($aggregate, static::IDENTITY)->setValue(objectOrValue: $aggregate, value: $identity);
+ new ReflectionProperty($aggregate, $aggregate->identityName())
+ ->setValue(objectOrValue: $aggregate, value: $identity);
$aggregate->sequenceNumber = SequenceNumber::initial();
$aggregate->recordedEvents = EventRecords::createFromEmpty();
diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php
index 8a30a79..4d57625 100644
--- a/src/Aggregate/EventualAggregateRootBehavior.php
+++ b/src/Aggregate/EventualAggregateRootBehavior.php
@@ -26,7 +26,7 @@ public function clearRecordedEvents(): void
$this->recordedEvents = EventRecords::createFromEmpty();
}
- protected function pushEvent(DomainEvent $event, Revision $revision): void
+ protected function push(DomainEvent $event, Revision $revision): void
{
$this->nextSequenceNumber();
$this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())
diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php
index ea4e9f5..5917022 100644
--- a/src/Entity/Entity.php
+++ b/src/Entity/Entity.php
@@ -4,7 +4,6 @@
namespace TinyBlocks\BuildingBlocks\Entity;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant;
use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
/**
@@ -14,8 +13,8 @@
* across distinct representations and lifecycle transitions. Two entities are equal when their
* identities are equal, regardless of attribute differences.
*
- * Concrete entities declare the IDENTITY class constant pointing to the property that
- * holds their {@see Identity}. The default behavior uses reflection to resolve and compare it.
Concrete entities implement the protected identityName() method returning the property
+ * that holds their {@see Identity}. The default behavior uses reflection to resolve and compare it.
IDENTITY class constant is not defined.
- * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist.
+ * @throws MissingIdentityProperty When the property referenced by identityName() does not exist.
*/
public function getIdentity(): Identity;
/**
* Returns the name of the property that holds this entity's Identity.
*
- * @return string The property name, resolved from the IDENTITY class constant.
- * @throws MissingIdentityConstant When the IDENTITY class constant is not defined.
- * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist.
+ * @return string The property name, resolved from identityName().
+ * @throws MissingIdentityProperty When the property referenced by identityName() does not exist.
*/
public function getIdentityName(): string;
diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php
index 5cccb42..7a84bfb 100644
--- a/src/Entity/EntityBehavior.php
+++ b/src/Entity/EntityBehavior.php
@@ -4,18 +4,15 @@
namespace TinyBlocks\BuildingBlocks\Entity;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant;
use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
trait EntityBehavior
{
+ abstract protected function identityName(): string;
+
public function getIdentityName(): string
{
- if (!defined('static::IDENTITY')) {
- throw new MissingIdentityConstant(className: static::class);
- }
-
- $name = static::IDENTITY;
+ $name = $this->identityName();
if (!property_exists($this, $name)) {
throw new MissingIdentityProperty(propertyName: $name, className: static::class);
diff --git a/src/Event/EventType.php b/src/Event/EventType.php
index a05c9a1..4937e19 100644
--- a/src/Event/EventType.php
+++ b/src/Event/EventType.php
@@ -15,7 +15,7 @@
private const string PATTERN = '/^[A-Z][A-Za-z0-9]+$/';
- public function __construct(public string $value)
+ private function __construct(public string $value)
{
if (!preg_match(pattern: self::PATTERN, subject: $value)) {
throw new InvalidEventType(value: $value, pattern: self::PATTERN);
diff --git a/src/Event/Revision.php b/src/Event/Revision.php
index 51f0fce..9a8738c 100644
--- a/src/Event/Revision.php
+++ b/src/Event/Revision.php
@@ -12,10 +12,20 @@
{
use ValueObjectBehavior;
- public function __construct(public int $value)
+ private function __construct(public int $value)
{
if ($value < 1) {
throw new InvalidRevision(value: $value);
}
}
+
+ public static function initial(): Revision
+ {
+ return new Revision(value: 1);
+ }
+
+ public static function of(int $value): Revision
+ {
+ return new Revision(value: $value);
+ }
}
diff --git a/src/Event/SequenceNumber.php b/src/Event/SequenceNumber.php
index c52f21a..217906b 100644
--- a/src/Event/SequenceNumber.php
+++ b/src/Event/SequenceNumber.php
@@ -12,7 +12,7 @@
{
use ValueObjectBehavior;
- public function __construct(public int $value)
+ private function __construct(public int $value)
{
if ($value < 0) {
throw new InvalidSequenceNumber(value: $value);
@@ -29,6 +29,11 @@ public static function first(): SequenceNumber
return new SequenceNumber(value: 1);
}
+ public static function of(int $value): SequenceNumber
+ {
+ return new SequenceNumber(value: $value);
+ }
+
public function next(): SequenceNumber
{
return new SequenceNumber(value: $this->value + 1);
diff --git a/src/Internal/Exceptions/MissingIdentityConstant.php b/src/Internal/Exceptions/MissingIdentityConstant.php
deleted file mode 100644
index 3327ea7..0000000
--- a/src/Internal/Exceptions/MissingIdentityConstant.php
+++ /dev/null
@@ -1,15 +0,0 @@
-.', $className));
- }
-}
diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php
index 12da42f..b6e882f 100644
--- a/src/Upcast/IntermediateEvent.php
+++ b/src/Upcast/IntermediateEvent.php
@@ -6,12 +6,15 @@
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
+use TinyBlocks\Mapper\ObjectMapper;
+use TinyBlocks\Mapper\ObjectMappability;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
-final readonly class IntermediateEvent implements ValueObject
+final readonly class IntermediateEvent implements ValueObject, ObjectMapper
{
use ValueObjectBehavior;
+ use ObjectMappability;
public function __construct(
public EventType $type,
diff --git a/src/Upcast/SingleUpcasterBehavior.php b/src/Upcast/SingleUpcasterBehavior.php
index f19511f..5d0712d 100644
--- a/src/Upcast/SingleUpcasterBehavior.php
+++ b/src/Upcast/SingleUpcasterBehavior.php
@@ -20,7 +20,7 @@ public function upcast(IntermediateEvent $event): IntermediateEvent
return $event
->withSerializedEvent(serializedEvent: $this->doUpcast(data: $event->serializedEvent))
- ->withRevision(revision: new Revision(value: static::TO_REVISION));
+ ->withRevision(revision: Revision::of(value: static::TO_REVISION));
}
abstract protected function doUpcast(array $data): array;
diff --git a/src/Upcast/Upcaster.php b/src/Upcast/Upcaster.php
index 70c3e5a..278871d 100644
--- a/src/Upcast/Upcaster.php
+++ b/src/Upcast/Upcaster.php
@@ -16,6 +16,8 @@
* (EXPECTED_EVENT_TYPE, FROM_REVISION, TO_REVISION) triple through class constants and
* delegates the payload transformation to an abstract doUpcast() hook.
*
+ * To apply a sequence of upcasters in order, use {@see Upcasters::chain}.
+ * * @see Greg Young, Versioning in an Event Sourced System (Leanpub, 2017), * "Basic Type Based Conversion" and "Upcasting". */ diff --git a/src/Upcast/Upcasters.php b/src/Upcast/Upcasters.php new file mode 100644 index 0000000..2dd8d93 --- /dev/null +++ b/src/Upcast/Upcasters.php @@ -0,0 +1,19 @@ +upcast(event: $carried); + }; + + return $this->reduce(accumulator: $upcast, initial: $event); + } +} diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Aggregate/EventSourcingRootBehaviorTest.php index b6c4251..4561e60 100644 --- a/tests/Aggregate/EventSourcingRootBehaviorTest.php +++ b/tests/Aggregate/EventSourcingRootBehaviorTest.php @@ -7,9 +7,7 @@ use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; -use Test\TinyBlocks\BuildingBlocks\Models\CartWithoutIdentityConstant; use Test\TinyBlocks\BuildingBlocks\Models\ProductAdded; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class EventSourcingRootBehaviorTest extends TestCase @@ -262,17 +260,6 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequen self::assertSame(2, $reconstituted->getSequenceNumber()->value); } - public function testBlankThrowsWhenIdentityConstantIsMissing(): void - { - /** @Given an event-sourced aggregate class omitting the IDENTITY constant */ - /** @Then a MissingIdentityConstant exception carrying the class name is thrown */ - $this->expectException(MissingIdentityConstant::class); - $this->expectExceptionMessage(CartWithoutIdentityConstant::class); - - /** @When creating a blank aggregate */ - CartWithoutIdentityConstant::blank(identity: new CartId(value: 'cart-missing')); - } - public function testReconstitutedAggregateHasNoRecordedEvents(): void { /** @Given a cart with one recorded event */ diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Entity/EntityBehaviorTest.php index 38da3fd..056e4d9 100644 --- a/tests/Entity/EntityBehaviorTest.php +++ b/tests/Entity/EntityBehaviorTest.php @@ -9,8 +9,6 @@ use Test\TinyBlocks\BuildingBlocks\Models\Order; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; use Test\TinyBlocks\BuildingBlocks\Models\OrderWithMissingIdentityProperty; -use Test\TinyBlocks\BuildingBlocks\Models\OrderWithoutIdentityConstant; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; final class EntityBehaviorTest extends TestCase @@ -38,7 +36,7 @@ public function testGetIdentityNameReturnsPropertyName(): void /** @When retrieving the identity property name */ $name = $order->getIdentityName(); - /** @Then it matches the IDENTITY constant value */ + /** @Then it matches the value returned by identityName() */ self::assertSame('orderId', $name); } @@ -126,22 +124,9 @@ public function testIdentityEqualsReturnsFalseForDifferentIdentity(): void self::assertFalse($result); } - public function testShipThrowsWhenIdentityConstantIsMissing(): void - { - /** @Given an aggregate whose class omits the IDENTITY constant */ - $order = new OrderWithoutIdentityConstant(); - - /** @Then a MissingIdentityConstant exception carrying the class name is thrown */ - $this->expectException(MissingIdentityConstant::class); - $this->expectExceptionMessage(OrderWithoutIdentityConstant::class); - - /** @When shipping the order and indirectly reaching identity resolution */ - $order->ship(); - } - public function testShipThrowsWhenIdentityPropertyIsMissing(): void { - /** @Given an aggregate whose IDENTITY points to a non-existent property */ + /** @Given an aggregate whose identityName() points to a non-existent property */ $order = new OrderWithMissingIdentityProperty(); /** @Then a MissingIdentityProperty exception carrying the property name is thrown */ diff --git a/tests/Event/EventRecordTest.php b/tests/Event/EventRecordTest.php index d61f8c5..8663cd2 100644 --- a/tests/Event/EventRecordTest.php +++ b/tests/Event/EventRecordTest.php @@ -24,10 +24,10 @@ public function testEventRecordExposesEveryConstructorField(): void $orderId = new OrderId(value: 'ord-1'); $placedEvent = new OrderPlaced(item: 'book'); $eventType = EventType::fromString(value: 'OrderPlaced'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $occurredOn = Instant::now(); $snapshotData = new SnapshotData(data: ['status' => 'placed']); - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); /** @When constructing the EventRecord */ $record = new EventRecord( @@ -61,10 +61,10 @@ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void $orderId = new OrderId(value: 'ord-1'); $placedEvent = new OrderPlaced(item: 'book'); $eventType = EventType::fromString(value: 'OrderPlaced'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $occurredOn = Instant::now(); $snapshotData = new SnapshotData(data: []); - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); /** @And two records constructed from those identical values */ $first = new EventRecord( @@ -103,10 +103,10 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void $orderId = new OrderId(value: 'ord-1'); $placedEvent = new OrderPlaced(item: 'book'); $eventType = EventType::fromString(value: 'OrderPlaced'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $occurredOn = Instant::now(); $snapshotData = new SnapshotData(data: []); - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); /** @And two records with different UUIDs */ $first = new EventRecord( diff --git a/tests/Event/EventRecordsTest.php b/tests/Event/EventRecordsTest.php index 2f0c6d4..7133d65 100644 --- a/tests/Event/EventRecordsTest.php +++ b/tests/Event/EventRecordsTest.php @@ -41,11 +41,11 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void type: EventType::fromString(value: 'OrderPlaced'), event: new OrderPlaced(item: 'book'), identity: new OrderId(value: 'ord-1'), - revision: new Revision(value: 1), + revision: Revision::initial(), occurredOn: Instant::now(), snapshotData: new SnapshotData(data: []), aggregateType: 'Order', - sequenceNumber: new SequenceNumber(value: 1) + sequenceNumber: SequenceNumber::first() ); /** @When adding the record */ @@ -63,11 +63,11 @@ public function testFirstElementRoundTripsTheAddedRecord(): void type: EventType::fromString(value: 'OrderPlaced'), event: new OrderPlaced(item: 'book'), identity: new OrderId(value: 'ord-1'), - revision: new Revision(value: 1), + revision: Revision::initial(), occurredOn: Instant::now(), snapshotData: new SnapshotData(data: []), aggregateType: 'Order', - sequenceNumber: new SequenceNumber(value: 1) + sequenceNumber: SequenceNumber::first() ); $records = EventRecords::createFromEmpty()->add($record); diff --git a/tests/Event/EventTypeTest.php b/tests/Event/EventTypeTest.php index 7f7b63c..f84daaf 100644 --- a/tests/Event/EventTypeTest.php +++ b/tests/Event/EventTypeTest.php @@ -7,12 +7,23 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced; use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidEventType; final class EventTypeTest extends TestCase { + public function testConstructorIsPrivate(): void + { + /** @Given the EventType class constructor */ + $constructor = new ReflectionMethod(EventType::class, '__construct'); + + /** @When inspecting its visibility */ + /** @Then the constructor is private */ + self::assertTrue($constructor->isPrivate()); + } + public function testFromEventUsesTheShortClassNameOfTheDomainEvent(): void { /** @Given a domain event instance */ @@ -66,7 +77,7 @@ public function testEqualsReturnsFalseForDifferentValues(): void } #[DataProvider('invalidPatterns')] - public function testConstructorRejectsValuesNotMatchingPattern(string $invalidValue): void + public function testFromStringRejectsValuesNotMatchingPattern(string $invalidValue): void { /** @Given a value that violates the event-type pattern */ /** @Then an InvalidEventType exception mentioning the pattern is thrown */ @@ -74,7 +85,7 @@ public function testConstructorRejectsValuesNotMatchingPattern(string $invalidVa $this->expectExceptionMessage('does not match the required pattern'); /** @When constructing with the invalid value */ - new EventType(value: $invalidValue); + EventType::fromString(value: $invalidValue); } public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): void @@ -84,7 +95,7 @@ public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): voi $this->expectException(InvalidArgumentException::class); /** @When constructing with an invalid value */ - new EventType(value: 'invalid'); + EventType::fromString(value: 'invalid'); } public function testInvalidEventTypeCarriesTheOffendingValue(): void @@ -95,7 +106,7 @@ public function testInvalidEventTypeCarriesTheOffendingValue(): void $this->expectExceptionMessage('lowercaseStart'); /** @When constructing with an invalid value */ - new EventType(value: 'lowercaseStart'); + EventType::fromString(value: 'lowercaseStart'); } /** diff --git a/tests/Event/RevisionTest.php b/tests/Event/RevisionTest.php index b20f308..7ccde97 100644 --- a/tests/Event/RevisionTest.php +++ b/tests/Event/RevisionTest.php @@ -7,38 +7,59 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidRevision; final class RevisionTest extends TestCase { - public function testRevisionStoresTheMinimumValidValue(): void + public function testConstructorIsPrivate(): void { - /** @Given the minimum valid revision value */ - /** @When constructing the revision */ - $revision = new Revision(value: 1); + /** @Given the Revision class constructor */ + $constructor = new ReflectionMethod(Revision::class, '__construct'); - /** @Then the value is stored verbatim */ + /** @When inspecting its visibility */ + /** @Then the constructor is private */ + self::assertTrue($constructor->isPrivate()); + } + + public function testInitialReturnsRevisionOfOne(): void + { + /** @Given the initial revision factory */ + /** @When requesting the initial revision */ + $revision = Revision::initial(); + + /** @Then the value is one */ self::assertSame(1, $revision->value); } - public function testRevisionStoresAHigherValidValue(): void + public function testOfReturnsRevisionWithGivenValue(): void { - /** @Given a larger valid revision value */ - /** @When constructing the revision */ - $revision = new Revision(value: 42); + /** @Given a valid revision value */ + /** @When requesting a revision of that value */ + $revision = Revision::of(value: 42); - /** @Then the value is stored verbatim */ + /** @Then the value matches */ self::assertSame(42, $revision->value); } + public function testOfStoresTheMinimumValidValue(): void + { + /** @Given the minimum valid revision value */ + /** @When constructing the revision via factory */ + $revision = Revision::of(value: 1); + + /** @Then the value is stored verbatim */ + self::assertSame(1, $revision->value); + } + public function testEqualsReturnsTrueForSameRevision(): void { /** @Given two revisions with the same value */ - $first = new Revision(value: 2); + $first = Revision::of(value: 2); /** @And a matching counterpart */ - $second = new Revision(value: 2); + $second = Revision::of(value: 2); /** @When comparing them */ $result = $first->equals(other: $second); @@ -50,10 +71,10 @@ public function testEqualsReturnsTrueForSameRevision(): void public function testEqualsReturnsFalseForDifferentRevisions(): void { /** @Given two revisions with different values */ - $first = new Revision(value: 1); + $first = Revision::of(value: 1); /** @And a distinct counterpart */ - $second = new Revision(value: 2); + $second = Revision::of(value: 2); /** @When comparing them */ $result = $first->equals(other: $second); @@ -63,7 +84,7 @@ public function testEqualsReturnsFalseForDifferentRevisions(): void } #[DataProvider('invalidValues')] - public function testConstructorRejectsNonPositiveValue(int $invalidValue): void + public function testOfRejectsNonPositiveValue(int $invalidValue): void { /** @Given a value that violates the revision invariant */ /** @Then an InvalidRevision exception carrying the invalid value is thrown */ @@ -71,7 +92,7 @@ public function testConstructorRejectsNonPositiveValue(int $invalidValue): void $this->expectExceptionMessage((string) $invalidValue); /** @When constructing with a non-positive value */ - new Revision(value: $invalidValue); + Revision::of(value: $invalidValue); } public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void @@ -81,7 +102,7 @@ public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void $this->expectException(InvalidArgumentException::class); /** @When constructing with an invalid value */ - new Revision(value: 0); + Revision::of(value: 0); } public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void @@ -92,7 +113,7 @@ public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void $this->expectExceptionMessage('greater than or equal to 1'); /** @When constructing with an invalid value */ - new Revision(value: 0); + Revision::of(value: 0); } /** diff --git a/tests/Event/SequenceNumberTest.php b/tests/Event/SequenceNumberTest.php index 9d9782f..1c326cf 100644 --- a/tests/Event/SequenceNumberTest.php +++ b/tests/Event/SequenceNumberTest.php @@ -7,11 +7,22 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidSequenceNumber; final class SequenceNumberTest extends TestCase { + public function testConstructorIsPrivate(): void + { + /** @Given the SequenceNumber class constructor */ + $constructor = new ReflectionMethod(SequenceNumber::class, '__construct'); + + /** @When inspecting its visibility */ + /** @Then the constructor is private */ + self::assertTrue($constructor->isPrivate()); + } + public function testInitialYieldsZero(): void { /** @Given the initial-sequence factory */ @@ -32,10 +43,20 @@ public function testFirstYieldsOne(): void self::assertSame(1, $sequenceNumber->value); } + public function testOfReturnsSequenceNumberWithGivenValue(): void + { + /** @Given a valid sequence number value */ + /** @When requesting a sequence number of that value */ + $sequenceNumber = SequenceNumber::of(value: 5); + + /** @Then the value matches */ + self::assertSame(5, $sequenceNumber->value); + } + public function testNextYieldsTheFollowingValue(): void { /** @Given a sequence number of 5 */ - $sequenceNumber = new SequenceNumber(value: 5); + $sequenceNumber = SequenceNumber::of(value: 5); /** @When advancing to the next */ $next = $sequenceNumber->next(); @@ -47,7 +68,7 @@ public function testNextYieldsTheFollowingValue(): void public function testNextDoesNotMutateTheSource(): void { /** @Given a sequence number of 5 */ - $sequenceNumber = new SequenceNumber(value: 5); + $sequenceNumber = SequenceNumber::of(value: 5); /** @When advancing */ $sequenceNumber->next(); @@ -59,10 +80,10 @@ public function testNextDoesNotMutateTheSource(): void public function testIsAfterReturnsTrueWhenStrictlyGreater(): void { /** @Given a larger sequence number */ - $larger = new SequenceNumber(value: 10); + $larger = SequenceNumber::of(value: 10); /** @And a smaller counterpart */ - $smaller = new SequenceNumber(value: 5); + $smaller = SequenceNumber::of(value: 5); /** @When checking if the larger is after the smaller */ $result = $larger->isAfter(other: $smaller); @@ -74,10 +95,10 @@ public function testIsAfterReturnsTrueWhenStrictlyGreater(): void public function testIsAfterReturnsFalseWhenEqual(): void { /** @Given two equal sequence numbers */ - $first = new SequenceNumber(value: 3); + $first = SequenceNumber::of(value: 3); /** @And a counterpart with the same value */ - $second = new SequenceNumber(value: 3); + $second = SequenceNumber::of(value: 3); /** @When checking if one is strictly after the other */ $result = $first->isAfter(other: $second); @@ -89,10 +110,10 @@ public function testIsAfterReturnsFalseWhenEqual(): void public function testIsAfterReturnsFalseWhenStrictlySmaller(): void { /** @Given a smaller sequence number */ - $smaller = new SequenceNumber(value: 2); + $smaller = SequenceNumber::of(value: 2); /** @And a larger counterpart */ - $larger = new SequenceNumber(value: 8); + $larger = SequenceNumber::of(value: 8); /** @When checking if the smaller is after the larger */ $result = $smaller->isAfter(other: $larger); @@ -104,10 +125,10 @@ public function testIsAfterReturnsFalseWhenStrictlySmaller(): void public function testEqualsReturnsTrueForSameValue(): void { /** @Given two sequence numbers with the same value */ - $first = new SequenceNumber(value: 7); + $first = SequenceNumber::of(value: 7); /** @And a matching counterpart */ - $second = new SequenceNumber(value: 7); + $second = SequenceNumber::of(value: 7); /** @When comparing them */ $result = $first->equals(other: $second); @@ -119,10 +140,10 @@ public function testEqualsReturnsTrueForSameValue(): void public function testEqualsReturnsFalseForDifferentValues(): void { /** @Given two sequence numbers with different values */ - $first = new SequenceNumber(value: 1); + $first = SequenceNumber::of(value: 1); /** @And a distinct counterpart */ - $second = new SequenceNumber(value: 2); + $second = SequenceNumber::of(value: 2); /** @When comparing them */ $result = $first->equals(other: $second); @@ -132,7 +153,7 @@ public function testEqualsReturnsFalseForDifferentValues(): void } #[DataProvider('negativeValues')] - public function testConstructorRejectsNegativeValue(int $negativeValue): void + public function testOfRejectsNegativeValue(int $negativeValue): void { /** @Given a value that violates the sequence-number invariant */ /** @Then an InvalidSequenceNumber exception carrying the invalid value is thrown */ @@ -140,7 +161,7 @@ public function testConstructorRejectsNegativeValue(int $negativeValue): void $this->expectExceptionMessage((string) $negativeValue); /** @When constructing with a negative value */ - new SequenceNumber(value: $negativeValue); + SequenceNumber::of(value: $negativeValue); } public function testInvalidSequenceNumberIsCatchableAsInvalidArgumentException(): void @@ -150,7 +171,7 @@ public function testInvalidSequenceNumberIsCatchableAsInvalidArgumentException() $this->expectException(InvalidArgumentException::class); /** @When constructing with a negative value */ - new SequenceNumber(value: -1); + SequenceNumber::of(value: -1); } public function testInvalidSequenceNumberMessageMentionsTheMinimumAllowed(): void @@ -161,7 +182,7 @@ public function testInvalidSequenceNumberMessageMentionsTheMinimumAllowed(): voi $this->expectExceptionMessage('greater than or equal to 0'); /** @When constructing with a negative value */ - new SequenceNumber(value: -1); + SequenceNumber::of(value: -1); } /** diff --git a/tests/Models/Cart.php b/tests/Models/Cart.php index 821def9..1017d48 100644 --- a/tests/Models/Cart.php +++ b/tests/Models/Cart.php @@ -13,9 +13,6 @@ final class Cart implements EventSourcingRoot { use EventSourcingRootBehavior; - private const string IDENTITY = 'cartId'; - private const int MODEL_VERSION = 1; - private CartId $cartId; /** @var list