{"id":49012811,"url":"https://github.com/tiny-blocks/building-blocks","last_synced_at":"2026-06-09T03:07:01.652Z","repository":{"id":352308204,"uuid":"1214450467","full_name":"tiny-blocks/building-blocks","owner":"tiny-blocks","description":"Implements tactical DDD building blocks for PHP: entities, aggregate roots, domain events, snapshots, and upcasters.","archived":false,"fork":false,"pushed_at":"2026-05-21T13:19:37.000Z","size":265,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-27T01:06:54.661Z","etag":null,"topics":["aggregate-root","building-blocks","ddd","domain-driven-design","domain-events","event-sourcing","integration-events","php","snapshot","tiny-blocks","upcaster","value-object"],"latest_commit_sha":null,"homepage":"https://packagist.org/packages/tiny-blocks/building-blocks","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tiny-blocks.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-18T15:41:05.000Z","updated_at":"2026-05-21T13:19:32.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tiny-blocks/building-blocks","commit_stats":null,"previous_names":["tiny-blocks/building-blocks"],"tags_count":10,"template":false,"template_full_name":null,"purl":"pkg:github/tiny-blocks/building-blocks","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fbuilding-blocks","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fbuilding-blocks/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fbuilding-blocks/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fbuilding-blocks/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tiny-blocks","download_url":"https://codeload.github.com/tiny-blocks/building-blocks/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tiny-blocks%2Fbuilding-blocks/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34089414,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-09T02:00:06.510Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aggregate-root","building-blocks","ddd","domain-driven-design","domain-events","event-sourcing","integration-events","php","snapshot","tiny-blocks","upcaster","value-object"],"created_at":"2026-04-19T00:08:36.990Z","updated_at":"2026-06-09T03:07:01.646Z","avatar_url":"https://github.com/tiny-blocks.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Building Blocks\n\n[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE)\n\n* [Overview](#overview)\n* [Installation](#installation)\n* [How to use](#how-to-use)\n    + [Entity](#entity)\n        - [Single-field identity](#single-field-identity)\n        - [Compound identity](#compound-identity)\n        - [Identity access on entities](#identity-access-on-entities)\n    + [Aggregate](#aggregate)\n    + [Domain events with transactional outbox](#domain-events-with-transactional-outbox)\n        - [Declaring events](#declaring-events)\n        - [Emitting events from the aggregate](#emitting-events-from-the-aggregate)\n        - [Draining events](#draining-events)\n        - [Restoring aggregate version on reload](#restoring-aggregate-version-on-reload)\n        - [Constructing event records directly](#constructing-event-records-directly)\n    + [Integration events and the Anti-Corruption Layer](#integration-events-and-the-anti-corruption-layer)\n        - [Declaring integration events](#declaring-integration-events)\n        - [Writing a translator](#writing-a-translator)\n        - [Registering translators](#registering-translators)\n        - [Constructing integration event records directly](#constructing-integration-event-records-directly)\n    + [Event sourcing](#event-sourcing)\n        - [Applying events to state](#applying-events-to-state)\n        - [Creating a blank aggregate](#creating-a-blank-aggregate)\n        - [Replaying an event stream](#replaying-an-event-stream)\n    + [Snapshots](#snapshots)\n        - [Capturing aggregate state](#capturing-aggregate-state)\n        - [Taking a snapshot](#taking-a-snapshot)\n        - [Persisting snapshots](#persisting-snapshots)\n        - [Built-in conditions](#built-in-conditions)\n    + [Upcasting](#upcasting)\n        - [Defining an upcaster](#defining-an-upcaster)\n        - [Chaining upcasters](#chaining-upcasters)\n        - [Default values for new fields](#default-values-for-new-fields)\n* [FAQ](#faq)\n* [License](#license)\n* [Contributing](#contributing)\n\n## Overview\n\nThe `Building Blocks` library provides the tactical design building blocks of Domain-Driven Design: `Entity`,\n`Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox\nor an event-sourced store.\n\nThis library implements the tactical patterns from Evans (Entity, Identity, Aggregate Root, Value Object) and Vernon\n(Domain Event) together with pragmatic extensions that production code needs but the original DDD literature does\nnot address: aggregate versioning for optimistic offline locking (Fowler PEAA), model versioning and rolling\nsnapshots for event-sourced aggregates (Greg Young), event upcasting for schema evolution (Greg Young), and an\nevent envelope decoupling domain events from infrastructure metadata (Hohpe/Woolf EIP). Every extension is annotated\nin its own PHPDoc with its source.\n\nDomain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not\nreplace PSR-14, it defines what flows through it. Serialization to wire formats is delegated to adapters such as\n[`tiny-blocks/outbox`](https://github.com/tiny-blocks/outbox).\n\n## Installation\n\n```\ncomposer require tiny-blocks/building-blocks\n```\n\n## How to use\n\nThe library exposes three styles of aggregate modeling through sibling interfaces:\n\n* `AggregateRoot` for plain DDD modeling without events.\n* `EventualAggregateRoot` for aggregates that persist state and emit events as side effects via a transactional\n  outbox.\n* `EventSourcingRoot` for aggregates whose state is derived entirely from their ordered event stream.\n\n### Entity\n\nEvery entity declares which property holds its `Identity`. By default, the property is named `id`, aggregates with a\ndifferently named property override `identityProperty()`.\n\n#### Single-field identity\n\n* `SingleIdentity`: identity backed by a single scalar value (UUID, auto-increment integer, slug).\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Entity\\SingleIdentity;\n  use TinyBlocks\\BuildingBlocks\\Entity\\SingleIdentityBehavior;\n\n  final readonly class OrderId implements SingleIdentity\n  {\n      use SingleIdentityBehavior;\n\n      public function __construct(public string $value)\n      {\n      }\n  }\n\n  $orderId = new OrderId(value: 'ord-1');\n  $orderId-\u003eidentityValue();\n  ```\n\n#### Compound identity\n\n* `CompoundIdentity`: identity composed of multiple fields treated as a tuple. Fields may carry any\n  type the application requires, including primitive scalars (`int`, `string`) and value objects.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Entity\\CompoundIdentity;\n  use TinyBlocks\\BuildingBlocks\\Entity\\CompoundIdentityBehavior;\n\n  final readonly class AppointmentId implements CompoundIdentity\n  {\n      use CompoundIdentityBehavior;\n\n      public function __construct(\n          public string $tenantId,\n          public int $practitionerId\n      ) {\n      }\n  }\n\n  $appointmentId = new AppointmentId(tenantId: 'tenant-1', practitionerId: 42);\n  $appointmentId-\u003eidentityValue();\n  ```\n\n#### Identity access on entities\n\n* `identity()`, `identityValue()`, `sameIdentityOf()`, `identityEquals()`: provided by `EntityBehavior` for any\n  entity that declares its identity property.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateRootBehavior;\n\n  final class User implements AggregateRoot\n  {\n      use AggregateRootBehavior;\n\n      private function __construct(private UserId $id, private string $email)\n      {\n      }\n  }\n\n  $user-\u003eidentity();\n  $user-\u003eidentityValue();\n  $user-\u003esameIdentityOf(other: $otherUser);\n  $user-\u003eidentityEquals(other: new UserId(value: 'usr-1'));\n  ```\n\n* Override `identityProperty()` only when the identity property has a name other than `id`:\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateRootBehavior;\n\n  final class Cart implements AggregateRoot\n  {\n      use AggregateRootBehavior;\n\n      private CartId $cartId;\n\n      protected function identityProperty(): string\n      {\n          return 'cartId';\n      }\n  }\n  ```\n\n### Aggregate\n\n`AggregateRoot` adds two pragmatic fields to Evans' aggregate: a monotonic `AggregateVersion` for optimistic\nconcurrency control, and a `ModelVersion` for schema evolution of the aggregate type.\n\n* `aggregateVersion()`: the current aggregate version, starting at zero for a blank aggregate and advancing by one\n  for every recorded event. `AggregateVersion::isAfter()` and `AggregateVersion::isBefore()` compare two\n  versions when reasoning about replay progress or concurrency conflicts.\n\n  ```php\n  $user-\u003eaggregateVersion();\n  ```\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateVersion;\n\n  $previous = AggregateVersion::initial();\n  $current = AggregateVersion::of(value: 5);\n\n  $current-\u003eisAfter(other: $previous);   # true\n  $previous-\u003eisBefore(other: $current);  # true\n  ```\n\n* `modelVersion()`: typed as `ModelVersion`. Defaults to `ModelVersion::initial()` (value `0`). Override on\n  aggregates that have a versioned schema. `ModelVersion::isAfter()` and `ModelVersion::isBefore()` compare two\n  schema versions during migration logic.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateRootBehavior;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\ModelVersion;\n\n  final class Cart implements AggregateRoot\n  {\n      use AggregateRootBehavior;\n\n      public function modelVersion(): ModelVersion\n      {\n          return ModelVersion::of(value: 2);\n      }\n  }\n\n  $cart-\u003emodelVersion();\n  ```\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\ModelVersion;\n\n  $previous = ModelVersion::initial();\n  $current = ModelVersion::of(value: 2);\n\n  $current-\u003eisAfter(other: $previous);   # true\n  $previous-\u003eisBefore(other: $current);  # true\n  ```\n\n* `aggregateType()`: short class name, used as the aggregate type identifier on each `EventRecord`.\n\n  ```php\n  $user-\u003eaggregateType();\n  ```\n\n### Domain events with transactional outbox\n\n`EventualAggregateRoot` records domain events during the unit of work. State is the source of truth, events are\nemitted as side effects and must be delivered at-least-once.\n\nAfter persisting the aggregate state, the application service drains the recorded events with `pullEvents()`, which\nreturns them and clears the buffer, so a second save of the same instance does not re-emit the events already\ndrained. `peekEvents()` returns a non-destructive copy for inspection without touching the buffer. An instance models a\nsingle unit of work: reload from the repository before operating on the same logical aggregate again rather than\nreusing a drained instance.\n\n#### Declaring events\n\n* `DomainEvent`: contract for a fact that happened in the domain. The only required method is `revision()`,\n  defaulted to `Revision::initial()` by `DomainEventBehavior`. Override only when bumping the event schema.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\DomainEvent;\n  use TinyBlocks\\BuildingBlocks\\Event\\DomainEventBehavior;\n  use TinyBlocks\\BuildingBlocks\\Event\\Revision;\n\n  final readonly class OrderPlaced implements DomainEvent\n  {\n      use DomainEventBehavior;\n\n      public function __construct(public string $item)\n      {\n      }\n  }\n  ```\n\n  Bumping a revision:\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\DomainEvent;\n  use TinyBlocks\\BuildingBlocks\\Event\\DomainEventBehavior;\n  use TinyBlocks\\BuildingBlocks\\Event\\Revision;\n\n  final readonly class OrderPlacedV2 implements DomainEvent\n  {\n      use DomainEventBehavior;\n\n      public function __construct(public string $item, public int $quantity)\n      {\n      }\n\n      public function revision(): Revision\n      {\n          return Revision::of(value: 2);\n      }\n  }\n  ```\n\n  Comparing revisions:\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\Revision;\n\n  $previous = Revision::initial();\n  $current = Revision::of(value: 2);\n\n  $current-\u003eisAfter(other: $previous);   # true\n  $previous-\u003eisBefore(other: $current);  # true\n  ```\n\n#### Emitting events from the aggregate\n\n* `pushEvent()`: protected method on `EventualAggregateRootBehavior`. Increments the aggregate version and appends a\n  fully-built `EventRecord` to the recorded buffer.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventualAggregateRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventualAggregateRootBehavior;\n\n  final class Order implements EventualAggregateRoot\n  {\n      use EventualAggregateRootBehavior;\n\n      private function __construct(private OrderId $id)\n      {\n      }\n\n      public static function place(OrderId $id, string $item): Order\n      {\n          $order = new Order(id: $id);\n          $order-\u003epushEvent(event: new OrderPlaced(item: $item));\n\n          return $order;\n      }\n  }\n  ```\n\n#### Draining events\n\n* `pullEvents()`: drains the buffer. Returns the events recorded since the last drain and clears the buffer, so a\n  subsequent call returns an empty collection until new events are recorded. This is the persistence path: drain\n  into the outbox after the aggregate state has been saved.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  $order = Order::place(id: new OrderId(value: 'ord-1'), item: 'book');\n\n  foreach ($order-\u003epullEvents() as $record) {\n      $outbox-\u003eappend(record: $record);\n  }\n  ```\n\n* `peekEvents()`: returns a fresh copy of the buffer without clearing it, safe to iterate. The aggregate's own buffer is\n  not mutated by external iteration, and a later `pullEvents()` still drains every recorded event. Use it to inspect the\n  buffer, for example in tests, without consuming it.\n\n  ```php\n  $order-\u003epeekEvents();\n  ```\n\n#### Restoring aggregate version on reload\n\n* `reconstituteStrict()`: the recommended static factory for repositories that rehydrate an\n  `EventualAggregateRoot` from a full persisted row. It delegates to `reconstitutePartial()` (honoring any\n  override), then verifies by reflection that hydration left no declared property uninitialized, throwing\n  `IncompleteAggregateState` when a required property is still unset. Properties that carry a default value,\n  and untyped properties, are always initialized by PHP, so they are never flagged.\n\n* `reconstitutePartial()`: the hydration step on its own, without the completeness check. The default\n  implementation provided by `EventualAggregateRootBehavior` instantiates the aggregate without invoking its\n  constructor, assigns the identity to the property declared by `identityProperty()`, hydrates the remaining\n  state by reflection from the `$aggregateState` map (entries with keys absent from the aggregate are silently\n  ignored), and assigns the aggregate version so subsequent events advance from the correct value. It throws\n  `MissingIdentityProperty` when the aggregate has no property named by `identityProperty()`. The buffer of\n  recorded events starts empty, so events emitted after reconstitution are drained with `pullEvents()` exactly as for a\n  freshly created aggregate.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateVersion;\n\n  # Recommended path when a full row is rehydrated: the aggregate does not declare its own factory, so the\n  # repository calls the trait default with the persisted identity, state map, and version. The completeness\n  # check throws IncompleteAggregateState if hydration leaves any declared property uninitialized.\n  $reservation = Reservation::reconstituteStrict(\n      identity: new ReservationId(value: 'res-1'),\n      aggregateState: ['status' =\u003e 'pending'],\n      aggregateVersion: AggregateVersion::of(value: 7)\n  );\n  ```\n\n  Call `reconstitutePartial(...)` with the same arguments when the persisted state is intentionally partial and\n  the completeness check should be skipped.\n\n  Aggregates may override `reconstitutePartial()` to enforce a concrete identity type at the entry point.\n  `reconstituteStrict()` delegates to it, so the override is honored on both paths. The static signature cannot\n  narrow the parameter type per LSP, so the override keeps `Identity` in the signature and guards with\n  `instanceof` inside:\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use InvalidArgumentException;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateVersion;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventualAggregateRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventualAggregateRootBehavior;\n  use TinyBlocks\\BuildingBlocks\\Entity\\Identity;\n\n  final class Order implements EventualAggregateRoot\n  {\n      use EventualAggregateRootBehavior;\n\n      private function __construct(private OrderId $id)\n      {\n      }\n\n      public static function reconstitutePartial(\n          Identity $orderId,\n          array $aggregateState,\n          AggregateVersion $aggregateVersion\n      ): static {\n          if (!$orderId instanceof OrderId) {\n              $template = 'Expected identity of type \u003c%s\u003e, got \u003c%s\u003e.';\n\n              throw new InvalidArgumentException(message: sprintf($template, OrderId::class, $orderId::class));\n          }\n\n          $order = new Order(id: $orderId);\n          $order-\u003eaggregateVersion = $aggregateVersion;\n\n          return $order;\n      }\n  }\n  ```\n\n#### Constructing event records directly\n\nEvery envelope carries `$id`, `$event`, `$revision`, `$eventType`, `$occurredAt`, `$aggregateId`,\n`$aggregateType`, and `$aggregateVersion`. The aggregate normally builds the record, so consumers\nread these fields off `EventRecord` directly without instantiating one.\n\n* `EventRecord::from()`: factory for the rare cases that require building an envelope outside the aggregate boundary,\n  typically test code that fabricates envelopes as inputs to handlers, or consumer-side code deserializing payloads\n  from a wire format. The constructor is private, so `from()` is the only construction path. The `id` and\n  `occurredAt` parameters fall back to sensible defaults (`Uuid::generateV7()` and `Utc::now()`) when omitted. The id\n  and\n  occurrence timestamp are the value objects `TinyBlocks\\BuildingBlocks\\Uuid` and `TinyBlocks\\BuildingBlocks\\Utc`.\n\n  | Parameter          | Type               | Required | Description                                                             |\n        |--------------------|--------------------|----------|-------------------------------------------------------------------------|\n  | `id`               | `?Uuid`            | No       | Explicit envelope identifier. Defaults to a fresh `Uuid::generateV7()`. |\n  | `event`            | `DomainEvent`      | Yes      | The event being recorded.                                               |\n  | `occurredAt`       | `?Utc`             | No       | Explicit occurrence timestamp. Defaults to `Utc::now()`.                |\n  | `aggregateId`      | `Identity`         | Yes      | The aggregate identity that produced the event.                         |\n  | `aggregateType`    | `string`           | Yes      | The short class name of the aggregate.                                  |\n  | `aggregateVersion` | `AggregateVersion` | Yes      | The aggregate version assigned to this envelope.                        |\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\AggregateVersion;\n  use TinyBlocks\\BuildingBlocks\\Event\\EventRecord;\n\n  $record = EventRecord::from(\n      event: new OrderPlaced(item: 'book'),\n      aggregateId: new OrderId(value: 'ord-1'),\n      aggregateType: 'Order',\n      aggregateVersion: AggregateVersion::first()\n  );\n  ```\n\n### Integration events and the Anti-Corruption Layer\n\n`DomainEvent` describes facts that happened inside the bounded context and evolves freely with the\ninternal model. `IntegrationEvent` describes the stable public contract that flows to external\nconsumers and must remain backward-compatible. The two interfaces are siblings, not parent and\nchild. An `IntegrationEvent` is produced by an `IntegrationEventTranslator`, which acts as the\nAnti-Corruption Layer (Vernon, IDDD Chapter 3) between the internal model and the public contract.\n\n#### Declaring integration events\n\n* `IntegrationEvent`: marker interface for events that cross bounded-context boundaries. Carries\n  a `revision()` method that versions the public schema independently of the underlying domain\n  event's schema.\n* `IntegrationEventBehavior`: default implementation that returns `Revision::initial()`. Use it\n  on every integration event unless the public schema has been bumped.\n\nClass names for integration events must follow the bounded-context ubiquitous language and must\n**not** carry a technical suffix such as `IntegrationEvent`. The domain event `TransactionConfirmed`\nis translated into the integration event `PaymentConfirmed`, not `PaymentConfirmedIntegrationEvent`.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEvent;\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEventBehavior;\n\n  final readonly class PaymentConfirmed implements IntegrationEvent\n  {\n      use IntegrationEventBehavior;\n\n      public function __construct(public string $orderId, public float $amount)\n      {\n      }\n  }\n  ```\n\nBumping the public schema revision independently of the underlying domain event:\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEvent;\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEventBehavior;\n  use TinyBlocks\\BuildingBlocks\\Event\\Revision;\n\n  final readonly class PaymentConfirmedV2 implements IntegrationEvent\n  {\n      use IntegrationEventBehavior;\n\n      public function __construct(\n          public string $orderId,\n          public float $amount,\n          public string $currency\n      ) {\n      }\n\n      public function revision(): Revision\n      {\n          return Revision::of(value: 2);\n      }\n  }\n  ```\n\n#### Writing a translator\n\n`IntegrationEventTranslator` is the Anti-Corruption Layer seam. Each implementation declares\nwhich `EventRecord` it handles via `supports()` and produces the corresponding\n`IntegrationEvent` via `translate()`. Implementations must be pure functions with no side\neffects or I/O.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\EventRecord;\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEvent;\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEventTranslator;\n\n  final readonly class TransactionConfirmedTranslator implements IntegrationEventTranslator\n  {\n      public function supports(EventRecord $record): bool\n      {\n          return $record-\u003eevent instanceof TransactionConfirmed;\n      }\n\n      public function translate(EventRecord $record): IntegrationEvent\n      {\n          /** @var TransactionConfirmed $event */\n          $event = $record-\u003eevent;\n\n          return new PaymentConfirmed(orderId: $event-\u003eorderId, amount: $event-\u003eamount);\n      }\n  }\n  ```\n\n#### Registering translators\n\n`IntegrationEventTranslators` is an ordered collection of translators. `findFor()` returns the\nfirst translator whose `supports()` returns `true` for a given record, or `null` when no\ntranslator handles it. A `null` result is the canonical signal that the event is purely internal\nand must not cross the bounded-context boundary.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEventTranslators;\n\n  $translators = IntegrationEventTranslators::createFrom(elements: [\n      new TransactionConfirmedTranslator(),\n      new OrderShippedTranslator()\n  ]);\n\n  $translator = $translators-\u003efindFor(record: $record);\n\n  if (!is_null($translator)) {\n      $integrationEvent = $translator-\u003etranslate(record: $record);\n  }\n  ```\n\n#### Constructing integration event records directly\n\n`IntegrationEventRecord::from()` envelopes a translated integration event with the transport\nmetadata from the originating `EventRecord`. The identifier is reused from the originating\nrecord so that outbox relay retries remain idempotent. The revision and event type are derived\nfrom the integration event, not from the domain event.\n\n| Parameter          | Type               | Description                                                                       |\n|--------------------|--------------------|-----------------------------------------------------------------------------------|\n| `eventRecord`      | `EventRecord`      | Originating domain event record. Supplies transport metadata.                     |\n| `integrationEvent` | `IntegrationEvent` | Integration event produced by the translator. Supplies payload and public schema. |\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\IntegrationEventRecord;\n\n  $integrationEventRecord = IntegrationEventRecord::from(\n      eventRecord: $eventRecord,\n      integrationEvent: $integrationEvent\n  );\n  ```\n\n### Event sourcing\n\n`EventSourcingRoot` stores no state of its own, state is derived by replaying the event stream.\n\n#### Applying events to state\n\n* `when()`: protected method that records the event and immediately applies it to state by dispatching to a\n  `when\u003cEventShortName\u003e` method by reflection.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventSourcingRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventSourcingRootBehavior;\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\Snapshot;\n\n  final class Cart implements EventSourcingRoot\n  {\n      use EventSourcingRootBehavior;\n\n      private CartId $id;\n      private array $productIds = [];\n\n      public function addProduct(string $productId): void\n      {\n          $this-\u003ewhen(event: new ProductAdded(productId: $productId));\n      }\n\n      public function applySnapshot(Snapshot $snapshot): void\n      {\n          $this-\u003eproductIds = $snapshot-\u003eaggregateState()['productIds'] ?? [];\n      }\n\n      protected function whenProductAdded(ProductAdded $event): void\n      {\n          $this-\u003eproductIds[] = $event-\u003eproductId;\n      }\n  }\n  ```\n\n* `eventHandlers()`: explicit registration. Returns a map of `class-string\u003cDomainEvent\u003e` to callable. When the map\n  is non-empty, the trait dispatches through it instead of using the implicit `when\u003cX\u003e` convention. Use this when\n  handler names should not follow the convention or when static analysis on dispatch is desired.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventSourcingRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventSourcingRootBehavior;\n\n  final class ExplicitCart implements EventSourcingRoot\n  {\n      use EventSourcingRootBehavior;\n\n      private CartId $id;\n      private array $productIds = [];\n\n      public function eventHandlers(): array\n      {\n          return [\n              ProductAdded::class =\u003e $this-\u003eonProductAdded(...)\n          ];\n      }\n\n      private function onProductAdded(ProductAdded $event): void\n      {\n          $this-\u003eproductIds[] = $event-\u003eproductId;\n      }\n  }\n  ```\n\n#### Creating a blank aggregate\n\n* `blank()`: factory that instantiates the aggregate via reflection without invoking its constructor. All state\n  must come from events or from a snapshot.\n\n  ```php\n  $cart = Cart::blank(identity: new CartId(value: 'cart-1'));\n  ```\n\n#### Replaying an event stream\n\n* `reconstitute()`: replays an ordered stream of `EventRecord` instances, optionally starting from a snapshot to\n  skip earlier events. When a snapshot is provided, its aggregate version is authoritative.\n\n  ```php\n  $cart = Cart::reconstitute(records: $records, identity: new CartId(value: 'cart-1'));\n  ```\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  $cart = Cart::reconstitute(\n      records: $laterRecords,\n      identity: new CartId(value: 'cart-1'),\n      snapshot: $snapshot\n  );\n  ```\n\n### Snapshots\n\nSnapshots let the event store skip replay of early events when reconstituting a long-lived aggregate. A snapshot\ncaptures the aggregate's state at a specific version so that reconstitution can resume from that point instead of\nreplaying the entire history.\n\n#### Capturing aggregate state\n\nAggregates control what fields enter the snapshot by overriding `snapshotState()`. The default captures every\ndeclared property except `recordedEvents` and `aggregateVersion` (which are tracked separately on the envelope).\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use Psr\\Log\\LoggerInterface;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventSourcingRoot;\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventSourcingRootBehavior;\n\n  final class CartWithLogger implements EventSourcingRoot\n  {\n      use EventSourcingRootBehavior;\n\n      private CartId $id;\n      private array $productIds = [];\n      private LoggerInterface $logger;\n\n      public function snapshotState(): array\n      {\n          return ['id' =\u003e $this-\u003eid, 'productIds' =\u003e $this-\u003eproductIds];\n      }\n  }\n  ```\n\n#### Taking a snapshot\n\n* `Snapshot::fromAggregate()`: captures the aggregate's current state via the `snapshotState()` hook.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\Snapshot;\n\n  $snapshot = Snapshot::fromAggregate(aggregate: $cart);\n  $snapshot-\u003eaggregateState();\n  $snapshot-\u003eaggregateVersion();\n  ```\n\n#### Persisting snapshots\n\n* `Snapshotter`: port for snapshot persistence. The `SnapshotterBehavior` trait captures the snapshot and delegates\n  storage to a `persist` hook implemented by the consumer.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\Snapshot;\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\Snapshotter;\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\SnapshotterBehavior;\n\n  final class FileSnapshotter implements Snapshotter\n  {\n      use SnapshotterBehavior;\n\n      protected function persist(Snapshot $snapshot): void\n      {\n          file_put_contents('/var/snapshots/cart.json', json_encode($snapshot-\u003eaggregateState()));\n      }\n  }\n\n  new FileSnapshotter()-\u003etake(aggregate: $cart);\n  ```\n\n#### Built-in conditions\n\n* `SnapshotCondition`: strategy for deciding whether a snapshot should be taken at a given point.\n* `SnapshotEvery::events(count: N)`: ready-made condition that triggers every `N` events (skipping version `0`).\n* `SnapshotNever::create()`: condition that never triggers, useful in tests and when snapshotting is explicitly\n  disabled.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\SnapshotEvery;\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\SnapshotNever;\n\n  $every100 = SnapshotEvery::events(count: 100);\n  $never = SnapshotNever::create();\n  ```\n\n  Custom conditions implement the interface directly:\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Aggregate\\EventSourcingRoot;\n  use TinyBlocks\\BuildingBlocks\\Snapshot\\SnapshotCondition;\n\n  final class WhenStatusChanges implements SnapshotCondition\n  {\n      public function shouldSnapshot(EventSourcingRoot $aggregate): bool\n      {\n          # domain-specific logic\n      }\n  }\n  ```\n\n### Upcasting\n\nUpcasters migrate serialized events across schema changes without touching the event classes.\n\n#### Defining an upcaster\n\n* `Upcaster`: transforms one `(type, revision)` pair forward by one step. Returns the event unchanged when the\n  type or revision does not match.\n* `SingleUpcasterBehavior`: binds the upcaster to a specific migration via three class constants and delegates the\n  payload transformation to an abstract `doUpcast()` method.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Upcast\\SingleUpcasterBehavior;\n  use TinyBlocks\\BuildingBlocks\\Upcast\\Upcaster;\n\n  final class ProductV1Upcaster implements Upcaster\n  {\n      use SingleUpcasterBehavior;\n\n      private const string EXPECTED_EVENT_TYPE = 'ProductAdded';\n      private const int FROM_REVISION = 1;\n      private const int TO_REVISION = 2;\n\n      protected function doUpcast(array $data): array\n      {\n          return [...$data, 'quantity' =\u003e 1];\n      }\n  }\n  ```\n\n#### Chaining upcasters\n\n* `Upcasters::chain()`: runs every upcaster in insertion order in a single forward pass. Upcasters whose type or\n  revision does not match pass the event through.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Event\\EventType;\n  use TinyBlocks\\BuildingBlocks\\Event\\Revision;\n  use TinyBlocks\\BuildingBlocks\\Upcast\\IntermediateEvent;\n  use TinyBlocks\\BuildingBlocks\\Upcast\\Upcasters;\n\n  $event = IntermediateEvent::from(\n      type: EventType::fromString(value: 'ProductAdded'),\n      revision: Revision::initial(),\n      serializedEvent: ['productId' =\u003e 'prod-1']\n  );\n\n  $chain = Upcasters::createFrom(elements: [\n      new ProductV1Upcaster(),\n      new ProductV2Upcaster()\n  ]);\n\n  $upcasted = $chain-\u003echain(event: $event);\n  ```\n\n#### Default values for new fields\n\n* `DefaultValues::get()`: type-to-default-value map for common primitive types, used when an upcast introduces a\n  new field with a sensible zero-value default.\n\n  ```php\n  \u003c?php\n\n  declare(strict_types=1);\n\n  use TinyBlocks\\BuildingBlocks\\Upcast\\DefaultValues;\n\n  $defaults = DefaultValues::get();\n  ```\n\n## FAQ\n\n### 01. Why is `DomainEvent` close to a marker interface?\n\nA domain event is a fact about something that happened in the domain. The contract carries only `revision()` so\nthe library can route schema migrations through upcasters. Everything else (aggregate identity, aggregate version,\naggregate type, occurrence timestamp) is envelope metadata that belongs to `EventRecord`. Keeping the event itself\nminimal prevents infrastructure concerns from leaking into the domain model.\n\n\u003e Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8, \"Domain Events\".\n\n### 02. Why does `EventualAggregateRoot` store `EventRecord` instead of `DomainEvent`?\n\nOnly the aggregate has the context needed to build the complete envelope: identity, aggregate version, aggregate\ntype name. Storing raw events and wrapping them later would either duplicate that context or require a second\npass. `pushEvent()` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no\ntranslation.\n\n\u003e Gregor Hohpe and Bobby Woolf, *Enterprise Integration Patterns* (Addison-Wesley, 2003), \"Envelope Wrapper\".\n\n### 03. Why are `EventualAggregateRoot` and `EventSourcingRoot` siblings instead of a hierarchy?\n\nOutbox and event sourcing are mutually exclusive persistence strategies. An aggregate either persists its state\nand emits events as side effects, or persists only its events as the source of truth. A common base beyond\n`AggregateRoot` would imply the two patterns can coexist on the same aggregate, which they cannot.\n\n\u003e Martin Fowler, *Event Sourcing* (martinfowler.com, 2005).\n\u003e Chris Richardson, *Microservices Patterns* (Manning, 2018), Chapter 3, \"Transactional Outbox\".\n\n### 04. Why does `Revision` live on the `DomainEvent` instead of the call site?\n\nThe revision of an event is a property of the event's schema. Keeping it on the event means the call site (`pushEvent`,\n`when`) does not need to know the schema version, the event class is the single source of truth. Bumping a\nrevision is always paired with a payload change (added field, removed field, renamed field), so creating a new\nevent class to carry the new revision is the natural unit of work.\n\n\u003e Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017).\n\n### 05. Why does `blank()` skip the constructor?\n\n`EventSourcingRootBehavior::blank()` instantiates the aggregate via reflection without invoking its constructor\nbecause all aggregate state in an event-sourced model must come from events or from a snapshot. Any invariants\nestablished by the constructor would contradict that principle. Concrete aggregates should treat their constructor\nas private and reserved for internal use during command handling.\n\n\u003e Greg Young, *CQRS Documents* (2010), \"Event Sourcing\" section.\n\n### 06. Why doesn't the library serialize envelopes to JSON or any other wire format?\n\nSerialization is an infrastructure concern. Putting encoding methods on domain value objects mixes that concern\ninto the domain layer, which contradicts the library's persistence-agnostic stance. Adapters such as\n`tiny-blocks/outbox` provide dedicated serializer ports. The domain layer exposes `EventRecord`, `Snapshot`, and\nthe value objects as pure data, downstream adapters decide how to map them onto bytes.\n\n\u003e Alistair Cockburn, *Hexagonal Architecture* (alistair.cockburn.us, 2005).\n\n### 07. What is the difference between `ModelVersion` and `AggregateVersion`?\n\n`AggregateVersion` counts events per aggregate instance. It is the basis for optimistic concurrency control: a\nsave fails if the aggregate version in storage differs from the in-memory version the aggregate believed it had.\n\n`ModelVersion` versions the aggregate type itself. When the aggregate schema changes in a backwards-incompatible\nway (a property is removed, renamed, or its semantics shift), bumping the model version gives migration code a\nsingle source of truth to branch on.\n\nThe two are different concepts that happen to share an integer representation. They are typed as separate value\nobjects to prevent accidental comparisons across them at compile time.\n\n\u003e Martin Fowler, *Patterns of Enterprise Application Architecture* (Addison-Wesley, 2002), \"Optimistic Offline\n\u003e Lock\", source of `AggregateVersion` semantics.\n\u003e Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017), source of `ModelVersion` semantics.\n\n### 08. How are recorded events drained from an `EventualAggregateRoot`?\n\nAfter the aggregate state has been persisted, the application service calls `pullEvents()`, which returns the events\nrecorded since the last drain and clears the buffer. Draining through `pullEvents()` publishes each event once: a\nsecond save of the same instance finds an empty buffer and re-emits nothing. `peekEvents()` is the non-destructive\ncounterpart, returning a fresh copy for inspection (in tests, for example) while leaving the buffer intact.\n\nAn instance models a single transactional unit of work. Reload from the repository before operating on the same\nlogical aggregate again rather than reusing a drained instance, so its aggregate version and state reflect what\nstorage holds.\n\n\u003e Eric Evans, *Domain-Driven Design* (Addison-Wesley, 2003), Chapter 6, \"Aggregates\" (single transactional unit\n\u003e per aggregate per request).\n\n### 09. Should I add `identity()`, `aggregateType()`, or `toArray()` to my `DomainEvent`?\n\nNo. These three concerns live elsewhere:\n\n* Identity and aggregate type are envelope metadata. They are added by the aggregate when it builds the\n  `EventRecord` (see `AggregateRootBehavior::buildEventRecord`) and are accessed on the consumer side through the\n  envelope, not the event.\n* Serialization is an infrastructure concern. The event remains a pure PHP object, serialization happens in the\n  outbox writer and the consumer deserializer, both of which live downstream of the library.\n\nA `DomainEvent` that grows methods like these duplicates envelope data already on the `EventRecord` and pulls\ninfrastructure into the domain layer.\n\n### 10. Why does the library include `AggregateVersion` and `ModelVersion` if Evans never mentioned them?\n\nEvans defined the tactical patterns of DDD, but optimistic concurrency control and aggregate schema evolution\nare concerns that emerged later in mainstream production code. `AggregateVersion` carries the optimistic offline\nlock formalized by Fowler in PEAA: the value travels with the aggregate, the persistence adapter compares the\nin-memory value against the stored one, and a mismatch raises a concurrency exception instead of overwriting\nanother process's change. `ModelVersion` carries Greg Young's schema versioning for aggregate types, so migration\ncode has a single source of truth to branch on when older shapes show up in storage.\n\n\u003e Martin Fowler, *Patterns of Enterprise Application Architecture* (Addison-Wesley, 2002), \"Optimistic Offline\n\u003e Lock\".\n\u003e Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017).\n\n### 11. Why are `reconstitutePartial()` and\n\n`reconstituteStrict()` static on the interface even though PHP's polymorphism for static methods is limited?\n\nThe interface declaration documents the contract: every `EventualAggregateRoot` exposes two static factories with\nthe shape `(Identity, array, AggregateVersion): static` that repositories can call. PHP does not dispatch static\ncalls through interfaces at runtime, so the consumer always names the concrete class\n(`Order::reconstituteStrict(...)`, `Reservation::reconstitutePartial(...)`). The interface still earns its keep: it\nforces aggregates to expose the factories, the trait default provides both for free (`reconstituteStrict` delegates\nto `reconstitutePartial`), and overrides remain bound to the declared signature. The parameter name is free per\nLSP, so an override of `reconstitutePartial` can rename `$identity` to `$orderId` for readability, but the type\nmust remain `Identity`, narrowing to a concrete identity class would break LSP. Concrete types are enforced inside\nthe override with `instanceof`.\n\n\u003e Barbara Liskov and Jeannette Wing, *A Behavioral Notion of Subtyping* (ACM TOPLAS, 1994).\n\n### 12. Why was `reconstituteAggregateVersion()` removed?\n\nIt was never part of the external contract. The only caller was the trait's own `reconstitute()` factory, which\nneeded to set the aggregate version on the instance it had just built. Exposing that internal step as a public\ninstance method invited misuse (repositories calling it on aggregates they had not just reconstituted) without\nadding any expressiveness over assigning the property directly. The factory now writes `$aggregate-\u003eaggregateVersion`\ndirectly inside the trait, which is legal because the assignment happens in the static method of the same class\nafter the trait flattens into the aggregate. Eliminating the public method tightens the surface and removes the\ndocumentation burden of explaining when calling it is correct.\n\n### 13. Why are `DomainEvent` and `IntegrationEvent` siblings instead of parent and child?\n\nDomain events evolve freely with the internal model. Integration events are a public contract\nthat must remain backward-compatible across bounded-context consumers. A parent/child relationship\nwould make every domain event eligible to cross the bounded-context boundary by virtue of typing,\nreintroducing the very coupling the distinction exists to eliminate. Sibling interfaces force the\nboundary crossing to be an explicit translation step, observable in the type system. There is no\naccidental publication: the compiler rejects a `DomainEvent` where an `IntegrationEvent` is\nexpected, and the `IntegrationEventTranslator` is the only path between the two.\n\n\u003e Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 3,\n\u003e \"Context Maps\".\n\n### 14. Why doesn't the library let me publish a `DomainEvent` directly through the outbox?\n\nThe Anti-Corruption Layer exists precisely to keep the public contract isolated from the internal\nmodel. A shortcut that lets a domain event become an integration event without an explicit\ntranslation step erases that boundary.\n\nWithout translation, internal model refactors propagate silently to external consumers. A renamed\nfield or a new value object on a domain event changes the published payload with no compile-time\nsignal. Consumers break at runtime, not at the CI boundary where the change was introduced.\n\nDomain events are versioned by the internal model; integration events are versioned by the public\ncontract. Coupling them forces a single revision counter to serve two evolution speeds, which\ncollapses the ability to evolve each side independently.\n\nEven when a domain event and an integration event happen to share the same shape today, the cost\nof writing a translator that copies fields is a few seconds per event. In return: static analysis\nflags drift between the two shapes, refactor pressure surfaces in CI as a compile error inside the\ntranslator, and the public contract is locatable as a single namespace in the codebase.\n\n\u003e Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 3,\n\u003e \"Context Maps\", section \"Anticorruption Layer\".\n\n## License\n\nBuilding Blocks is licensed under [MIT](LICENSE).\n\n## Contributing\n\nPlease follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to\ncontribute to the project.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftiny-blocks%2Fbuilding-blocks","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftiny-blocks%2Fbuilding-blocks","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftiny-blocks%2Fbuilding-blocks/lists"}