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.
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.
composer require tiny-blocks/building-blocks
The library exposes three styles of aggregate modeling through sibling interfaces:
AggregateRootfor plain DDD modeling without events.EventualAggregateRootfor aggregates that persist state and emit events as side effects via a transactional outbox.EventSourcingRootfor aggregates whose state is derived entirely from their ordered event stream.
Every entity implements a protected identityName() method returning the name of the property that holds its
Identity.
-
SingleIdentity: identity backed by a single scalar value (UUID, auto-increment integer, etc.).use TinyBlocks\BuildingBlocks\Entity\SingleIdentity; use TinyBlocks\BuildingBlocks\Entity\SingleIdentityBehavior; final readonly class OrderId implements SingleIdentity { use SingleIdentityBehavior; public function __construct(public string $value) { } } $orderId = new OrderId(value: 'ord-1'); $orderId->getIdentityValue();
-
CompoundIdentity: identity composed of multiple fields treated as a tuple.use TinyBlocks\BuildingBlocks\Entity\CompoundIdentity; use TinyBlocks\BuildingBlocks\Entity\CompoundIdentityBehavior; final readonly class AppointmentId implements CompoundIdentity { use CompoundIdentityBehavior; public function __construct( public string $tenantId, public string $appointmentId ) { } } $appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); $appointmentId->getIdentityValue();
-
getIdentity,getIdentityValue,sameIdentityOf,identityEquals: provided byEntityBehaviorfor any entity that implementsidentityName().use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior; final class User implements AggregateRoot { use AggregateRootBehavior; private function __construct(private UserId $userId, private string $email) { } protected function identityName(): string { return 'userId'; } } $user->sameIdentityOf(other: $otherUser); $user->identityEquals(other: new UserId(value: 'usr-1'));
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.use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior; final class User implements AggregateRoot { use AggregateRootBehavior; protected function identityName(): string { return 'userId'; } } $user->getSequenceNumber();
-
getModelVersion: resolved from the protectedmodelVersion()method, defaults to zero when not overridden.final class Cart implements AggregateRoot { use AggregateRootBehavior; protected function identityName(): string { return 'cartId'; } protected function modelVersion(): int { return 1; } } $cart->getModelVersion();
-
buildAggregateName: short class name, used as the aggregate type identifier on eachEventRecord.$user->buildAggregateName();
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.
-
DomainEvent: empty marker interface. A domain event is a plain PHP object.use TinyBlocks\BuildingBlocks\Event\DomainEvent; final readonly class OrderPlaced implements DomainEvent { public function __construct(public string $item) { } }
-
push: protected method onEventualAggregateRootBehavior. Increments the sequence number and appends a fully-builtEventRecordto the recorded buffer. TheRevisionis provided on the call site, so the event class stays pure.use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior; use TinyBlocks\BuildingBlocks\Event\Revision; final class Order implements EventualAggregateRoot { use EventualAggregateRootBehavior; private function __construct(private OrderId $orderId) { } public static function place(OrderId $orderId, string $item): Order { $order = new Order(orderId: $orderId); $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial()); return $order; } protected function identityName(): string { return 'orderId'; } }
-
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.$order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book'); foreach ($order->recordedEvents() as $record) { $outbox->append(record: $record); } $order->clearRecordedEvents();
EventSourcingRoot stores no state of its own; state is derived by replaying the event stream.
-
when: protected method that records the event and immediately applies it to state by dispatching to awhen<EventShortName>method by reflection.use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class Cart implements EventSourcingRoot { use EventSourcingRootBehavior; private CartId $cartId; private array $productIds = []; public function addProduct(string $productId): void { $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; } }
-
blank: factory that instantiates the aggregate without invoking its constructor. All state must come from events or from a snapshot.$cart = Cart::blank(identity: new CartId(value: 'cart-1'));
-
reconstitute: replays an ordered stream ofEventRecordinstances, optionally starting from a snapshot to skip earlier events. When a snapshot is provided, its sequence number is authoritative.$cart = Cart::reconstitute(identity: new CartId(value: 'cart-1'), records: $records);
$cart = Cart::reconstitute( identity: new CartId(value: 'cart-1'), records: $laterRecords, snapshot: $snapshot );
Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate.
-
Snapshot::fromAggregate: reads all declared properties exceptrecordedEventsandsequenceNumber. Both are tracked outsideaggregateStatebecause the snapshot has dedicated fields for them.use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; $snapshot = Snapshot::fromAggregate(aggregate: $cart);
-
Snapshotter: port for snapshot persistence. TheSnapshotterBehaviortrait captures the snapshot and delegates storage to a concretepersisthook.use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; use TinyBlocks\BuildingBlocks\Snapshot\Snapshotter; use TinyBlocks\BuildingBlocks\Snapshot\SnapshotterBehavior; final class FileSnapshotter implements Snapshotter { use SnapshotterBehavior; protected function persist(Snapshot $snapshot): void { file_put_contents('/var/snapshots/cart.json', $snapshot->getAggregateState()); } } $snapshotter = new FileSnapshotter(); $snapshotter->take(aggregate: $cart);
-
SnapshotCondition: strategy for deciding whether a snapshot should be taken at a given point.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; } }
Upcasters migrate serialized events across schema changes without touching the event classes.
-
Upcaster: transforms one(type, revision)pair forward by one step. Chains of upcasters handle multistep evolution. TheSingleUpcasterBehaviortrait binds the upcaster to a specific migration via three class constants.use TinyBlocks\BuildingBlocks\Upcast\SingleUpcasterBehavior; use TinyBlocks\BuildingBlocks\Upcast\Upcaster; final class ProductV1Upcaster implements Upcaster { use SingleUpcasterBehavior; private const string EXPECTED_EVENT_TYPE = 'ProductAdded'; private const int FROM_REVISION = 1; private const int TO_REVISION = 2; protected function doUpcast(array $data): array { return [...$data, 'quantity' => 1]; } }
-
upcast: transforms the event if it matches the expected(type, revision), otherwise returns it unchanged.use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); $upcasted = new ProductV1Upcaster()->upcast(event: $event);
-
Upcasters: ordered collection ofUpcasterinstances.chainfolds them left-to-right over anIntermediateEvent, applying each upcaster in sequence. Upcasters that do not match the current(type, revision)pair pass the event through unchanged.use TinyBlocks\BuildingBlocks\Upcast\Upcasters; $upcasters = Upcasters::createFrom(elements: [ new ProductV1Upcaster(), new ProductV2Upcaster(), ]); $upcasted = $upcasters->chain(event: $event);
-
IntermediateEventimplementsObjectMapper, so it can be reconstituted from an iterable of typed field values. Pass already-constructedEventTypeandRevisioninstances — the mapper maps each field by name.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], ]);
-
DefaultValues: type-to-default-value map for common primitive types, used when an upcast introduces a new field.use TinyBlocks\BuildingBlocks\Upcast\DefaultValues; $defaults = DefaultValues::get();
A domain event is a fact about something that happened in the domain. It has no technical contract beyond being that
fact. Persistence and transport concerns (type name, revision, aggregate identity) belong to EventRecord, not to
the event itself. Keeping the event pure prevents infrastructure concerns from leaking into the domain model.
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.
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.
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.
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.
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.
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.
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.
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.
Building Blocks is licensed under MIT.
Please follow the contributing guidelines to contribute to the project.