Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 97 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
+ [Event sourcing](#event-sourcing)
+ [Snapshots](#snapshots)
+ [Upcasting](#upcasting)
- [Defining an upcaster](#defining-an-upcaster)
- [Upcasting an event](#upcasting-an-event)
- [Chaining upcasters](#chaining-upcasters)
- [Reconstituting from an iterable](#reconstituting-from-an-iterable)
- [Default values for new fields](#default-values-for-new-fields)
* [FAQ](#faq)
* [License](#license)
* [Contributing](#contributing)
Expand All @@ -19,10 +24,7 @@

The `Building Blocks` library provides the tactical design building blocks of Domain-Driven Design: `Entity`,
`Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox
or an event-sourced store.

It is persistence-agnostic and framework-agnostic. It depends only on the other `tiny-blocks` primitives
(`immutable-object`, `value-object`, `collection`, `time`) and `ramsey/uuid` for event identifiers.
or an event-sourced store. It is persistence-agnostic and framework-agnostic.

Domain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not
replace PSR-14; it defines what flows through it.
Expand All @@ -44,7 +46,8 @@ The library exposes three styles of aggregate modeling through sibling interface

### Entity

Every entity declares an `IDENTITY` constant pointing to the property that holds its `Identity`.
Every entity implements a protected `identityName()` method returning the name of the property that holds its
`Identity`.

#### Single-field identity

Expand Down Expand Up @@ -93,7 +96,7 @@ Every entity declares an `IDENTITY` constant pointing to the property that holds
#### Identity access

* `getIdentity`, `getIdentityValue`, `sameIdentityOf`, `identityEquals`: provided by `EntityBehavior` for any entity
that declares the `IDENTITY` constant.
that implements `identityName()`.

```php
use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot;
Expand All @@ -103,11 +106,14 @@ Every entity declares an `IDENTITY` constant pointing to the property that holds
{
use AggregateRootBehavior;

private const string IDENTITY = 'userId';

private function __construct(private UserId $userId, private string $email)
{
}

protected function identityName(): string
{
return 'userId';
}
}

$user->sameIdentityOf(other: $otherUser);
Expand All @@ -129,21 +135,31 @@ control and a `ModelVersion` for schema evolution of the aggregate type itself.
{
use AggregateRootBehavior;

private const string IDENTITY = 'userId';
protected function identityName(): string
{
return 'userId';
}
}

$user->getSequenceNumber();
```

* `getModelVersion`: resolved from the optional `MODEL_VERSION` class constant, defaults to zero when absent.
* `getModelVersion`: resolved from the protected `modelVersion()` method, defaults to zero when not overridden.

```php
final class Cart implements AggregateRoot
{
use AggregateRootBehavior;

private const string IDENTITY = 'cartId';
private const int MODEL_VERSION = 1;
protected function identityName(): string
{
return 'cartId';
}

protected function modelVersion(): int
{
return 1;
}
}

$cart->getModelVersion();
Expand Down Expand Up @@ -177,7 +193,7 @@ emitted as side effects and must be delivered at-least-once.

#### Emitting events from the aggregate

* `pushEvent`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a
* `push`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a
fully-built `EventRecord` to the recorded buffer. The `Revision` is provided on the call site, so the event class
stays pure.

Expand All @@ -190,19 +206,22 @@ emitted as side effects and must be delivered at-least-once.
{
use EventualAggregateRootBehavior;

private const string IDENTITY = 'orderId';

private function __construct(private OrderId $orderId)
{
}

public static function place(OrderId $orderId, string $item): Order
{
$order = new Order(orderId: $orderId);
$order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1));
$order->push(event: new OrderPlaced(item: $item), revision: Revision::initial());

return $order;
}

protected function identityName(): string
{
return 'orderId';
}
}
```

Expand Down Expand Up @@ -240,21 +259,24 @@ emitted as side effects and must be delivered at-least-once.
{
use EventSourcingRootBehavior;

private const string IDENTITY = 'cartId';

private CartId $cartId;
private array $productIds = [];

public function addProduct(string $productId): void
{
$this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1));
$this->when(event: new ProductAdded(productId: $productId), revision: Revision::initial());
}

public function applySnapshot(Snapshot $snapshot): void
{
$this->productIds = $snapshot->getAggregateState()['productIds'] ?? [];
}

protected function identityName(): string
{
return 'cartId';
}

protected function whenProductAdded(ProductAdded $event): void
{
$this->productIds[] = $event->productId;
Expand Down Expand Up @@ -383,13 +405,47 @@ Upcasters migrate serialized events across schema changes without touching the e

$event = new IntermediateEvent(
type: EventType::fromString(value: 'ProductAdded'),
revision: new Revision(value: 1),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);

$upcasted = new ProductV1Upcaster()->upcast(event: $event);
```

#### Chaining upcasters

* `Upcasters`: ordered collection of `Upcaster` instances. `chain` folds them left-to-right over an
`IntermediateEvent`, applying each upcaster in sequence. Upcasters that do not match the current `(type, revision)`
pair pass the event through unchanged.

```php
use TinyBlocks\BuildingBlocks\Upcast\Upcasters;

$upcasters = Upcasters::createFrom(elements: [
new ProductV1Upcaster(),
new ProductV2Upcaster(),
]);

$upcasted = $upcasters->chain(event: $event);
```

#### Reconstituting from an iterable

* `IntermediateEvent` implements `ObjectMapper`, so it can be reconstituted from an iterable of typed field values.
Pass already-constructed `EventType` and `Revision` instances — the mapper maps each field by name.

```php
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent;

$event = IntermediateEvent::fromIterable(iterable: [
'type' => EventType::fromString(value: 'ProductAdded'),
'revision' => Revision::of(value: 2),
'serializedEvent' => ['productId' => 'prod-1', 'quantity' => 1],
]);
```

#### Default values for new fields

* `DefaultValues`: type-to-default-value map for common primitive types, used when an upcast introduces a new field.
Expand All @@ -412,7 +468,7 @@ the event itself. Keeping the event pure prevents infrastructure concerns from l

Only the aggregate has the context needed to build the complete envelope: identity, sequence number, aggregate type
name. Storing raw events and wrapping them later would either duplicate that context or require a second pass.
`pushEvent` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation.
`push` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation.

### 03. Why are `EventualAggregateRoot` and `EventSourcingRoot` siblings instead of a hierarchy?

Expand All @@ -422,7 +478,7 @@ would imply the two patterns can coexist on the same aggregate, which they canno

### 04. Why does `Revision` live on the call site instead of on the event class?

Keeping `Revision` on the `pushEvent` or `when` call site makes the aggregate the author of schema evolution. The
Keeping `Revision` on the `push` or `when` call site makes the aggregate the author of schema evolution. The
event class stays pure. Bumping the revision of an existing event does not require creating a new class.

### 05. Why does `blank` skip the constructor?
Expand All @@ -440,11 +496,28 @@ consumers to decide which copy is authoritative.

### 07. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace?

Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, `MissingIdentityConstant`
and `MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or
Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, and
`MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or
`RuntimeException` from the PHP standard library, so consumers that catch the broad standard types continue to work;
consumers that need precise handling can catch the specific classes.

### 08. Why did `IDENTITY` and `MODEL_VERSION` move from constants to methods?

Class constants read by reflection inside traits are invisible to static analyzers such as PHPStan and Psalm. Every
concrete aggregate had to annotate `@phpstan-ignore-next-line` or equivalent suppressions just to satisfy level-9
analysis. Replacing them with a protected `identityName(): string` method and a protected `modelVersion(): int`
method makes the contract explicit in PHP's type system: the compiler enforces implementation, IDEs can navigate to
it, and static analyzers raise no warnings — in the library or at consumer sites.

### 09. Why do `Revision`, `SequenceNumber`, and `EventType` now have private constructors?

These value objects have named static factories that carry semantic meaning: `Revision::initial()` communicates
"first schema revision", `SequenceNumber::first()` communicates "first recorded event", and
`EventType::fromEvent($event)` communicates "derive the type name from this event". Leaving the constructor public
allowed `new Revision(value: 1)` at call sites, which bypasses the semantic intent and mixes raw construction with
factory conventions. A private constructor forces all creation through the factories, making the intent visible at
every call site. The `of()` factory on `Revision` and `SequenceNumber` covers the loading-from-persistence path.

## License

Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE).
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"ramsey/uuid": "^4.9",
"tiny-blocks/collection": "^2.3",
"tiny-blocks/immutable-object": "^1.1",
"tiny-blocks/mapper": "^2.0",
"tiny-blocks/time": "^1.5",
"tiny-blocks/value-object": "^3.2"
},
Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ parameters:
- '#destructuring on mixed#'
- identifier: trait.unused
- '#expects int<1, max>#'
- message: '#Upcasters::chain\(\) should return#'
path: src/Upcast/Upcasters.php
Comment on lines +14 to +15
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

phpstan.neon.dist adds an ignore for the Upcasters::chain() return-type error. This reduces static-analysis signal for a real typing issue; it would be better to adjust Upcasters::chain() so PHPStan can infer/guarantee the return type and then drop this suppression.

Suggested change
- message: '#Upcasters::chain\(\) should return#'
path: src/Upcast/Upcasters.php

Copilot uses AI. Check for mistakes.
reportUnmatchedIgnoredErrors: false
6 changes: 3 additions & 3 deletions src/Aggregate/AggregateRoot.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ public function getSequenceNumber(): SequenceNumber;
/**
* Returns the schema version of this aggregate type.
*
* <p>Resolved from the optional <code>MODEL_VERSION</code> class constant, defaults to <code>0</code>
* when the constant is not declared. Used by consumers to migrate aggregate schemas when loading older
* <p>Resolved from the protected <code>modelVersion()</code> method, defaults to <code>0</code>
* when the method is not overridden. Used by consumers to migrate aggregate schemas when loading older
* persisted state.</p>
*
* @return SequenceNumber The declared model version, or 0 when undefined.
* @return SequenceNumber The declared model version, or 0 when not overridden.
*/
public function getModelVersion(): SequenceNumber;

Expand Down
27 changes: 14 additions & 13 deletions src/Aggregate/AggregateRootBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,32 @@ public function getSequenceNumber(): SequenceNumber

public function getModelVersion(): SequenceNumber
{
if (!defined('static::MODEL_VERSION')) {
return new SequenceNumber(value: 0);
}

return new SequenceNumber(value: static::MODEL_VERSION);
return SequenceNumber::of(value: $this->modelVersion());
}

public function buildAggregateName(): string
{
return new ReflectionClass(objectOrClass: static::class)->getShortName();
}

protected function modelVersion(): int
{
return 0;
}

protected function nextSequenceNumber(): void
{
$this->sequenceNumber = $this->getSequenceNumber()->next();
}

protected function generateSnapshotData(): SnapshotData
{
$state = get_object_vars($this);
unset($state['recordedEvents']);

return new SnapshotData(data: $state);
}

protected function buildEventRecord(DomainEvent $event, Revision $revision): EventRecord
{
return new EventRecord(
Expand All @@ -59,12 +68,4 @@ protected function buildEventRecord(DomainEvent $event, Revision $revision): Eve
sequenceNumber: $this->getSequenceNumber()
);
}

protected function generateSnapshotData(): SnapshotData
{
$state = get_object_vars($this);
unset($state['recordedEvents']);

return new SnapshotData(data: $state);
}
}
6 changes: 3 additions & 3 deletions src/Aggregate/EventSourcingRoot.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant;
use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;

/**
Expand Down Expand Up @@ -41,7 +41,7 @@ public function recordedEvents(): EventRecords;
*
* @param Identity $identity The identity to assign to the new aggregate.
* @return static A new aggregate in its initial state.
* @throws MissingIdentityConstant When the <code>IDENTITY</code> class constant is not defined.
* @throws MissingIdentityProperty When the property referenced by <code>identityName()</code> does not exist.
*/
public static function blank(Identity $identity): static;

Expand All @@ -56,7 +56,7 @@ public static function blank(Identity $identity): static;
* @param iterable<EventRecord> $records The event stream to replay, ordered by sequence number.
* @param Snapshot|null $snapshot Optional snapshot to restore from before replay.
* @return static The reconstituted aggregate.
* @throws MissingIdentityConstant When the <code>IDENTITY</code> class constant is not defined.
* @throws MissingIdentityProperty When the property referenced by <code>identityName()</code> does not exist.
*/
public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static;

Expand Down
8 changes: 2 additions & 6 deletions src/Aggregate/EventSourcingRootBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use TinyBlocks\BuildingBlocks\Event\EventRecords;
use TinyBlocks\BuildingBlocks\Event\Revision;
use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;

trait EventSourcingRootBehavior
Expand All @@ -30,12 +29,9 @@ public function recordedEvents(): EventRecords

public static function blank(Identity $identity): static
{
if (!defined('static::IDENTITY')) {
throw new MissingIdentityConstant(className: static::class);
}

$aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor();
new ReflectionProperty($aggregate, static::IDENTITY)->setValue(objectOrValue: $aggregate, value: $identity);
new ReflectionProperty($aggregate, $aggregate->identityName())
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EventSourcingRootBehavior::blank() uses ReflectionProperty with $aggregate->identityName() directly. If identityName() returns a non-existent property, this will throw a ReflectionException instead of the library’s MissingIdentityProperty (and contradicts the EventSourcingRoot PHPDoc that now advertises MissingIdentityProperty). Consider resolving the property name via $aggregate->getIdentityName() (or explicitly checking property_exists and throwing MissingIdentityProperty) before constructing ReflectionProperty.

Suggested change
new ReflectionProperty($aggregate, $aggregate->identityName())
$identityProperty = $aggregate->identityName();
if (!property_exists($aggregate, $identityProperty)) {
throw new MissingIdentityProperty($identityProperty);
}
new ReflectionProperty($aggregate, $identityProperty)

Copilot uses AI. Check for mistakes.
->setValue(objectOrValue: $aggregate, value: $identity);
$aggregate->sequenceNumber = SequenceNumber::initial();
$aggregate->recordedEvents = EventRecords::createFromEmpty();

Expand Down
2 changes: 1 addition & 1 deletion src/Aggregate/EventualAggregateRootBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function clearRecordedEvents(): void
$this->recordedEvents = EventRecords::createFromEmpty();
}

protected function pushEvent(DomainEvent $event, Revision $revision): void
protected function push(DomainEvent $event, Revision $revision): void
{
$this->nextSequenceNumber();
$this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())
Expand Down
Loading
Loading