From 26de340b7bf60f5cfdcf1872abef015fca23a1df Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Tue, 12 May 2026 09:12:09 -0300
Subject: [PATCH] refactor: Strict DDD pass,introduce ModelVersion as a
distinct value object, modelVersion() now returns ModelVersion instead of
SequenceNumber.
---
README.md | 514 +++++++++---------
src/Aggregate/AggregateRoot.php | 13 +-
src/Aggregate/AggregateRootBehavior.php | 40 +-
src/Aggregate/EventSourcingRoot.php | 8 +-
src/Aggregate/EventSourcingRootBehavior.php | 4 +-
src/Aggregate/EventualAggregateRoot.php | 21 +-
.../EventualAggregateRootBehavior.php | 5 -
src/Aggregate/ModelVersion.php | 31 ++
src/Entity/CompoundIdentity.php | 2 +-
src/Entity/CompoundIdentityBehavior.php | 2 +-
src/Entity/Entity.php | 14 +-
src/Entity/EntityBehavior.php | 26 +-
src/Entity/Identity.php | 2 +-
src/Entity/SingleIdentity.php | 2 +-
src/Entity/SingleIdentityBehavior.php | 2 +-
src/Event/EventRecord.php | 24 +
src/Event/Revision.php | 10 +
.../Exceptions/InvalidModelVersion.php | 17 +
src/Snapshot/Snapshot.php | 18 +-
src/{Event => Snapshot}/SnapshotData.php | 7 +-
src/Snapshot/SnapshotEvery.php | 2 +-
tests/Aggregate/AggregateRootBehaviorTest.php | 24 +-
.../EventSourcingRootBehaviorTest.php | 34 +-
.../EventualAggregateRootBehaviorTest.php | 58 +-
tests/Aggregate/ModelVersionTest.php | 95 ++++
tests/Entity/CompoundIdentityBehaviorTest.php | 4 +-
tests/Entity/EntityBehaviorTest.php | 26 +-
tests/Entity/SingleIdentityBehaviorTest.php | 4 +-
tests/Event/EventRecordTest.php | 72 ++-
tests/Event/EventRecordsTest.php | 2 +-
tests/Event/RevisionTest.php | 90 +++
tests/Event/SnapshotDataTest.php | 103 ----
tests/Models/Cart.php | 11 +-
tests/Models/CartWithLogger.php | 8 +-
tests/Models/CartWithoutHandler.php | 7 +-
tests/Models/ExplicitCart.php | 6 +-
tests/Models/Order.php | 5 -
.../OrderWithMissingIdentityProperty.php | 2 +-
tests/Snapshot/SnapshotDataTest.php | 65 +++
tests/Snapshot/SnapshotTest.php | 26 +-
tests/Snapshot/SnapshotterBehaviorTest.php | 6 +-
41 files changed, 847 insertions(+), 565 deletions(-)
create mode 100644 src/Aggregate/ModelVersion.php
create mode 100644 src/Internal/Exceptions/InvalidModelVersion.php
rename src/{Event => Snapshot}/SnapshotData.php (62%)
create mode 100644 tests/Aggregate/ModelVersionTest.php
delete mode 100644 tests/Event/SnapshotDataTest.php
create mode 100644 tests/Snapshot/SnapshotDataTest.php
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` method. Alternatively, register an explicit handler map via `eventHandlers()`.
- Override `identityName()` only when the identity property is not named `id` (for example, `Cart` uses `cartId`).
+* `when()`: protected method that records the event and immediately applies it to state by dispatching to a
+ `when` method by reflection.
```php
use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
@@ -279,7 +301,7 @@ emitted as side effects and must be delivered at-least-once.
{
use EventSourcingRootBehavior;
- private CartId $cartId;
+ private CartId $id;
private array $productIds = [];
public function addProduct(string $productId): void
@@ -289,12 +311,7 @@ emitted as side effects and must be delivered at-least-once.
public function applySnapshot(Snapshot $snapshot): void
{
- $this->productIds = $snapshot->getAggregateState()['productIds'] ?? [];
- }
-
- protected function identityName(): string
- {
- return 'cartId';
+ $this->productIds = $snapshot->aggregateState()['productIds'] ?? [];
}
protected function whenProductAdded(ProductAdded $event): void
@@ -304,52 +321,36 @@ emitted as side effects and must be delivered at-least-once.
}
```
- To register handlers explicitly instead of relying on the `when` convention, override
- `eventHandlers()`. When the map is non-empty, only listed event classes are dispatched; any other event
- causes a `LogicException`.
+* `eventHandlers()`: explicit registration. Returns a map of `class-string` to callable. When the map
+ is non-empty, the trait dispatches through it instead of using the implicit `when` convention. Use this when
+ handler names should not follow the convention or when static analysis on dispatch is desired.
```php
- use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
- use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior;
- use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
-
- final class Cart implements EventSourcingRoot
+ final class ExplicitCart implements EventSourcingRoot
{
use EventSourcingRootBehavior;
- private CartId $cartId;
+ private CartId $id;
private array $productIds = [];
- public function addProduct(string $productId): void
- {
- $this->when(event: new ProductAdded(productId: $productId));
- }
-
- public function applySnapshot(Snapshot $snapshot): void
- {
- $this->productIds = $snapshot->getAggregateState()['productIds'] ?? [];
- }
-
public function eventHandlers(): array
{
return [
- ProductAdded::class => function (ProductAdded $event): void {
- $this->productIds[] = $event->productId;
- }
+ ProductAdded::class => $this->onProductAdded(...)
];
}
- protected function identityName(): string
+ private function onProductAdded(ProductAdded $event): void
{
- return 'cartId';
+ $this->productIds[] = $event->productId;
}
}
```
#### Creating a blank aggregate
-* `blank`: factory that instantiates the aggregate without invoking its constructor. All state must come from events
- or from a snapshot.
+* `blank()`: factory that instantiates the aggregate via reflection without invoking its constructor. All state
+ must come from events or from a snapshot.
```php
$cart = Cart::blank(identity: new CartId(value: 'cart-1'));
@@ -357,8 +358,8 @@ emitted as side effects and must be delivered at-least-once.
#### Replaying an event stream
-* `reconstitute`: replays an ordered stream of `EventRecord` instances, optionally starting from a snapshot to skip
- earlier events. When a snapshot is provided, its sequence number is authoritative.
+* `reconstitute()`: replays an ordered stream of `EventRecord` instances, optionally starting from a snapshot to
+ skip earlier events. When a snapshot is provided, its sequence number is authoritative.
```php
$cart = Cart::reconstitute(identity: new CartId(value: 'cart-1'), records: $records);
@@ -372,61 +373,58 @@ emitted as side effects and must be delivered at-least-once.
);
```
-### Consuming events
-
-Domain events travel between services through whatever broker the consumer chooses (SQS, Kafka, RabbitMQ, etc.).
-The library is intentionally silent about the transport: it produces and consumes `EventRecord` envelopes,
-which the consumer is responsible for serializing and deserializing.
-
-A typical consumer integration deserializes the broker payload back into an `EventRecord` and dispatches
-the wrapped `DomainEvent` to a handler. Sketch of the consumer side:
-
-```php
-$record = new EventRecord(
- id: Uuid::fromString($payload['event_id']),
- type: EventType::fromString(value: $payload['event_type']),
- event: $eventDeserializer->deserialize(type: $payload['event_type'], data: $payload['event_data']),
- identity: $identityDeserializer->deserialize(
- type: $payload['aggregate_type'],
- value: $payload['aggregate_id']
- ),
- revision: Revision::of(value: $payload['revision']),
- occurredOn: Instant::fromString($payload['occurred_on']),
- snapshotData: new SnapshotData(payload: json_decode($payload['snapshot'], true)),
- aggregateType: $payload['aggregate_type'],
- sequenceNumber: SequenceNumber::of(value: $payload['sequence_number'])
-);
-
-$handler->handle(record: $record);
-```
+### Snapshots
-The aggregate identity, aggregate type, sequence number, and revision are all available on the envelope.
-Handlers receive the full `EventRecord` rather than just the `DomainEvent`, so they can route or filter
-based on envelope metadata without that metadata leaking into the event itself.
+Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate.
-The library does not ship deserializers because the format depends entirely on the consumer's transport
-and storage choices. Consumers typically maintain a small registry mapping `EventType` values to concrete
-`DomainEvent` classes, and a similar mapping for identity types.
+#### Capturing aggregate state
-### Snapshots
+* `SnapshotData`: immutable record of aggregate state at a point in time. Exposes `toArray()` for read access. The
+ library deliberately does not provide encoding methods, serialization is the responsibility of the adapter that
+ persists or transmits the data.
-Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate.
+ ```php
+ use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
-#### Capturing a snapshot
+ $data = new SnapshotData(payload: ['status' => 'placed']);
+ $data->toArray();
+ ```
+
+* Aggregates control what fields enter the snapshot by overriding `getSnapshotState()`. The default captures every
+ declared property except `recordedEvents` and `sequenceNumber` (which are tracked separately on the envelope).
+
+ ```php
+ final class CartWithLogger implements EventSourcingRoot
+ {
+ use EventSourcingRootBehavior;
+
+ private CartId $id;
+ private array $productIds = [];
+ private LoggerInterface $logger;
+
+ protected function getSnapshotState(): array
+ {
+ return ['id' => $this->id, 'productIds' => $this->productIds];
+ }
+ }
+ ```
+
+#### Taking a snapshot
-* `Snapshot::fromAggregate`: reads all declared properties except `recordedEvents` and `sequenceNumber`. Both are
- tracked outside `aggregateState` because the snapshot has dedicated fields for them.
+* `Snapshot::fromAggregate()`: captures the aggregate's current state via the `getSnapshotState()` hook.
```php
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
+ $snapshot->aggregateState();
+ $snapshot->sequenceNumber();
```
-#### Persisting a snapshot
+#### Persisting snapshots
* `Snapshotter`: port for snapshot persistence. The `SnapshotterBehavior` trait captures the snapshot and delegates
- storage to a concrete `persist` hook.
+ storage to a `persist` hook implemented by the consumer.
```php
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
@@ -439,51 +437,38 @@ Snapshots let the event store skip replay of early events when reconstituting a
protected function persist(Snapshot $snapshot): void
{
- file_put_contents('/var/snapshots/cart.json', $snapshot->getAggregateState());
+ file_put_contents('/var/snapshots/cart.json', json_encode($snapshot->aggregateState()));
}
}
- $snapshotter = new FileSnapshotter();
- $snapshotter->take(aggregate: $cart);
- ```
-
-#### Deciding when to snapshot
-
-* `SnapshotCondition`: strategy for deciding whether a snapshot should be taken at a given point.
-
- ```php
- use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
- use TinyBlocks\BuildingBlocks\Snapshot\SnapshotCondition;
-
- final class EveryHundredEvents implements SnapshotCondition
- {
- public function shouldSnapshot(EventSourcingRoot $aggregate): bool
- {
- return $aggregate->getSequenceNumber()->value % 100 === 0;
- }
- }
+ new FileSnapshotter()->take(aggregate: $cart);
```
#### Built-in conditions
-Two ready-made implementations ship with the library:
-
-* `SnapshotEvery::events(count: N)` — returns `true` when the sequence number is a positive multiple of `N`.
- Throws `InvalidArgumentException` when `N < 1`.
+* `SnapshotCondition`: strategy for deciding whether a snapshot should be taken at a given point.
+* `SnapshotEvery::events(count: N)`: ready-made condition that triggers every `N` events (skipping sequence `0`).
+* `SnapshotNever::create()`: condition that never triggers, useful in tests and when snapshotting is explicitly
+ disabled.
```php
use TinyBlocks\BuildingBlocks\Snapshot\SnapshotEvery;
+ use TinyBlocks\BuildingBlocks\Snapshot\SnapshotNever;
- $condition = SnapshotEvery::events(count: 100);
- $condition->shouldSnapshot(aggregate: $cart); # true at sequences 100, 200, 300, …
+ $every100 = SnapshotEvery::events(count: 100);
+ $never = SnapshotNever::create();
```
-* `SnapshotNever::create()` — always returns `false`. Useful in tests or to explicitly disable snapshotting.
+ Custom conditions implement the interface directly:
```php
- use TinyBlocks\BuildingBlocks\Snapshot\SnapshotNever;
-
- $condition = SnapshotNever::create();
+ final class WhenStatusChanges implements SnapshotCondition
+ {
+ public function shouldSnapshot(EventSourcingRoot $aggregate): bool
+ {
+ # domain-specific logic
+ }
+ }
```
### Upcasting
@@ -492,8 +477,10 @@ Upcasters migrate serialized events across schema changes without touching the e
#### Defining an upcaster
-* `Upcaster`: transforms one `(type, revision)` pair forward by one step. Chains of upcasters handle multistep
- evolution. The `SingleUpcasterBehavior` trait binds the upcaster to a specific migration via three class constants.
+* `Upcaster`: transforms one `(type, revision)` pair forward by one step. Returns the event unchanged when the
+ type or revision does not match.
+* `SingleUpcasterBehavior`: binds the upcaster to a specific migration via three class constants and delegates the
+ payload transformation to an abstract `doUpcast()` method.
```php
use TinyBlocks\BuildingBlocks\Upcast\SingleUpcasterBehavior;
@@ -514,14 +501,16 @@ Upcasters migrate serialized events across schema changes without touching the e
}
```
-#### Upcasting an event
+#### Chaining upcasters
-* `upcast`: transforms the event if it matches the expected `(type, revision)`, otherwise returns it unchanged.
+* `Upcasters::chain()`: runs every upcaster in insertion order in a single forward pass. Upcasters whose type or
+ revision does not match pass the event through.
```php
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent;
+ use TinyBlocks\BuildingBlocks\Upcast\Upcasters;
$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
@@ -529,46 +518,18 @@ Upcasters migrate serialized events across schema changes without touching the e
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: [
+ $chain = Upcasters::createFrom(elements: [
new ProductV1Upcaster(),
- new ProductV2Upcaster(),
+ 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]
- ]);
+ $upcasted = $chain->chain(event: $event);
```
#### Default values for new fields
-* `DefaultValues`: type-to-default-value map for common primitive types, used when an upcast introduces a new field.
+* `DefaultValues::get()`: type-to-default-value map for common primitive types, used when an upcast introduces a
+ new field with a sensible zero-value default.
```php
use TinyBlocks\BuildingBlocks\Upcast\DefaultValues;
@@ -578,78 +539,101 @@ Upcasters migrate serialized events across schema changes without touching the e
## FAQ
-### 01. Why does `DomainEvent` only declare `revision()`?
+### 01. Why is `DomainEvent` close to a marker interface?
-`DomainEvent` declares one method, `revision()`, because schema versioning is an intrinsic property of the
-event's structure: it tells consumers which fields the event carries and what semantics they have.
-All other concerns — aggregate identity, aggregate type, sequence number, and serialization format —
-belong to `EventRecord`, not to the event itself. Keeping those out of `DomainEvent` prevents
-infrastructure from leaking into the domain model.
+A domain event is a fact about something that happened in the domain. The contract carries only `revision()` so
+the library can route schema migrations through upcasters. Everything else (aggregate identity, sequence number,
+aggregate type, occurrence timestamp) is envelope metadata that belongs to `EventRecord`. Keeping the event itself
+minimal prevents infrastructure concerns from leaking into the domain model.
+
+> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8, "Domain Events".
### 02. Why does `EventualAggregateRoot` store `EventRecord` instead of `DomainEvent`?
-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.
-`push` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation.
+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. `push()` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no
+translation.
+
+> Gregor Hohpe and Bobby Woolf, *Enterprise Integration Patterns* (Addison-Wesley, 2003), "Envelope Wrapper".
### 03. Why are `EventualAggregateRoot` and `EventSourcingRoot` siblings instead of a hierarchy?
-Outbox and event sourcing are mutually exclusive persistence strategies. An aggregate either persists its state and
-emits events as side effects, or persists only its events as the source of truth. A common base beyond `AggregateRoot`
-would imply the two patterns can coexist on the same aggregate, which they cannot.
+Outbox and event sourcing are mutually exclusive persistence strategies. An aggregate either persists its state
+and emits events as side effects, or persists only its events as the source of truth. A common base beyond
+`AggregateRoot` would imply the two patterns can coexist on the same aggregate, which they cannot.
+
+> Martin Fowler, *Event Sourcing* (martinfowler.com, 2005).
+> Chris Richardson, *Microservices Patterns* (Manning, 2018), Chapter 3, "Transactional Outbox".
+
+### 04. Why does `Revision` live on the `DomainEvent` instead of the call site?
-### 04. Why does `blank` skip the constructor?
+The revision of an event is a property of the event's schema. Keeping it on the event means the call site (`push`,
+`when`) does not need to know the schema version, the event class is the single source of truth. Bumping a
+revision is always paired with a payload change (added field, removed field, renamed field), so creating a new
+event class to carry the new revision is the natural unit of work.
-`EventSourcingRootBehavior::blank` instantiates the aggregate via reflection without invoking its constructor because
-all aggregate state in an event-sourced model must come from events or from a snapshot. Any invariants established by
-the constructor would contradict that principle. Concrete aggregates should treat their constructor as private and
-reserved for internal use.
+> Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017).
-### 05. Why are `recordedEvents` and `sequenceNumber` excluded from `Snapshot::aggregateState`?
+### 05. Why does `blank()` skip the constructor?
-`recordedEvents` belongs to the current unit of work, not to the aggregate's intrinsic state. `sequenceNumber` is
-already carried by the snapshot as a first-class field, so duplicating it inside `aggregateState` would force
-consumers to decide which copy is authoritative.
+`EventSourcingRootBehavior::blank()` instantiates the aggregate via reflection without invoking its constructor
+because all aggregate state in an event-sourced model must come from events or from a snapshot. Any invariants
+established by the constructor would contradict that principle. Concrete aggregates should treat their constructor
+as private and reserved for internal use during command handling.
-### 06. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace?
+> Greg Young, *CQRS Documents* (2010), "Event Sourcing" section.
-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.
+### 06. Why doesn't the library serialize envelopes to JSON or any other wire format?
-### 07. Why did `IDENTITY` and `MODEL_VERSION` move from constants to methods?
+Serialization is an infrastructure concern. Putting encoding methods on domain value objects mixes that concern
+into the domain layer, which contradicts the library's persistence-agnostic stance. Adapters such as
+`tiny-blocks/outbox` provide dedicated serializer ports. The domain layer exposes `EventRecord`, `SnapshotData`,
+and the value objects as pure data, downstream adapters decide how to map them onto bytes.
-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.
+> Alistair Cockburn, *Hexagonal Architecture* (alistair.cockburn.us, 2005).
-### 08. Why do `Revision`, `SequenceNumber`, and `EventType` now have private constructors?
+### 07. What is the difference between `ModelVersion` and `SequenceNumber`?
-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.
+`SequenceNumber` counts events per aggregate instance. It is the basis for optimistic concurrency control: a save
+fails if the sequence number in storage differs from the in-memory sequence the aggregate believed it had.
-### 09. Should I add `identity()`, `aggregateType()`, or `toSnapshot()` to my `DomainEvent`?
+`ModelVersion` versions the aggregate type itself. When the aggregate schema changes in a backwards-incompatible
+way (a property is removed, renamed, or its semantics shift), bumping the model version gives migration code a
+single source of truth to branch on.
+
+The two are different concepts that happen to share an integer representation. They are typed as separate value
+objects to prevent accidental comparisons across them at compile time.
+
+> Martin Fowler, *Patterns of Enterprise Application Architecture* (Addison-Wesley, 2002), "Optimistic Offline
+> Lock", source of `SequenceNumber` semantics.
+> Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017), source of `ModelVersion` semantics.
+
+### 08. Why is the `EventualAggregateRoot` use-once?
+
+The recorded-events buffer is never cleared by the library. After the application service drains
+`recordedEvents()` into the outbox, the aggregate instance must be discarded. Re-saving the same instance pushes
+the same envelopes again and deterministically fails with a duplicate-event error from the outbox.
+
+This is intentional. It surfaces re-save bugs at the database layer instead of hiding them via implicit state
+mutation. Applications that genuinely need to mutate the same logical aggregate twice in one process must reload
+from the repository between operations.
+
+> Eric Evans, *Domain-Driven Design* (Addison-Wesley, 2003), Chapter 6, "Aggregates" (single transactional unit
+> per aggregate per request).
+
+### 09. Should I add `identity()`, `aggregateType()`, or `toArray()` to my `DomainEvent`?
No. These three concerns live elsewhere:
-- **Identity and aggregate type** are envelope metadata. They are added by the aggregate when it builds
- the `EventRecord` (see `AggregateRootBehavior::buildEventRecord`) and are accessed on the consumer
- side through the envelope, not the event.
-- **Serialization** is an infrastructure concern. The event remains a pure PHP object; serialization
- happens in the outbox writer and the consumer deserializer, both of which live in the consumer
- project.
-
-A `DomainEvent` that grows methods like `identity()`, `aggregateType()`, or `toSnapshot()` is duplicating
-envelope data already on the `EventRecord` and pulling infrastructure into the domain layer. If you find
-yourself reaching for these methods, the likely root cause is that consumer code is not unwrapping the
-envelope correctly. See the *Consuming events* section above for the intended consumer-side pattern.
+* Identity and aggregate type are envelope metadata. They are added by the aggregate when it builds the
+ `EventRecord` (see `AggregateRootBehavior::buildEventRecord`) and are accessed on the consumer side through the
+ envelope, not the event.
+* Serialization is an infrastructure concern. The event remains a pure PHP object, serialization happens in the
+ outbox writer and the consumer deserializer, both of which live downstream of the library.
+
+A `DomainEvent` that grows methods like these duplicates envelope data already on the `EventRecord` and pulls
+infrastructure into the domain layer.
## License
diff --git a/src/Aggregate/AggregateRoot.php b/src/Aggregate/AggregateRoot.php
index 0118b16..bc81631 100644
--- a/src/Aggregate/AggregateRoot.php
+++ b/src/Aggregate/AggregateRoot.php
@@ -39,18 +39,17 @@ interface AggregateRoot extends Entity
*
* @return SequenceNumber The current sequence number.
*/
- public function getSequenceNumber(): SequenceNumber;
+ public function sequenceNumber(): SequenceNumber;
/**
* Returns the schema version of this aggregate type.
*
- * 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.
+ * Defaults to ModelVersion::initial() (value 0) when not overridden. Used by consumers
+ * to migrate aggregate schemas when loading older persisted state.
*
- * @return SequenceNumber The declared model version, or 0 when not overridden.
+ * @return ModelVersion The declared model version.
*/
- public function getModelVersion(): SequenceNumber;
+ public function modelVersion(): ModelVersion;
/**
* Returns the short class name of this aggregate.
@@ -60,5 +59,5 @@ public function getModelVersion(): SequenceNumber;
*
* @return string The short class name.
*/
- public function buildAggregateName(): string;
+ public function aggregateName(): string;
}
diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php
index 6889985..942c168 100644
--- a/src/Aggregate/AggregateRootBehavior.php
+++ b/src/Aggregate/AggregateRootBehavior.php
@@ -12,7 +12,7 @@
use TinyBlocks\BuildingBlocks\Event\EventRecords;
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Event\SnapshotData;
+use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
trait AggregateRootBehavior
@@ -23,44 +23,44 @@ trait AggregateRootBehavior
private SequenceNumber $sequenceNumber;
- public function getSequenceNumber(): SequenceNumber
+ public function sequenceNumber(): SequenceNumber
{
return $this->sequenceNumber ?? SequenceNumber::initial();
}
- public function getModelVersion(): SequenceNumber
+ public function modelVersion(): ModelVersion
{
- return SequenceNumber::of(value: $this->modelVersion());
+ return ModelVersion::initial();
}
- public function buildAggregateName(): string
+ public function aggregateName(): string
{
- return new ReflectionClass(static::class)->getShortName();
+ return new ReflectionClass(objectOrClass: static::class)->getShortName();
}
- protected function modelVersion(): int
+ protected function nextSequenceNumber(): void
{
- return 0;
+ $this->sequenceNumber = $this->sequenceNumber()->next();
}
- protected function nextSequenceNumber(): void
+ protected function generateSnapshotData(): SnapshotData
{
- $this->sequenceNumber = $this->getSequenceNumber()->next();
+ return new SnapshotData(payload: $this->snapshotState());
}
- public function recordedEvents(): EventRecords
+ protected function snapshotState(): array
{
- $records = $this->recordedEvents ?? EventRecords::createFromEmpty();
+ $state = get_object_vars($this);
+ unset($state['recordedEvents'], $state['sequenceNumber']);
- return EventRecords::createFrom(elements: $records);
+ return $state;
}
- protected function generateSnapshotData(): SnapshotData
+ public function recordedEvents(): EventRecords
{
- $state = get_object_vars($this);
- unset($state['recordedEvents']);
+ $records = $this->recordedEvents ?? EventRecords::createFromEmpty();
- return new SnapshotData(payload: $state);
+ return EventRecords::createFrom(elements: $records);
}
protected function buildEventRecord(DomainEvent $event): EventRecord
@@ -69,12 +69,12 @@ protected function buildEventRecord(DomainEvent $event): EventRecord
id: Uuid::uuid4(),
type: EventType::fromEvent(event: $event),
event: $event,
- identity: $this->getIdentity(),
+ identity: $this->identity(),
revision: $event->revision(),
occurredOn: Instant::now(),
snapshotData: $this->generateSnapshotData(),
- aggregateType: $this->buildAggregateName(),
- sequenceNumber: $this->getSequenceNumber()
+ aggregateType: $this->aggregateName(),
+ sequenceNumber: $this->sequenceNumber()
);
}
}
diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php
index c22079e..973c769 100644
--- a/src/Aggregate/EventSourcingRoot.php
+++ b/src/Aggregate/EventSourcingRoot.php
@@ -54,7 +54,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 MissingIdentityProperty When the property referenced by 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 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 MissingIdentityProperty When the property referenced by identityName() 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 array Keyed by property name.
*/
- public function getSnapshotState(): array;
+ public function snapshotState(): array;
/**
* Restores aggregate state from the given snapshot.
*
- * 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
* reconstitute(); implementations should not touch it.
*
diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php
index 31bc602..c29920a 100644
--- a/src/Aggregate/EventSourcingRootBehavior.php
+++ b/src/Aggregate/EventSourcingRootBehavior.php
@@ -39,7 +39,7 @@ public static function reconstitute(
if (!is_null($snapshot)) {
$aggregate->applySnapshot(snapshot: $snapshot);
- $aggregate->sequenceNumber = $snapshot->getSequenceNumber();
+ $aggregate->sequenceNumber = $snapshot->sequenceNumber();
}
foreach ($records as $record) {
@@ -54,7 +54,7 @@ public function eventHandlers(): array
return [];
}
- public function getSnapshotState(): array
+ public function snapshotState(): array
{
$state = get_object_vars($this);
unset($state['recordedEvents'], $state['sequenceNumber']);
diff --git a/src/Aggregate/EventualAggregateRoot.php b/src/Aggregate/EventualAggregateRoot.php
index 2a8fc9f..642473d 100644
--- a/src/Aggregate/EventualAggregateRoot.php
+++ b/src/Aggregate/EventualAggregateRoot.php
@@ -10,9 +10,15 @@
* Aggregate root variant that records domain events for eventual publication via transactional outbox.
*
* 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
- * recordedEvents() after persisting the aggregate state and then call
- * clearRecordedEvents() to reset the buffer for the next unit of work.
+ * at-least-once to external consumers. The repository drains 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.
*/
interface CompoundIdentity extends Identity
diff --git a/src/Entity/CompoundIdentityBehavior.php b/src/Entity/CompoundIdentityBehavior.php
index 55bf559..c679a75 100644
--- a/src/Entity/CompoundIdentityBehavior.php
+++ b/src/Entity/CompoundIdentityBehavior.php
@@ -10,7 +10,7 @@ trait CompoundIdentityBehavior
{
use ValueObjectBehavior;
- public function getIdentityValue(): mixed
+ public function identityValue(): mixed
{
return get_object_vars($this);
}
diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php
index 5917022..5896f64 100644
--- a/src/Entity/Entity.php
+++ b/src/Entity/Entity.php
@@ -13,7 +13,7 @@
* across distinct representations and lifecycle transitions. Two entities are equal when their
* identities are equal, regardless of attribute differences.
*
- * 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.
*
* @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
@@ -25,17 +25,17 @@ interface Entity
* Returns the Identity that uniquely identifies this entity.
*
* @return Identity The identity instance held by this entity.
- * @throws MissingIdentityProperty When the property referenced by 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().
+ * reads it by reflection and returns it from 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
*/
- public function getProductIds(): array
+ public function productIds(): array
{
return $this->productIds;
}
- protected function identityName(): string
+ protected function identityProperty(): string
{
return 'cartId';
}
- protected function modelVersion(): int
+ public function modelVersion(): ModelVersion
{
- return 1;
+ return ModelVersion::of(value: 1);
}
protected function whenProductAdded(ProductAdded $event): void
diff --git a/tests/Models/CartWithLogger.php b/tests/Models/CartWithLogger.php
index bf72e27..242ece4 100644
--- a/tests/Models/CartWithLogger.php
+++ b/tests/Models/CartWithLogger.php
@@ -27,10 +27,10 @@ public function addProduct(string $productId): void
public function applySnapshot(Snapshot $snapshot): void
{
- $this->productIds = $snapshot->getAggregateState()['productIds'] ?? [];
+ $this->productIds = $snapshot->aggregateState()['productIds'] ?? [];
}
- public function getSnapshotState(): array
+ public function snapshotState(): array
{
$state = get_object_vars($this);
unset($state['recordedEvents'], $state['sequenceNumber'], $state['logBuffer']);
@@ -41,12 +41,12 @@ public function getSnapshotState(): array
/**
* @return list
*/
- public function getProductIds(): array
+ public function productIds(): array
{
return $this->productIds;
}
- protected function identityName(): string
+ protected function identityProperty(): string
{
return 'cartId';
}
diff --git a/tests/Models/CartWithoutHandler.php b/tests/Models/CartWithoutHandler.php
index 8c545f3..86b6b8d 100644
--- a/tests/Models/CartWithoutHandler.php
+++ b/tests/Models/CartWithoutHandler.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 CartWithoutHandler implements EventSourcingRoot
@@ -18,13 +19,13 @@ public function applySnapshot(Snapshot $snapshot): void
{
}
- protected function identityName(): string
+ protected function identityProperty(): string
{
return 'cartId';
}
- protected function modelVersion(): int
+ public function modelVersion(): ModelVersion
{
- return 1;
+ return ModelVersion::of(value: 1);
}
}
diff --git a/tests/Models/ExplicitCart.php b/tests/Models/ExplicitCart.php
index 8875743..14352e1 100644
--- a/tests/Models/ExplicitCart.php
+++ b/tests/Models/ExplicitCart.php
@@ -29,7 +29,7 @@ public function addProductV2(string $productId, int $quantity): void
public function applySnapshot(Snapshot $snapshot): void
{
- $this->productIds = $snapshot->getAggregateState()['productIds'] ?? [];
+ $this->productIds = $snapshot->aggregateState()['productIds'] ?? [];
}
public function eventHandlers(): array
@@ -47,12 +47,12 @@ public function eventHandlers(): array
/**
* @return list
*/
- public function getProductIds(): array
+ public function productIds(): array
{
return $this->productIds;
}
- protected function identityName(): string
+ protected function identityProperty(): string
{
return 'cartId';
}
diff --git a/tests/Models/Order.php b/tests/Models/Order.php
index 4059585..0fc63f5 100644
--- a/tests/Models/Order.php
+++ b/tests/Models/Order.php
@@ -31,9 +31,4 @@ public function ship(string $carrier): void
$this->status = 'shipped';
$this->push(event: new OrderShipped(carrier: $carrier));
}
-
- public function getStatus(): string
- {
- return $this->status;
- }
}
diff --git a/tests/Models/OrderWithMissingIdentityProperty.php b/tests/Models/OrderWithMissingIdentityProperty.php
index 04af2a5..5019d08 100644
--- a/tests/Models/OrderWithMissingIdentityProperty.php
+++ b/tests/Models/OrderWithMissingIdentityProperty.php
@@ -16,7 +16,7 @@ public function ship(): void
$this->push(event: new OrderShipped(carrier: 'DHL'));
}
- protected function identityName(): string
+ protected function identityProperty(): string
{
return 'nonExistentProperty';
}
diff --git a/tests/Snapshot/SnapshotDataTest.php b/tests/Snapshot/SnapshotDataTest.php
new file mode 100644
index 0000000..1073d87
--- /dev/null
+++ b/tests/Snapshot/SnapshotDataTest.php
@@ -0,0 +1,65 @@
+ '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 testToArrayReturnsSameReferenceForNestedPayload(): void
+ {
+ /** @Given snapshot data with a nested payload */
+ $snapshotData = new SnapshotData(payload: ['order' => ['item' => 'book', 'qty' => 2]]);
+
+ /** @When converting to array */
+ $payload = $snapshotData->toArray();
+
+ /** @Then the nested structure is preserved exactly */
+ self::assertSame(['order' => ['item' => 'book', 'qty' => 2]], $payload);
+ }
+
+ 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/Snapshot/SnapshotTest.php b/tests/Snapshot/SnapshotTest.php
index 07d1535..b21852f 100644
--- a/tests/Snapshot/SnapshotTest.php
+++ b/tests/Snapshot/SnapshotTest.php
@@ -26,7 +26,7 @@ public function testFromAggregateCapturesAggregateType(): void
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
/** @Then the type matches the aggregate's short class name */
- self::assertSame('Cart', $snapshot->getType());
+ self::assertSame('Cart', $snapshot->type());
}
public function testFromAggregateCapturesAggregateId(): void
@@ -38,7 +38,7 @@ public function testFromAggregateCapturesAggregateId(): void
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
/** @Then the aggregate id reflects the identity value */
- self::assertSame('cart-id-42', $snapshot->getAggregateId());
+ self::assertSame('cart-id-42', $snapshot->aggregateId());
}
public function testFromAggregateCapturesSequenceNumber(): void
@@ -50,7 +50,7 @@ public function testFromAggregateCapturesSequenceNumber(): void
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
/** @Then the sequence number is captured */
- self::assertSame(2, $snapshot->getSequenceNumber()->value);
+ self::assertSame(2, $snapshot->sequenceNumber()->value);
}
public function testFromAggregateCapturesCreatedAt(): void
@@ -62,7 +62,7 @@ public function testFromAggregateCapturesCreatedAt(): void
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
/** @Then the createdAt timestamp is set */
- self::assertInstanceOf(Instant::class, $snapshot->getCreatedAt());
+ self::assertInstanceOf(Instant::class, $snapshot->createdAt());
}
public function testFromAggregateCarriesDomainFieldsInState(): void
@@ -74,7 +74,7 @@ public function testFromAggregateCarriesDomainFieldsInState(): void
$cart->addProduct(productId: 'prod-x');
/** @When taking a snapshot */
- $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState();
+ $state = Snapshot::fromAggregate(aggregate: $cart)->aggregateState();
/** @Then the state carries the domain fields */
self::assertSame(['prod-x'], $state['productIds']);
@@ -89,7 +89,7 @@ public function testFromAggregateStateOmitsRecordedEventsBuffer(): void
$cart->addProduct(productId: 'prod-x');
/** @When taking a snapshot */
- $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState();
+ $state = Snapshot::fromAggregate(aggregate: $cart)->aggregateState();
/** @Then the transient recording buffer is not part of the persisted state */
self::assertArrayNotHasKey('recordedEvents', $state);
@@ -104,7 +104,7 @@ public function testFromAggregateStateOmitsSequenceNumber(): void
$cart->addProduct(productId: 'prod-x');
/** @When taking a snapshot */
- $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState();
+ $state = Snapshot::fromAggregate(aggregate: $cart)->aggregateState();
/** @Then the sequence number is not duplicated into the state */
self::assertArrayNotHasKey('sequenceNumber', $state);
@@ -128,10 +128,10 @@ public function testRoundTripThroughSnapshotRestoresDomainState(): void
$reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
/** @Then the reconstituted aggregate carries the same domain state */
- self::assertSame(['prod-roundtrip'], $reconstituted->getProductIds());
+ self::assertSame(['prod-roundtrip'], $reconstituted->productIds());
}
- public function testGetSnapshotStateExcludesInfrastructureProperty(): void
+ public function testSnapshotStateExcludesInfrastructureProperty(): void
{
/** @Given a blank cart with a logger */
$cart = CartWithLogger::blank(identity: new CartId(value: 'cart-logger-1'));
@@ -140,10 +140,10 @@ public function testGetSnapshotStateExcludesInfrastructureProperty(): void
$cart->addProduct(productId: 'prod-1');
/** @Then the snapshot state does not contain the log buffer */
- self::assertArrayNotHasKey('logBuffer', $cart->getSnapshotState());
+ self::assertArrayNotHasKey('logBuffer', $cart->snapshotState());
}
- public function testGetSnapshotStateIncludesDomainFields(): void
+ public function testSnapshotStateIncludesDomainFields(): void
{
/** @Given a blank cart with a logger */
$cart = CartWithLogger::blank(identity: new CartId(value: 'cart-logger-2'));
@@ -152,7 +152,7 @@ public function testGetSnapshotStateIncludesDomainFields(): void
$cart->addProduct(productId: 'prod-snapshot');
/** @Then the snapshot state includes the domain fields */
- self::assertSame(['prod-snapshot'], $cart->getSnapshotState()['productIds']);
+ self::assertSame(['prod-snapshot'], $cart->snapshotState()['productIds']);
}
public function testFromAggregateWithOverriddenSnapshotStateExcludesInfrastructureProperty(): void
@@ -164,7 +164,7 @@ public function testFromAggregateWithOverriddenSnapshotStateExcludesInfrastructu
$cart->addProduct(productId: 'prod-x');
/** @Then the snapshot does not carry the log buffer in the aggregate state */
- self::assertArrayNotHasKey('logBuffer', Snapshot::fromAggregate(aggregate: $cart)->getAggregateState());
+ self::assertArrayNotHasKey('logBuffer', Snapshot::fromAggregate(aggregate: $cart)->aggregateState());
}
public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void
diff --git a/tests/Snapshot/SnapshotterBehaviorTest.php b/tests/Snapshot/SnapshotterBehaviorTest.php
index 4ec2715..fb13d67 100644
--- a/tests/Snapshot/SnapshotterBehaviorTest.php
+++ b/tests/Snapshot/SnapshotterBehaviorTest.php
@@ -36,7 +36,7 @@ public function testPersistedSnapshotReflectsTheAggregateType(): void
$snapshotter->take(aggregate: $cart);
/** @Then the persisted snapshot carries the aggregate's type */
- self::assertSame('Cart', $snapshotter->lastSnapshot()->getType());
+ self::assertSame('Cart', $snapshotter->lastSnapshot()->type());
}
public function testPersistedSnapshotReflectsTheAggregateSequenceNumber(): void
@@ -51,7 +51,7 @@ public function testPersistedSnapshotReflectsTheAggregateSequenceNumber(): void
$snapshotter->take(aggregate: $cart);
/** @Then the persisted snapshot carries the aggregate's sequence number */
- self::assertSame(2, $snapshotter->lastSnapshot()->getSequenceNumber()->value);
+ self::assertSame(2, $snapshotter->lastSnapshot()->sequenceNumber()->value);
}
public function testPersistedSnapshotReflectsTheAggregateIdentity(): void
@@ -64,6 +64,6 @@ public function testPersistedSnapshotReflectsTheAggregateIdentity(): void
$snapshotter->take(aggregate: $cart);
/** @Then the persisted snapshot carries the aggregate id */
- self::assertSame('cart-4', $snapshotter->lastSnapshot()->getAggregateId());
+ self::assertSame('cart-4', $snapshotter->lastSnapshot()->aggregateId());
}
}