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()); } }