diff --git a/README.md b/README.md
index 5038821..6b7dcf8 100644
--- a/README.md
+++ b/README.md
@@ -9,25 +9,24 @@
+ [Aggregate](#aggregate)
+ [Domain events with transactional outbox](#domain-events-with-transactional-outbox)
+ [Event sourcing](#event-sourcing)
- + [Consuming events](#consuming-events)
+ [Snapshots](#snapshots)
- - [Built-in conditions](#built-in-conditions)
+ [Upcasting](#upcasting)
- - [Defining an upcaster](#defining-an-upcaster)
- - [Upcasting an event](#upcasting-an-event)
- - [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)
## Overview
-Implements tactical DDD building blocks for PHP, covering entities, single and compound identities, aggregate roots,
-domain events, event records, snapshots, and upcasters. Supports both the transactional outbox pattern and event
-sourcing through sibling aggregate variants. Persistence-agnostic and PSR-14 friendly, keeping infrastructure concerns
-out of the domain layer.
+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.
+
+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. Serialization to wire formats is delegated to adapters such as
+[`tiny-blocks/outbox`](https://github.com/tiny-blocks/outbox).
## Installation
@@ -46,13 +45,12 @@ The library exposes three styles of aggregate modeling through sibling interface
### Entity
-Every entity exposes identity through `EntityBehavior`. The protected `identityName()` method returns the name of
-the property that holds the `Identity` and defaults to `'id'`. Override it only when the property has a different
-name.
+Every entity declares which property holds its `Identity`. By default, the property is named `id`, aggregates with a
+differently named property override `identityProperty()`.
#### Single-field identity
-* `SingleIdentity`: identity backed by a single scalar value (UUID, auto-increment integer, etc.).
+* `SingleIdentity`: identity backed by a single scalar value (UUID, auto-increment integer, slug).
```php
use TinyBlocks\BuildingBlocks\Entity\SingleIdentity;
@@ -68,7 +66,7 @@ name.
}
$orderId = new OrderId(value: 'ord-1');
- $orderId->getIdentityValue();
+ $orderId->identityValue();
```
#### Compound identity
@@ -91,13 +89,13 @@ name.
}
$appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
- $appointmentId->getIdentityValue();
+ $appointmentId->identityValue();
```
-#### Identity access
+#### Identity access on entities
-* `getIdentity`, `getIdentityValue`, `sameIdentityOf`, `identityEquals`: provided by `EntityBehavior` for any entity
- that implements `identityName()`.
+* `identity()`, `identityValue()`, `sameIdentityOf()`, `identityEquals()`: provided by `EntityBehavior` for any
+ entity that declares its identity property.
```php
use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot;
@@ -107,84 +105,86 @@ name.
{
use AggregateRootBehavior;
- private function __construct(private UserId $userId, private string $email)
- {
- }
-
- protected function identityName(): string
+ private function __construct(private UserId $id, private string $email)
{
- return 'userId';
}
}
+ $user->identity();
+ $user->identityValue();
$user->sameIdentityOf(other: $otherUser);
$user->identityEquals(other: new UserId(value: 'usr-1'));
```
-### Aggregate
-
-`AggregateRoot` adds two pragmatic fields to Evans' aggregate: a monotonic `SequenceNumber` for optimistic concurrency
-control and a `ModelVersion` for schema evolution of the aggregate type itself.
-
-* `getSequenceNumber`: the current sequence number, starting at zero for a blank aggregate.
+* Override `identityProperty()` only when the identity property has a name other than `id`:
```php
- use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot;
- use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior;
-
- final class User implements AggregateRoot
+ final class Cart implements AggregateRoot
{
use AggregateRootBehavior;
- protected function identityName(): string
+ private CartId $cartId;
+
+ protected function identityProperty(): string
{
- return 'userId';
+ return 'cartId';
}
}
+ ```
- $user->getSequenceNumber();
+### Aggregate
+
+`AggregateRoot` adds two pragmatic fields to Evans' aggregate: a monotonic `SequenceNumber` for optimistic
+concurrency control, and a `ModelVersion` for schema evolution of the aggregate type.
+
+* `sequenceNumber()`: the current sequence number, starting at zero for a blank aggregate and advancing by one for
+ every recorded event.
+
+ ```php
+ $user->sequenceNumber();
```
-* `getModelVersion`: resolved from the protected `modelVersion()` method, defaults to zero when not overridden.
+* `modelVersion()`: typed as `ModelVersion`. Defaults to `ModelVersion::initial()` (value `0`). Override on
+ aggregates that have a versioned schema.
```php
final class Cart implements AggregateRoot
{
use AggregateRootBehavior;
- protected function identityName(): string
+ public function modelVersion(): ModelVersion
{
- return 'cartId';
- }
-
- protected function modelVersion(): int
- {
- return 1;
+ return ModelVersion::of(value: 2);
}
}
- $cart->getModelVersion();
+ $cart->modelVersion();
```
-* `buildAggregateName`: short class name, used as the aggregate type identifier on each `EventRecord`.
+* `aggregateName()`: short class name, used as the aggregate type identifier on each `EventRecord`.
```php
- $user->buildAggregateName();
+ $user->aggregateName();
```
### Domain events with transactional outbox
-`EventualAggregateRoot` records domain events during the unit of work. State is the source of truth; events are
+`EventualAggregateRoot` records domain events during the unit of work. State is the source of truth, events are
emitted as side effects and must be delivered at-least-once.
+Aggregates of this type are **use-once**: after the application service drains `recordedEvents()` into the outbox,
+the aggregate instance must be discarded. The recorded-events buffer is never cleared, re-saving the same instance
+fails by design with a duplicate-event error from the outbox.
+
#### Declaring events
-* `DomainEvent`: interface declaring `revision()`. A domain event is a plain PHP object. Use
- `DomainEventBehavior` to get the default revision of 1; override `revision()` only when bumping schema.
+* `DomainEvent`: contract for a fact that happened in the domain. The only required method is `revision()`,
+ defaulted to `Revision::initial()` by `DomainEventBehavior`. Override only when bumping the event schema.
```php
use TinyBlocks\BuildingBlocks\Event\DomainEvent;
use TinyBlocks\BuildingBlocks\Event\DomainEventBehavior;
+ use TinyBlocks\BuildingBlocks\Event\Revision;
final readonly class OrderPlaced implements DomainEvent
{
@@ -196,18 +196,14 @@ emitted as side effects and must be delivered at-least-once.
}
```
- When a schema change requires a new revision, override `revision()`:
+ Bumping a revision:
```php
- use TinyBlocks\BuildingBlocks\Event\DomainEvent;
- use TinyBlocks\BuildingBlocks\Event\DomainEventBehavior;
- use TinyBlocks\BuildingBlocks\Event\Revision;
-
final readonly class OrderPlacedV2 implements DomainEvent
{
use DomainEventBehavior;
- public function __construct(public string $item, public string $currency)
+ public function __construct(public string $item, public int $quantity)
{
}
@@ -218,10 +214,20 @@ emitted as side effects and must be delivered at-least-once.
}
```
+ Comparing revisions:
+
+ ```php
+ $previous = Revision::initial();
+ $current = Revision::of(value: 2);
+
+ $current->isAfter(other: $previous); # true
+ $previous->isBefore(other: $current); # true
+ ```
+
#### Emitting events from the aggregate
-* `push`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a
- fully-built `EventRecord` to the recorded buffer. The `Revision` is read from the event via `revision()`.
+* `push()`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a
+ fully-built `EventRecord` to the recorded buffer.
```php
use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot;
@@ -235,9 +241,9 @@ emitted as side effects and must be delivered at-least-once.
{
}
- public static function place(OrderId $orderId, string $item): Order
+ public static function place(OrderId $id, string $item): Order
{
- $order = new Order(id: $orderId);
+ $order = new Order(id: $id);
$order->push(event: new OrderPlaced(item: $item));
return $order;
@@ -245,30 +251,46 @@ emitted as side effects and must be delivered at-least-once.
}
```
-#### Draining events in the repository
+#### Draining events
-* `recordedEvents`: returns a fresh copy of the buffer, safe to iterate without mutating the aggregate.
-* `clearRecordedEvents`: discards the buffer, typically called after persisting the events.
+* `recordedEvents()`: returns a copy of the buffer, safe to iterate. The aggregate's own buffer is not mutated by
+ external iteration. The buffer is **never cleared** by the library, the aggregate is use-once.
```php
- $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book');
+ $order = Order::place(id: new OrderId(value: 'ord-1'), item: 'book');
foreach ($order->recordedEvents() as $record) {
$outbox->append(record: $record);
}
+ ```
+
+#### Constructing event records directly
+
+* `EventRecord::of()`: factory for the rare cases that require building an envelope outside the aggregate boundary,
+ typically test code that fabricates envelopes as inputs to handlers, or consumer-side code deserializing payloads
+ from a wire format. The `id`, `occurredOn`, and `snapshotData` parameters fall back to sensible defaults
+ (`Uuid::uuid4()`, `Instant::now()`, an empty payload) when omitted.
- $order->clearRecordedEvents();
+ ```php
+ use TinyBlocks\BuildingBlocks\Event\EventRecord;
+ use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
+
+ $record = EventRecord::of(
+ event: new OrderPlaced(item: 'book'),
+ identity: new OrderId(value: 'ord-1'),
+ aggregateType: 'Order',
+ sequenceNumber: SequenceNumber::first()
+ );
```
### Event sourcing
-`EventSourcingRoot` stores no state of its own; state is derived by replaying the event stream.
+`EventSourcingRoot` stores no state of its own, state is derived by replaying the event stream.
#### Applying events to state
-* `when`: protected method that records the event and immediately applies it to state. By default, it dispatches
- to a `when Resolved from the protected Defaults to Implementations read {@see Snapshot::getAggregateState()} and copy the relevant fields into
+ * Implementations read {@see Snapshot::aggregateState()} and copy the relevant fields into
* their own properties. The sequence number is applied automatically by
* State is persisted as the source of truth; events are emitted as side effects and delivered
- * at-least-once to external consumers. The repository is expected to drain
- * modelVersion() method, defaults to 0
- * when the method is not overridden. Used by consumers to migrate aggregate schemas when loading older
- * persisted state.ModelVersion::initial() (value 0) when not overridden. Used by consumers
+ * to migrate aggregate schemas when loading older persisted state.identityName() does not exist.
+ * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
*/
public static function blank(Identity $identity): static;
@@ -69,7 +69,7 @@ public static function blank(Identity $identity): static;
* @param iterableidentityName() does not exist.
+ * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
*/
public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static;
@@ -83,12 +83,12 @@ public static function reconstitute(Identity $identity, iterable $records, ?Snap
*
* @return arrayreconstitute(); implementations should not touch it.recordedEvents() after persisting the aggregate state and then call
- * clearRecordedEvents() to reset the buffer for the next unit of work.recordedEvents() after
+ * persisting the aggregate state.
Use-once contract: the recorded-events buffer is never cleared. After the
+ * repository drains recordedEvents() and persists the records to the outbox, the aggregate
+ * instance must be discarded. Re-saving the same instance attempts to push the same envelopes again and
+ * fails with a duplicate-event error from the outbox. Applications that need to perform multiple
+ * operations on the same logical aggregate within one process must reload from the repository between
+ * operations.
Sibling of {@see EventSourcingRoot}, not a parent. Outbox and event sourcing are mutually exclusive * persistence strategies: an aggregate either persists its state and emits events as side effects, or @@ -25,7 +31,7 @@ interface EventualAggregateRoot extends AggregateRoot { /** - * Returns a copy of the events recorded since the last clear. + * Returns a copy of all events recorded since the aggregate was created. * *
Always returns a fresh copy: external mutation of the returned collection does not leak into the * aggregate's internal buffer.
@@ -33,11 +39,4 @@ interface EventualAggregateRoot extends AggregateRoot * @return EventRecords A snapshot of the recorded events, safe to iterate and mutate. */ public function recordedEvents(): EventRecords; - - /** - * Discards all recorded events. - * - *Typically called by the repository after the events have been persisted to the outbox.
- */ - public function clearRecordedEvents(): void; } diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php index 7f72cc6..3b639b5 100644 --- a/src/Aggregate/EventualAggregateRootBehavior.php +++ b/src/Aggregate/EventualAggregateRootBehavior.php @@ -11,11 +11,6 @@ trait EventualAggregateRootBehavior { use AggregateRootBehavior; - public function clearRecordedEvents(): void - { - $this->recordedEvents = EventRecords::createFromEmpty(); - } - protected function push(DomainEvent $event): void { $this->nextSequenceNumber(); diff --git a/src/Aggregate/ModelVersion.php b/src/Aggregate/ModelVersion.php new file mode 100644 index 0000000..f2876fd --- /dev/null +++ b/src/Aggregate/ModelVersion.php @@ -0,0 +1,31 @@ +(tenantId, appointmentId) in multi-tenant contexts). Not a concept * from Evans. * - *All declared properties participate in the identity: getIdentityValue() returns them
+ *
All declared properties participate in the identity: identityValue() returns them
* as an associative array keyed by property name.
Concrete entities implement the protected identityName() method returning the property
+ *
Concrete entities implement the protected identityProperty() method returning the property
* that holds their {@see Identity}. The default behavior uses reflection to resolve and compare it.
identityName() does not exist.
+ * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
*/
- public function getIdentity(): Identity;
+ public function identity(): Identity;
/**
* Returns the name of the property that holds this entity's Identity.
*
- * @return string The property name, resolved from identityName().
- * @throws MissingIdentityProperty When the property referenced by identityName() does not exist.
+ * @return string The property name, resolved from identityProperty().
+ * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
*/
- public function getIdentityName(): string;
+ public function identityName(): string;
/**
* Returns the raw value of this entity's identity.
@@ -45,7 +45,7 @@ public function getIdentityName(): string;
*
* @return mixed The raw identity value.
*/
- public function getIdentityValue(): mixed;
+ public function identityValue(): mixed;
/**
* Checks whether this entity and the given one share the same identity.
diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php
index 0c46adb..280f56a 100644
--- a/src/Entity/EntityBehavior.php
+++ b/src/Entity/EntityBehavior.php
@@ -8,14 +8,9 @@
trait EntityBehavior
{
- protected function identityName(): string
+ public function identityName(): string
{
- return 'id';
- }
-
- public function getIdentityName(): string
- {
- $name = $this->identityName();
+ $name = $this->identityProperty();
if (!property_exists($this, $name)) {
throw new MissingIdentityProperty(className: static::class, propertyName: $name);
@@ -24,23 +19,28 @@ public function getIdentityName(): string
return $name;
}
- public function getIdentity(): Identity
+ protected function identityProperty(): string
+ {
+ return 'id';
+ }
+
+ public function identity(): Identity
{
- return $this->{$this->getIdentityName()};
+ return $this->{$this->identityName()};
}
- public function getIdentityValue(): mixed
+ public function identityValue(): mixed
{
- return $this->getIdentity()->getIdentityValue();
+ return $this->identity()->identityValue();
}
public function sameIdentityOf(Entity $other): bool
{
- return $this->identityEquals(other: $other->getIdentity());
+ return $this->identityEquals(other: $other->identity());
}
public function identityEquals(Identity $other): bool
{
- return $this->getIdentity()->equals(other: $other);
+ return $this->identity()->equals(other: $other);
}
}
diff --git a/src/Entity/Identity.php b/src/Entity/Identity.php
index 38c3d0e..df7bb5d 100644
--- a/src/Entity/Identity.php
+++ b/src/Entity/Identity.php
@@ -27,5 +27,5 @@ interface Identity extends ValueObject
*
* @return mixed A scalar value for single-field identities, an associative array for composite ones.
*/
- public function getIdentityValue(): mixed;
+ public function identityValue(): mixed;
}
diff --git a/src/Entity/SingleIdentity.php b/src/Entity/SingleIdentity.php
index 599111a..3f8160b 100644
--- a/src/Entity/SingleIdentity.php
+++ b/src/Entity/SingleIdentity.php
@@ -12,7 +12,7 @@
* single-value and composite identities.
*
* Implementations should declare exactly one property holding the scalar value; the default trait
- * reads it by reflection and returns it from getIdentityValue().
identityValue().
*/
interface SingleIdentity extends Identity
{
diff --git a/src/Entity/SingleIdentityBehavior.php b/src/Entity/SingleIdentityBehavior.php
index 4430a9e..75aa680 100644
--- a/src/Entity/SingleIdentityBehavior.php
+++ b/src/Entity/SingleIdentityBehavior.php
@@ -10,7 +10,7 @@ trait SingleIdentityBehavior
{
use ValueObjectBehavior;
- public function getIdentityValue(): mixed
+ public function identityValue(): mixed
{
$properties = get_object_vars($this);
diff --git a/src/Event/EventRecord.php b/src/Event/EventRecord.php
index d8f19e8..649cfd2 100644
--- a/src/Event/EventRecord.php
+++ b/src/Event/EventRecord.php
@@ -4,8 +4,10 @@
namespace TinyBlocks\BuildingBlocks\Event;
+use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use TinyBlocks\BuildingBlocks\Entity\Identity;
+use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -26,4 +28,26 @@ public function __construct(
public SequenceNumber $sequenceNumber
) {
}
+
+ public static function of(
+ DomainEvent $event,
+ Identity $identity,
+ string $aggregateType,
+ SequenceNumber $sequenceNumber,
+ ?UuidInterface $id = null,
+ ?Instant $occurredOn = null,
+ ?SnapshotData $snapshotData = null
+ ): EventRecord {
+ return new EventRecord(
+ id: $id ?? Uuid::uuid4(),
+ type: EventType::fromEvent(event: $event),
+ event: $event,
+ identity: $identity,
+ revision: $event->revision(),
+ occurredOn: $occurredOn ?? Instant::now(),
+ snapshotData: $snapshotData ?? new SnapshotData(payload: []),
+ aggregateType: $aggregateType,
+ sequenceNumber: $sequenceNumber
+ );
+ }
}
diff --git a/src/Event/Revision.php b/src/Event/Revision.php
index 9a8738c..40fec6a 100644
--- a/src/Event/Revision.php
+++ b/src/Event/Revision.php
@@ -28,4 +28,14 @@ public static function of(int $value): Revision
{
return new Revision(value: $value);
}
+
+ public function isAfter(Revision $other): bool
+ {
+ return $this->value > $other->value;
+ }
+
+ public function isBefore(Revision $other): bool
+ {
+ return $this->value < $other->value;
+ }
}
diff --git a/src/Internal/Exceptions/InvalidModelVersion.php b/src/Internal/Exceptions/InvalidModelVersion.php
new file mode 100644
index 0000000..1d875ef
--- /dev/null
+++ b/src/Internal/Exceptions/InvalidModelVersion.php
@@ -0,0 +1,17 @@
+.';
+
+ parent::__construct(sprintf($template, $value));
+ }
+}
diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php
index 5c827ed..04bed16 100644
--- a/src/Snapshot/Snapshot.php
+++ b/src/Snapshot/Snapshot.php
@@ -42,35 +42,35 @@ public static function restore(
public static function fromAggregate(EventSourcingRoot $aggregate): Snapshot
{
return new Snapshot(
- type: $aggregate->buildAggregateName(),
+ type: $aggregate->aggregateName(),
createdAt: Instant::now(),
- aggregateId: $aggregate->getIdentityValue(),
- aggregateState: $aggregate->getSnapshotState(),
- sequenceNumber: $aggregate->getSequenceNumber()
+ aggregateId: $aggregate->identityValue(),
+ aggregateState: $aggregate->snapshotState(),
+ sequenceNumber: $aggregate->sequenceNumber()
);
}
- public function getType(): string
+ public function type(): string
{
return $this->type;
}
- public function getCreatedAt(): Instant
+ public function createdAt(): Instant
{
return $this->createdAt;
}
- public function getAggregateId(): mixed
+ public function aggregateId(): mixed
{
return $this->aggregateId;
}
- public function getAggregateState(): array
+ public function aggregateState(): array
{
return $this->aggregateState;
}
- public function getSequenceNumber(): SequenceNumber
+ public function sequenceNumber(): SequenceNumber
{
return $this->sequenceNumber;
}
diff --git a/src/Event/SnapshotData.php b/src/Snapshot/SnapshotData.php
similarity index 62%
rename from src/Event/SnapshotData.php
rename to src/Snapshot/SnapshotData.php
index 6102589..99cb3dd 100644
--- a/src/Event/SnapshotData.php
+++ b/src/Snapshot/SnapshotData.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Event;
+namespace TinyBlocks\BuildingBlocks\Snapshot;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -19,9 +19,4 @@ public function toArray(): array
{
return $this->payload;
}
-
- public function toJson(int $flags = JSON_PRESERVE_ZERO_FRACTION): string
- {
- return json_encode($this->payload, $flags | JSON_THROW_ON_ERROR);
- }
}
diff --git a/src/Snapshot/SnapshotEvery.php b/src/Snapshot/SnapshotEvery.php
index 2d1f64e..b8c98d1 100644
--- a/src/Snapshot/SnapshotEvery.php
+++ b/src/Snapshot/SnapshotEvery.php
@@ -23,7 +23,7 @@ public static function events(int $count): SnapshotEvery
public function shouldSnapshot(EventSourcingRoot $aggregate): bool
{
- $value = $aggregate->getSequenceNumber()->value;
+ $value = $aggregate->sequenceNumber()->value;
return $value > 0 && $value % $this->count === 0;
}
diff --git a/tests/Aggregate/AggregateRootBehaviorTest.php b/tests/Aggregate/AggregateRootBehaviorTest.php
index 7ebcd9c..5c1a955 100644
--- a/tests/Aggregate/AggregateRootBehaviorTest.php
+++ b/tests/Aggregate/AggregateRootBehaviorTest.php
@@ -12,61 +12,61 @@
final class AggregateRootBehaviorTest extends TestCase
{
- public function testGetSequenceNumberIsZeroForBlankAggregate(): void
+ public function testSequenceNumberIsZeroForBlankAggregate(): void
{
/** @Given a freshly instantiated aggregate with no events */
$cart = Cart::blank(identity: new CartId(value: 'cart-1'));
/** @When retrieving the sequence number */
- $sequenceNumber = $cart->getSequenceNumber();
+ $sequenceNumber = $cart->sequenceNumber();
/** @Then it is zero */
self::assertSame(0, $sequenceNumber->value);
}
- public function testGetModelVersionReflectsDeclaredConstant(): void
+ public function testModelVersionReflectsDeclaredValue(): void
{
/** @Given an aggregate with model version 1 */
$cart = Cart::blank(identity: new CartId(value: 'cart-2'));
/** @When retrieving the model version */
- $version = $cart->getModelVersion();
+ $version = $cart->modelVersion();
/** @Then the version reflects the declared value */
self::assertSame(1, $version->value);
}
- public function testGetModelVersionDefaultsToZeroWhenUndefined(): void
+ public function testModelVersionDefaultsToZeroWhenUndefined(): void
{
/** @Given an aggregate with the default model version */
$order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen');
/** @When retrieving the model version */
- $version = $order->getModelVersion();
+ $version = $order->modelVersion();
/** @Then the default is zero */
self::assertSame(0, $version->value);
}
- public function testBuildAggregateNameForEventSourcedAggregate(): void
+ public function testAggregateNameForEventSourcedAggregate(): void
{
/** @Given a Cart aggregate */
$cart = Cart::blank(identity: new CartId(value: 'cart-3'));
- /** @When building the aggregate name */
- $name = $cart->buildAggregateName();
+ /** @When retrieving the aggregate name */
+ $name = $cart->aggregateName();
/** @Then it matches the short class name */
self::assertSame('Cart', $name);
}
- public function testBuildAggregateNameForOutboxAggregate(): void
+ public function testAggregateNameForOutboxAggregate(): void
{
/** @Given an Order aggregate */
$order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'lamp');
- /** @When building the aggregate name */
- $name = $order->buildAggregateName();
+ /** @When retrieving the aggregate name */
+ $name = $order->aggregateName();
/** @Then it matches the short class name */
self::assertSame('Order', $name);
diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Aggregate/EventSourcingRootBehaviorTest.php
index ec4026c..9e7479e 100644
--- a/tests/Aggregate/EventSourcingRootBehaviorTest.php
+++ b/tests/Aggregate/EventSourcingRootBehaviorTest.php
@@ -27,7 +27,7 @@ public function testBlankAggregateStartsWithInitialSequenceNumber(): void
$cart = Cart::blank(identity: $cartId);
/** @Then the aggregate starts at sequence number zero */
- self::assertSame(0, $cart->getSequenceNumber()->value);
+ self::assertSame(0, $cart->sequenceNumber()->value);
}
public function testBlankAggregateStartsWithEmptyDomainState(): void
@@ -39,7 +39,7 @@ public function testBlankAggregateStartsWithEmptyDomainState(): void
$cart = Cart::blank(identity: $cartId);
/** @Then the aggregate's domain state is empty */
- self::assertSame([], $cart->getProductIds());
+ self::assertSame([], $cart->productIds());
}
public function testBlankAggregateCarriesTheGivenIdentity(): void
@@ -51,7 +51,7 @@ public function testBlankAggregateCarriesTheGivenIdentity(): void
$cart = Cart::blank(identity: $cartId);
/** @Then the aggregate exposes the given identity */
- self::assertSame($cartId, $cart->getIdentity());
+ self::assertSame($cartId, $cart->identity());
}
public function testBlankAggregateStartsWithNoRecordedEvents(): void
@@ -75,7 +75,7 @@ public function testDomainOperationAppliesStateFromEmittedEvent(): void
$cart->addProduct(productId: 'prod-1');
/** @Then the domain state reflects the event */
- self::assertSame(['prod-1'], $cart->getProductIds());
+ self::assertSame(['prod-1'], $cart->productIds());
}
public function testDomainOperationAdvancesSequenceNumber(): void
@@ -90,7 +90,7 @@ public function testDomainOperationAdvancesSequenceNumber(): void
$cart->addProduct(productId: 'prod-2');
/** @Then the sequence number equals the number of events */
- self::assertSame(2, $cart->getSequenceNumber()->value);
+ self::assertSame(2, $cart->sequenceNumber()->value);
}
public function testDomainOperationAppendsToRecordedEvents(): void
@@ -141,7 +141,7 @@ public function testReconstituteReplaysEventsInOrder(): void
$reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
/** @Then the replayed state preserves event order */
- self::assertSame(['prod-1', 'prod-2'], $reconstituted->getProductIds());
+ self::assertSame(['prod-1', 'prod-2'], $reconstituted->productIds());
}
public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream(): void
@@ -165,7 +165,7 @@ public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream
$reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
/** @Then the replayed state preserves the exact insertion order */
- self::assertSame(['zebra', 'apple', 'mango'], $reconstituted->getProductIds());
+ self::assertSame(['zebra', 'apple', 'mango'], $reconstituted->productIds());
}
public function testReconstituteAdvancesSequenceNumberToLastEvent(): void
@@ -180,7 +180,7 @@ public function testReconstituteAdvancesSequenceNumberToLastEvent(): void
$reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
/** @Then the sequence number equals the last event's */
- self::assertSame(2, $reconstituted->getSequenceNumber()->value);
+ self::assertSame(2, $reconstituted->sequenceNumber()->value);
}
public function testReconstituteWithEmptyStreamYieldsBlankState(): void
@@ -192,7 +192,7 @@ public function testReconstituteWithEmptyStreamYieldsBlankState(): void
$reconstituted = Cart::reconstitute(identity: $cartId, records: []);
/** @Then the state matches a blank aggregate */
- self::assertSame([], $reconstituted->getProductIds());
+ self::assertSame([], $reconstituted->productIds());
}
public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): void
@@ -204,7 +204,7 @@ public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): vo
$reconstituted = Cart::reconstitute(identity: $cartId, records: []);
/** @Then the sequence number remains at the initial value */
- self::assertSame(0, $reconstituted->getSequenceNumber()->value);
+ self::assertSame(0, $reconstituted->sequenceNumber()->value);
}
public function testReconstituteFromSnapshotRestoresDomainState(): void
@@ -225,7 +225,7 @@ public function testReconstituteFromSnapshotRestoresDomainState(): void
$reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
/** @Then the domain state is fully restored */
- self::assertSame(['prod-snapshot'], $reconstituted->getProductIds());
+ self::assertSame(['prod-snapshot'], $reconstituted->productIds());
}
public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): void
@@ -246,7 +246,7 @@ public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber():
$reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
/** @Then the sequence number matches the snapshot's */
- self::assertSame(1, $reconstituted->getSequenceNumber()->value);
+ self::assertSame(1, $reconstituted->sequenceNumber()->value);
}
public function testReconstituteCombinesSnapshotWithLaterEvents(): void
@@ -269,7 +269,7 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void
/** @And the records after the snapshot filtered out */
$laterRecords = $cart->recordedEvents()->filter(
predicates: static fn($record): bool => $record->sequenceNumber->isAfter(
- other: $snapshot->getSequenceNumber()
+ other: $snapshot->sequenceNumber()
)
);
@@ -277,7 +277,7 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void
$reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot);
/** @Then the full state is restored */
- self::assertSame(['prod-1', 'prod-2'], $reconstituted->getProductIds());
+ self::assertSame(['prod-1', 'prod-2'], $reconstituted->productIds());
}
public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequence(): void
@@ -300,7 +300,7 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequen
/** @And the records after the snapshot filtered out */
$laterRecords = $cart->recordedEvents()->filter(
predicates: static fn($record): bool => $record->sequenceNumber->isAfter(
- other: $snapshot->getSequenceNumber()
+ other: $snapshot->sequenceNumber()
)
);
@@ -308,7 +308,7 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequen
$reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot);
/** @Then the sequence number reflects the last applied event */
- self::assertSame(2, $reconstituted->getSequenceNumber()->value);
+ self::assertSame(2, $reconstituted->sequenceNumber()->value);
}
public function testReconstitutedAggregateHasNoRecordedEvents(): void
@@ -335,7 +335,7 @@ public function testExplicitHandlerIsInvokedForRegisteredEvent(): void
$cart->addProduct(productId: 'prod-explicit');
/** @Then the product appears in the aggregate state */
- self::assertSame(['prod-explicit'], $cart->getProductIds());
+ self::assertSame(['prod-explicit'], $cart->productIds());
}
public function testRevisionOverrideIsCarriedOnEventRecord(): void
diff --git a/tests/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Aggregate/EventualAggregateRootBehaviorTest.php
index 2db78e8..4e93ac2 100644
--- a/tests/Aggregate/EventualAggregateRootBehaviorTest.php
+++ b/tests/Aggregate/EventualAggregateRootBehaviorTest.php
@@ -18,7 +18,7 @@ public function testSequenceNumberIsOneAfterSinglePlacement(): void
$order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book');
/** @When retrieving the sequence number */
- $sequenceNumber = $order->getSequenceNumber();
+ $sequenceNumber = $order->sequenceNumber();
/** @Then the sequence number is 1 */
self::assertSame(1, $sequenceNumber->value);
@@ -33,7 +33,7 @@ public function testSequenceNumberAdvancesOnEverySubsequentEvent(): void
$order->ship(carrier: 'DHL');
/** @When retrieving the sequence number */
- $sequenceNumber = $order->getSequenceNumber();
+ $sequenceNumber = $order->sequenceNumber();
/** @Then the sequence number reflects every emitted event */
self::assertSame(2, $sequenceNumber->value);
@@ -93,18 +93,6 @@ public function testSecondRecordedEventCarriesShippingMetadata(): void
self::assertSame('UPS', $record->event->carrier);
}
- public function testClearRecordedEventsResetsTheBuffer(): void
- {
- /** @Given an order with recorded events */
- $order = Order::place(orderId: new OrderId(value: 'ord-5'), item: 'desk');
-
- /** @When clearing the buffer */
- $order->clearRecordedEvents();
-
- /** @Then no events remain */
- self::assertTrue($order->recordedEvents()->isEmpty());
- }
-
public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void
{
/** @Given an order with one recorded event */
@@ -120,19 +108,20 @@ public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void
self::assertSame(1, $secondCopy->count());
}
- public function testRecordedEventsIsEmptyAfterClear(): void
+ public function testBufferAccumulatesAcrossOperationsWithoutClearing(): void
{
- /** @Given an order that was placed and immediately cleared */
+ /** @Given a placed order whose events are still buffered */
$order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle');
- /** @And the buffer cleared */
- $order->clearRecordedEvents();
+ /** @And the buffer drained without clearing, simulating a save that reads but does not reset */
+ $firstBatch = $order->recordedEvents();
- /** @When retrieving recorded events */
- $records = $order->recordedEvents();
+ /** @When a second operation emits a further event on the same instance */
+ $order->ship(carrier: 'DHL');
- /** @Then the collection is empty */
- self::assertTrue($records->isEmpty());
+ /** @Then the buffer accumulates events from both operations */
+ self::assertSame(2, $order->recordedEvents()->count());
+ self::assertSame(1, $firstBatch->count());
}
public function testSnapshotDataCapturesDomainStateOnEveryEvent(): void
@@ -158,4 +147,29 @@ public function testSnapshotDataOmitsTransientRecordedEventsBuffer(): void
/** @Then the recording buffer is not part of the persisted state */
self::assertArrayNotHasKey('recordedEvents', $state);
}
+
+ public function testSnapshotDataOmitsSequenceNumber(): void
+ {
+ /** @Given an order that emits a placement event */
+ $order = Order::place(orderId: new OrderId(value: 'ord-11'), item: 'mug');
+
+ /** @When inspecting the persistable state attached to the record */
+ $state = $order->recordedEvents()->first()->snapshotData->toArray();
+
+ /** @Then the sequence number is not duplicated in the snapshot payload */
+ self::assertArrayNotHasKey('sequenceNumber', $state);
+ }
+
+ public function testSnapshotDataContainsAllDomainFields(): void
+ {
+ /** @Given a placed order */
+ $order = Order::place(orderId: new OrderId(value: 'ord-12'), item: 'desk');
+
+ /** @When reading the snapshot payload from the first event record */
+ $state = $order->recordedEvents()->first()->snapshotData->toArray();
+
+ /** @Then all domain fields are present in the payload */
+ self::assertArrayHasKey('id', $state);
+ self::assertArrayHasKey('status', $state);
+ }
}
diff --git a/tests/Aggregate/ModelVersionTest.php b/tests/Aggregate/ModelVersionTest.php
new file mode 100644
index 0000000..7ae91ad
--- /dev/null
+++ b/tests/Aggregate/ModelVersionTest.php
@@ -0,0 +1,95 @@
+value);
+ }
+
+ public function testOfReturnsVersionWithGivenValue(): void
+ {
+ /** @Given a valid model version value */
+ /** @When requesting a model version of that value */
+ $version = ModelVersion::of(value: 2);
+
+ /** @Then the value matches */
+ self::assertSame(2, $version->value);
+ }
+
+ public function testEqualsReturnsTrueForSameValue(): void
+ {
+ /** @Given two model versions with the same value */
+ $first = ModelVersion::of(value: 3);
+
+ /** @And a matching counterpart */
+ $second = ModelVersion::of(value: 3);
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($areEqual);
+ }
+
+ public function testEqualsReturnsFalseForDifferentValues(): void
+ {
+ /** @Given two model versions with different values */
+ $first = ModelVersion::of(value: 1);
+
+ /** @And a distinct counterpart */
+ $second = ModelVersion::of(value: 2);
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ public function testOfRejectsNegativeValue(): void
+ {
+ /** @Given a negative model version value */
+ /** @Then an InvalidModelVersion exception is thrown */
+ $this->expectException(InvalidModelVersion::class);
+ $this->expectExceptionMessage('-1');
+
+ /** @When constructing with a negative value */
+ ModelVersion::of(value: -1);
+ }
+
+ public function testInvalidModelVersionIsCatchableAsInvalidArgumentException(): void
+ {
+ /** @Given consumer code catching the PHP-standard InvalidArgumentException */
+ /** @Then InvalidModelVersion is caught by the standard exception type */
+ $this->expectException(InvalidArgumentException::class);
+
+ /** @When constructing with a negative value */
+ ModelVersion::of(value: -1);
+ }
+
+ public function testInvalidModelVersionMessageMentionsTheMinimumAllowed(): void
+ {
+ /** @Given a consumer inspecting the exception message */
+ /** @Then the message mentions the minimum allowed value */
+ $this->expectException(InvalidModelVersion::class);
+ $this->expectExceptionMessage('greater than or equal to 0');
+
+ /** @When constructing with a negative value */
+ ModelVersion::of(value: -1);
+ }
+}
diff --git a/tests/Entity/CompoundIdentityBehaviorTest.php b/tests/Entity/CompoundIdentityBehaviorTest.php
index 888829b..517bc2e 100644
--- a/tests/Entity/CompoundIdentityBehaviorTest.php
+++ b/tests/Entity/CompoundIdentityBehaviorTest.php
@@ -9,13 +9,13 @@
final class CompoundIdentityBehaviorTest extends TestCase
{
- public function testGetIdentityValueReturnsAllFieldsAsAssociativeArray(): void
+ public function testIdentityValueReturnsAllFieldsAsAssociativeArray(): void
{
/** @Given a compound identity with two fields */
$appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
/** @When retrieving the identity value */
- $value = $appointmentId->getIdentityValue();
+ $value = $appointmentId->identityValue();
/** @Then both fields are returned in an associative array */
self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value);
diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Entity/EntityBehaviorTest.php
index 83e7b96..3988b07 100644
--- a/tests/Entity/EntityBehaviorTest.php
+++ b/tests/Entity/EntityBehaviorTest.php
@@ -15,7 +15,7 @@
final class EntityBehaviorTest extends TestCase
{
- public function testGetIdentityReturnsHeldIdentity(): void
+ public function testIdentityReturnsHeldIdentity(): void
{
/** @Given an order constructed with a known identity */
$orderId = new OrderId(value: 'ord-1');
@@ -24,55 +24,55 @@ public function testGetIdentityReturnsHeldIdentity(): void
$order = Order::place(orderId: $orderId, item: 'book');
/** @When retrieving the identity */
- $identity = $order->getIdentity();
+ $identity = $order->identity();
/** @Then the same identity instance is returned */
self::assertSame($orderId, $identity);
}
- public function testGetIdentityNameReturnsPropertyName(): void
+ public function testIdentityNameReturnsPropertyName(): void
{
/** @Given an order aggregate */
$order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen');
/** @When retrieving the identity property name */
- $name = $order->getIdentityName();
+ $name = $order->identityName();
- /** @Then it matches the value returned by identityName() */
+ /** @Then it matches the value returned by identityProperty() */
self::assertSame('id', $name);
}
- public function testGetIdentityNameReturnsOverriddenPropertyName(): void
+ public function testIdentityNameReturnsOverriddenPropertyName(): void
{
- /** @Given a blank Cart with an explicit identityName override */
+ /** @Given a blank Cart with an explicit identityProperty override */
$cart = Cart::blank(identity: new CartId(value: 'cart-identity'));
/** @When retrieving the identity property name */
- $name = $cart->getIdentityName();
+ $name = $cart->identityName();
/** @Then it matches the overridden value */
self::assertSame('cartId', $name);
}
- public function testGetIdentityValueReturnsScalarForSingleIdentity(): void
+ public function testIdentityValueReturnsScalarForSingleIdentity(): void
{
/** @Given an order whose identity is a single-value identifier */
$order = Order::place(orderId: new OrderId(value: 'ord-42'), item: 'pen');
/** @When retrieving the identity value */
- $value = $order->getIdentityValue();
+ $value = $order->identityValue();
/** @Then the raw scalar is returned */
self::assertSame('ord-42', $value);
}
- public function testGetIdentityValueReturnsAssociativeArrayForCompoundIdentity(): void
+ public function testIdentityValueReturnsAssociativeArrayForCompoundIdentity(): void
{
/** @Given a compound identity */
$appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
/** @When retrieving the identity value */
- $value = $appointmentId->getIdentityValue();
+ $value = $appointmentId->identityValue();
/** @Then an associative array with all fields is returned */
self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value);
@@ -140,7 +140,7 @@ public function testIdentityEqualsReturnsFalseForDifferentIdentity(): void
public function testShipThrowsWhenIdentityPropertyIsMissing(): void
{
- /** @Given an aggregate whose identityName() points to a non-existent property */
+ /** @Given an aggregate whose identityProperty() points to a non-existent property */
$order = new OrderWithMissingIdentityProperty();
/** @Then a MissingIdentityProperty exception carrying the property name is thrown */
diff --git a/tests/Entity/SingleIdentityBehaviorTest.php b/tests/Entity/SingleIdentityBehaviorTest.php
index ba26f2a..cd3fe2d 100644
--- a/tests/Entity/SingleIdentityBehaviorTest.php
+++ b/tests/Entity/SingleIdentityBehaviorTest.php
@@ -9,13 +9,13 @@
final class SingleIdentityBehaviorTest extends TestCase
{
- public function testGetIdentityValueReturnsTheSingleScalarField(): void
+ public function testIdentityValueReturnsTheSingleScalarField(): void
{
/** @Given a single-field identity */
$orderId = new OrderId(value: 'ord-1');
/** @When retrieving the identity value */
- $value = $orderId->getIdentityValue();
+ $value = $orderId->identityValue();
/** @Then the scalar value is returned as-is */
self::assertSame('ord-1', $value);
diff --git a/tests/Event/EventRecordTest.php b/tests/Event/EventRecordTest.php
index 537661f..a0aab13 100644
--- a/tests/Event/EventRecordTest.php
+++ b/tests/Event/EventRecordTest.php
@@ -12,7 +12,7 @@
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Event\SnapshotData;
+use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
final class EventRecordTest extends TestCase
@@ -138,4 +138,74 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void
/** @Then they are not equal */
self::assertFalse($areEqual);
}
+
+ public function testOfFactoryBuildsRecordWithRequiredFields(): void
+ {
+ /** @Given required fields for a record built via the factory */
+ $orderId = new OrderId(value: 'ord-of-1');
+ $placedEvent = new OrderPlaced(item: 'notebook');
+ $sequenceNumber = SequenceNumber::first();
+
+ /** @When building the record via the factory */
+ $record = EventRecord::of(
+ event: $placedEvent,
+ identity: $orderId,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber
+ );
+
+ /** @Then the envelope carries the expected metadata */
+ self::assertSame('OrderPlaced', $record->type->value);
+ self::assertSame(1, $record->revision->value);
+ self::assertSame($placedEvent, $record->event);
+ self::assertSame($orderId, $record->identity);
+ self::assertSame('Order', $record->aggregateType);
+ self::assertSame($sequenceNumber, $record->sequenceNumber);
+ }
+
+ public function testOfFactoryUsesProvidedOptionalFields(): void
+ {
+ /** @Given a specific id, timestamp, and snapshot data */
+ $id = Uuid::uuid4();
+ $orderId = new OrderId(value: 'ord-of-2');
+ $placedEvent = new OrderPlaced(item: 'pen');
+ $occurredOn = Instant::now();
+ $snapshotData = new SnapshotData(payload: ['status' => 'placed']);
+ $sequenceNumber = SequenceNumber::first();
+
+ /** @When building the record via the factory with all optional fields */
+ $record = EventRecord::of(
+ event: $placedEvent,
+ identity: $orderId,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber,
+ id: $id,
+ occurredOn: $occurredOn,
+ snapshotData: $snapshotData
+ );
+
+ /** @Then the optional fields are applied exactly */
+ self::assertSame($id, $record->id);
+ self::assertSame($occurredOn, $record->occurredOn);
+ self::assertSame($snapshotData, $record->snapshotData);
+ }
+
+ public function testOfFactoryDefaultsSnapshotDataToEmptyArray(): void
+ {
+ /** @Given required fields only */
+ $orderId = new OrderId(value: 'ord-of-3');
+ $placedEvent = new OrderPlaced(item: 'lamp');
+ $sequenceNumber = SequenceNumber::first();
+
+ /** @When building the record without providing snapshot data */
+ $record = EventRecord::of(
+ event: $placedEvent,
+ identity: $orderId,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber
+ );
+
+ /** @Then the snapshot data payload is empty */
+ self::assertSame([], $record->snapshotData->toArray());
+ }
}
diff --git a/tests/Event/EventRecordsTest.php b/tests/Event/EventRecordsTest.php
index 1d97f8d..c67fc2f 100644
--- a/tests/Event/EventRecordsTest.php
+++ b/tests/Event/EventRecordsTest.php
@@ -13,7 +13,7 @@
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Event\SnapshotData;
+use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
final class EventRecordsTest extends TestCase
diff --git a/tests/Event/RevisionTest.php b/tests/Event/RevisionTest.php
index b72baa0..31d3b4c 100644
--- a/tests/Event/RevisionTest.php
+++ b/tests/Event/RevisionTest.php
@@ -83,6 +83,96 @@ public function testEqualsReturnsFalseForDifferentRevisions(): void
self::assertFalse($areEqual);
}
+ public function testIsAfterReturnsTrueWhenValueIsGreater(): void
+ {
+ /** @Given a revision with a higher value */
+ $higher = Revision::of(value: 3);
+
+ /** @And a revision with a lower value */
+ $lower = Revision::of(value: 1);
+
+ /** @When checking if higher is after lower */
+ $isAfter = $higher->isAfter(other: $lower);
+
+ /** @Then the result is true */
+ self::assertTrue($isAfter);
+ }
+
+ public function testIsAfterReturnsFalseWhenValueIsEqual(): void
+ {
+ /** @Given two revisions with equal values */
+ $first = Revision::of(value: 2);
+
+ /** @And a matching counterpart */
+ $second = Revision::of(value: 2);
+
+ /** @When checking if first is after second */
+ $isAfter = $first->isAfter(other: $second);
+
+ /** @Then the result is false */
+ self::assertFalse($isAfter);
+ }
+
+ public function testIsAfterReturnsFalseWhenValueIsLower(): void
+ {
+ /** @Given a revision with a lower value */
+ $lower = Revision::of(value: 1);
+
+ /** @And a revision with a higher value */
+ $higher = Revision::of(value: 3);
+
+ /** @When checking if lower is after higher */
+ $isAfter = $lower->isAfter(other: $higher);
+
+ /** @Then the result is false */
+ self::assertFalse($isAfter);
+ }
+
+ public function testIsBeforeReturnsTrueWhenValueIsLower(): void
+ {
+ /** @Given a revision with a lower value */
+ $lower = Revision::of(value: 1);
+
+ /** @And a revision with a higher value */
+ $higher = Revision::of(value: 3);
+
+ /** @When checking if lower is before higher */
+ $isBefore = $lower->isBefore(other: $higher);
+
+ /** @Then the result is true */
+ self::assertTrue($isBefore);
+ }
+
+ public function testIsBeforeReturnsFalseWhenValueIsEqual(): void
+ {
+ /** @Given two revisions with equal values */
+ $first = Revision::of(value: 2);
+
+ /** @And a matching counterpart */
+ $second = Revision::of(value: 2);
+
+ /** @When checking if first is before second */
+ $isBefore = $first->isBefore(other: $second);
+
+ /** @Then the result is false */
+ self::assertFalse($isBefore);
+ }
+
+ public function testIsBeforeReturnsFalseWhenValueIsGreater(): void
+ {
+ /** @Given a revision with a higher value */
+ $higher = Revision::of(value: 3);
+
+ /** @And a revision with a lower value */
+ $lower = Revision::of(value: 1);
+
+ /** @When checking if higher is before lower */
+ $isBefore = $higher->isBefore(other: $lower);
+
+ /** @Then the result is false */
+ self::assertFalse($isBefore);
+ }
+
#[DataProvider('invalidValues')]
public function testOfRejectsNonPositiveValue(int $invalidValue): void
{
diff --git a/tests/Event/SnapshotDataTest.php b/tests/Event/SnapshotDataTest.php
deleted file mode 100644
index def9529..0000000
--- a/tests/Event/SnapshotDataTest.php
+++ /dev/null
@@ -1,103 +0,0 @@
- 'placed', 'amount' => 100]);
-
- /** @When converting to array */
- $payload = $snapshotData->toArray();
-
- /** @Then the original data is returned */
- self::assertSame(['status' => 'placed', 'amount' => 100], $payload);
- }
-
- public function testToJsonProducesValidJson(): void
- {
- /** @Given snapshot data with a simple payload */
- $snapshotData = new SnapshotData(payload: ['status' => 'shipped']);
-
- /** @When converting to JSON */
- $json = $snapshotData->toJson();
-
- /** @Then the result is valid JSON */
- self::assertSame('{"status":"shipped"}', $json);
- }
-
- public function testToJsonPreservesZeroFractionOnFloats(): void
- {
- /** @Given snapshot data with a float value */
- $snapshotData = new SnapshotData(payload: ['amount' => 1.0]);
-
- /** @When converting to JSON with default flags */
- $json = $snapshotData->toJson();
-
- /** @Then the float zero fraction is preserved */
- self::assertSame('{"amount":1.0}', $json);
- }
-
- public function testToJsonHonorsAdditionalFlags(): void
- {
- /** @Given snapshot data with a nested payload */
- $snapshotData = new SnapshotData(payload: ['amount' => 1.0]);
-
- /** @When converting to JSON with an additional pretty-print flag */
- $json = $snapshotData->toJson(flags: JSON_PRESERVE_ZERO_FRACTION | JSON_PRETTY_PRINT);
-
- /** @Then the output reflects the requested formatting */
- self::assertStringContainsString("\n", $json);
- self::assertStringContainsString('"amount": 1.0', $json);
- }
-
- public function testToJsonThrowsForNonSerializableValue(): void
- {
- /** @Given snapshot data containing a non-JSON-serializable value */
- $snapshotData = new SnapshotData(payload: ['infinity' => INF]);
-
- /** @Then a JsonException is thrown */
- $this->expectException(JsonException::class);
-
- /** @When converting to JSON */
- $snapshotData->toJson();
- }
-
- public function testEqualsReturnsTrueForIdenticalPayloads(): void
- {
- /** @Given two snapshot data instances with identical payloads */
- $first = new SnapshotData(payload: ['status' => 'placed']);
-
- /** @And a matching counterpart */
- $second = new SnapshotData(payload: ['status' => 'placed']);
-
- /** @When comparing them */
- $areEqual = $first->equals(other: $second);
-
- /** @Then they are equal */
- self::assertTrue($areEqual);
- }
-
- public function testEqualsReturnsFalseForDifferentPayloads(): void
- {
- /** @Given two snapshot data instances with different payloads */
- $first = new SnapshotData(payload: ['status' => 'placed']);
-
- /** @And a distinct counterpart */
- $second = new SnapshotData(payload: ['status' => 'shipped']);
-
- /** @When comparing them */
- $areEqual = $first->equals(other: $second);
-
- /** @Then they are not equal */
- self::assertFalse($areEqual);
- }
-}
diff --git a/tests/Models/Cart.php b/tests/Models/Cart.php
index b67f09d..56f5ad2 100644
--- a/tests/Models/Cart.php
+++ b/tests/Models/Cart.php
@@ -6,6 +6,7 @@
use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior;
+use TinyBlocks\BuildingBlocks\Aggregate\ModelVersion;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
final class Cart implements EventSourcingRoot
@@ -34,26 +35,26 @@ public function addProduct(string $productId): void
public function applySnapshot(Snapshot $snapshot): void
{
- $state = $snapshot->getAggregateState();
+ $state = $snapshot->aggregateState();
$this->productIds = $state['productIds'] ?? [];
}
/**
* @return list