From e210a6f8a8fbb0a61b8082f8a9e7bf27c806bfd5 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 18 Apr 2026 19:20:18 -0300
Subject: [PATCH] refactor: Replace constants with methods for identity and
model version in aggregates.
---
README.md | 121 ++++++++++++++----
composer.json | 1 +
phpstan.neon.dist | 2 +
src/Aggregate/AggregateRoot.php | 6 +-
src/Aggregate/AggregateRootBehavior.php | 27 ++--
src/Aggregate/EventSourcingRoot.php | 6 +-
src/Aggregate/EventSourcingRootBehavior.php | 8 +-
.../EventualAggregateRootBehavior.php | 2 +-
src/Entity/Entity.php | 13 +-
src/Entity/EntityBehavior.php | 9 +-
src/Event/EventType.php | 2 +-
src/Event/Revision.php | 12 +-
src/Event/SequenceNumber.php | 7 +-
.../Exceptions/MissingIdentityConstant.php | 15 ---
src/Upcast/IntermediateEvent.php | 5 +-
src/Upcast/SingleUpcasterBehavior.php | 2 +-
src/Upcast/Upcaster.php | 2 +
src/Upcast/Upcasters.php | 19 +++
.../EventSourcingRootBehaviorTest.php | 13 --
tests/Entity/EntityBehaviorTest.php | 19 +--
tests/Event/EventRecordTest.php | 12 +-
tests/Event/EventRecordsTest.php | 8 +-
tests/Event/EventTypeTest.php | 19 ++-
tests/Event/RevisionTest.php | 57 ++++++---
tests/Event/SequenceNumberTest.php | 53 +++++---
tests/Models/Cart.php | 15 ++-
tests/Models/CartWithoutIdentityConstant.php | 18 ---
tests/Models/Order.php | 11 +-
.../OrderWithMissingIdentityProperty.php | 9 +-
tests/Models/OrderWithoutIdentityConstant.php | 19 ---
tests/Models/ProductV2Upcaster.php | 22 ++++
tests/Snapshot/SnapshotTest.php | 4 +-
tests/Upcast/IntermediateEventTest.php | 80 ++++++++++--
tests/Upcast/SingleUpcasterBehaviorTest.php | 8 +-
tests/Upcast/UpcastersTest.php | 111 ++++++++++++++++
35 files changed, 510 insertions(+), 227 deletions(-)
delete mode 100644 src/Internal/Exceptions/MissingIdentityConstant.php
create mode 100644 src/Upcast/Upcasters.php
delete mode 100644 tests/Models/CartWithoutIdentityConstant.php
delete mode 100644 tests/Models/OrderWithoutIdentityConstant.php
create mode 100644 tests/Models/ProductV2Upcaster.php
create mode 100644 tests/Upcast/UpcastersTest.php
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.
*
- * @return SequenceNumber The declared model version, or 0 when undefined.
+ * @return SequenceNumber The declared model version, or 0 when not overridden.
*/
public function getModelVersion(): SequenceNumber;
diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php
index 6cb166b..1c36c77 100644
--- a/src/Aggregate/AggregateRootBehavior.php
+++ b/src/Aggregate/AggregateRootBehavior.php
@@ -28,11 +28,7 @@ public function getSequenceNumber(): SequenceNumber
public function getModelVersion(): SequenceNumber
{
- if (!defined('static::MODEL_VERSION')) {
- return new SequenceNumber(value: 0);
- }
-
- return new SequenceNumber(value: static::MODEL_VERSION);
+ return SequenceNumber::of(value: $this->modelVersion());
}
public function buildAggregateName(): string
@@ -40,11 +36,24 @@ public function buildAggregateName(): string
return new ReflectionClass(objectOrClass: static::class)->getShortName();
}
+ protected function modelVersion(): int
+ {
+ return 0;
+ }
+
protected function nextSequenceNumber(): void
{
$this->sequenceNumber = $this->getSequenceNumber()->next();
}
+ protected function generateSnapshotData(): SnapshotData
+ {
+ $state = get_object_vars($this);
+ unset($state['recordedEvents']);
+
+ return new SnapshotData(data: $state);
+ }
+
protected function buildEventRecord(DomainEvent $event, Revision $revision): EventRecord
{
return new EventRecord(
@@ -59,12 +68,4 @@ protected function buildEventRecord(DomainEvent $event, Revision $revision): Eve
sequenceNumber: $this->getSequenceNumber()
);
}
-
- protected function generateSnapshotData(): SnapshotData
- {
- $state = get_object_vars($this);
- unset($state['recordedEvents']);
-
- return new SnapshotData(data: $state);
- }
}
diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php
index 7e19e9c..801b703 100644
--- a/src/Aggregate/EventSourcingRoot.php
+++ b/src/Aggregate/EventSourcingRoot.php
@@ -7,7 +7,7 @@
use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant;
+use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
/**
@@ -41,7 +41,7 @@ public function recordedEvents(): EventRecords;
*
* @param Identity $identity The identity to assign to the new aggregate.
* @return static A new aggregate in its initial state.
- * @throws MissingIdentityConstant When the 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 iterable $records The event stream to replay, ordered by sequence number.
* @param Snapshot|null $snapshot Optional snapshot to restore from before replay.
* @return static The reconstituted aggregate.
- * @throws MissingIdentityConstant When the IDENTITY 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.
*
* @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
* (Addison-Wesley, 2003), Chapter 5 "Entities (a.k.a. Reference Objects)".
@@ -26,17 +25,15 @@ interface Entity
* Returns the Identity that uniquely identifies this entity.
*
* @return Identity The identity instance held by this entity.
- * @throws MissingIdentityConstant When the 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 */
@@ -23,7 +20,7 @@ final class Cart implements EventSourcingRoot
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
@@ -40,6 +37,16 @@ public function getProductIds(): array
return $this->productIds;
}
+ protected function identityName(): string
+ {
+ return 'cartId';
+ }
+
+ protected function modelVersion(): int
+ {
+ return 1;
+ }
+
protected function whenProductAdded(ProductAdded $event): void
{
$this->productIds[] = $event->productId;
diff --git a/tests/Models/CartWithoutIdentityConstant.php b/tests/Models/CartWithoutIdentityConstant.php
deleted file mode 100644
index 2d574cb..0000000
--- a/tests/Models/CartWithoutIdentityConstant.php
+++ /dev/null
@@ -1,18 +0,0 @@
-status = 'placed';
- $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1));
+ $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial());
return $order;
}
@@ -32,11 +30,16 @@ public static function place(OrderId $orderId, string $item): Order
public function ship(string $carrier): void
{
$this->status = 'shipped';
- $this->pushEvent(event: new OrderShipped(carrier: $carrier), revision: new Revision(value: 1));
+ $this->push(event: new OrderShipped(carrier: $carrier), revision: Revision::initial());
}
public function getStatus(): string
{
return $this->status;
}
+
+ protected function identityName(): string
+ {
+ return 'orderId';
+ }
}
diff --git a/tests/Models/OrderWithMissingIdentityProperty.php b/tests/Models/OrderWithMissingIdentityProperty.php
index 8a03d0e..072aa86 100644
--- a/tests/Models/OrderWithMissingIdentityProperty.php
+++ b/tests/Models/OrderWithMissingIdentityProperty.php
@@ -12,10 +12,13 @@ final class OrderWithMissingIdentityProperty implements EventualAggregateRoot
{
use EventualAggregateRootBehavior;
- private const string IDENTITY = 'nonExistentProperty';
-
public function ship(): void
{
- $this->pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1));
+ $this->push(event: new OrderShipped(carrier: 'DHL'), revision: Revision::initial());
+ }
+
+ protected function identityName(): string
+ {
+ return 'nonExistentProperty';
}
}
diff --git a/tests/Models/OrderWithoutIdentityConstant.php b/tests/Models/OrderWithoutIdentityConstant.php
deleted file mode 100644
index cd73ce8..0000000
--- a/tests/Models/OrderWithoutIdentityConstant.php
+++ /dev/null
@@ -1,19 +0,0 @@
-pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1));
- }
-}
diff --git a/tests/Models/ProductV2Upcaster.php b/tests/Models/ProductV2Upcaster.php
new file mode 100644
index 0000000..f34e7b1
--- /dev/null
+++ b/tests/Models/ProductV2Upcaster.php
@@ -0,0 +1,22 @@
+ ''];
+ }
+}
diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Snapshot/SnapshotTest.php
index 8ad8fbf..4177b9f 100644
--- a/tests/Snapshot/SnapshotTest.php
+++ b/tests/Snapshot/SnapshotTest.php
@@ -121,7 +121,7 @@ public function testRoundTripThroughSnapshotRestoresDomainState(): void
public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void
{
/** @Given shared fields for two snapshots */
- $sequenceNumber = new SequenceNumber(value: 1);
+ $sequenceNumber = SequenceNumber::first();
$createdAt = Instant::now();
/** @And two snapshots built from those identical fields */
@@ -150,7 +150,7 @@ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void
public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void
{
/** @Given two snapshots that differ only by type */
- $sequenceNumber = new SequenceNumber(value: 1);
+ $sequenceNumber = SequenceNumber::first();
$createdAt = Instant::now();
/** @And the two snapshots constructed accordingly */
diff --git a/tests/Upcast/IntermediateEventTest.php b/tests/Upcast/IntermediateEventTest.php
index 9790c75..9be2660 100644
--- a/tests/Upcast/IntermediateEventTest.php
+++ b/tests/Upcast/IntermediateEventTest.php
@@ -8,6 +8,7 @@
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent;
+use TinyBlocks\Mapper\KeyPreservation;
final class IntermediateEventTest extends TestCase
{
@@ -15,7 +16,7 @@ public function testIntermediateEventExposesEveryConstructorField(): void
{
/** @Given every required field for an IntermediateEvent */
$eventType = EventType::fromString(value: 'ProductAdded');
- $revision = new Revision(value: 1);
+ $revision = Revision::initial();
$serializedEvent = ['productId' => 'prod-1'];
/** @When constructing the intermediate event */
@@ -32,12 +33,12 @@ public function testWithRevisionOnlyReplacesTheRevision(): void
/** @Given an intermediate event at revision 1 */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
/** @When bumping to revision 2 */
- $bumped = $event->withRevision(revision: new Revision(value: 2));
+ $bumped = $event->withRevision(revision: Revision::of(value: 2));
/** @Then the revision changes */
self::assertSame(2, $bumped->revision->value);
@@ -48,12 +49,12 @@ public function testWithRevisionPreservesTheTypeAndPayload(): void
/** @Given an intermediate event at revision 1 */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
/** @When bumping to revision 2 */
- $bumped = $event->withRevision(revision: new Revision(value: 2));
+ $bumped = $event->withRevision(revision: Revision::of(value: 2));
/** @Then neither the type nor the payload are affected */
self::assertSame('ProductAdded', $bumped->type->value);
@@ -65,12 +66,12 @@ public function testWithRevisionReturnsANewInstance(): void
/** @Given an intermediate event at revision 1 */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
/** @When bumping to revision 2 */
- $bumped = $event->withRevision(revision: new Revision(value: 2));
+ $bumped = $event->withRevision(revision: Revision::of(value: 2));
/** @Then the source event remains untouched */
self::assertNotSame($event, $bumped);
@@ -82,7 +83,7 @@ public function testWithSerializedEventOnlyReplacesThePayload(): void
/** @Given an intermediate event with an original payload */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
@@ -98,7 +99,7 @@ public function testWithSerializedEventPreservesTheTypeAndRevision(): void
/** @Given an intermediate event with an original payload */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
@@ -114,7 +115,7 @@ public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void
{
/** @Given shared fields for two intermediate events */
$eventType = EventType::fromString(value: 'ProductAdded');
- $revision = new Revision(value: 1);
+ $revision = Revision::initial();
$payload = ['productId' => 'prod-1'];
/** @And two intermediate events with identical values */
@@ -132,7 +133,7 @@ public function testEqualsReturnsFalseForDifferentPayloads(): void
{
/** @Given two intermediate events with different payloads */
$eventType = EventType::fromString(value: 'ProductAdded');
- $revision = new Revision(value: 1);
+ $revision = Revision::initial();
/** @And the two events constructed accordingly */
$first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']);
@@ -144,4 +145,61 @@ public function testEqualsReturnsFalseForDifferentPayloads(): void
/** @Then they are not equal */
self::assertFalse($result);
}
+
+ public function testFromIterableWithTypedFieldsCreatesEqualEvent(): void
+ {
+ /** @Given an existing intermediate event */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: Revision::initial(),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When reconstituting from an iterable of typed values */
+ $restored = IntermediateEvent::fromIterable(iterable: [
+ 'type' => EventType::fromString(value: 'ProductAdded'),
+ 'revision' => Revision::initial(),
+ 'serializedEvent' => ['productId' => 'prod-1']
+ ]);
+
+ /** @Then the restored event equals the original */
+ self::assertTrue($restored->equals(other: $event));
+ }
+
+ public function testToArraySerializesTypeAndRevisionToScalars(): void
+ {
+ /** @Given an intermediate event */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: Revision::initial(),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When converting to array */
+ $array = $event->toArray(KeyPreservation::PRESERVE);
+
+ /** @Then type and revision are unwrapped to their scalar values */
+ self::assertSame('ProductAdded', $array['type']);
+ self::assertSame(1, $array['revision']);
+ self::assertSame(['productId' => 'prod-1'], $array['serializedEvent']);
+ }
+
+ public function testToJsonSerializesToJsonString(): void
+ {
+ /** @Given an intermediate event */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: Revision::initial(),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When converting to JSON */
+ $json = $event->toJson(KeyPreservation::PRESERVE);
+
+ /** @Then the result is a valid JSON string with the expected structure */
+ self::assertSame(
+ '{"type":"ProductAdded","revision":1,"serializedEvent":{"productId":"prod-1"}}',
+ $json
+ );
+ }
}
diff --git a/tests/Upcast/SingleUpcasterBehaviorTest.php b/tests/Upcast/SingleUpcasterBehaviorTest.php
index df0a744..cb1ccc9 100644
--- a/tests/Upcast/SingleUpcasterBehaviorTest.php
+++ b/tests/Upcast/SingleUpcasterBehaviorTest.php
@@ -17,7 +17,7 @@ public function testUpcastBumpsTheRevisionOfAMatchingEvent(): void
/** @Given a ProductAdded event at revision 1 */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
@@ -33,7 +33,7 @@ public function testUpcastEnrichesThePayloadOfAMatchingEvent(): void
/** @Given a ProductAdded event at revision 1 */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
@@ -49,7 +49,7 @@ public function testUpcastReturnsUnchangedEventForMismatchedType(): void
/** @Given an event whose type is not the one the upcaster handles */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'OrderPlaced'),
- revision: new Revision(value: 1),
+ revision: Revision::initial(),
serializedEvent: ['item' => 'book']
);
@@ -65,7 +65,7 @@ public function testUpcastReturnsUnchangedEventForMismatchedRevision(): void
/** @Given a ProductAdded event at revision 2, past the upcaster's FROM_REVISION */
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
- revision: new Revision(value: 2),
+ revision: Revision::of(value: 2),
serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]
);
diff --git a/tests/Upcast/UpcastersTest.php b/tests/Upcast/UpcastersTest.php
new file mode 100644
index 0000000..1ec84c4
--- /dev/null
+++ b/tests/Upcast/UpcastersTest.php
@@ -0,0 +1,111 @@
+ 'prod-1']
+ );
+
+ /** @When chaining through an empty Upcasters collection */
+ $result = Upcasters::createFromEmpty()->chain(event: $event);
+
+ /** @Then the event is returned unchanged */
+ self::assertTrue($result->equals(other: $event));
+ }
+
+ public function testSingleMatchingUpcasterTransformsEvent(): void
+ {
+ /** @Given an event at revision 1 eligible for V1 migration */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: Revision::initial(),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @And a chain containing only the V1 upcaster */
+ $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster()]);
+
+ /** @When chaining the event */
+ $result = $upcasters->chain(event: $event);
+
+ /** @Then the revision advances to 2 and the payload gains the quantity field */
+ self::assertSame(2, $result->revision->value);
+ self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $result->serializedEvent);
+ }
+
+ public function testSingleNonMatchingUpcasterReturnsEventUnchanged(): void
+ {
+ /** @Given an event at revision 2 — past the V1 migration window */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: Revision::of(value: 2),
+ serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]
+ );
+
+ /** @And a chain containing only the V1 upcaster */
+ $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster()]);
+
+ /** @When chaining the event */
+ $result = $upcasters->chain(event: $event);
+
+ /** @Then the event is returned unchanged */
+ self::assertTrue($result->equals(other: $event));
+ }
+
+ public function testChainedUpcastersApplySequentially(): void
+ {
+ /** @Given an event at revision 1 eligible for both V1 and V2 migrations */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: Revision::initial(),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @And a chain with both upcasters in order */
+ $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster(), new ProductV2Upcaster()]);
+
+ /** @When chaining the event */
+ $result = $upcasters->chain(event: $event);
+
+ /** @Then the revision reaches 3 and both fields are added */
+ self::assertSame(3, $result->revision->value);
+ self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $result->serializedEvent);
+ }
+
+ public function testOnlyMatchingUpcastersInChainApply(): void
+ {
+ /** @Given an event at revision 2 — only eligible for V2 migration */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: Revision::of(value: 2),
+ serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]
+ );
+
+ /** @And a chain with both upcasters */
+ $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster(), new ProductV2Upcaster()]);
+
+ /** @When chaining the event */
+ $result = $upcasters->chain(event: $event);
+
+ /** @Then only V2 applies: revision advances to 3 and notes is added */
+ self::assertSame(3, $result->revision->value);
+ self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $result->serializedEvent);
+ }
+}