From 5f139d57bd9ec87dd4e076876338f2e7465fe7b1 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Tue, 19 May 2026 00:33:16 -0300
Subject: [PATCH 1/5] chore: Refresh tooling configs and agent rule files.
---
.claude/CLAUDE.md | 48 +-
.claude/rules/github-workflows.md | 78 --
.claude/rules/php-library-architecture.md | 145 ++++
.claude/rules/php-library-code-style.md | 674 +++++++++++++++---
.claude/rules/php-library-commits.md | 111 +++
.claude/rules/php-library-documentation.md | 325 ++++++++-
.claude/rules/php-library-github-workflows.md | 287 ++++++++
.claude/rules/php-library-modeling.md | 315 +++++---
.claude/rules/php-library-testing.md | 335 +++++++--
.claude/rules/php-library-tooling.md | 464 ++++++++++++
.editorconfig | 1 +
.gitattributes | 4 +-
.github/copilot-instructions.md | 9 +-
.gitignore | 27 +-
Makefile | 29 +-
composer.json | 36 +-
infection.json.dist | 6 +-
phpcs.xml | 7 +
phpstan.neon.dist | 58 +-
phpunit.xml | 22 +-
20 files changed, 2498 insertions(+), 483 deletions(-)
delete mode 100644 .claude/rules/github-workflows.md
create mode 100644 .claude/rules/php-library-architecture.md
create mode 100644 .claude/rules/php-library-commits.md
create mode 100644 .claude/rules/php-library-github-workflows.md
create mode 100644 .claude/rules/php-library-tooling.md
create mode 100644 phpcs.xml
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 11885b0..a561aa6 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -1,32 +1,16 @@
-# Project
-
-PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure
-dependencies in core, small public surface area. Public API at `src/` root; implementation details
-under `src/Internal/`.
-
-## Rules
-
-All coding standards, architecture, naming, testing, and documentation conventions
-are defined in `rules/`. Read the applicable rule files before generating any code or documentation.
-
-## Commands
-
-- `make test` — run tests with coverage.
-- `make mutation-test` — run mutation testing (Infection).
-- `make review` — run lint.
-- `make help` — list all available commands.
-
-## Post-change validation
-
-After any code change, run `make review`, `make test`, and `make mutation-test`.
-If any fails, iterate on the fix while respecting all project rules until all pass.
-Never deliver code that breaks lint, tests, or leaves surviving mutants.
-
-## File formatting
-
-Every file produced or modified must:
-
-- Use **LF** line endings. Never CRLF.
-- Have no trailing whitespace on any line.
-- End with a single trailing newline.
-- Have no consecutive blank lines (max one blank line between blocks).
+# CLAUDE.md
+
+This is a PHP library in the tiny-blocks ecosystem. Detailed rules live in `.claude/rules/`.
+Each file is scoped via its `paths` frontmatter. Read the relevant file before producing or
+editing content under its scope.
+
+## Rule files
+
+- `php-library-architecture.md` — folder structure, public API boundary, `Internal/` semantics.
+- `php-library-code-style.md` — semantic code rules for `.php` files in `src/` and `tests/`.
+- `php-library-commits.md` — Conventional Commits format. Applied only when generating commit messages.
+- `php-library-documentation.md` — README and Markdown documentation standards.
+- `php-library-github-workflows.md` — CI workflow structure and action pinning.
+- `php-library-modeling.md` — nomenclature, value objects, exceptions, enums, complexity.
+- `php-library-testing.md` — BDD Given/When/Then, PHPUnit conventions, coverage discipline.
+- `php-library-tooling.md` — canonical config files (`composer.json`, `phpcs.xml`, etc).
diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md
deleted file mode 100644
index a369ba4..0000000
--- a/.claude/rules/github-workflows.md
+++ /dev/null
@@ -1,78 +0,0 @@
----
-description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files.
-paths:
- - ".github/workflows/**/*.yml"
- - ".github/workflows/**/*.yaml"
----
-
-# Workflows
-
-Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used
-inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`.
-
-## Pre-output checklist
-
-Verify every item before producing any workflow YAML. If any item fails, revise before outputting.
-
-1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD.
-2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash
- (e.g., `CD — Run migration`, not `CD — Run Migration`).
-3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger.
-4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both.
-5. Every input has a `description` field. Descriptions use American English and end with a period.
-6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`.
-7. Inputs are ordered: required first, then optional. Each group by **name length ascending**.
-8. Choice input options are in **alphabetical order**.
-9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**.
-10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`).
-11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`.
-12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`,
- `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`.
-13. All other YAML property names within a block are ordered by **name length ascending**.
-14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`.
-15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`.
-16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`,
- `package.json`). No version is hardcoded in any workflow.
-17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo,
- not in the workflows repository.
-18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent.
-19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they
- need.
-20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden.
-21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded.
-22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment.
-23. Third-party actions are pinned to the latest available full commit SHA with a version comment:
- `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest
- version before generating a workflow.
-24. First-party actions (`actions/*`) are pinned to the latest major version tag available:
- `actions/checkout@v4`. Always check for the most recent major version before generating a workflow.
-25. Production deployments require GitHub Environments protection rules (manual approval).
-26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30
- minutes. Adjust only with justification in a comment.
-27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid
- redundant runs.
-28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to
- prevent interrupted deployments.
-29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps.
-30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`.
-
-## Style
-
-- All text (workflow names, step names, input descriptions, comments) uses American English with correct
- spelling and punctuation. Sentences and descriptions end with a period.
-
-## Callers
-
-- Callers trigger on `pull_request` targeting `main` only. No `push` trigger.
-- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`.
-- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production.
-
-## Image tagging
-
-- CD deploy builds: `-sha-` + `latest`.
-
-## Migrations
-
-- Migrations run **before** service deployment (schema first, code second).
-- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks.
-- Database credentials are fetched from SSM at runtime, never stored in workflow files.
diff --git a/.claude/rules/php-library-architecture.md b/.claude/rules/php-library-architecture.md
new file mode 100644
index 0000000..7e4be10
--- /dev/null
+++ b/.claude/rules/php-library-architecture.md
@@ -0,0 +1,145 @@
+---
+description: Folder structure, public API boundary, and Internal/ semantics for PHP libraries.
+paths:
+ - "src/**/*.php"
+---
+
+# Architecture
+
+Covers the physical layout of the library. Folder structure, the boundary between public API and
+implementation detail, and where each type of class lives. Semantic rules (value objects,
+exceptions, enums, complexity, nomenclature) live in `php-library-modeling.md`. Code style lives
+in `php-library-code-style.md`.
+
+## Pre-output checklist
+
+Verify every item before producing or relocating any file. If any item fails, revise before
+outputting.
+
+1. None of the following folder names exist in `src/`: `Models/`, `Entities/`, `ValueObjects/`,
+ `Enums/`, `Domain/`. They carry no semantic content and conflate technical role with domain
+ meaning.
+2. The `src/` root contains only interfaces, extension points, public enums, thin orchestration
+ classes, and primary implementations or façades. Substantial logic (algorithms, state machines,
+ I/O) lives in `src/Internal/`, never at the root.
+3. `src/Internal/` is implementation detail and not part of the public API. Breaking changes
+ inside `src/Internal/` are not semver-breaking.
+4. Consumers must not reference, extend, or depend on any type inside `src/Internal/`. The
+ namespace itself is the boundary.
+5. Public exception classes live in `src/Exceptions/`.
+6. Internal exception classes live in `src/Internal/Exceptions/`.
+7. Public enums live at the `src/` root or inside a public `/` folder. Enums used
+ only by internals live in `src/Internal/`.
+8. Public interfaces live at the `src/` root or inside a public `/` folder.
+9. A `/` folder at the `src/` root groups related public types under a shared
+ concept. Each group has its own namespace and is part of the public API.
+10. `/` is optional. Use it only when the library exposes several coherent groups of
+ types (for example, aggregates and events) rather than a flat set of types around a single
+ concept.
+11. Test fixtures representing domain concepts live in `tests/Models/`. Test doubles for system
+ boundaries live at the root of `tests/Unit/` or `tests/Integration/`. No dedicated `Mocks/`
+ or `Doubles/` subdirectory exists. `tests/Drivers//` is permitted when the library
+ exposes a port exercised against multiple third-party implementations (PSR adapters,
+ framework integrations). Each `/` subdir holds tests against one specific
+ implementation.
+12. The `tests/Integration/` folder exists only when the library interacts with external
+ infrastructure (filesystem, database, network). Otherwise, the folder is absent.
+
+## Folder structure
+
+Canonical layout for a PHP library in the tiny-blocks ecosystem.
+
+```
+src/
+├── .php # public contract at root
+├── .php # main implementation or extension point at root
+├── .php # public enum at root
+├── / # public folder grouping related public types under a shared concept
+│ ├── .php
+│ └── ...
+├── Internal/ # implementation details, not part of the public API
+│ ├── .php
+│ └── Exceptions/ # internal exception classes
+└── Exceptions/ # public exception classes
+
+tests/
+├── Models/ # domain fixtures reused across tests
+├── Unit/ # unit tests targeting the public API
+│ ├── .php # test doubles at root of Unit/
+│ └── .php
+└── Integration/ # only present when the library interacts with infrastructure
+ └── .php # test doubles at root of Integration/ when needed
+```
+
+Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. They
+carry no semantic content and describe technical role instead of domain meaning.
+
+## Public API boundary
+
+The `src/` root is the contract. Everything at the root, plus everything inside public
+`/` folders and the public `Exceptions/` folder, is what consumers depend on. Changes
+to these types follow semver rules.
+
+`src/Internal/` is implementation detail. The namespace itself signals the boundary. Consumers
+must not depend on any type inside `src/Internal/`. Breaking changes inside `src/Internal/` are
+not semver-breaking for the library.
+
+### What lives at the public boundary
+
+- Interfaces that define contracts for consumers.
+- Extension points designed to be subclassed or composed by consumers.
+- Public enums and value objects consumers manipulate directly.
+- Thin orchestration classes that wire collaborators together without containing substantial logic.
+- Public exception classes consumers may catch.
+
+### What lives in `src/Internal/`
+
+- Algorithms, state machines, and complex transformations.
+- Adapters for I/O (filesystem, network, database).
+- Collaborators that exist purely to break a public class into testable units.
+- Implementation details that may change between minor or patch releases.
+- Internal exception classes raised by collaborators.
+
+## Reference examples
+
+### Small library with flat root
+
+```
+src/
+├── Timezone.php # public value object
+├── Timezones.php # public collection
+├── Clock.php # public interface
+└── Internal/
+ ├── SystemClock.php # default Clock implementation
+ └── Exceptions/
+ └── InvalidTimezone.php
+```
+
+Everything lives at the root or inside `Internal/`. No `/` folders. Suitable when
+the library exposes a small, cohesive set of types around a single concept.
+
+### Library with public concept groups
+
+```
+src/
+├── ValueObject.php # public extension point at root
+├── Aggregate/ # public namespace grouping aggregate types
+│ ├── AggregateRoot.php
+│ ├── EventualAggregateRoot.php
+│ └── ModelVersion.php
+├── Event/ # public namespace grouping event types
+│ ├── EventRecord.php
+│ ├── EventRecords.php
+│ └── SequenceNumber.php
+├── Internal/
+│ ├── DefaultModelVersionResolver.php
+│ └── Exceptions/
+│ └── InvalidSequenceNumber.php
+└── Exceptions/
+ └── EventRecordingFailure.php
+```
+
+`Aggregate/` and `Event/` are public folders at the root, each grouping a coherent set of public
+types under one shared concept. Consumers import directly, for example
+`TinyBlocks\\Aggregate\AggregateRoot`. Suitable when the library exposes several distinct
+concept areas, each with its own set of related types.
diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md
index 7ec196e..8485df7 100644
--- a/.claude/rules/php-library-code-style.md
+++ b/.claude/rules/php-library-code-style.md
@@ -1,5 +1,5 @@
---
-description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries.
+description: Semantic code rules for all PHP files in libraries.
paths:
- "src/**/*.php"
- "tests/**/*.php"
@@ -7,136 +7,447 @@ paths:
# Code style
-Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml`
-and are not repeated here. Refer to `php-library-modeling.md` for library modeling rules.
+Semantic rules for all PHP files in libraries. Formatting rules covered by `PSR-12` are enforced
+by `phpcs.xml`. Two formatting rules outside `PSR-12` (no vertical alignment, no trailing comma in
+multi-line lists) are documented at the end of this file under "Formatting overrides". Complexity
+rules live in `php-library-modeling.md`. Folder structure, public API boundary, and the semantics
+of `Internal/` live in `php-library-architecture.md`.
## Pre-output checklist
Verify every item before producing any PHP code. If any item fails, revise before outputting.
1. `declare(strict_types=1)` is present.
-2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is
- designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without
- `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class).
-3. All parameters, return types, and properties have explicit types.
-4. Constructor property promotion is used.
-5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks).
- Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`,
- `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`,
- `assertTrue`, `expectException`, etc.).
-6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead.
-7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`.
-8. No generic identifiers exist. Use domain-specific names instead:
- `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`,
- `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`.
-9. No raw arrays exist where a typed collection or value object is available. Use the `tiny-blocks/collection`
- fluent API (`Collection`, `Collectible`) when data is `Collectible`. Use `createLazyFrom` when elements are
- consumed once. Raw arrays are acceptable only for primitive configuration data, variadic pass-through, and
- interop at system boundaries. See "Collection usage" below for the full rule and example.
-10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site
- or extract it to a collaborator or value object.
-11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each
- group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have
- no body, are ordered by name length ascending.
-12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type),
- except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`),
- which takes precedence. Parameters with default values go last, regardless of name length. The same rule
- applies to named arguments at call sites.
- Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9).
-13. Time and space complexity are first-class design concerns.
- - No `O(N²)` or worse time complexity exists unless the problem inherently requires it and the cost is
- documented in PHPDoc on the interface method.
- - Space complexity is kept minimal: prefer lazy/streaming pipelines (`createLazyFrom`) over materializing
- intermediate collections.
- - Never re-iterate the same source; fuse stages when possible.
- - Public interface methods document time and space complexity in Big O form (see "PHPDoc" section).
+2. All parameters, return types, and properties have explicit types.
+3. Constructor property promotion is used.
+4. Named arguments are used at call sites for own code, tests, and third-party library methods
+ (for example, tiny-blocks). Never use named arguments on:
+ - Native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`,
+ `iterator_to_array`, `sprintf`, `implode`, and similar).
+ - Native PHP enum methods (`from`, `tryFrom`, `cases`).
+ - PHPUnit assertions and expectations (`assertEquals`, `assertSame`, `assertTrue`,
+ `expectException`, and similar).
+ - Interfaces from PHP-FIG PSR standards (PSR-7 `withHeader`, PSR-18 `sendRequest`, etc.).
+ The PSR contract does not include parameter names. Implementations may rename parameters.
+ - Calls that include variadic spread (`...$args`). PHP rejects positional argument unpacking
+ after named arguments. When the caller passes through a `...$variadic`, all arguments are
+ positional. New own-code APIs should prefer a typed collection parameter over a variadic
+ so named-argument call sites remain possible.
+
+ Native PHP **class constructors** (`parent::__construct` calls to `\Exception`,
+ `\RuntimeException`, `\InvalidArgumentException`, `\LogicException`, and similar) are not
+ in the list above. They accept named arguments, and rule 8 requires using them whenever
+ the positional call would pass an argument whose value equals the parameter's default.
+ Example: `parent::__construct(message: sprintf(...), previous: $previous)` instead of
+ `parent::__construct(sprintf(...), 0, $previous)`. The exclusion above covers native
+ functions and enum methods, not native class instantiation.
+5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default,
+ with documented exceptions for extension points and for parents that are not `readonly`.
+6. Members are ordered constants first, then constructor, then static methods, then instance
+ methods. Within each group, order by body size ascending (number of lines between `{` and `}`).
+ Constants and enum cases, which have no body, are ordered by name length ascending. This
+ ordering may be overridden only when the alternative carries explicit documentation value:
+ grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc),
+ mirroring the order of an implemented interface, or similar evident structure. The override
+ must be obvious at first reading.
+
+ **At call sites** (chained method calls in production code, tests, or documentation
+ examples), consecutive method invocations on the same receiver are ordered by the **visible
+ width** of each call expression ascending. The body is not visible at the call site, so the
+ visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and
+ `->httpOnly()` come before parameterized `with*` builders for the same reason. When two
+ calls have equal width, order them alphabetically by method name.
+
+ **Terminal methods that change the receiver type** stay at the end of the chain regardless
+ of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of
+ work, a `send()` that flushes a request, are terminal: the chain ends with them. The
+ ordering rule applies only to consecutive calls on the same receiver type; calls that
+ transition to a different type are not reorderable. The same applies in reverse to the
+ factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays
+ at its position.
+7. Constructor parameters are ordered by parameter name length ascending (count the name only,
+ without `$` or type), except when parameters have an implicit semantic order (for example,
+ `$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default
+ values go last, regardless of name length. The same rule applies to named arguments at call
+ sites. Example order: `$id` (2), `$value` (5), `$status` (6), `$precision` (9).
+8. Never pass an argument whose value equals the parameter's default. Omit the argument entirely.
+ Example with `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`. The call
+ `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` becomes
+ `$collection->toArray()`. Only pass the argument when the value differs from the default.
+9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead.
+10. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of
+ `$acc`.
+11. No generic identifiers exist. Use domain-specific names instead. Examples are `$data` to
+ `$payload`, `$value` to `$totalAmount`, `$item` to `$element`, `$info` to `$currencyDetails`,
+ `$result` to `$conversionOutcome`.
+12. No raw arrays exist where a typed collection or value object is available. When data is
+ `Collectible`, use the `tiny-blocks/collection` fluent API (`Collection`, `Collectible`). Use
+ `createLazyFrom` when elements are consumed once. Raw arrays are acceptable only for primitive
+ configuration data, variadic pass-through, and interop at system boundaries. See "Collection
+ usage" for the full rule and example.
+13. No private methods exist except for private constructors in factory patterns, methods inside
+ `src/Internal/` (implementation detail by definition, where the namespace is the abstraction
+ boundary), and `setUp` or `tearDown` overrides in PHPUnit test classes. Outside these cases,
+ inline trivial logic at the call site or extract it to a collaborator or value object.
14. No logic is duplicated across two or more places (DRY).
15. No abstraction exists without real duplication or isolation need (KISS).
-16. All identifiers, comments, and documentation are written in American English.
-17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself.
-18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred.
- Never leave silent gaps.
-19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited.
-20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports.
-21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not
- need it, it does not exist.
-22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch,
- first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods
- over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5),
- and `Collection::map` over foreach accumulation.
-23. No vertical alignment of types in parameter lists or property declarations. Use a single space between
- type and variable name. Never pad with extra spaces to align columns:
- `public OrderId $id` — not `public OrderId $id`.
-24. Opening brace `{` follows PSR-12: on a **new line** for classes, interfaces, traits, enums, and methods
- (including constructors); on the **same line** for closures and control structures (`if`, `for`, `foreach`,
- `while`, `switch`, `match`, `try`).
-25. Never pass an argument whose value equals the parameter's default. Omit the argument entirely.
- Example — `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`:
- `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` → `$collection->toArray()`.
- Only pass the argument when the value differs from the default.
-26. No trailing comma in any multi-line list. This applies to parameter lists (constructors, methods,
- closures), argument lists at call sites, array literals, match arms, and any other comma-separated
- multi-line structure. The last element never has a comma after it. PHP accepts trailing commas in
- parameter lists, but this project prohibits them for visual consistency.
- Example — correct:
- ```
- new Precision(
- value: 2,
- rounding: RoundingMode::HALF_UP
- );
- ```
- Example — prohibited:
- ```
- new Precision(
- value: 2,
- rounding: RoundingMode::HALF_UP,
- );
- ```
+16. No inline comments exist in `src/` or `tests/`, except `# TODO: ` when implementation
+ is unknown, uncertain, or intentionally deferred. Code is the documentation. Block comments
+ (`/* */`) never appear outside docblocks (`/** */`). The `#` style for inline PHP comments
+ applies only to code examples inside Markdown files (see `php-library-documentation.md`).
+17. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports.
+18. Never create public methods, constants, or classes in `src/` solely to serve tests. If
+ production code does not need it, it does not exist.
+19. Format strings with placeholders (`%s`, `%d`, `%f`, etc.) are assigned to a `$template`
+ variable before being passed to `sprintf`. The variable assignment and the `sprintf` call live
+ on separate statements. See "Format strings" for examples.
+20. All class references use `use` imports at the top of the file. Fully qualified names inline are
+ prohibited.
+21. Return types and `new` calls use the explicit class name. `self` is prohibited as a type,
+ as a return type, and in `new self()` instantiation. Constant access via `self::CONST_NAME`
+ is permitted. `static` is permitted only inside extension-point classes (declared `class`
+ without `final readonly`) and inside traits, where late static binding lets subclasses or
+ consuming classes instantiate the correct concrete type. In every other context, use the
+ class name.
+22. Always use the most current and clean syntax available in the target PHP version. Prefer
+ `match` over `switch`, first-class callables over `Closure::fromCallable()`, readonly promotion
+ over manual assignment, enum methods over external switch or if chains, named arguments over
+ positional ambiguity (except where excluded by rule 4), `Collection::map` over foreach
+ accumulation, and **unparenthesized constructor chaining** (PHP 8.4+):
+ `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses around the `new`
+ expression are no longer required and add visual noise.
+23. All identifiers, comments, and documentation use American English. See "American English" for
+ the spelling list.
-## Casing conventions
+## Naming
-- Internal code (variables, methods, classes): **`camelCase`**.
-- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**.
+- Internal code (variables, methods, classes) uses `camelCase`.
+- Constants and enum-backed values when representing codes use `SCREAMING_SNAKE_CASE`.
+- Names describe what in domain terms, not how technically. `$monthlyRevenue` instead of
+ `$calculatedValue`. Generic technical verbs are avoided. See `php-library-modeling.md` for the
+ full banlist of generic and anemic names.
+- Booleans use predicate form. Examples are `isActive`, `hasPermission`, `wasProcessed`.
+- Collections are always plural. Examples are `$orders`, `$lines`.
+- Methods returning `bool` use prefixes `is`, `has`, `can`, `was`, `should`.
-## Naming
+## Class self-references
+
+Type declarations, return types, and `new` calls inside a class use the explicit class name.
+The class name is unambiguous, survives refactors that move the method to a different class,
+and reads identically inside the class body and at the call site.
+
+- `self` is prohibited everywhere as a type, as a return type, and in `new self()`
+ instantiation. Constant access via `self::CONST_NAME` is **permitted**. The prohibition
+ covers the forms that carry refactoring ambiguity when a method moves to a different class
+ (the type-or-instantiation forms). Constant access does not have that ambiguity because the
+ constant is declared in the same class body.
+- `static` is permitted only inside extension-point classes (declared `class` without
+ `final readonly`) and inside traits, where late static binding is required for subclasses or
+ consuming classes to instantiate the correct concrete type.
+- In every other context (the default `final readonly class`, factory methods, return types),
+ use the class name.
+
+**Prohibited.** `self` as return type and `new self()` inside a final class:
+
+```php
+final readonly class UserAgent
+{
+ public static function from(string $product): self
+ {
+ return new self(product: $product);
+ }
+}
+```
+
+**Correct.** Explicit class name in a final class:
+
+```php
+final readonly class UserAgent
+{
+ public static function from(string $product): UserAgent
+ {
+ return new UserAgent(product: $product);
+ }
+}
+```
-- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`.
-- Generic technical verbs are avoided. See `php-library-modeling.md` — Nomenclature.
-- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`.
-- Collections are always plural: `$orders`, `$lines`.
-- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`.
+**Correct.** `static` permitted in an extension-point class:
+
+```php
+class Collection
+{
+ public static function createFrom(iterable $elements): static
+ {
+ return new static(elements: $elements);
+ }
+}
+```
+
+## Inheritance and constructors
+
+- All classes are `final readonly` by default.
+- Use `class` (without `final` or `readonly`) only when the class is designed as an extension point
+ for consumers, for example `Collection` or `ValueObject`.
+- Use `final class` without `readonly` only when the parent class is not readonly, for example
+ when extending a third-party abstract class.
+- Use `final class` without `readonly` is also permitted for `src/Internal/` collaborators that
+ carry intrinsically mutable state (resource handles, counters, cursors) where the mutation is
+ central to the class's responsibility (`Stream` closing a resource, `Cursor` advancing a
+ position). The class must remain confined to `src/Internal/`.
+- Use `final class` without `readonly` for classes that consist exclusively of `static` methods
+ (no instance properties, no instance methods, only static factories or utilities). Pair it
+ with `private function __construct() {}` to prevent instantiation. `readonly` is meaningless
+ without instance state, and the private constructor signals that the class is a static
+ surface, not a value type.
+- Inheritance between concrete classes is prohibited. Every concrete class is `final`.
+- Polymorphism uses interfaces plus composition, never extension of concrete types.
+- The only allowed `extends` is against framework or SPL base classes that the language requires.
+ Examples are `RuntimeException`, `LogicException`, `PHPUnit\Framework\TestCase`.
+- Constructors of `final` classes are `private` when paired with named factory methods, `public`
+ otherwise. `protected` constructors are prohibited because no subclasses exist to call them.
## Comparisons
-1. Null checks: use `is_null($variable)`, never `$variable === null`.
-2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings
- because `empty('0')` returns `true`.
-3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`.
+1. Null checks use `is_null($variable)`, never `$variable === null`.
+2. Empty string checks on typed `string` parameters use `$variable === ''`. Avoid `empty()` on
+ typed strings because `empty('0')` returns `true`.
+3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`) use
+ `empty($variable)`.
## American English
-All identifiers, enum values, comments, and error codes use American English spelling:
-`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`),
-`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`),
-`fulfill` (not `fulfil`), `color` (not `colour`).
+All identifiers, enum values, comments, and error codes use American English spelling. Examples
+are `canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not
+`initialise`), `behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not
+`labelled`), `fulfill` (not `fulfil`), `color` (not `colour`).
## PHPDoc
-- PHPDoc is restricted to interfaces only, documenting obligations, `@throws`, and complexity.
-- Never add PHPDoc to concrete classes.
+### When required
+
+- Every method of an interface, **including interfaces declared inside `src/Internal/`**.
+ Interfaces define contracts. The contract is documentation by definition, regardless of
+ namespace. The `Internal/` boundary applies to implementations, not to the contracts that
+ internal collaborators expose to each other.
+- Every public method of a concrete class outside `src/Internal/`. Public classes are at the
+ public API boundary by definition. Consumers call every public method directly, and the
+ PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt.
+ The only exception is a public method whose contract is already documented on an implemented
+ interface (the interface carries the docblock).
+
+### When prohibited
+
+- Constructors. The constructor signature with property promotion is self-documenting. Parameter
+ types are already explicit in the signature.
+- Private and protected methods.
+- Public methods of concrete classes whose contract is already documented on an implemented
+ interface. The interface carries the docblock.
+- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry
+ PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the
+ architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An
+ interface declared inside `src/Internal/` still defines a contract, and the contract is
+ documented per `### When required` regardless of namespace. The prohibition covers concrete
+ classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces.
+- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz`
+ naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in
+ `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus
+ `@param`/`@return` descriptions) is prohibited on test methods, data providers, fixtures,
+ setUp/tearDown overrides, and anonymous classes inside tests. The BDD annotations are not
+ PHPDoc documentation in the sense of this section and remain required per the testing rule.
+- Single-line PHPDocs with only a tag (`/** @param ... */`, `/** @return ... */`,
+ `/** @throws ... */`). PHPDoc always opens with a summary line. Bare-tag docblocks are
+ prohibited regardless of how few tags they carry.
+
+The prohibitions above apply to **every form of PHPDoc** in the prohibited scope:
+method-level docblocks, property-level docblocks, inline `@var` annotations on local variables,
+and PHPDoc blocks placed above anonymous functions or closures inside method bodies. Inside
+`src/Internal/` and `tests/`, zero PHPDoc is the rule with no exception. PHPStan errors that
+result from the missing annotations route through `ignoreErrors` (see below).
+
+The PHPDoc prohibitions above take priority over the typed-array case. When PHPStan at
+`level: max` flags a missing iterable value type (`missingType.iterableValue`,
+`argument.type`, `return.type`):
+
+- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not
+ add PHPDoc.
+- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via
+ `ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception:
+ they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved
+ through the PHPDoc, never through `ignoreErrors`.
+- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
+- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary,
+ `@param` descriptions, and the typed-array information. The bare-tag form remains
+ prohibited. This is the normal case where PHPDoc is permitted by "When required" above.
+
+The summary requirement and the bare-tag prohibition are never waived. Use `ignoreErrors` only
+when the context (constructor, `src/Internal/`, `tests/`) makes PHPDoc impossible. Every public
+method of a public concrete class carries PHPDoc per "When required", whether the method
+has typed-array parameters.
+
+### Style
+
+- Summary on the first line, in domain terms. **Mandatory.** PHPDoc without a summary line is
+ prohibited, even when it carries a single `@param` or `@return`.
+- Optional detailed body in `
` paragraphs below the summary.
+- Tags use the form `@param Type $name Description.`, `@return Type Description.`,
+ `@throws ExceptionClass If .`.
- Document `@throws` for every exception the method may raise.
-- Document time and space complexity in Big O form. When a method participates in a fused pipeline (e.g., collection
- pipelines), express cost as a two-part form: call-site cost + fused-pass contribution. Include a legend defining
- variables (e.g., `N` for input size, `K` for number of stages).
+- HTML tags allowed inside descriptions are `
` for paragraphs, `
` for lists,
+ `` for inline code, `` and `` for emphasis.
+
+### Summary patterns
+
+The summary line is not a creative intent statement. It is a template selected by the method's
+name prefix. Apply the matching template. Only methods with no matching prefix require a
+free-form one-line summary in domain terms.
+
+| Method shape | Template |
+|-------------------------------------------------------------------------|--------------------------------------------------------------------------------|
+| Static factory (`create`, `from`, `fromX`, `with*` when static) | `Creates a {ClassName} from {input}.` or `Builds a {ClassName} with {fields}.` |
+| `with*` instance method | `Returns a copy of the {ClassName} with the {field} replaced.` |
+| Getter (no prefix, returns a property: `code()`, `body()`, `headers()`) | `Returns the {field}.` |
+| Predicate (`is*`, `has*`, `can*`, `was*`, `should*`) | `Tells whether {condition}.` |
+| Converter (`toArray`, `toString`, `asX`) | `Returns the {ClassName} as {target shape}.` |
+| `apply*`, `merge*`, `add*`, and other side-effect-free operations | One-line summary in domain terms describing the operation. |
+
+The patterns are mandatory when applicable. They make summary lines mechanical: substitute
+`{ClassName}` and `{field}` and the summary is complete. No per-method intent decision is
+required. Volume is never a reason to skip the summary. Many methods just mean applying the
+template many times.
+
+### Cross-references
+
+- `{@see ClassName}` for links to other types in the codebase.
+- `@see Author, Title (Publisher, Year), Chapter X.` for bibliographical references.
+
+### Examples
+
+**Prohibited.** Single-line bare-tag PHPDoc, no summary:
+
+```php
+/** @param array|null $body */
+public static function with(Code $code, ?array $body = null): Response
+```
+
+**Prohibited.** PHPDoc on a constructor:
+
+```php
+/** @param array $entries */
+public function __construct(public array $entries)
+{
+}
+```
+
+**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does
+not extend to interfaces; see "Correct" below for an Internal/ interface):
+
+```php
+namespace TinyBlocks\Http\Internal\Client;
+
+final readonly class Url
+{
+ /** @param array|null $query */
+ public static function compose(string $path, ?array $query, string $baseUrl): string
+ {
+ }
+}
+```
+
+**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every
+method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they
+are the contract:
+
+```php
+namespace TinyBlocks\Http\Internal\Client;
+
+interface RequestResolver
+{
+ /**
+ * Resolves the given URL against the configured base URL.
+ *
+ * @param string $url The path or absolute URL to resolve.
+ * @return string The absolute URL to dispatch.
+ * @throws MalformedPath If the URL violates RFC 3986.
+ */
+ public function resolve(string $url): string;
+}
+```
+
+**Correct.** Generic array type with summary and `@param` description:
+
+```php
+/**
+ * Builds a synthesized response from a status code and an optional body.
+ *
+ * @param array|null $body The response body as an associative array.
+ * @return Response The synthesized response instance.
+ */
+public static function with(Code $code, ?array $body = null): Response
+```
+
+**Correct.** Interface with rich description, paragraphs, cross-references, and bibliography:
+
+```php
+/**
+ * Money tied to a specific currency.
+ *
+ *
Operations between different currencies raise CurrencyMismatch. Arithmetic
+ * preserves the currency.
+ *
+ *
Sibling of {@see Quantity}, not a parent. Money carries currency semantics.
+ *
+ * @see Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), Chapter 5.
+ */
+interface Money
+{
+ /**
+ * Adds the given amount.
+ *
+ * @param Money $other The amount to add.
+ * @return Money A new instance with the summed amount.
+ * @throws CurrencyMismatch If $other has a different currency.
+ */
+ public function add(Money $other): Money;
+}
+```
+
+**Correct.** Concrete class with a short summary and direct tags:
+
+```php
+/**
+ * IANA timezone identifier (e.g. America/Sao_Paulo).
+ */
+final readonly class Timezone
+{
+ /**
+ * Creates a Timezone from a valid IANA identifier.
+ *
+ * @param string $identifier The IANA timezone identifier.
+ * @return Timezone The created instance.
+ * @throws InvalidTimezone If the identifier is not a valid IANA timezone.
+ */
+ public static function from(string $identifier): Timezone
+ {
+ # ...
+ }
+}
+```
+
+## Dependencies
+
+When the library needs an external dependency, prefer packages from the `tiny-blocks` ecosystem
+(https://github.com/tiny-blocks) whenever a suitable option exists. Reach for outside packages
+only when the ecosystem has no equivalent that fits the use case.
## Collection usage
-When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions such as
-`array_map`, `array_filter`, `iterator_to_array`, or `foreach` + accumulation. The same applies to `filter()`,
-`reduce()`, `each()`, and all other `Collectible` operations. Chain them fluently. Never materialize with
-`iterator_to_array` to then pass into a raw `array_*` function.
+When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array
+functions such as `array_map`, `array_filter`, `iterator_to_array`, or `foreach` plus accumulation.
+The same applies to `filter()`, `reduce()`, `each()`, and every other `Collectible` operation.
+Chain them fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*`
+function.
-**Prohibited — `array_map` + `iterator_to_array` on a Collectible:**
+**Prohibited.** `array_map` plus `iterator_to_array` on a `Collectible`:
```php
$names = array_map(
@@ -145,10 +456,161 @@ $names = array_map(
);
```
-**Correct — fluent chain with `map()` + `toArray()`:**
+**Correct.** Fluent chain with `map()` plus `toArray()`:
```php
$names = $collection
->map(transformations: static fn(Element $element): string => $element->name())
->toArray(keyPreservation: KeyPreservation::DISCARD);
```
+
+## Format strings
+
+When building a message with placeholders, assign the format string to a `$template` variable
+first. Pass it to `sprintf` on a separate statement. The format and the data are visually
+separated, and the template line stays scannable.
+
+**Prohibited.** Format string inline with the call:
+
+```php
+if ($value < 0 || $value > 16) {
+ throw new PrecisionOutOfRange(
+ message: sprintf('Precision must be between 0 and 16, got %d.', $value)
+ );
+}
+```
+
+**Correct.** Format string in a `$template` variable:
+
+```php
+if ($value < 0 || $value > 16) {
+ $template = 'Precision must be between 0 and 16, got %d.';
+
+ throw new PrecisionOutOfRange(message: sprintf($template, $value));
+}
+```
+
+## Constructor chaining
+
+PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in
+parentheses. The parentheses are no longer required and only add visual noise. Apply this
+everywhere a `new` is followed by a method call.
+
+**Prohibited.** Parentheses around the `new` expression:
+
+```php
+$body = (new ServerRequest(method: 'GET', uri: 'https://api.example.com'))
+ ->withHeader('Accept', 'application/json')
+ ->getBody();
+```
+
+**Correct.** No parentheses:
+
+```php
+$body = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withHeader('Accept', 'application/json')
+ ->getBody();
+```
+
+## Formatting overrides
+
+Three formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12`
+only). Apply them manually.
+
+### No vertical alignment in parameter lists
+
+Use a single space between the type and the variable name in parameter lists (constructors,
+function signatures, closures). Never pad with extra spaces to align columns. This rule applies
+only to parameter lists, not to other contexts that use `=>` alignment (see "Vertical alignment
+of `=>`" below).
+
+**Prohibited.** Vertical alignment of types:
+
+```php
+public function __construct(
+ public OrderId $id,
+ public Money $total,
+ public Customer $customer,
+ public Precision $precision
+) {}
+```
+
+**Correct.** Single space between type and variable:
+
+```php
+public function __construct(
+ public OrderId $id,
+ public Money $total,
+ public Customer $customer,
+ public Precision $precision
+) {}
+```
+
+### Vertical alignment of `=>` in match arms and array literals
+
+Multi-line `match` expressions and multi-line array literals with `=>` align the `=>` column
+across all arms or entries by padding shorter left-hand sides with spaces. Single-line cases
+(one-arm match, single-line array) keep the standard PSR-12 single-space form.
+
+**Prohibited.** Unaligned `=>` in match:
+
+```php
+return match ($this) {
+ self::MAX_AGE => sprintf($template, $this->value, $value),
+ default => $this->value
+};
+```
+
+**Correct.** Aligned `=>` in match:
+
+```php
+return match ($this) {
+ self::MAX_AGE => sprintf($template, $this->value, $value),
+ default => $this->value
+};
+```
+
+**Prohibited.** Unaligned `=>` in array literal:
+
+```php
+return [
+ 'name' => 'Gustavo',
+ 'role' => 'developer',
+ 'company' => 'Anthropic'
+];
+```
+
+**Correct.** Aligned `=>` in array literal:
+
+```php
+return [
+ 'name' => 'Gustavo',
+ 'role' => 'developer',
+ 'company' => 'Anthropic'
+];
+```
+
+### No trailing comma in multi-line lists
+
+Never place a trailing comma after the last element of any multi-line list. Applies to parameter
+lists, argument lists, array literals, match arms, and every other comma-separated multi-line
+structure. PHP accepts trailing commas in these positions, but this ecosystem prohibits them for
+visual consistency.
+
+**Prohibited.** Trailing comma after the last argument:
+
+```php
+new Precision(
+ value: 2,
+ rounding: RoundingMode::HALF_UP,
+);
+```
+
+**Correct.** No trailing comma:
+
+```php
+new Precision(
+ value: 2,
+ rounding: RoundingMode::HALF_UP
+);
+```
diff --git a/.claude/rules/php-library-commits.md b/.claude/rules/php-library-commits.md
new file mode 100644
index 0000000..feefcf5
--- /dev/null
+++ b/.claude/rules/php-library-commits.md
@@ -0,0 +1,111 @@
+---
+description: Conventional Commits format. Applied on request when generating commit messages.
+---
+
+# Commits
+
+Applied only when generating commit messages, never automatically. All commit messages are
+written in English.
+
+## Format
+
+`: `
+
+The description starts with a capital letter, uses imperative present tense ("Add", "Fix",
+"Change", not "Added", "Adds", or "Adding"), and ends with a period. Subject under 300
+characters. If it does not fit, split the change into multiple commits or move detail into the
+body.
+
+Scopes are prohibited. `feat(orders): ...` is wrong. The type stands alone.
+
+## Allowed types
+
+Each entry below is a bullet that starts with a capital letter and ends with a period. This is
+the canonical example of bullet punctuation enforced everywhere in this document.
+
+- `ci` for CI configuration changes.
+- `fix` for a bug fix.
+- `feat` for a user-facing feature.
+- `docs` for documentation only.
+- `test` for adding or correcting tests.
+- `chore` for maintenance with no production code change.
+- `build` for build or dependency changes.
+- `revert` for reverting a previous commit.
+- `refactor` for a code change that neither fixes a bug nor adds a feature.
+
+`style` is not used. Formatting is enforced by the linter and never appears as a standalone
+commit.
+
+## Subject examples
+
+Good:
+
+- `fix: Handle zero-amount transactions.`
+- `feat: Add order cancellation endpoint.`
+- `refactor: Extract OrderStatus into its own enum.`
+
+Bad:
+
+- `Added order cancellation` is past tense, missing type, missing period.
+- `feat: Adds order cancellation.` is third-person singular instead of imperative.
+- `feat: added order cancellation.` starts lowercase and is past tense.
+- `feat: Add cancellation, and fix billing rounding.` bundles two changes. Split.
+- `feat(orders): Add cancellation.` uses a scope. Prohibited.
+
+## Body
+
+The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body
+ONLY when the reason cannot be inferred from the diff (a non-obvious trade-off, a workaround for
+an external bug, a decision worth recording).
+
+Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain
+why, not what. The diff already shows what.
+
+## Prose vs. bullets in the body
+
+**Default to prose.** One or two paragraphs fits almost every commit that has a body at all.
+
+**Use bullets only when ALL of these are true:**
+
+1. The commit covers 3 or more independent changes that genuinely belong in the same commit.
+2. The list cannot be expressed as continuous prose without becoming disconnected sentences.
+3. Each item is independently meaningful (no sub-bullets, no continuation across bullets).
+
+A two-item bullet list is the wrong shape. Use prose.
+
+## Bullet formatting (when used)
+
+Every bullet starts with a capital letter and ends with a period. Imperative verb in present
+tense, same as the subject line. Without exception.
+
+Wrong (do NOT generate):
+
+- `add the OrderCancelling port` lowercase, missing period.
+- `Add the OrderCancelling port` capital but missing period.
+- `Adds the OrderCancelling port.` third-person singular instead of imperative.
+
+## Body example with bullets
+
+```
+feat: Add order cancellation flow.
+
+- Add the OrderCancelling inbound port and OrderCancellingHandler.
+- Add the CancelOrder command and its validator.
+- Cover the cancellation path in the integration test suite.
+```
+
+## Body example with prose (preferred for most commits)
+
+```
+fix: Handle zero-amount transactions.
+
+The payment gateway rejects zero-amount charges with a generic 400 instead
+of a documented error code, so the adapter short-circuits before the HTTP
+call and raises ZeroAmountNotAllowed directly.
+```
+
+## Commit splitting
+
+Prefer one logical change per commit. Refactor commits never modify behavior. When a task
+requires multiple types of change, produce multiple commits in order: `refactor` first, then
+`feat` or `fix` on top.
diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md
index 4791cb9..b7e0da4 100644
--- a/.claude/rules/php-library-documentation.md
+++ b/.claude/rules/php-library-documentation.md
@@ -1,40 +1,313 @@
---
-description: Standards for README files and all project documentation in PHP libraries.
+description: Standards for README and other public-facing Markdown docs in PHP libraries.
paths:
- "**/*.md"
---
# Documentation
+Standards for `README.md` and other public-facing Markdown files in the repository. PHPDoc rules
+for `.php` files live in `php-library-code-style.md`. American English applies everywhere (see
+the American English section in `php-library-code-style.md`).
+
+The `CONTRIBUTING.md` file is centralized at
+`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and
+pull request template link to that location. No local `CONTRIBUTING.md` is created per library.
+
+## Pre-output checklist
+
+Verify every item before producing any Markdown documentation. If any item fails, revise before
+outputting.
+
+1. README title is `# ` with spaces between words (`# Building Blocks`, not
+ `# BuildingBlocks`).
+2. License badge is the only badge. No build, coverage, Packagist, or version badges.
+3. Header is followed by an anchor-linked table of contents.
+4. Table of contents uses `*` for top-level (H2) entries, `+` indented by 4 spaces for
+ second-level (H3) entries, and `-` indented by 8 spaces for third-level (H4) entries. Every
+ heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by
+ a single `* [FAQ](#faq)` line regardless of how many questions it contains.
+5. Sections appear in the canonical order: Overview, Installation, How to use, FAQ (optional),
+ License, Contributing.
+6. FAQ exists only when there are genuine points of confusion or unusual design decisions. Skip
+ it entirely when not needed.
+7. **Self-contained code examples** are blocks that include any of: a `use` statement, a
+ `class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of
+ executable code. Self-contained blocks open with `?` with zero-padded numbering
+ (`### 01.`, `### 02.`).
+12. FAQ bibliographic citations use the format
+ `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".`
+13. License and Contributing sections each follow the canonical one-line template.
+14. Repository includes `SECURITY.md`, `.github/ISSUE_TEMPLATE/bug_report.md`,
+ `.github/ISSUE_TEMPLATE/feature_request.md`, and `.github/PULL_REQUEST_TEMPLATE.md`, each
+ matching the canonical template in "Other documentation files".
+
## README
-1. Include an anchor-linked table of contents.
-2. Start with a concise one-line description of what the library does.
-3. Include a **license** badge. Do not include any other badges.
-4. Provide an **Overview** section explaining the problem the library solves and its design philosophy.
-5. **Installation** section: Composer command (`composer require vendor/package`).
-6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example
- includes a brief heading describing what it demonstrates.
-7. If the library exposes multiple entry points, strategies, or container types, document each with its own
- subsection and example.
-8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users
- frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`)
- followed by a concise explanation. Only include entries that address real confusion points.
-9. **License** and **Contributing** sections at the end.
-10. Write strictly in American English. See `php-library-code-style.md` American English section for spelling
- conventions.
+### Structure
+
+The README follows a fixed section order:
+
+1. **Overview**. One or more paragraphs explaining the problem the library solves and its design
+ philosophy. Cross-references to related `tiny-blocks` libraries belong here.
+2. **Installation**. Composer command in a code block, with no surrounding prose unless strictly
+ necessary.
+3. **How to use**. Runnable examples covering the primary use cases. Each subsection demonstrates
+ one capability with a heading and a self-contained code block.
+4. **FAQ** (optional). Numbered questions that address real points of confusion or unusual design
+ decisions.
+5. **License**. One-line link to the `LICENSE` file.
+6. **Contributing**. One-line link to the centralized `CONTRIBUTING.md` in
+ `tiny-blocks/tiny-blocks`.
+
+### Header and license badge
+
+The first line is `# ` followed by a blank line and the license badge:
+
+```markdown
+# Outbox
+
+[](https://github.com/tiny-blocks//blob/main/LICENSE)
+```
+
+Replace `` with the library's repository name. The badge is the only badge in the document.
+
+### Table of contents
+
+The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3)
+entries use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces.
+Every heading from the document appears, with one exception: the FAQ is represented by a single
+`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many
+exist.
+
+```markdown
+* [Overview](#overview)
+* [Installation](#installation)
+* [How to use](#how-to-use)
+ + [Subtopic A](#subtopic-a)
+ + [Subtopic B](#subtopic-b)
+* [FAQ](#faq)
+* [License](#license)
+* [Contributing](#contributing)
+```
+
+Use the third level whenever the document has H4 headings, regardless of whether they form a
+two-axis split. The TOC mirrors the document structure exactly.
+
+```markdown
+* [How to use](#how-to-use)
+ + [Entity](#entity)
+ - [Single-field identity](#single-field-identity)
+ - [Compound identity](#compound-identity)
+ + [Aggregate](#aggregate)
+```
+
+### Code examples
+
+Code examples fall into two categories.
+
+**Self-contained examples** include at least one of:
+
+- A `use` statement.
+- A `class`, `enum`, `interface`, `trait`, or `function` declaration.
+- More than 3 lines of executable code.
+
+They open with `push(records: $order->recordedEvents());
+```
+
+**Inline fragment examples** have all of:
+
+- At most 3 lines of executable code.
+- No `use` statements.
+- No type declarations.
+
+Fragments may omit the prologue.
+
+```php
+Code::OK->value;
+```
+
+The criteria are mechanical: a block that meets any self-contained condition gets the prologue. A block that meets every fragment condition may omit it. There is no middle ground.
+
+The `#` convention for inline comments applies only to code examples inside Markdown files. PHP
+files under `src/` and `tests/` have no inline comments at all, except `# TODO: ` (see
+item 16 in `php-library-code-style.md`).
+
+### FAQ
+
+FAQ entries are numbered with zero-padded prefixes and end with a question mark:
+
+```markdown
+### 01. Why is DomainEvent close to a marker interface?
+
+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`.
+
+> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8,
+> "Domain Events".
+```
+
+Bibliographic citations follow the format
+`> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` The chapter and section
+fragments are optional when the title is precise enough on its own. Multiple citations can be
+stacked as separate blockquote lines.
+
+### License and Contributing
+
+The License section is a single line:
+
+```markdown
+## License
+
+ is licensed under [MIT](LICENSE).
+```
+
+The Contributing section is a single line pointing to the centralized guideline:
+
+```markdown
+## Contributing
+
+Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
+contribute to the project.
+```
## Structured data
-1. When documenting constructors, factory methods, or configuration options with more than 3 parameters,
- use tables with columns: Parameter, Type, Required, Description.
-2. Prefer tables to prose for any structured information.
+Tables are preferred to prose for any structured information: constructor parameter lists,
+builder method catalogs, default value tables, complexity tables, and configuration matrices.
+Column layout is chosen per case. No fixed column set is mandated.
+
+## Other documentation files
+
+Every library repository includes the following files in addition to the README. Each follows
+the canonical template below.
+
+### SECURITY.md
+
+```markdown
+# Security Policy
+
+## Supported versions
+
+Only the latest release receives security updates.
+
+## Reporting a vulnerability
+
+Report security vulnerabilities privately via
+[GitHub Security Advisories](https://github.com/tiny-blocks//security/advisories/new).
+
+Please do not disclose the vulnerability publicly until it has been addressed.
+```
+
+Replace `` with the repository name.
+
+### .github/ISSUE_TEMPLATE/bug_report.md
+
+```markdown
+---
+name: Bug report
+about: Report a bug to help improve the library
+labels: bug
+---
+
+## Description
+
+A clear and concise description of the bug.
+
+## Steps to reproduce
+
+1.
+2.
+3.
+
+## Expected behavior
+
+What should happen.
+
+## Actual behavior
+
+What actually happens.
+
+## Environment
+
+- PHP version:
+- Library version:
+- OS:
+```
+
+### .github/ISSUE_TEMPLATE/feature_request.md
+
+```markdown
+---
+name: Feature request
+about: Suggest a feature for the library
+labels: enhancement
+---
+
+## Problem
+
+What problem does this feature solve?
+
+## Proposed solution
+
+How should the feature work?
+
+## Alternatives considered
+
+Other approaches considered.
+```
+
+### .github/PULL_REQUEST_TEMPLATE.md
+
+```markdown
+> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md).
+
+## Summary
+
+What this pull request does.
+
+## Related issue
+
+Closes #...
-## Style
+## Checklist
-1. Keep language concise and scannable.
-2. Never include placeholder content (`TODO`, `TBD`).
-3. Code examples must be syntactically correct and self-contained.
-4. Code examples include every `use` statement needed to compile. Each example stands alone — copyable into
- a fresh file without modification.
-5. Do not document `Internal/` classes or private API. Only document what consumers interact with.
+- [ ] Tests added or updated.
+- [ ] Documentation updated when applicable.
+- [ ] `composer review` passes.
+- [ ] `composer tests` passes.
+```
diff --git a/.claude/rules/php-library-github-workflows.md b/.claude/rules/php-library-github-workflows.md
new file mode 100644
index 0000000..396c40a
--- /dev/null
+++ b/.claude/rules/php-library-github-workflows.md
@@ -0,0 +1,287 @@
+---
+description: Structure, ordering, and pinning rules for GitHub Actions workflows in PHP libraries.
+paths:
+ - ".github/workflows/**/*.yml"
+ - ".github/workflows/**/*.yaml"
+---
+
+# Workflows
+
+Conventions for GitHub Actions workflows in PHP libraries. CD does not apply. Libraries publish
+to Packagist via tags and never deploy.
+
+`.github/workflows/ci.yml` is mandatory and follows the canonical structure defined in the
+"ci.yml" section below. Additional workflow files (security scanning, automated triage,
+scheduled tasks, dependency updates, etc.) may exist and follow the general rules in this file.
+Their trigger, job structure, and steps are chosen by their purpose.
+
+The Composer scripts invoked by `ci.yml` (`composer review`, `composer tests`) are defined in
+`php-library-tooling.md`.
+
+## Pre-output checklist
+
+Verify every item before producing or editing any workflow YAML. If any item fails, revise
+before outputting.
+
+### Rules for every workflow
+
+These rules apply to `ci.yml` and to every additional workflow in `.github/workflows/`.
+
+1. Keys at the workflow root follow the canonical order `name`, `on`, `concurrency`,
+ `permissions`, `jobs`. Keys absent in a given workflow are simply omitted. The relative order
+ of the remaining keys is preserved.
+2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`,
+ `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule as above.
+3. Inside any block (`env`, `outputs`, `with`, `permissions`), entries are ordered by key length
+ ascending.
+4. The workflow `name`, every job `name`, and every step `name` are mandatory and use sentence
+ case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION` or `resolve_php_version`). Step names
+ start with a verb. Job keys describe the job's purpose. Generic keys (`run`, `job`, `do`) are
+ discouraged in favor of descriptive identifiers (`auto-assign`, `analyze`, `notify`).
+5. `concurrency` is set at the workflow root with `cancel-in-progress: true` and a `group`
+ expression scoped by the workflow's trigger:
+ - `pull_request`: `-${{ github.event.pull_request.number }}`.
+ - `issues`, or `issues` combined with `pull_request`:
+ `-${{ github.event.issue.number || github.event.pull_request.number }}`.
+ - `push`, `schedule`, or both: `-${{ github.ref }}`.
+
+ `` is the workflow's short name (`ci`, `codeql`, `auto-assign`).
+6. `permissions` is declared at the workflow root with the minimum scope every job needs.
+ Job-level `permissions` blocks are allowed only when a specific job needs a narrower scope
+ than the root, never broader.
+7. Every job sets `timeout-minutes`. Defaults: 5 for trivial steps (single API call, lightweight
+ script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL,
+ security scanning). Adjust based on observed runtime when prior runs exist.
+8. Every action is pinned to a fixed major version tag written explicitly. Examples are
+ `actions/checkout@v6` and `shivammathur/setup-php@v2`. Never use `@latest`, `@main`, a branch
+ name, or a commit SHA. When the existing pin is an explicit minor or patch, derive the major
+ version while **preserving the prefix style** of the original tag: `@v2.1.0` → `@v2`,
+ `@2.1.0` → `@2`. The action's tag convention is reflected in the existing pin. Web lookup is
+ required only when the existing pin is missing, ambiguous, or pointing to a non-version
+ reference. Example versions cited in this file may be outdated and are not a license to skip
+ the lookup when it is required.
+9. Inline shell logic longer than 3 lines is extracted to a script in `scripts/ci/`.
+10. All text (workflow name, job names, step names, comments) uses American English with correct
+ spelling and punctuation. Sentences and descriptions end with a period.
+
+### Rules specific to ci.yml
+
+These rules apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them.
+
+1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`.
+2. Trigger is `pull_request` only. No `push`, no branch filter, no `workflow_dispatch`.
+3. Jobs run in the fixed sequence `resolve-php-version`, `build`, `auto-review`, `tests`. Each
+ downstream job lists its upstream jobs in `needs`.
+4. PHP version is never hardcoded. The `resolve-php-version` job reads `.require.php` from
+ `composer.json` at runtime and exposes the minor version (for example, `8.5`) as the job
+ output `php-version`. Downstream jobs reference
+ `${{ needs.resolve-php-version.outputs.php-version }}` when setting up PHP.
+5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. Both
+ scripts are defined in `composer.json` per `php-library-tooling.md`. No other command is
+ invoked in either job.
+6. The `build` job uploads `vendor/` and `composer.lock` as a single artifact named
+ `vendor-artifact`. The `auto-review` and `tests` jobs download that artifact instead of
+ running `composer install` again.
+7. The `tests` job is the only job that may extend with extra setup required by the library,
+ such as service containers, fixture preparation, or environment variables used during
+ testing. The other three jobs are identical across every library in the ecosystem.
+8. `concurrency.group` is `pr-${{ github.event.pull_request.number }}`. `timeout-minutes` is 5
+ for `resolve-php-version` and 15 for `build`, `auto-review`, and `tests`. `permissions` is
+ `contents: read`.
+
+## ci.yml
+
+`ci.yml` is the mandatory workflow that gates every pull request. It contains four jobs in the
+exact order below. The first three jobs are identical across every library. Only `tests` may
+extend with extra setup required by the library.
+
+### Resolve PHP version
+
+Reads `.require.php` from `composer.json` and exposes the minor version (for example, `8.5`) as the
+output `php-version`. A single step uses `jq` and a short regex to extract the value. Downstream jobs
+consume the output to configure their PHP setup.
+
+### Build
+
+Sets up PHP using the resolved version, validates `composer.json`, installs dependencies with
+`--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and
+`composer.lock` as the artifact `vendor-artifact`.
+
+### Auto review
+
+Depends on `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up PHP, and runs
+`composer review`. The `review` script in `composer.json` aggregates lint, static analysis, and style
+checks for the library.
+
+### Tests
+
+Depends on `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up PHP, and runs
+`composer tests`. Any setup required by the library's tests (service containers, fixture preparation,
+environment variables used during testing) lives in this job only.
+
+## Reference shape
+
+The YAML below is the canonical minimal form. Every library starts from this exact shape and extends
+only the `tests` job when its tests require extra setup. Action versions cited here may be outdated.
+Look up the current major version of every action via web search before adopting this shape verbatim.
+
+### Minimal workflow
+
+```yaml
+name: CI
+
+on:
+ pull_request:
+
+concurrency:
+ group: pr-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ resolve-php-version:
+ name: Resolve PHP version
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ php-version: ${{ steps.config.outputs.php-version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Resolve PHP version from composer.json
+ id: config
+ run: |
+ version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1)
+ echo "php-version=$version" >> "$GITHUB_OUTPUT"
+
+ build:
+ name: Build
+ needs: resolve-php-version
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - name: Validate composer.json
+ run: composer validate --no-interaction
+
+ - name: Install dependencies
+ run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction
+
+ - name: Upload vendor and composer.lock as artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: vendor-artifact
+ path: |
+ vendor
+ composer.lock
+
+ auto-review:
+ name: Auto review
+ needs: [resolve-php-version, build]
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v8
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run review
+ run: composer review
+
+ tests:
+ name: Tests
+ needs: [resolve-php-version, auto-review]
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v8
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run tests
+ run: composer tests
+```
+
+### Extending the tests job
+
+When the library's tests need external services, env vars, or fixture preparation, the additions live
+inside the `tests` job only. The example below shows the same `tests` job extended with a MySQL service
+container and the env vars consumed by the test suite.
+
+```yaml
+tests:
+ name: Tests
+ needs: [resolve-php-version, auto-review]
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ env:
+ DB_HOST: 127.0.0.1
+ DB_NAME: library_test
+ DB_PORT: '3306'
+ DB_USER: library
+ DB_PASSWORD: library
+ services:
+ mysql:
+ image: mysql:8
+ ports:
+ - 3306:3306
+ env:
+ MYSQL_DATABASE: library_test
+ MYSQL_ROOT_PASSWORD: library
+ options: >-
+ --health-cmd="mysqladmin ping"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v8
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run tests
+ run: composer tests
+```
diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md
index bedb733..127413c 100644
--- a/.claude/rules/php-library-modeling.md
+++ b/.claude/rules/php-library-modeling.md
@@ -1,112 +1,199 @@
---
-description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity.
+description: Semantic modeling rules for PHP libraries (nomenclature, value objects, exceptions, enums, extension points, complexity).
paths:
- "src/**/*.php"
---
-# Library modeling
+# Modeling
+
+Library modeling rules. How to model the concepts the library exposes. Folder structure and
+public API boundary live in `php-library-architecture.md`. Code style lives in
+`php-library-code-style.md`. Tooling lives in `php-library-tooling.md`.
+
+## Pre-output checklist
+
+Verify every item before producing any PHP code that defines a model, an exception, or an
+algorithm. If any item fails, revise before outputting.
+
+1. Each model has a single, clear responsibility. Apply DDD, SOLID, DRY, and KISS where they
+ sharpen the design, not as dogma.
+2. Concept names. Every class, property, method, and exception name reflects the concept the
+ library represents, not a technical role.
+3. No always-banned names. Never use `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity` as
+ class suffix, prefix, or method name. Never use `Exception` as a class suffix. Exception:
+ names that correspond to externally standardized identifiers (HTTP status text from RFC
+ documents, PSR interface names being mirrored, etc.) are permitted. The standard reference
+ is the meaning carrier.
+4. No anemic verbs as the primary operation name (`ensure`, `validate`, `check`, `verify`,
+ `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, `transform`, `parse`) unless
+ the verb is the library's reason to exist.
+5. Architectural role names (`Manager`, `Handler`, `Processor`, `Service`, and their verb forms
+ `process`, `handle`, `execute`) are allowed only when the class IS that role for consumers
+ integrating with the library.
+6. Value objects are immutable. No setters. Operations return new instances.
+7. Value objects compare by value, never by reference. No identity field.
+8. Value objects validate invariants in the constructor and throw a dedicated exception on
+ invalid input.
+9. Value objects with multiple creation paths use static factory methods (`from`, `of`, `zero`)
+ with a private constructor.
+10. Every failure throws a dedicated exception class named after the invariant it guards. Never
+ `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, or any other
+ generic native exception directly.
+11. Dedicated exception classes extend the appropriate native PHP exception (`DomainException`,
+ `InvalidArgumentException`, `OverflowException`, etc.).
+12. Exceptions are pure. No transport-specific fields (HTTP status in `code`, formatted message
+ for end-user display). They signal invariant violations only, never control flow.
+13. Enums are PHP backed enums. They include methods only when those methods carry vocabulary
+ meaning.
+14. Extension points use `class` instead of `final readonly class`. They expose a private
+ constructor with static factory methods as the only creation path. Internal state is
+ injected via the constructor.
+15. Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²)
+ or worse needs explicit justification.
+16. Prefer lazy or streaming evaluation over materializing intermediate results. Memory usage
+ is bounded and proportional to the output, not to the sum of intermediate stages.
+
+## Modeling principles
+
+Apply the following principles where they sharpen the design. Treat them as guides, not as dogma.
+
+- Single responsibility. Each model represents one concept, has one reason to change, and
+ exposes operations that belong to that concept.
+- DDD ubiquitous language. Names, types, and operations match the vocabulary the library's
+ domain uses. Code and conversation share the same terms.
+- SOLID. Interfaces define narrow contracts. Composition is preferred to inheritance.
+ Substitutability holds at every interface boundary.
+- DRY. No duplicated logic across two or more places.
+- KISS. No abstraction without real duplication or isolation need.
-Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. Refer to
-`php-library-code-style.md` for the pre-output checklist applied to all PHP code.
+## Nomenclature
-## Folder structure
+- Every class, property, method, and exception name reflects the concept the library represents.
+ A math library uses `Precision` and `RoundingMode`. A money library uses `Currency` and
+ `Amount`. A collection library uses `Collectible` and `Order`.
+- Name classes after what they represent, not after what they do technically. Use `Money`,
+ `Color`, `Pipeline`, not `MoneyCalculator`, `ColorHelper`, `PipelineProcessor`.
+- Name methods after the operation in the library's vocabulary. Use `add()`, `convertTo()`,
+ `splitAt()`, not `compute()`, `process()`, `handle()`.
-```
-src/
-├── .php # Primary contract for consumers
-├── .php # Main implementation or extension point
-├── .php # Public enum
-├── Contracts/ # Interfaces for data returned to consumers
-├── Internal/ # Implementation details (not part of public API)
-│ ├── .php
-│ └── Exceptions/ # Internal exception classes
-├── / # Feature-specific subdirectory when needed
-└── Exceptions/ # Public exception classes (when part of the API)
-```
+### Always banned
-Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names.
+These names carry zero semantic content. Never use them anywhere as class suffix, prefix, or
+method name.
-## Public API boundary
+- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`.
+- `Exception` as a class suffix (e.g., `FooException`). Use the invariant name when extending a
+ native exception (e.g., `PrecisionOutOfRange`, not `InvalidPrecisionException`).
-Only interfaces, extension points, enums, and thin orchestration classes live at the `src/` root. These classes
-define the contract consumers interact with and delegate all real work to collaborators inside `src/Internal/`.
-If a class contains substantial logic (algorithms, state machines, I/O), it belongs in `Internal/`, not at the root.
+### Externally standardized names (exception to the banlist)
-The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them.
-Breaking changes inside `Internal/` are not semver-breaking for the library.
+Names that correspond to externally standardized identifiers are exempt from the banlist. The
+standard reference is the meaning carrier. Renaming weakens it. Examples:
-## Nomenclature
+- HTTP status text from RFC documents (`unprocessableEntity` from RFC 4918, `noContent`).
+- PSR interface names being mirrored as test doubles (`ClientException` mirroring
+ `Psr\Http\Client\ClientExceptionInterface`).
+- Unicode category names, locale identifiers, MIME type tokens, and similar registered names.
-1. Every class, property, method, and exception name reflects the **concept** the library represents. A math library
- uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection library uses
- `Collectible`, `Order`.
-2. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically.
-3. Name methods after the operation in the library's vocabulary: `add()`, `convertTo()`, `splitAt()`.
+This exception applies only when the external standard is the actual source of the name. It
+does not authorize using `Data` or `Entity` as generic suffixes when no external reference is
+involved.
-### Always banned
+### Anemic verbs
-These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names:
+These verbs hide what is actually happening behind a generic action. Banned unless the verb IS
+the operation that constitutes the library's reason to exist (e.g., a JSON parser may have
+`parse()`, a hashing library may have `compute()`).
-- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`.
-- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception).
+- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`,
+ `compute`, `transform`, `parse`.
-### Anemic verbs (banned by default)
+When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`.
+`Email::parse()` is fine in a parser library but suspicious elsewhere. Use `Email::from()`
+instead.
-These verbs hide what is actually happening behind a generic action. Banned unless the verb **is** the operation
-that constitutes the library's reason to exist (e.g., a JSON parser may have `parse()`; a hashing library may
-have `compute()`):
+### Architectural roles
-- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`,
- `transform`, `parse`.
+These names describe a role the library offers as a building block. Acceptable when the class IS
+that role (e.g., `EventHandler` in an events library, `CacheManager` in a cache library,
+`Upcaster` in an event-sourcing library). Not acceptable on domain objects inside the library
+(value objects, enums, contract interfaces).
-When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`; `Email::parse()`
-is fine in a parser library but suspicious elsewhere (use `Email::from()` instead).
+- `Manager`, `Handler`, `Processor`, `Service`.
+- Verb forms: `process`, `handle`, `execute`.
-### Architectural roles (allowed with justification)
+The test. If the consumer instantiates or extends this class to integrate with the library, the
+role name is legitimate. If the class models a concept the consumer manipulates (a money amount,
+a country code, a color), the role name is wrong.
-These names describe a role the library offers as a building block. Acceptable when the class **is** that role
-(e.g., `EventHandler` in an events library, `CacheManager` in a cache library, `Upcaster` in an event-sourcing
-library). Not acceptable on domain objects inside the library (value objects, enums, contract interfaces):
+## Value objects
-- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`.
+- Are immutable. No setters. No mutation after construction. Operations return new instances.
+- Compare by value, not by reference.
+- Validate invariants in the constructor and throw a dedicated exception on invalid input.
+- Have no identity field.
+- Use static factory methods (`from`, `of`, `zero`) with a private constructor when multiple
+ creation paths exist. The factory name communicates the semantic intent.
-The test: if the consumer instantiates or extends this class to integrate with the library, the role name is
-legitimate. If the class models a concept the consumer manipulates (a money amount, a country code, a color),
-the role name is wrong.
+**Prohibited.** Public constructor with multiple creation paths. Semantics are unclear at the
+call site:
-## Value objects
+```php
+final readonly class Money
+{
+ public function __construct(public int $amount, public Currency $currency) {}
+}
+
+new Money(amount: 1000, currency: Currency::BRL);
+new Money(amount: 0, currency: Currency::USD);
+```
+
+**Correct.** Private constructor with named factory methods. Each factory name communicates
+intent:
+
+```php
+final readonly class Money
+{
+ private function __construct(public int $amount, public Currency $currency) {}
-1. Are immutable: no setters, no mutation after construction. Operations return new instances.
-2. Compare by value, not by reference.
-3. Validate invariants in the constructor and throw on invalid input.
-4. Have no identity field.
-5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation paths
- exist. The factory name communicates the semantic intent.
+ public static function of(int $amount, Currency $currency): Money
+ {
+ return new Money(amount: $amount, currency: $currency);
+ }
+
+ public static function zero(Currency $currency): Money
+ {
+ return new Money(amount: 0, currency: $currency);
+ }
+}
+
+Money::of(amount: 1000, currency: Currency::BRL);
+Money::zero(currency: Currency::USD);
+```
## Exceptions
-1. Every failure throws a **dedicated exception class** named after the invariant it guards — never
- `throw new DomainException('...')`, `throw new InvalidArgumentException('...')`,
- `throw new RuntimeException('...')`, or any other generic native exception thrown directly. If the invariant
- is worth throwing for, it is worth a named class.
-2. Dedicated exception classes **extend** the appropriate native PHP exception (`DomainException`,
- `InvalidArgumentException`, `OverflowException`, etc.) — the native class is the parent, never the thing that
- is thrown. Consumers that catch the broad standard types continue to work; consumers that need precise handling
- can catch the specific classes.
-3. Exceptions are pure: no transport-specific fields (`code` populated with HTTP status, formatted `message` meant
- for end-user display). Formatting to any transport happens at the consumer's boundary, not inside the library.
-4. Exceptions signal invariant violations only, not control flow.
-5. Name the class after the invariant violated, never after the technical type:
- - `PrecisionOutOfRange` — not `InvalidPrecisionException`.
- - `CurrencyMismatch` — not `BadCurrencyException`.
- - `ContainerWaitTimeout` — not `TimeoutException`.
-6. A descriptive `message` argument is allowed and encouraged when it carries **debugging context** — the violating
- value, the boundary that was crossed, the state the library was in. The class name identifies the invariant;
- the message describes the specific violation for stack traces and test assertions. Do not build messages meant
- for end-user display or transport rendering. Keep them short, factual, and in American English.
-7. Public exceptions live in `src/Exceptions/`. Internal exceptions live in `src/Internal/Exceptions/`.
-
-**Prohibited** — throwing a native exception directly:
+- Every failure throws a dedicated exception class named after the invariant it guards. Never
+ `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`,
+ `throw new RuntimeException(...)`, or any other generic native exception directly. If the
+ invariant is worth throwing for, it is worth a named class.
+- Dedicated exception classes extend the appropriate native PHP exception (`DomainException`,
+ `InvalidArgumentException`, `OverflowException`, etc.). The native class is the parent, never
+ the thing that is thrown. Consumers that catch the broad standard types continue to work.
+ Consumers that need precise handling can catch the specific classes.
+- Exceptions are pure. No transport-specific fields (`code` populated with HTTP status,
+ formatted `message` meant for end-user display). Formatting to any transport happens at the
+ consumer's boundary, not inside the library.
+- Exceptions signal invariant violations only, not control flow.
+- Name the class after the invariant violated, never after the technical type. Use
+ `PrecisionOutOfRange`, not `InvalidPrecisionException`. Use `CurrencyMismatch`, not
+ `BadCurrencyException`. Use `ContainerWaitTimeout`, not `TimeoutException`.
+- A descriptive `message` argument is allowed and encouraged when it carries debugging context
+ (the violating value, the boundary crossed, the state the library was in). The class name
+ identifies the invariant. The message describes the specific violation for stack traces and
+ test assertions. Keep messages short, factual, and in American English.
+
+**Prohibited.** Throwing a native exception directly:
```php
if ($value < 0) {
@@ -114,50 +201,76 @@ if ($value < 0) {
}
```
-**Correct** — dedicated class, no message (class name is sufficient):
+**Correct.** Dedicated class, no message (class name is sufficient):
```php
-// src/Exceptions/PrecisionOutOfRange.php
final class PrecisionOutOfRange extends InvalidArgumentException
{
}
-// at the callsite
if ($value < 0) {
throw new PrecisionOutOfRange();
}
```
-**Correct** — dedicated class with debugging context:
+**Correct.** Dedicated class with debugging context in the message:
```php
if ($value < 0 || $value > 16) {
- throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value));
+ $template = 'Precision must be between 0 and 16, got %d.';
+
+ throw new PrecisionOutOfRange(message: sprintf($template, $value));
}
```
## Enums
-1. Are PHP backed enums.
-2. Include methods when they carry vocabulary meaning (e.g., `Order::ASCENDING_KEY`, `RoundingMode::apply()`).
-3. Live at the `src/` root when public. Enums used only by internals live in `src/Internal/`.
+- Are PHP backed enums.
+- Include methods only when those methods carry vocabulary meaning. Examples are
+ `Order::ASCENDING_KEY` and `RoundingMode::apply()`.
## Extension points
-1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` instead
- of `final readonly class`. All other classes use `final readonly class`.
-2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`)
- as the only creation path.
-3. Internal state is injected via the constructor and stored in a `private readonly` property.
+- A class designed to be extended by consumers (e.g., `Collection`, `ValueObject`) uses `class`
+ instead of `final readonly class`. All other classes use `final readonly class`. See
+ "Inheritance and constructors" in `php-library-code-style.md`.
+- Extension point classes use a private constructor with static factory methods (`createFrom`,
+ `createFromEmpty`) as the only creation path.
+- Internal state is injected via the constructor and stored in a `private readonly` property.
## Time and space complexity
-1. Every public method has predictable, documented complexity. Document Big O in PHPDoc on the interface
- (see `php-library-code-style.md`, "PHPDoc" section).
-2. Algorithms run in `O(N)` or `O(N log N)` unless the problem inherently requires worse. `O(N²)` or worse must
- be justified and documented.
-3. Prefer lazy/streaming evaluation over materializing intermediate results. In pipeline-style libraries, fuse
- stages so a single pass suffices.
-4. Memory usage is bounded and proportional to the output, not to the sum of intermediate stages.
-5. Validate complexity claims with benchmarks against a reference implementation when optimizing critical paths.
- Parity testing against the reference library is the validation standard for optimization work.
+- Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) or
+ worse needs explicit justification at the point of definition.
+- Prefer lazy or streaming evaluation over materializing intermediate results. In pipeline-style
+ libraries, fuse stages so a single pass suffices over the input.
+- Memory usage is bounded and proportional to the output, not to the sum of intermediate stages.
+- Never re-iterate the same source. When a sequence is consumed once, use lazy creation
+ primitives (`createLazyFrom`) instead of materializing.
+
+**Prohibited.** Eager pipeline that materializes between stages:
+
+```php
+$paidTotals = array_map(
+ static fn(Order $order): float => $order->total(),
+ array_filter(
+ $orders->toArray(),
+ static fn(Order $order): bool => $order->isPaid()
+ )
+);
+```
+
+Each stage allocates a full intermediate array. Memory grows with the input size, even when only
+the final scalar matters.
+
+**Correct.** Fused pipeline that runs in a single pass:
+
+```php
+$paidTotals = $orders
+ ->filter(predicates: static fn(Order $order): bool => $order->isPaid())
+ ->map(transformations: static fn(Order $order): float => $order->total())
+ ->toArray(keyPreservation: KeyPreservation::DISCARD);
+```
+
+Operations stack on the same iterator. No intermediate array is built. Memory stays bounded by
+the final output.
diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md
index 610b928..86a0c10 100644
--- a/.claude/rules/php-library-testing.md
+++ b/.claude/rules/php-library-testing.md
@@ -1,17 +1,79 @@
---
-description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries.
+description: BDD Given/When/Then structure, PHPUnit conventions, fixture rules, and coverage discipline.
paths:
- "tests/**/*.php"
---
-# Testing conventions
+# Testing
-Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to
-test files.
+PHPUnit conventions for tests in PHP libraries. Covers BDD structure, fixture rules, and coverage
+discipline. Code style applies to test files as well. See `php-library-code-style.md`. Folder
+structure for `tests/` lives in `php-library-architecture.md`. Canonical thresholds (MSI 100,
+covered MSI 100) live in `php-library-tooling.md`.
+
+## Pre-output checklist
+
+Verify every item before producing any test code. If any item fails, revise before outputting.
+
+1. Each test contains exactly one `@When` block. Two actions require two tests.
+2. Use `@And` for complementary preconditions or actions within the same scenario, avoiding
+ consecutive `@Given` or `@When` tags.
+3. Each `@Given` or `@And` block contains exactly one annotation line followed by one expression
+ or assignment. Never place multiple variable declarations or object constructions under a
+ single annotation. **Exception for data-provider tests.** When the test method binds its
+ inputs through a `#[DataProvider]` attribute (or the equivalent `@dataProvider` annotation),
+ the `@Given` block may declare the input shape in prose form, without an expression below
+ it. The values are bound by PHPUnit before the test body runs, so the prose annotation
+ replaces the assignment that would otherwise sit under the `@Given`.
+
+ `@When` blocks follow the same one-expression rule by default: the block represents the
+ single action under test. **Exception for repeated-invocation tests** (idempotence, caching,
+ memoization). When the purpose of the test is asserting that the same operation produces the
+ same outcome across N invocations, the `@When` block may contain N consecutive identical
+ invocations, each captured in a numbered variable (`$first`, `$second`, ...), and the
+ annotation reads `@When invoked twice` (or thrice, etc.) to make the composite-action
+ semantic explicit. Two unrelated actions still require two tests.
+4. No intermediate variables used only once. Chain method calls when the intermediate state is
+ not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of
+ `$money = Money::of(...)` followed by `$money->add(...)`).
+5. No private or helper methods in test classes. The only non-test methods allowed are data
+ providers. Setup logic complex enough to extract belongs in a dedicated fixture class.
+6. Test only the public API. Never assert on private state or `Internal/` classes directly.
+7. Test the behavior that **raises** an exception, never the exception itself. Exception classes
+ represent invariant violations and are value objects, not the subject of behavior tests. A
+ test constructs the conditions, invokes the public method that is supposed to fail, and
+ asserts the expected exception class is raised (plus its accessor values when they carry
+ information relevant to the failure). Constructing an exception directly
+ (`new HttpRequestInvalid(...)`) and asserting on its accessors is **prohibited**: the
+ exception's structure is exercised through the call path that produces it. If a method does
+ not exist whose call path produces the exception, the exception is dead code and should be
+ removed.
+8. Never mock internal collaborators. Use real objects. Test doubles are used only at system
+ boundaries (filesystem, clock, network) when the library interacts with external resources.
+9. Name tests after behavior, not method names.
+10. Use domain-specific names in variables and properties. Never `$spy`, `$mock`, `$stub`,
+ `$fake`, `$dummy` as variable or property names. Use the domain concept the object
+ represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like
+ `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what matters.
+11. Annotations use domain language. Write `/** @Given a collection of amounts */`, not
+ `/** @Given a mocked collection in test state */`.
+12. Never use the `/** @test */` annotation. Test methods are discovered by the `test` prefix in
+ the method name.
+13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`,
+ `expectException`, etc.). Pass arguments positionally.
+14. Never include conditional logic inside tests. Each `@Then` block expresses one logical
+ concept. The only allowed `try`/`catch` is when the assertion target is a property of the
+ caught exception that cannot be expressed via `expectException*` methods (notably
+ `getPrevious()` for chain inspection). The catch block contains only assertions against the
+ caught exception, no branching.
+15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from
+ coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See
+ "Coverage and mutation discipline".
## Structure: Given/When/Then (BDD)
-Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception.
+Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments
+without exception.
### Happy path example
@@ -20,26 +82,30 @@ public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void
{
/** @Given two money instances in the same currency */
$ten = Money::of(amount: 1000, currency: Currency::BRL);
+
+ /** @And another money instance with the same currency */
$five = Money::of(amount: 500, currency: Currency::BRL);
/** @When adding them together */
$total = $ten->add(other: $five);
/** @Then the result contains the sum of both amounts */
- self::assertEquals(expected: 1500, actual: $total->amount());
+ self::assertEquals(1500, $total->amount());
}
```
### Exception example
-When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this
-ordering.
+When testing that an exception is thrown, place `@Then` (`expectException`) before `@When`.
+PHPUnit requires this ordering.
```php
public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void
{
/** @Given two money instances in different currencies */
$brl = Money::of(amount: 1000, currency: Currency::BRL);
+
+ /** @And another money instance with a different currency */
$usd = Money::of(amount: 500, currency: Currency::USD);
/** @Then an exception indicating currency mismatch should be thrown */
@@ -50,67 +116,210 @@ public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void
}
```
-Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or
-`@When` tags.
-
-## Rules
-
-1. Include exactly one `@When` per test. Two actions require two tests.
-2. Test only the public API. Never assert on private state or `Internal/` classes directly.
-3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem,
- clock, network) when the library interacts with external resources.
-4. Name tests to describe behavior, not method names.
-5. Never include conditional logic inside tests.
-6. Include one logical concept per `@Then` block.
-7. Maintain strict independence between tests. No inherited state.
-8. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts
- (e.g., `Amount`, `Invoice`, `Order`).
-9. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries
- (e.g., `ClientMock`, `ExecutionCompletedMock`).
-10. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class
- for an internal model only when the condition cannot be reached through the public API.
-11. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name.
-12. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`,
- `expectException`, etc.). Pass arguments positionally.
+Use `@And` for complementary preconditions or actions within the same scenario, avoiding
+consecutive `@Given` or `@When` tags.
+
+## Testing exceptions
+
+Exception classes are value objects describing an invariant violation. They are not the subject
+of behavior tests. A test verifies that a public method, under specific conditions, raises a
+specific exception. Constructing the exception directly and asserting on its accessors is
+prohibited. The exception's structure is exercised through the call path that produces it.
+
+**Prohibited.** Testing the exception as a value object:
+
+```php
+public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void
+{
+ /** @Given a URL */
+ $url = 'https://api.example.com';
+
+ /** @And an HTTP method */
+ $method = Method::GET;
+
+ /** @And a reason */
+ $reason = 'Connection refused.';
+
+ /** @When the exception is constructed */
+ $exception = HttpNetworkFailed::from(url: $url, method: $method, reason: $reason);
+
+ /** @Then it exposes the URL */
+ self::assertSame($url, $exception->url());
+}
+```
+
+The test constructs the exception in isolation and asserts on its accessors. No production code
+is exercised. The same coverage is achieved (and made meaningful) by the test below, which
+drives the path that raises the exception.
+
+**Correct.** Testing the behavior that raises the exception:
+
+```php
+public function testSendRequestWhenTransportCannotReachServerThenThrowsHttpNetworkFailed(): void
+{
+ /** @Given an HTTP client backed by a transport that always raises a network error */
+ $http = Http::usingTransport(transport: new ThrowingClient());
+
+ /** @And a target request to that transport */
+ $request = Request::create(url: 'https://api.example.com', method: Method::GET);
+
+ /** @Then a network failure exception describing the unreachable target is raised */
+ $this->expectException(HttpNetworkFailed::class);
+
+ /** @When the request is sent */
+ $http->send(request: $request);
+}
+```
+
+When the accessor values on the raised exception are part of the assertion, `expectException`
+alone is not enough (it asserts only the class). Use a `try`/`catch` block as permitted by
+rule 14. The catch block contains only assertions against the caught exception, no branching.
+
+```php
+public function testSendRequestWhenTargetUnreachableThenExceptionCarriesUrlAndMethod(): void
+{
+ /** @Given an HTTP client backed by a transport that always raises a network error */
+ $http = Http::usingTransport(transport: new ThrowingClient());
+
+ /** @And a target request to that transport */
+ $request = Request::create(url: 'https://api.example.com', method: Method::GET);
+
+ try {
+ /** @When the request is sent */
+ $http->send(request: $request);
+ } catch (HttpNetworkFailed $failure) {
+ /** @Then the exception exposes the target URL and method */
+ self::assertSame('https://api.example.com', $failure->url());
+ self::assertSame(Method::GET, $failure->method());
+ }
+}
+```
+
+If a method does not exist whose call path produces the exception, the exception itself is dead
+code. Remove it instead of writing a behavior test against a constructor.
+
+**The `try`/`catch` form is reserved for assertions that PHPUnit's `expectException*` family
+does not cover.** Message, code, and class are covered by PHPUnit (`expectException`,
+`expectExceptionMessage`, `expectExceptionMessageMatches`, `expectExceptionCode`): use those
+methods, not `try`/`catch`. The only case that warrants `try`/`catch` is inspecting accessors
+that PHPUnit cannot reach — notably `getPrevious()` for chain inspection, or domain-specific
+accessors on a `TransportFailure` (`url()`, `method()`, `reason()`).
+
+**Prohibited.** `try`/`catch` to assert message:
+
+```php
+try {
+ $http->send(request: $request);
+ self::fail('NoMoreResponses was expected.');
+} catch (NoMoreResponses $exception) {
+ self::assertStringContainsString('queue exhausted', $exception->getMessage());
+}
+```
+
+**Correct.** PHPUnit's `expectExceptionMessage`:
+
+```php
+$this->expectException(NoMoreResponses::class);
+$this->expectExceptionMessage('queue exhausted');
+
+$http->send(request: $request);
+```
## Test setup and fixtures
-1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line
- followed by one expression or assignment. Never place multiple variable declarations or object
- constructions under a single annotation.
-2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the
- call site. Chain method calls when the intermediate state is not referenced elsewhere
- (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`).
-3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers.
- If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a
- private method on the test class.
-4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`,
- `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object
- represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like
- `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters.
-5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not
- `/** @Given a mocked collection in test state */`. The annotation describes the domain
- scenario, not the technical setup.
-
-## Test organization
+- Each `@Given` or `@And` block contains exactly one annotation followed by one expression or
+ assignment. Never place multiple declarations under a single annotation. The exception for
+ data-provider tests applies here as well (see rule 3).
+- No intermediate variables used only once. Chain method calls when the intermediate state is
+ not referenced elsewhere.
+- No private or helper methods in test classes. The only non-test methods allowed are data
+ providers. Setup logic complex enough to extract belongs in a dedicated fixture class, not in
+ a private method on the test class.
+- Domain terms in variables and properties. Never use technical testing jargon (`$spy`, `$mock`,
+ `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object
+ represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like
+ `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what
+ matters.
+- Annotations use domain language. Write `/** @Given a collection of amounts */`, not
+ `/** @Given a mocked collection in test state */`. The annotation describes the domain
+ scenario, not the technical setup.
+
+**Prohibited.** Multiple declarations under a single annotation:
+
+```php
+/** @And two money instances in different currencies */
+$usd = Money::of(amount: 500, currency: Currency::USD);
+$eur = Money::of(amount: 300, currency: Currency::EUR);
+```
+
+**Correct.** One annotation per declaration:
+```php
+/** @And a money instance in USD */
+$usd = Money::of(amount: 500, currency: Currency::USD);
+
+/** @And a money instance in EUR */
+$eur = Money::of(amount: 300, currency: Currency::EUR);
```
-tests/
-├── Models/ # Domain-specific fixtures reused across tests
-├── Mocks/ # Test doubles for system boundaries
-├── Unit/ # Unit tests for public API
-│ └── Mocks/ # Alternative location for test doubles
-├── Integration/ # Tests requiring real external resources (Docker, filesystem)
-└── bootstrap.php # Test bootstrap when needed
+
+**Also prohibited.** Setup multi-statement grouped under a single annotation because "the
+statements build one coherent concept":
+
+```php
+/** @Given transport seeded with two responses */
+$first = Response::with(code: Code::OK);
+$second = Response::with(code: Code::CREATED);
+$transport = InMemoryTransport::with(responses: [$first, $second]);
+```
+
+Three statements, one annotation. The fact that the three lines together build a single
+setup concept is **not** a license to share one annotation. Each declaration takes its own
+`@And` block. The same applies under `@When` when the test prepares the input alongside the
+action: the input preparation goes back to `@And` under `@Given`, and `@When` contains only
+the action under test.
+
+**Correct.** Each statement keeps its own annotation:
+
+```php
+/** @Given a first queued response */
+$first = Response::with(code: Code::OK);
+
+/** @And a second queued response */
+$second = Response::with(code: Code::CREATED);
+
+/** @And transport with both responses */
+$transport = InMemoryTransport::with(responses: [$first, $second]);
```
-`tests/Integration/` is only present when the library interacts with infrastructure.
+## Test doubles
+
+Conventions for naming and locating test doubles (mocks, spies, stubs, fakes, dummies).
+
+### Naming
+
+- Variables and properties never carry the technical role in their name. Never `$spy`, `$mock`,
+ `$stub`, `$fake`, `$dummy`. Use the domain concept the object represents (`$gateway`,
+ `$clock`, `$repository`, `$client`).
+- Class names may carry the technical role as suffix when the class IS a test double
+ (`ClientMock`, `GatewaySpy`, `ClockFake`). The suffix signals that the file is a collaborator
+ built for tests, not a production type.
+
+### Location
+
+- Test doubles live at the root of `tests/Unit/`. When integration tests exist, doubles used
+ there live at the root of `tests/Integration/`.
+- No dedicated `Mocks/` or `Doubles/` subdirectory exists.
+- Domain fixtures that represent real domain concepts live in `tests/Models/`. See
+ `php-library-architecture.md` for the canonical `tests/` folder layout.
+
+## Coverage and mutation discipline
-## Coverage and mutation testing
+- Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage.
+- Never suppress mutants via `infection.json.dist` or any other mechanism.
+- If a line or mutation cannot be covered or killed, the design is wrong. Refactor the
+ production code to make it testable. Never work around the tool.
-1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration
- that exclude code from coverage are allowed.
-2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist`
- or any other mechanism.
-3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor
- the code to make it testable, do not work around the tool.
+Canonical thresholds (MSI 100, covered MSI 100) live in `php-library-tooling.md`. They are
+enforced by `infection.json.dist`. Achieving MSI 100 implies effective full coverage of `src/`
+because every mutation must be killed by an assertion. This file covers only the behavioral
+rules that complement those thresholds.
diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md
new file mode 100644
index 0000000..3b55111
--- /dev/null
+++ b/.claude/rules/php-library-tooling.md
@@ -0,0 +1,464 @@
+---
+description: Canonical config files for PHP libraries in the tiny-blocks ecosystem.
+paths:
+ - "composer.json"
+ - "phpcs.xml"
+ - "phpstan.neon.dist"
+ - "phpunit.xml"
+ - "infection.json.dist"
+ - ".editorconfig"
+ - ".gitattributes"
+ - ".gitignore"
+ - "Makefile"
+---
+
+# Tooling
+
+Canonical configuration files for a PHP library in the tiny-blocks ecosystem. Each file has a
+fixed shape. Deviations require justification. Folder structure lives in
+`php-library-architecture.md`. Code style lives in `php-library-code-style.md`.
+
+## Pre-output checklist
+
+Verify every item before creating, editing, or relocating any of the files below. If any item
+fails, revise before outputting.
+
+1. The library repository contains all the following files at its root: `composer.json`,
+ `phpcs.xml`, `phpstan.neon.dist`, `phpunit.xml`, `infection.json.dist`, `.editorconfig`,
+ `.gitattributes`, `.gitignore`, `Makefile`.
+2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`,
+ `test-file`, `tests`. No other public scripts are defined.
+3. `composer.json` fixed fields use the canonical values defined in the "composer.json" section
+ (`license`, `type`, `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`).
+4. `composer.json` `description` is a single short sentence describing what the library does.
+ Multi-sentence or multi-paragraph descriptions belong in the README Overview, not in Composer
+ metadata.
+5. `composer.json` includes a `keywords` array. The first keyword is always `"tiny-blocks"`.
+ Additional keywords are topic tokens derived from the library's purpose (`psr-7`,
+ `http-client`, `event-sourcing`, etc.).
+6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs are added.
+7. `phpunit.xml` sets all five `failOn*` flags to `true`: `failOnDeprecation`, `failOnNotice`,
+ `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`.
+8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`.
+9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either value is
+ prohibited.
+10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`, and
+ `end_of_line = lf` for PHP files. YAML uses `indent_size = 2`. Makefile uses `indent_style = tab`.
+11. `.gitattributes` sets `* text=auto eol=lf` and lists every dev-only file under `export-ignore`.
+ The Packagist tarball contains only `src/`, `composer.json`, `README.md`, and `LICENSE`.
+ `.claude/` is listed under `export-ignore` (versioned on GitHub for contributor parity,
+ excluded from the published package).
+12. `.gitignore` follows the canonical content in the ".gitignore" section. `.claude/` is **not**
+ listed (it is versioned on GitHub).
+13. `Makefile` wraps every PHP and Composer command in a Docker container using the canonical
+ image `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly.
+14. All test artifact paths use `reports/` (plural). The directory is consistent across
+ `composer tests`, `infection.json.dist`, `phpunit.xml`, and `Makefile`.
+15. The `reports/` directory is listed under `export-ignore` in `.gitattributes`.
+
+## composer.json
+
+Fixed fields, identical in every library: `license`, `type`, `minimum-stability`, `prefer-stable`,
+`require.php`, `authors`, `config.allow-plugins`, `config.sort-packages`, `scripts`, and the five
+universal dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`,
+`phpunit/phpunit`, `squizlabs/php_codesniffer`).
+
+Per-library fields, vary by library: `name`, `description`, `keywords`, `homepage`, `support`,
+`autoload`, `autoload-dev`. The `require-dev` section may add libraries needed by tests (for
+example, HTTP client implementations in a PSR-7 library) on top of the five universal tools.
+
+```json
+{
+ "name": "tiny-blocks/",
+ "description": "",
+ "license": "MIT",
+ "type": "library",
+ "keywords": [
+ "tiny-blocks",
+ "",
+ ""
+ ],
+ "authors": [
+ {
+ "name": "Gustavo Freze de Araujo Santos",
+ "homepage": "https://github.com/gustavofreze"
+ }
+ ],
+ "homepage": "https://github.com/tiny-blocks/",
+ "support": {
+ "issues": "https://github.com/tiny-blocks//issues",
+ "source": "https://github.com/tiny-blocks/"
+ },
+ "require": {
+ "php": "^8.5"
+ },
+ "require-dev": {
+ "ergebnis/composer-normalize": "^2.51",
+ "infection/infection": "^0.32",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^13.1",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true,
+ "autoload": {
+ "psr-4": {
+ "TinyBlocks\\\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Test\\TinyBlocks\\\\": "tests/"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "ergebnis/composer-normalize": true,
+ "infection/extension-installer": true
+ },
+ "sort-packages": true
+ },
+ "scripts": {
+ "configure": [
+ "@composer install --optimize-autoloader",
+ "@composer normalize"
+ ],
+ "configure-and-update": [
+ "@composer update --optimize-autoloader",
+ "@composer normalize"
+ ],
+ "review": [
+ "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests",
+ "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress"
+ ],
+ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
+ "tests": [
+ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
+ "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage"
+ ]
+ }
+}
+```
+
+Script usage:
+
+- `composer configure` runs `composer install --optimize-autoloader` followed by `composer normalize`.
+ Use this after cloning the repository or pulling new changes.
+- `composer configure-and-update` runs `composer update --optimize-autoloader` followed by
+ `composer normalize`. Use this when intentionally updating dependencies.
+- `composer review` runs `phpcs` and `phpstan` in sequence. Used by CI and local validation.
+- `composer tests` runs `phpunit` followed by `infection`. Used by CI.
+- `composer test-file ` runs a filtered subset of tests without coverage. Local
+ development only.
+
+## phpcs.xml
+
+References only the `PSR12` ruleset. Additional formatting rules (vertical alignment, trailing
+comma, etc.) live in `php-library-code-style.md` under "Formatting overrides".
+
+```xml
+
+
+ Code style for the tiny-blocks library.
+
+ src
+ tests
+
+```
+
+## phpstan.neon.dist
+
+Static analysis configuration. Runs at the highest level on both `src/` and `tests/`. Invoked
+by the `review` Composer script.
+
+```neon
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ reportUnmatchedIgnoredErrors: true
+```
+
+`ignoreErrors` is permitted to suppress legitimate false positives produced by `level: max`
+(third-party type signatures with `mixed`, PHP-FIG interfaces returning untyped arrays, trait
+unused-method warnings on shared behavior, etc.). Each entry follows these rules:
+
+- A short comment above the entry justifies its existence.
+- Prefer scoping via `identifier:` plus `path:` over raw `#...#` message patterns.
+- `reportUnmatchedIgnoredErrors: true` is mandatory. Obsolete entries fail the build, forcing
+ cleanup.
+
+Example with `ignoreErrors`:
+
+```neon
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ ignoreErrors:
+ # Trait method intentionally unused by the consuming aggregate; reflection wires it.
+ - identifier: trait.unused
+ path: src/Internal/EventualAggregateRootBehavior.php
+
+ # json_encode signature carries `mixed` for backward compatibility at level max.
+ - identifier: argument.type
+ path: src/Internal/Serialization/JsonEncoder.php
+ reportUnmatchedIgnoredErrors: true
+```
+
+## phpunit.xml
+
+Strict configuration. All `failOn*` flags are `true`. `executionOrder="random"` forces tests to be
+independent of one another. Coverage and JUnit reports go under `reports/`.
+
+```xml
+
+
+
+
+
+ src
+
+
+
+
+
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Root attributes are sorted alphabetically.
+
+## infection.json.dist
+
+Mutation testing configuration. `minMsi` and `minCoveredMsi` are both `100`. Mutants that escape
+make the build fail.
+
+```json
+{
+ "logs": {
+ "text": "reports/infection/logs/infection-text.log",
+ "summary": "reports/infection/logs/infection-summary.log"
+ },
+ "tmpDir": "reports/infection/",
+ "minMsi": 100,
+ "timeout": 30,
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "phpUnit": {
+ "configDir": "",
+ "customPath": "./vendor/bin/phpunit"
+ },
+ "mutators": {
+ "@default": true
+ },
+ "minCoveredMsi": 100,
+ "testFramework": "phpunit"
+}
+```
+
+## .editorconfig
+
+Whitespace and line ending rules applied by editor integrations.
+
+```ini
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+max_line_length = 120
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[Makefile]
+indent_style = tab
+
+[*.md]
+trim_trailing_whitespace = false
+```
+
+## .gitattributes
+
+Normalizes line endings to LF and excludes every dev-only file from the Packagist tarball. The
+published package contains only `src/`, `composer.json`, `README.md`, and `LICENSE`.
+
+```
+* text=auto eol=lf
+
+*.php text diff=php
+
+# Dev-only, excluded from the Packagist tarball
+/.github export-ignore
+/tests export-ignore
+/.claude export-ignore
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpunit.xml export-ignore
+/phpunit.xml.dist export-ignore
+/phpstan.neon export-ignore
+/phpstan.neon.dist export-ignore
+/phpcs.xml export-ignore
+/phpcs.xml.dist export-ignore
+/infection.json export-ignore
+/infection.json.dist export-ignore
+/Makefile export-ignore
+/CONTRIBUTING.md export-ignore
+/CHANGES.md export-ignore
+/reports export-ignore
+/.phpunit.cache export-ignore
+```
+
+## .gitignore
+
+Keeps the repository working tree clean of artifacts that should never be committed. Entries
+are grouped from most fundamental (PHP dependencies) to least critical (OS files). The
+`.claude/` directory is **not** listed here. It is versioned on GitHub so other contributors
+share the same rules, and it is excluded from the published Packagist tarball through
+`export-ignore` in `.gitattributes` (see above).
+
+```
+# PHP dependencies
+/vendor/
+composer.lock
+
+# Tooling cache
+.phpcs-cache
+.phpunit.cache/
+.php-cs-fixer.cache
+.phpunit.result.cache
+
+# Coverage and reports
+build/
+reports/
+coverage/
+infection.log
+
+# Editors and agents
+.idea/
+.cursor/
+.vscode/
+
+# OS
+Thumbs.db
+.DS_Store
+Desktop.ini
+```
+
+## Makefile
+
+Thin wrapper over Composer scripts. Every PHP and Composer command runs inside a Docker container
+using the canonical image `gustavofreze/php:8.5-alpine`. Targets that match a Composer script
+delegate to it directly, avoiding duplication.
+
+```makefile
+PWD := $(CURDIR)
+ARCH := $(shell uname -m)
+PLATFORM :=
+
+ifeq ($(ARCH),arm64)
+ PLATFORM := --platform=linux/amd64
+endif
+
+DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine
+
+RESET := \033[0m
+GREEN := \033[0;32m
+YELLOW := \033[0;33m
+
+.DEFAULT_GOAL := help
+
+.PHONY: configure
+configure: ## Configure development environment
+ @${DOCKER_RUN} composer configure
+
+.PHONY: configure-and-update
+configure-and-update: ## Configure development environment and update dependencies
+ @${DOCKER_RUN} composer configure-and-update
+
+.PHONY: tests
+tests: ## Run unit and mutation tests with coverage
+ @${DOCKER_RUN} composer tests
+
+.PHONY: test-file
+test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest)
+ @${DOCKER_RUN} composer test-file ${FILE}
+
+.PHONY: review
+review: ## Run lint and static analysis
+ @${DOCKER_RUN} composer review
+
+.PHONY: show-reports
+show-reports: ## Open coverage and mutation reports in the browser
+ @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html
+
+.PHONY: show-outdated
+show-outdated: ## Show outdated direct dependencies
+ @${DOCKER_RUN} composer outdated --direct
+
+.PHONY: clean
+clean: ## Remove dependencies and generated artifacts
+ @sudo chown -R ${USER}:${USER} ${PWD}
+ @rm -rf reports vendor .phpunit.cache *.lock
+
+.PHONY: help
+help: ## Display this help message
+ @echo "Usage: make [target]"
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')"
+ @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
+ @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')"
+ @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')"
+ @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')"
+ @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+```
diff --git a/.editorconfig b/.editorconfig
index 73e3c9a..be5640e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,6 +5,7 @@ charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
+max_line_length = 120
insert_final_newline = true
trim_trailing_whitespace = true
diff --git a/.gitattributes b/.gitattributes
index 744a43b..eedb473 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,7 +2,7 @@
*.php text diff=php
-# Dev-only — excluded from the Packagist tarball
+# Dev-only, excluded from the Packagist tarball
/.github export-ignore
/tests export-ignore
/.claude export-ignore
@@ -20,3 +20,5 @@
/Makefile export-ignore
/CONTRIBUTING.md export-ignore
/CHANGES.md export-ignore
+/reports export-ignore
+/.phpunit.cache export-ignore
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 77c2bb8..e34c801 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -2,10 +2,11 @@
## Context
-PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core.
+PHP library in the tiny-blocks ecosystem.
## Mandatory pre-task step
-Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and
-`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not
-deviate from the patterns, folder structure, or naming conventions defined in them.
+Before starting any task, read and strictly follow `.claude/CLAUDE.md` and every rule file in
+`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every
+rule strictly. Do not deviate from the patterns, folder structure, or naming conventions defined
+in them.
diff --git a/.gitignore b/.gitignore
index bd5baa3..6107765 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,25 @@
-# Agent/IDE
-.claude/
-.idea/
-.vscode/
-.cursor/
-
-# Composer
+# PHP dependencies
/vendor/
composer.lock
-# PHPUnit / coverage
+# Tooling cache
+.phpcs-cache
.phpunit.cache/
+.php-cs-fixer.cache
.phpunit.result.cache
-report/
-coverage/
+
+# Coverage and reports
build/
+reports/
+coverage/
+infection.log
+
+# Editors and agents
+.idea/
+.cursor/
+.vscode/
# OS
-.DS_Store
Thumbs.db
+.DS_Store
+Desktop.ini
diff --git a/Makefile b/Makefile
index 07acc3b..4f0e85d 100644
--- a/Makefile
+++ b/Makefile
@@ -16,28 +16,27 @@ YELLOW := \033[0;33m
.PHONY: configure
configure: ## Configure development environment
- @${DOCKER_RUN} composer update --optimize-autoloader
- @${DOCKER_RUN} composer normalize
+ @${DOCKER_RUN} composer configure
-.PHONY: test
-test: ## Run all tests with coverage
+.PHONY: configure-and-update
+configure-and-update: ## Configure development environment and update dependencies
+ @${DOCKER_RUN} composer configure-and-update
+
+.PHONY: tests
+tests: ## Run unit and mutation tests with coverage
@${DOCKER_RUN} composer tests
.PHONY: test-file
test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest)
@${DOCKER_RUN} composer test-file ${FILE}
-.PHONY: test-no-coverage
-test-no-coverage: ## Run all tests without coverage
- @${DOCKER_RUN} composer tests-no-coverage
-
.PHONY: review
-review: ## Run static code analysis
+review: ## Run lint and static analysis
@${DOCKER_RUN} composer review
.PHONY: show-reports
-show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser
- @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html
+show-reports: ## Open coverage and mutation reports in the browser
+ @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html
.PHONY: show-outdated
show-outdated: ## Show outdated direct dependencies
@@ -46,18 +45,18 @@ show-outdated: ## Show outdated direct dependencies
.PHONY: clean
clean: ## Remove dependencies and generated artifacts
@sudo chown -R ${USER}:${USER} ${PWD}
- @rm -rf report vendor .phpunit.cache *.lock
+ @rm -rf reports vendor .phpunit.cache *.lock
.PHONY: help
-help: ## Display this help message
+help: ## Display this help message
@echo "Usage: make [target]"
@echo ""
@echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')"
- @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \
+ @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
@echo ""
@echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
- @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \
+ @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
@echo ""
@echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')"
diff --git a/composer.json b/composer.json
index ced3a3c..4727d47 100644
--- a/composer.json
+++ b/composer.json
@@ -30,13 +30,13 @@
"php": "^8.5",
"ramsey/uuid": "^4.9",
"tiny-blocks/collection": "^2.3",
- "tiny-blocks/mapper": "^2.0",
- "tiny-blocks/time": "^1.5",
- "tiny-blocks/value-object": "^4.0"
+ "tiny-blocks/mapper": "^2.1",
+ "tiny-blocks/time": "^2.0",
+ "tiny-blocks/value-object": "^5.0"
},
"require-dev": {
- "ergebnis/composer-normalize": "^2.51",
- "infection/infection": "^0.32",
+ "ergebnis/composer-normalize": "^2.52",
+ "infection/infection": "^0.33",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^13.1",
"squizlabs/php_codesniffer": "^4.0"
@@ -61,22 +61,22 @@
"sort-packages": true
},
"scripts": {
- "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage",
- "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src",
- "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
+ "configure": [
+ "@composer install --optimize-autoloader",
+ "@composer normalize"
+ ],
+ "configure-and-update": [
+ "@composer update --optimize-autoloader",
+ "@composer normalize"
+ ],
"review": [
- "@phpcs",
- "@phpstan"
+ "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests",
+ "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress"
],
- "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
- "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
- "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests",
+ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
"tests": [
- "@test",
- "@mutation-test"
- ],
- "tests-no-coverage": [
- "@test-no-coverage"
+ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
+ "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage"
]
}
}
diff --git a/infection.json.dist b/infection.json.dist
index ee435dd..3307d81 100644
--- a/infection.json.dist
+++ b/infection.json.dist
@@ -1,9 +1,9 @@
{
"logs": {
- "text": "report/infection/logs/infection-text.log",
- "summary": "report/infection/logs/infection-summary.log"
+ "text": "reports/infection/logs/infection-text.log",
+ "summary": "reports/infection/logs/infection-summary.log"
},
- "tmpDir": "report/infection/",
+ "tmpDir": "reports/infection/",
"minMsi": 100,
"timeout": 30,
"source": {
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..a52372c
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,7 @@
+
+
+ Code style for the tiny-blocks library.
+
+ src
+ tests
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 6e9089c..a583202 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -1,16 +1,44 @@
parameters:
- paths:
- - src
- level: 9
- tmpDir: report/phpstan
- ignoreErrors:
- - '#mixed given#'
- - '#iterable type#'
- - '#of new static#'
- - '#generic interface#'
- - '#destructuring on mixed#'
- - identifier: trait.unused
- - '#expects int<1, max>#'
- - message: '#Upcasters::chain\(\) should return#'
- path: src/Upcast/Upcasters.php
- reportUnmatchedIgnoredErrors: false
+ level: max
+ paths:
+ - src
+ - tests
+ reportUnmatchedIgnoredErrors: true
+ ignoreErrors:
+ # Constructor parameter $aggregateState cannot carry PHPDoc per code-style rule;
+ # the getter return-type mismatch is the downstream symptom of that constraint.
+ -
+ identifier: missingType.iterableValue
+ path: src/Snapshot/Snapshot.php
+ -
+ identifier: return.type
+ path: src/Snapshot/Snapshot.php
+
+ # Constructor parameter $serializedEvent cannot carry PHPDoc per code-style rule.
+ -
+ identifier: missingType.iterableValue
+ path: src/Upcast/IntermediateEvent.php
+
+ # Trait argument-type error reported in context of test fixture upcasters,
+ # whose rewrite() return is untyped because PHPDoc is prohibited in tests/.
+ -
+ identifier: argument.type
+ path: src/Upcast/SingleUpcasterBehavior.php
+
+ # tests/ — PHPDoc and @var are prohibited inside tests/, so PHPStan errors
+ # for typed arrays in fixtures and helpers route through ignoreErrors.
+ -
+ identifier: missingType.iterableValue
+ path: tests/*
+ -
+ identifier: return.type
+ path: tests/*
+ -
+ identifier: assign.propertyType
+ path: tests/Models/*
+ -
+ identifier: property.nonObject
+ path: tests/Unit/*
+ -
+ identifier: method.nonObject
+ path: tests/Unit/*
diff --git a/phpunit.xml b/phpunit.xml
index 40c80a2..9cc6d13 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,13 +1,15 @@
+ failOnDeprecation="true"
+ failOnNotice="true"
+ failOnPhpunitDeprecation="true"
+ failOnRisky="true"
+ failOnWarning="true">
@@ -23,15 +25,15 @@
-
-
-
-
+
+
+
+
-
+
From 616267e136bc47e7fdfe0d8f376b922c57309950 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Tue, 19 May 2026 00:33:23 -0300
Subject: [PATCH 2/5] ci: Update GitHub workflows.
---
.github/workflows/auto-assign.yml | 19 +++++++++++++------
.github/workflows/ci.yml | 16 ++++++++--------
.github/workflows/codeql.yml | 6 +++++-
3 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
index d0ba49e..8c9683c 100644
--- a/.github/workflows/auto-assign.yml
+++ b/.github/workflows/auto-assign.yml
@@ -1,4 +1,4 @@
-name: Auto assign issues and pull requests
+name: Auto assign
on:
issues:
@@ -8,12 +8,19 @@ on:
types:
- opened
+concurrency:
+ group: auto-assign-${{ github.event.issue.number || github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ issues: write
+ pull-requests: write
+
jobs:
- run:
+ auto-assign:
+ name: Auto assign issues and pull requests
runs-on: ubuntu-latest
- permissions:
- issues: write
- pull-requests: write
+ timeout-minutes: 5
steps:
- name: Assign issues and pull requests
uses: gustavofreze/auto-assign@2.1.0
@@ -22,4 +29,4 @@ jobs:
github_token: '${{ secrets.GITHUB_TOKEN }}'
allow_self_assign: 'true'
allow_no_assignees: 'true'
- assignment_options: 'ISSUE,PULL_REQUEST'
\ No newline at end of file
+ assignment_options: 'ISSUE,PULL_REQUEST'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c74fb16..d395d35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,8 +11,8 @@ permissions:
contents: read
jobs:
- load-config:
- name: Load config
+ resolve-php-version:
+ name: Resolve PHP version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
@@ -29,7 +29,7 @@ jobs:
build:
name: Build
- needs: load-config
+ needs: resolve-php-version
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -40,7 +40,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
tools: composer:2
- php-version: ${{ needs.load-config.outputs.php-version }}
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
- name: Validate composer.json
run: composer validate --no-interaction
@@ -58,7 +58,7 @@ jobs:
auto-review:
name: Auto review
- needs: [load-config, build]
+ needs: [resolve-php-version, build]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -69,7 +69,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
tools: composer:2
- php-version: ${{ needs.load-config.outputs.php-version }}
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
- name: Download vendor artifact from build
uses: actions/download-artifact@v8
@@ -82,7 +82,7 @@ jobs:
tests:
name: Tests
- needs: [load-config, auto-review]
+ needs: [resolve-php-version, auto-review]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -93,7 +93,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
tools: composer:2
- php-version: ${{ needs.load-config.outputs.php-version }}
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
- name: Download vendor artifact from build
uses: actions/download-artifact@v8
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 4c6d7f7..0634bbf 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -8,6 +8,10 @@ on:
schedule:
- cron: "0 0 * * *"
+concurrency:
+ group: codeql-${{ github.ref }}
+ cancel-in-progress: true
+
permissions:
actions: read
contents: read
@@ -17,11 +21,11 @@ jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
+ timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [ "actions" ]
-
steps:
- name: Checkout repository
uses: actions/checkout@v6
From 19ebd5085478c5a91bbf4f682fc208972451f3d2 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Tue, 19 May 2026 00:33:37 -0300
Subject: [PATCH 3/5] refactor: Promote AggregateVersion and exceptions to
public API.
The renames are part of a single API-surface refresh: the version concept
moves to AggregateVersion to match DDD vocabulary, the formerly internal
exceptions become catchable from consumer code, and the surrounding
EventRecord and accessor names are realigned in the same pass so the
public surface lands consistent.
---
src/Aggregate/AggregateRoot.php | 31 ++++---
src/Aggregate/AggregateRootBehavior.php | 51 ++++-------
src/Aggregate/AggregateVersion.php | 85 +++++++++++++++++++
src/Aggregate/EventSourcingRoot.php | 56 ++++++------
src/Aggregate/EventSourcingRootBehavior.php | 46 ++++++----
src/Aggregate/EventualAggregateRoot.php | 49 ++++++++++-
.../EventualAggregateRootBehavior.php | 37 +++++++-
src/Aggregate/ModelVersion.php | 36 +++++++-
src/Entity/Entity.php | 10 +--
src/Entity/EntityBehavior.php | 32 ++++---
src/Entity/Identity.php | 2 +-
src/Entity/SingleIdentity.php | 2 +-
src/Event/DomainEvent.php | 10 +--
src/Event/EventRecord.php | 38 +++++----
src/Event/EventType.php | 18 +++-
src/Event/Revision.php | 26 +++++-
src/Event/SequenceNumber.php | 46 ----------
.../Exceptions/EventHandlerMethodNotFound.php | 4 +-
src/Exceptions/InvalidAggregateVersion.php | 17 ++++
.../Exceptions/InvalidEventType.php | 4 +-
.../Exceptions/InvalidModelVersion.php | 4 +-
.../Exceptions/InvalidRevision.php | 4 +-
.../Exceptions/InvalidSnapshotCount.php | 4 +-
.../Exceptions/MissingIdentityProperty.php | 4 +-
.../Exceptions/NoEventHandlerRegistered.php | 4 +-
.../Exceptions/InvalidSequenceNumber.php | 17 ----
src/Snapshot/Snapshot.php | 73 ++++++++++++----
src/Snapshot/SnapshotCondition.php | 6 +-
src/Snapshot/SnapshotData.php | 22 -----
src/Snapshot/SnapshotEvery.php | 11 ++-
src/Snapshot/SnapshotNever.php | 5 ++
src/Snapshot/SnapshotterBehavior.php | 9 ++
src/Upcast/DefaultValues.php | 7 +-
src/Upcast/IntermediateEvent.php | 12 +++
src/Upcast/SingleUpcasterBehavior.php | 10 +++
src/Upcast/Upcasters.php | 15 +++-
36 files changed, 541 insertions(+), 266 deletions(-)
create mode 100644 src/Aggregate/AggregateVersion.php
delete mode 100644 src/Event/SequenceNumber.php
rename src/{Internal => }/Exceptions/EventHandlerMethodNotFound.php (69%)
create mode 100644 src/Exceptions/InvalidAggregateVersion.php
rename src/{Internal => }/Exceptions/InvalidEventType.php (72%)
rename src/{Internal => }/Exceptions/InvalidModelVersion.php (71%)
rename src/{Internal => }/Exceptions/InvalidRevision.php (71%)
rename src/{Internal => }/Exceptions/InvalidSnapshotCount.php (70%)
rename src/{Internal => }/Exceptions/MissingIdentityProperty.php (71%)
rename src/{Internal => }/Exceptions/NoEventHandlerRegistered.php (70%)
delete mode 100644 src/Internal/Exceptions/InvalidSequenceNumber.php
delete mode 100644 src/Snapshot/SnapshotData.php
diff --git a/src/Aggregate/AggregateRoot.php b/src/Aggregate/AggregateRoot.php
index bc81631..bd8d5a0 100644
--- a/src/Aggregate/AggregateRoot.php
+++ b/src/Aggregate/AggregateRoot.php
@@ -5,16 +5,15 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
use TinyBlocks\BuildingBlocks\Entity\Entity;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
/**
* Cluster of associated objects treated as a single unit for data changes, with one Entity as the root.
*
- *
External references must target the root; invariants apply to the whole cluster; transactions never
+ *
External references must target the root. Invariants apply to the whole cluster. Transactions never
* straddle aggregate boundaries. This interface adds two pragmatic fields absent from Evans:
*
*
- *
sequenceNumber for optimistic offline locking.
+ *
aggregateVersion for optimistic offline locking.
*
modelVersion for aggregate schema evolution.
*
*
@@ -24,23 +23,12 @@
* @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
* (Addison-Wesley, 2003), Chapter 6 "Aggregates".
* @see Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002),
- * "Optimistic Offline Lock", source of sequenceNumber.
+ * "Optimistic Offline Lock", source of aggregateVersion.
* @see Greg Young, Versioning in an Event Sourced System (Leanpub, 2017), source of
* modelVersion.
*/
interface AggregateRoot extends Entity
{
- /**
- * Returns the aggregate's current sequence number.
- *
- *
The initial value is 0. The first recorded event increments it to 1,
- * and each subsequent event advances it by one. Persistence adapters compare the stored value against
- * the in-memory one to detect concurrent modifications.
- *
- * @return SequenceNumber The current sequence number.
- */
- public function sequenceNumber(): SequenceNumber;
-
/**
* Returns the schema version of this aggregate type.
*
@@ -59,5 +47,16 @@ public function modelVersion(): ModelVersion;
*
* @return string The short class name.
*/
- public function aggregateName(): string;
+ public function aggregateType(): string;
+
+ /**
+ * Returns the aggregate's current version.
+ *
+ *
The initial value is 0. The first recorded event increments it to 1,
+ * and each subsequent event advances it by one. Persistence adapters compare the stored value against
+ * the in-memory one to detect concurrent modifications.
+ *
+ * @return AggregateVersion The current aggregate version.
+ */
+ public function aggregateVersion(): AggregateVersion;
}
diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php
index c83dfab..d6d9217 100644
--- a/src/Aggregate/AggregateRootBehavior.php
+++ b/src/Aggregate/AggregateRootBehavior.php
@@ -11,8 +11,6 @@
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
use TinyBlocks\BuildingBlocks\Event\EventType;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
trait AggregateRootBehavior
@@ -21,44 +19,21 @@ trait AggregateRootBehavior
private EventRecords $recordedEvents;
- private SequenceNumber $sequenceNumber;
-
- public function sequenceNumber(): SequenceNumber
- {
- return $this->sequenceNumber ?? SequenceNumber::initial();
- }
+ private AggregateVersion $aggregateVersion;
public function modelVersion(): ModelVersion
{
return ModelVersion::initial();
}
- public function aggregateName(): string
+ public function aggregateType(): string
{
return new ReflectionClass(objectOrClass: static::class)->getShortName();
}
- protected function nextSequenceNumber(): void
- {
- $this->sequenceNumber = $this->sequenceNumber()->next();
- }
-
- protected function generateSnapshotData(): SnapshotData
- {
- return new SnapshotData(payload: $this->snapshotState());
- }
-
- protected function reconstituteSequenceNumber(SequenceNumber $sequenceNumber): void
- {
- $this->sequenceNumber = $sequenceNumber;
- }
-
- protected function snapshotState(): array
+ public function aggregateVersion(): AggregateVersion
{
- $state = get_object_vars($this);
- unset($state['recordedEvents'], $state['sequenceNumber']);
-
- return $state;
+ return $this->aggregateVersion ?? AggregateVersion::initial();
}
public function recordedEvents(): EventRecords
@@ -68,18 +43,22 @@ public function recordedEvents(): EventRecords
return EventRecords::createFrom(elements: $records);
}
- protected function buildEventRecord(DomainEvent $event): EventRecord
+ private function nextAggregateVersion(): void
+ {
+ $this->aggregateVersion = $this->aggregateVersion()->next();
+ }
+
+ private function buildEventRecord(DomainEvent $event): EventRecord
{
return new EventRecord(
id: Uuid::uuid4(),
- type: EventType::fromEvent(event: $event),
event: $event,
- identity: $this->identity(),
revision: $event->revision(),
- occurredOn: Instant::now(),
- snapshotData: $this->generateSnapshotData(),
- aggregateType: $this->aggregateName(),
- sequenceNumber: $this->sequenceNumber()
+ eventType: EventType::fromEvent(event: $event),
+ occurredAt: Instant::now(),
+ aggregateId: $this->identity(),
+ aggregateType: $this->aggregateType(),
+ aggregateVersion: $this->aggregateVersion()
);
}
}
diff --git a/src/Aggregate/AggregateVersion.php b/src/Aggregate/AggregateVersion.php
new file mode 100644
index 0000000..bb1101d
--- /dev/null
+++ b/src/Aggregate/AggregateVersion.php
@@ -0,0 +1,85 @@
+value + 1);
+ }
+
+ /**
+ * Tells whether this aggregate version is strictly after the given one.
+ *
+ * @param AggregateVersion $other The aggregate version to compare against.
+ * @return bool True when this value is greater than the other's.
+ */
+ public function isAfter(AggregateVersion $other): bool
+ {
+ return $this->value > $other->value;
+ }
+
+ /**
+ * Tells whether this aggregate version is strictly before the given one.
+ *
+ * @param AggregateVersion $other The aggregate version to compare against.
+ * @return bool True when this value is less than the other's.
+ */
+ public function isBefore(AggregateVersion $other): bool
+ {
+ return $this->value < $other->value;
+ }
+}
diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php
index 973c769..4c8b43b 100644
--- a/src/Aggregate/EventSourcingRoot.php
+++ b/src/Aggregate/EventSourcingRoot.php
@@ -8,13 +8,13 @@
use TinyBlocks\BuildingBlocks\Event\DomainEvent;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
+use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
/**
* Aggregate root variant whose state is derived entirely from its ordered stream of events.
*
- *
The event store is the source of truth; aggregate state is a projection. Instances are created
+ *
The event store is the source of truth. Aggregate state is a projection. Instances are created
* through {@see blank()} and populated by replaying events via {@see reconstitute()}, optionally starting
* from a {@see Snapshot} to skip earlier events.
*
@@ -28,25 +28,6 @@
*/
interface EventSourcingRoot extends AggregateRoot
{
- /**
- * Returns the explicit map of event class names to handler callables.
- *
- *
When the returned array is empty, the trait falls back to the implicit
- * convention when<EventShortName>. When the array is
- * non-empty, it is the authoritative source: only events whose class names
- * appear as keys can be applied; absence triggers an exception.
- *
- * @return array, callable>
- */
- public function eventHandlers(): array;
-
- /**
- * Returns the events recorded during the current unit of work.
- *
- * @return EventRecords The events awaiting append to the event store.
- */
- public function recordedEvents(): EventRecords;
-
/**
* Creates a blank aggregate with the given identity and no recorded events.
*
@@ -54,7 +35,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 identityProperty() does not exist.
+ * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist.
*/
public static function blank(Identity $identity): static;
@@ -62,14 +43,14 @@ public static function blank(Identity $identity): static;
* Reconstitutes an aggregate by replaying an ordered stream of event records.
*
*
When a snapshot is provided, the aggregate state is first restored from it and the snapshot's
- * sequence number is taken as authoritative. Only events recorded after the snapshot need to be
+ * aggregate version is taken as authoritative. Only events recorded after the snapshot need to be
* replayed.
*
* @param Identity $identity The identity of the aggregate.
- * @param iterable $records The event stream to replay, ordered by sequence number.
+ * @param iterable $records The event stream to replay, ordered by aggregate version.
* @param Snapshot|null $snapshot Optional snapshot to restore from before replay.
* @return static The reconstituted aggregate.
- * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
+ * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist.
*/
public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static;
@@ -77,7 +58,7 @@ public static function reconstitute(Identity $identity, iterable $records, ?Snap
* Returns the aggregate state to persist in a snapshot.
*
*
The default implementation provided by {@see EventSourcingRootBehavior} returns all object
- * properties except recordedEvents (transient buffer) and sequenceNumber
+ * properties except recordedEvents (transient buffer) and aggregateVersion
* (already a first-class field on the snapshot). Override to exclude infrastructure properties
* (loggers, caches, etc.) or to include only a curated subset of state.
*
@@ -89,10 +70,29 @@ public function snapshotState(): array;
* Restores aggregate state from the given snapshot.
*
*
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.
+ * their own properties. The aggregate version is applied automatically by
+ * reconstitute(). Implementations should not touch it.
*
* @param Snapshot $snapshot The snapshot to restore from.
*/
public function applySnapshot(Snapshot $snapshot): void;
+
+ /**
+ * Returns the explicit map of event class names to handler callables.
+ *
+ *
When the returned array is empty, the trait falls back to the implicit
+ * convention whenEventShortName. When the array is
+ * non-empty, it is the authoritative source: only events whose class names
+ * appear as keys can be applied. Absence triggers an exception.
+ *
+ * @return array, callable> The explicit event-class to handler map.
+ */
+ public function eventHandlers(): array;
+
+ /**
+ * Returns the events recorded during the current unit of work.
+ *
+ * @return EventRecords The events awaiting append to the event store.
+ */
+ public function recordedEvents(): EventRecords;
}
diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php
index c29920a..97f7d7d 100644
--- a/src/Aggregate/EventSourcingRootBehavior.php
+++ b/src/Aggregate/EventSourcingRootBehavior.php
@@ -10,9 +10,8 @@
use TinyBlocks\BuildingBlocks\Event\DomainEvent;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\EventHandlerMethodNotFound;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\NoEventHandlerRegistered;
+use TinyBlocks\BuildingBlocks\Exceptions\EventHandlerMethodNotFound;
+use TinyBlocks\BuildingBlocks\Exceptions\NoEventHandlerRegistered;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
trait EventSourcingRootBehavior
@@ -21,10 +20,10 @@ trait EventSourcingRootBehavior
public static function blank(Identity $identity): static
{
- $aggregate = new ReflectionClass(static::class)->newInstanceWithoutConstructor();
- new ReflectionProperty($aggregate, $aggregate->identityName())
+ $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor();
+ new ReflectionProperty(class: $aggregate, property: $aggregate->identityName())
->setValue($aggregate, $identity);
- $aggregate->sequenceNumber = SequenceNumber::initial();
+ $aggregate->aggregateVersion = AggregateVersion::initial();
$aggregate->recordedEvents = EventRecords::createFromEmpty();
return $aggregate;
@@ -39,7 +38,7 @@ public static function reconstitute(
if (!is_null($snapshot)) {
$aggregate->applySnapshot(snapshot: $snapshot);
- $aggregate->sequenceNumber = $snapshot->sequenceNumber();
+ $aggregate->aggregateVersion = $snapshot->aggregateVersion();
}
foreach ($records as $record) {
@@ -49,29 +48,40 @@ public static function reconstitute(
return $aggregate;
}
- public function eventHandlers(): array
- {
- return [];
- }
-
public function snapshotState(): array
{
+ /** @var array $state */
$state = get_object_vars($this);
- unset($state['recordedEvents'], $state['sequenceNumber']);
+ unset($state['recordedEvents'], $state['aggregateVersion']);
return $state;
}
+ public function eventHandlers(): array
+ {
+ return [];
+ }
+
+ /**
+ * Records a domain event and applies it to the aggregate's state in one step.
+ *
+ *
Invoked by command methods of an event-sourced aggregate. Advances the aggregate version,
+ * builds the {@see EventRecord}, applies it via the registered handler or the implicit
+ * whenEventShortName convention, and appends the record to the recorded-events
+ * collection. Not part of the public surface.
+ *
+ * @param DomainEvent $event The event to record and apply.
+ */
protected function when(DomainEvent $event): void
{
- $this->nextSequenceNumber();
+ $this->nextAggregateVersion();
$record = $this->buildEventRecord(event: $event);
$this->applyEvent(record: $record);
$this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())
->add(elements: $record);
}
- protected function applyEvent(EventRecord $record): void
+ private function applyEvent(EventRecord $record): void
{
$handlers = $this->eventHandlers();
$eventClass = $record->event::class;
@@ -82,17 +92,17 @@ protected function applyEvent(EventRecord $record): void
}
$handlers[$eventClass]($record->event);
- $this->sequenceNumber = $record->sequenceNumber;
+ $this->aggregateVersion = $record->aggregateVersion;
return;
}
- $methodName = sprintf('when%s', $record->type->value);
+ $methodName = sprintf('when%s', $record->eventType->value);
if (!method_exists($this, $methodName)) {
throw new EventHandlerMethodNotFound(methodName: $methodName, aggregateClass: static::class);
}
$this->{$methodName}($record->event);
- $this->sequenceNumber = $record->sequenceNumber;
+ $this->aggregateVersion = $record->aggregateVersion;
}
}
diff --git a/src/Aggregate/EventualAggregateRoot.php b/src/Aggregate/EventualAggregateRoot.php
index 642473d..e058bec 100644
--- a/src/Aggregate/EventualAggregateRoot.php
+++ b/src/Aggregate/EventualAggregateRoot.php
@@ -4,12 +4,14 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
+use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
+use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty;
/**
* 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
+ *
State is persisted as the source of truth. Events are emitted as side effects and delivered
* at-least-once to external consumers. The repository drains recordedEvents() after
* persisting the aggregate state.
Default factory for state-based aggregates. Used by repositories that load aggregate state
+ * from external storage (a relational row, a document, an in-memory cache) and need an instance
+ * ready to emit further events from the correct aggregate version.
+ *
+ *
The default implementation provided by {@see EventualAggregateRootBehavior} hydrates state
+ * properties by reflection from the $state map. Aggregates may override this factory
+ * to customize the hydration or to rename the identity parameter to reflect their specific
+ * identity type. For example:
+ *
+ *
+ * // Override with a custom parameter name:
+ * public static function reconstitute(
+ * Identity $orderId,
+ * AggregateVersion $aggregateVersion,
+ * array $state = []
+ * ): static
+ *
+ *
+ *
The parameter name is free, but the type must remain Identity per LSP rules on
+ * static methods. Concrete identity types (e.g. OrderId) can be enforced inside the
+ * override via instanceof.
+ *
+ * @param Identity $identity The aggregate's identity.
+ * @param AggregateVersion $aggregateVersion The version to restore. Subsequent emitted events
+ * advance from this value.
+ * @param array $state Optional map of property name to value. Entries whose key
+ * does not match a declared property are silently ignored by
+ * the default implementation.
+ * @return static The reconstituted aggregate.
+ * @throws MissingIdentityProperty When the property referenced by identityProperty()
+ * does not exist on the aggregate class.
+ */
+ public static function reconstitute(
+ Identity $identity,
+ AggregateVersion $aggregateVersion,
+ array $state = []
+ ): static;
+
/**
* 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.
+ *
Always returns a fresh copy: external mutation of the returned collection does not leak into
+ * the aggregate's internal buffer.
*
* @return EventRecords A snapshot of the recorded events, safe to iterate and mutate.
*/
diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php
index 3b639b5..31f8c15 100644
--- a/src/Aggregate/EventualAggregateRootBehavior.php
+++ b/src/Aggregate/EventualAggregateRootBehavior.php
@@ -4,16 +4,51 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
+use ReflectionClass;
+use ReflectionProperty;
+use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\DomainEvent;
+use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
trait EventualAggregateRootBehavior
{
use AggregateRootBehavior;
+ public static function reconstitute(
+ Identity $identity,
+ AggregateVersion $aggregateVersion,
+ array $state = []
+ ): static {
+ $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor();
+ new ReflectionProperty(class: $aggregate, property: $aggregate->identityName())
+ ->setValue($aggregate, $identity);
+
+ foreach ($state as $property => $value) {
+ if (property_exists($aggregate, $property)) {
+ new ReflectionProperty(class: $aggregate, property: $property)
+ ->setValue($aggregate, $value);
+ }
+ }
+
+ $aggregate->aggregateVersion = $aggregateVersion;
+
+ return $aggregate;
+ }
+
+ /**
+ * Records a domain event on the aggregate's internal buffer.
+ *
+ *
Invoked by command methods of the aggregate after state has been mutated. Advances the
+ * aggregate version and appends a fully-built {@see EventRecord} to the recorded-events collection.
+ * Not part of the public surface: external callers must go through command methods that establish
+ * domain invariants.
+ *
+ * @param DomainEvent $event The event to record.
+ */
protected function push(DomainEvent $event): void
{
- $this->nextSequenceNumber();
+ $this->nextAggregateVersion();
$this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())
->add(elements: $this->buildEventRecord(event: $event));
}
diff --git a/src/Aggregate/ModelVersion.php b/src/Aggregate/ModelVersion.php
index f2876fd..1b284fc 100644
--- a/src/Aggregate/ModelVersion.php
+++ b/src/Aggregate/ModelVersion.php
@@ -4,7 +4,7 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidModelVersion;
+use TinyBlocks\BuildingBlocks\Exceptions\InvalidModelVersion;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -19,13 +19,47 @@ private function __construct(public int $value)
}
}
+ /**
+ * Creates a ModelVersion from the given integer value.
+ *
+ * @param int $value The schema version. Must be greater than or equal to 0.
+ * @return ModelVersion The created instance.
+ * @throws InvalidModelVersion If the value is less than 0.
+ */
public static function of(int $value): ModelVersion
{
return new ModelVersion(value: $value);
}
+ /**
+ * Creates a ModelVersion with the initial value of 0.
+ *
+ * @return ModelVersion The initial model version.
+ */
public static function initial(): ModelVersion
{
return new ModelVersion(value: 0);
}
+
+ /**
+ * Tells whether this model version is strictly after the given one.
+ *
+ * @param ModelVersion $other The model version to compare against.
+ * @return bool True when this value is greater than the other's.
+ */
+ public function isAfter(ModelVersion $other): bool
+ {
+ return $this->value > $other->value;
+ }
+
+ /**
+ * Tells whether this model version is strictly before the given one.
+ *
+ * @param ModelVersion $other The model version to compare against.
+ * @return bool True when this value is less than the other's.
+ */
+ public function isBefore(ModelVersion $other): bool
+ {
+ return $this->value < $other->value;
+ }
}
diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php
index 5896f64..1daf0cd 100644
--- a/src/Entity/Entity.php
+++ b/src/Entity/Entity.php
@@ -4,7 +4,7 @@
namespace TinyBlocks\BuildingBlocks\Entity;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
+use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty;
/**
* Object whose identity persists through time and changes of state.
@@ -25,7 +25,7 @@ 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 identityProperty() does not exist.
+ * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist.
*/
public function identity(): Identity;
@@ -33,7 +33,7 @@ public function identity(): Identity;
* Returns the name of the property that holds this entity's Identity.
*
* @return string The property name, resolved from identityProperty().
- * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
+ * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist.
*/
public function identityName(): string;
@@ -48,7 +48,7 @@ public function identityName(): string;
public function identityValue(): mixed;
/**
- * Checks whether this entity and the given one share the same identity.
+ * Tells whether this entity and the given one share the same identity.
*
* @param Entity $other The entity whose identity will be compared.
* @return bool True when both entities hold equal identities.
@@ -56,7 +56,7 @@ public function identityValue(): mixed;
public function sameIdentityOf(Entity $other): bool;
/**
- * Checks whether the given Identity is equal to this entity's identity.
+ * Tells whether the given Identity is equal to this entity's identity.
*
* @param Identity $other The identity to compare against.
* @return bool True when the given identity equals this entity's identity.
diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php
index 280f56a..9c255e4 100644
--- a/src/Entity/EntityBehavior.php
+++ b/src/Entity/EntityBehavior.php
@@ -4,10 +4,18 @@
namespace TinyBlocks\BuildingBlocks\Entity;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
+use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty;
trait EntityBehavior
{
+ public function identity(): Identity
+ {
+ /** @var Identity $identity */
+ $identity = $this->{$this->identityName()};
+
+ return $identity;
+ }
+
public function identityName(): string
{
$name = $this->identityProperty();
@@ -19,16 +27,6 @@ public function identityName(): string
return $name;
}
- protected function identityProperty(): string
- {
- return 'id';
- }
-
- public function identity(): Identity
- {
- return $this->{$this->identityName()};
- }
-
public function identityValue(): mixed
{
return $this->identity()->identityValue();
@@ -43,4 +41,16 @@ public function identityEquals(Identity $other): bool
{
return $this->identity()->equals(other: $other);
}
+
+ /**
+ * Returns the property name that holds the entity's identity.
+ *
+ *
Defaults to 'id'. Override in entities whose identity property has a different name.
+ *
+ * @return string The property name backing the identity.
+ */
+ protected function identityProperty(): string
+ {
+ return 'id';
+ }
}
diff --git a/src/Entity/Identity.php b/src/Entity/Identity.php
index df7bb5d..82ec104 100644
--- a/src/Entity/Identity.php
+++ b/src/Entity/Identity.php
@@ -10,7 +10,7 @@
* Immutable value that uniquely identifies an {@see Entity} within its aggregate boundary.
*
*
Identity is the stable thread that allows an Entity to be recognized across distinct representations
- * and lifecycle states. Implementations are expected to be immutable; in PHP 8.5+ this is achieved through
+ * and lifecycle states. Implementations are expected to be immutable. In PHP 8.5+ this is achieved through
* `final readonly class`.
*
*
Implementations are expected to also be value objects for equality purposes. See the two shipped
diff --git a/src/Entity/SingleIdentity.php b/src/Entity/SingleIdentity.php
index 3f8160b..9d357ee 100644
--- a/src/Entity/SingleIdentity.php
+++ b/src/Entity/SingleIdentity.php
@@ -11,7 +11,7 @@
* auto-increment integer, slug, etc.). Not a concept from Evans: the book makes no distinction between
* single-value and composite identities.
*
- *
Implementations should declare exactly one property holding the scalar value; the default trait
+ *
Implementations should declare exactly one property holding the scalar value. The default trait
* reads it by reflection and returns it from identityValue().
*/
interface SingleIdentity extends Identity
diff --git a/src/Event/DomainEvent.php b/src/Event/DomainEvent.php
index a9ae71c..ab7efd5 100644
--- a/src/Event/DomainEvent.php
+++ b/src/Event/DomainEvent.php
@@ -12,10 +12,10 @@
* on a DomainEvent and must not be exposed as accessors on subtypes:
*
*
- *
Aggregate identity — added by the aggregate when building the {@see EventRecord}.
- *
Aggregate type — derived from the aggregate's class name into the envelope.
- *
Sequence number — assigned by the aggregate into the envelope.
- *
Serialization to storage — responsibility of outbox writers and consumer
+ *
Aggregate identity: added by the aggregate when building the {@see EventRecord}.
+ *
Aggregate type: derived from the aggregate's class name into the envelope.
+ *
Sequence number: assigned by the aggregate into the envelope.
+ *
Serialization to storage: responsibility of outbox writers and consumer
* deserializers, both infrastructure concerns outside this library.
*
*
@@ -35,7 +35,7 @@ interface DomainEvent
/**
* Returns the schema revision of this event.
*
- * @return Revision The current schema revision; defaults to {@see Revision::initial}.
+ * @return Revision The current schema revision. Defaults to {@see Revision::initial}.
*/
public function revision(): Revision;
}
diff --git a/src/Event/EventRecord.php b/src/Event/EventRecord.php
index 649cfd2..d60d18f 100644
--- a/src/Event/EventRecord.php
+++ b/src/Event/EventRecord.php
@@ -6,8 +6,8 @@
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Entity\Identity;
-use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -18,36 +18,44 @@
public function __construct(
public UuidInterface $id,
- public EventType $type,
public DomainEvent $event,
- public Identity $identity,
public Revision $revision,
- public Instant $occurredOn,
- public SnapshotData $snapshotData,
+ public EventType $eventType,
+ public Instant $occurredAt,
+ public Identity $aggregateId,
public string $aggregateType,
- public SequenceNumber $sequenceNumber
+ public AggregateVersion $aggregateVersion
) {
}
+ /**
+ * Creates an EventRecord from a domain event and its required envelope fields.
+ *
+ * @param DomainEvent $event The event being recorded.
+ * @param Identity $aggregateId The aggregate identity that produced the event.
+ * @param string $aggregateType The short class name of the aggregate.
+ * @param AggregateVersion $aggregateVersion The aggregate version assigned to this envelope.
+ * @param UuidInterface|null $id Optional explicit identifier. Defaults to a fresh UUIDv4.
+ * @param Instant|null $occurredAt Optional explicit occurrence timestamp. Defaults to now.
+ * @return EventRecord The constructed envelope.
+ */
public static function of(
DomainEvent $event,
- Identity $identity,
+ Identity $aggregateId,
string $aggregateType,
- SequenceNumber $sequenceNumber,
+ AggregateVersion $aggregateVersion,
?UuidInterface $id = null,
- ?Instant $occurredOn = null,
- ?SnapshotData $snapshotData = null
+ ?Instant $occurredAt = 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: []),
+ eventType: EventType::fromEvent(event: $event),
+ occurredAt: $occurredAt ?? Instant::now(),
+ aggregateId: $aggregateId,
aggregateType: $aggregateType,
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
}
}
diff --git a/src/Event/EventType.php b/src/Event/EventType.php
index c788cd5..4e2be45 100644
--- a/src/Event/EventType.php
+++ b/src/Event/EventType.php
@@ -5,7 +5,7 @@
namespace TinyBlocks\BuildingBlocks\Event;
use ReflectionClass;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidEventType;
+use TinyBlocks\BuildingBlocks\Exceptions\InvalidEventType;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -22,11 +22,25 @@ private function __construct(public string $value)
}
}
+ /**
+ * Creates an EventType from a domain event using its short class name.
+ *
+ * @param DomainEvent $event The event whose class name carries the type.
+ * @return EventType The created instance.
+ * @throws InvalidEventType If the resolved class name does not match the required pattern.
+ */
public static function fromEvent(DomainEvent $event): EventType
{
- return new EventType(value: new ReflectionClass($event)->getShortName());
+ return new EventType(value: new ReflectionClass(objectOrClass: $event)->getShortName());
}
+ /**
+ * Creates an EventType from a raw type identifier.
+ *
+ * @param string $value The PascalCase type identifier.
+ * @return EventType The created instance.
+ * @throws InvalidEventType If the value does not match the required pattern.
+ */
public static function fromString(string $value): EventType
{
return new EventType(value: $value);
diff --git a/src/Event/Revision.php b/src/Event/Revision.php
index 40fec6a..ace14fc 100644
--- a/src/Event/Revision.php
+++ b/src/Event/Revision.php
@@ -4,7 +4,7 @@
namespace TinyBlocks\BuildingBlocks\Event;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidRevision;
+use TinyBlocks\BuildingBlocks\Exceptions\InvalidRevision;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -19,21 +19,45 @@ private function __construct(public int $value)
}
}
+ /**
+ * Creates a Revision with the initial value of 1.
+ *
+ * @return Revision The initial revision.
+ */
public static function initial(): Revision
{
return new Revision(value: 1);
}
+ /**
+ * Creates a Revision from the given integer value.
+ *
+ * @param int $value The revision number. Must be greater than or equal to 1.
+ * @return Revision The created instance.
+ * @throws InvalidRevision If the value is less than 1.
+ */
public static function of(int $value): Revision
{
return new Revision(value: $value);
}
+ /**
+ * Tells whether this revision is strictly after the given one.
+ *
+ * @param Revision $other The revision to compare against.
+ * @return bool True when this revision's value is greater than the other's.
+ */
public function isAfter(Revision $other): bool
{
return $this->value > $other->value;
}
+ /**
+ * Tells whether this revision is strictly before the given one.
+ *
+ * @param Revision $other The revision to compare against.
+ * @return bool True when this revision's value is less than the other's.
+ */
public function isBefore(Revision $other): bool
{
return $this->value < $other->value;
diff --git a/src/Event/SequenceNumber.php b/src/Event/SequenceNumber.php
deleted file mode 100644
index 217906b..0000000
--- a/src/Event/SequenceNumber.php
+++ /dev/null
@@ -1,46 +0,0 @@
-value + 1);
- }
-
- public function isAfter(SequenceNumber $other): bool
- {
- return $this->value > $other->value;
- }
-}
diff --git a/src/Internal/Exceptions/EventHandlerMethodNotFound.php b/src/Exceptions/EventHandlerMethodNotFound.php
similarity index 69%
rename from src/Internal/Exceptions/EventHandlerMethodNotFound.php
rename to src/Exceptions/EventHandlerMethodNotFound.php
index 33ac4f1..6e2fd31 100644
--- a/src/Internal/Exceptions/EventHandlerMethodNotFound.php
+++ b/src/Exceptions/EventHandlerMethodNotFound.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Internal\Exceptions;
+namespace TinyBlocks\BuildingBlocks\Exceptions;
use LogicException;
@@ -12,6 +12,6 @@ public function __construct(public readonly string $methodName, public readonly
{
$template = 'Handler method <%s> not found in aggregate <%s>.';
- parent::__construct(sprintf($template, $methodName, $aggregateClass));
+ parent::__construct(message: sprintf($template, $methodName, $aggregateClass));
}
}
diff --git a/src/Exceptions/InvalidAggregateVersion.php b/src/Exceptions/InvalidAggregateVersion.php
new file mode 100644
index 0000000..34057d4
--- /dev/null
+++ b/src/Exceptions/InvalidAggregateVersion.php
@@ -0,0 +1,17 @@
+.';
+
+ parent::__construct(message: sprintf($template, $value));
+ }
+}
diff --git a/src/Internal/Exceptions/InvalidEventType.php b/src/Exceptions/InvalidEventType.php
similarity index 72%
rename from src/Internal/Exceptions/InvalidEventType.php
rename to src/Exceptions/InvalidEventType.php
index a722cfc..dbcad5f 100644
--- a/src/Internal/Exceptions/InvalidEventType.php
+++ b/src/Exceptions/InvalidEventType.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Internal\Exceptions;
+namespace TinyBlocks\BuildingBlocks\Exceptions;
use InvalidArgumentException;
@@ -12,6 +12,6 @@ public function __construct(public readonly string $value, public readonly strin
{
$template = 'Event type <%s> does not match the required pattern <%s>.';
- parent::__construct(sprintf($template, $value, $pattern));
+ parent::__construct(message: sprintf($template, $value, $pattern));
}
}
diff --git a/src/Internal/Exceptions/InvalidModelVersion.php b/src/Exceptions/InvalidModelVersion.php
similarity index 71%
rename from src/Internal/Exceptions/InvalidModelVersion.php
rename to src/Exceptions/InvalidModelVersion.php
index 1d875ef..702dd81 100644
--- a/src/Internal/Exceptions/InvalidModelVersion.php
+++ b/src/Exceptions/InvalidModelVersion.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Internal\Exceptions;
+namespace TinyBlocks\BuildingBlocks\Exceptions;
use InvalidArgumentException;
@@ -12,6 +12,6 @@ public function __construct(public readonly int $value)
{
$template = 'Model version must be greater than or equal to 0, got <%d>.';
- parent::__construct(sprintf($template, $value));
+ parent::__construct(message: sprintf($template, $value));
}
}
diff --git a/src/Internal/Exceptions/InvalidRevision.php b/src/Exceptions/InvalidRevision.php
similarity index 71%
rename from src/Internal/Exceptions/InvalidRevision.php
rename to src/Exceptions/InvalidRevision.php
index ce0a082..df408e4 100644
--- a/src/Internal/Exceptions/InvalidRevision.php
+++ b/src/Exceptions/InvalidRevision.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Internal\Exceptions;
+namespace TinyBlocks\BuildingBlocks\Exceptions;
use InvalidArgumentException;
@@ -12,6 +12,6 @@ public function __construct(public readonly int $value)
{
$template = 'Revision must be greater than or equal to 1, got <%d>.';
- parent::__construct(sprintf($template, $value));
+ parent::__construct(message: sprintf($template, $value));
}
}
diff --git a/src/Internal/Exceptions/InvalidSnapshotCount.php b/src/Exceptions/InvalidSnapshotCount.php
similarity index 70%
rename from src/Internal/Exceptions/InvalidSnapshotCount.php
rename to src/Exceptions/InvalidSnapshotCount.php
index d0996bd..34a8cc7 100644
--- a/src/Internal/Exceptions/InvalidSnapshotCount.php
+++ b/src/Exceptions/InvalidSnapshotCount.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Internal\Exceptions;
+namespace TinyBlocks\BuildingBlocks\Exceptions;
use InvalidArgumentException;
@@ -12,6 +12,6 @@ public function __construct(public readonly int $count)
{
$template = 'Snapshot count must be at least 1, got <%d>.';
- parent::__construct(sprintf($template, $count));
+ parent::__construct(message: sprintf($template, $count));
}
}
diff --git a/src/Internal/Exceptions/MissingIdentityProperty.php b/src/Exceptions/MissingIdentityProperty.php
similarity index 71%
rename from src/Internal/Exceptions/MissingIdentityProperty.php
rename to src/Exceptions/MissingIdentityProperty.php
index b2aa0a4..b327588 100644
--- a/src/Internal/Exceptions/MissingIdentityProperty.php
+++ b/src/Exceptions/MissingIdentityProperty.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Internal\Exceptions;
+namespace TinyBlocks\BuildingBlocks\Exceptions;
use RuntimeException;
@@ -12,6 +12,6 @@ public function __construct(public readonly string $className, public readonly s
{
$template = 'Property <%s> referenced by identityName() does not exist in <%s>.';
- parent::__construct(sprintf($template, $propertyName, $className));
+ parent::__construct(message: sprintf($template, $propertyName, $className));
}
}
diff --git a/src/Internal/Exceptions/NoEventHandlerRegistered.php b/src/Exceptions/NoEventHandlerRegistered.php
similarity index 70%
rename from src/Internal/Exceptions/NoEventHandlerRegistered.php
rename to src/Exceptions/NoEventHandlerRegistered.php
index be1bc89..3ddf333 100644
--- a/src/Internal/Exceptions/NoEventHandlerRegistered.php
+++ b/src/Exceptions/NoEventHandlerRegistered.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\BuildingBlocks\Internal\Exceptions;
+namespace TinyBlocks\BuildingBlocks\Exceptions;
use LogicException;
@@ -12,6 +12,6 @@ public function __construct(public readonly string $eventClass, public readonly
{
$template = 'No handler registered for event <%s> in aggregate <%s>.';
- parent::__construct(sprintf($template, $eventClass, $aggregateClass));
+ parent::__construct(message: sprintf($template, $eventClass, $aggregateClass));
}
}
diff --git a/src/Internal/Exceptions/InvalidSequenceNumber.php b/src/Internal/Exceptions/InvalidSequenceNumber.php
deleted file mode 100644
index 216595c..0000000
--- a/src/Internal/Exceptions/InvalidSequenceNumber.php
+++ /dev/null
@@ -1,17 +0,0 @@
-.';
-
- parent::__construct(sprintf($template, $value));
- }
-}
diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php
index 04bed16..8f0733f 100644
--- a/src/Snapshot/Snapshot.php
+++ b/src/Snapshot/Snapshot.php
@@ -4,8 +4,8 @@
namespace TinyBlocks\BuildingBlocks\Snapshot;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
use TinyBlocks\Time\Instant;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -15,63 +15,104 @@
use ValueObjectBehavior;
private function __construct(
- private string $type,
- private Instant $createdAt,
- private mixed $aggregateId,
- private array $aggregateState,
- private SequenceNumber $sequenceNumber
+ public string $aggregateType,
+ public Instant $createdAt,
+ public mixed $aggregateId,
+ public array $aggregateState,
+ public AggregateVersion $aggregateVersion
) {
}
+ /**
+ * Creates a Snapshot from the persisted fields.
+ *
+ * @param string $aggregateType The short class name of the aggregate.
+ * @param Instant $createdAt The instant the snapshot was taken.
+ * @param mixed $aggregateId The aggregate identity raw value.
+ * @param array $aggregateState The captured aggregate state keyed by property name.
+ * @param AggregateVersion $aggregateVersion The aggregate version captured with the snapshot.
+ * @return Snapshot The restored snapshot instance.
+ */
public static function restore(
- string $type,
+ string $aggregateType,
Instant $createdAt,
mixed $aggregateId,
array $aggregateState,
- SequenceNumber $sequenceNumber
+ AggregateVersion $aggregateVersion
): Snapshot {
return new Snapshot(
- type: $type,
+ aggregateType: $aggregateType,
createdAt: $createdAt,
aggregateId: $aggregateId,
aggregateState: $aggregateState,
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
}
+ /**
+ * Creates a Snapshot from the current state of the given aggregate.
+ *
+ * @param EventSourcingRoot $aggregate The aggregate to snapshot.
+ * @return Snapshot The captured snapshot.
+ */
public static function fromAggregate(EventSourcingRoot $aggregate): Snapshot
{
return new Snapshot(
- type: $aggregate->aggregateName(),
+ aggregateType: $aggregate->aggregateType(),
createdAt: Instant::now(),
aggregateId: $aggregate->identityValue(),
aggregateState: $aggregate->snapshotState(),
- sequenceNumber: $aggregate->sequenceNumber()
+ aggregateVersion: $aggregate->aggregateVersion()
);
}
- public function type(): string
+ /**
+ * Returns the aggregate type.
+ *
+ * @return string The short class name of the snapshotted aggregate.
+ */
+ public function aggregateType(): string
{
- return $this->type;
+ return $this->aggregateType;
}
+ /**
+ * Returns the creation timestamp.
+ *
+ * @return Instant The instant the snapshot was taken.
+ */
public function createdAt(): Instant
{
return $this->createdAt;
}
+ /**
+ * Returns the aggregate identity raw value.
+ *
+ * @return mixed The identity value captured with the snapshot.
+ */
public function aggregateId(): mixed
{
return $this->aggregateId;
}
+ /**
+ * Returns the aggregate state as an associative array.
+ *
+ * @return array The captured state keyed by property name.
+ */
public function aggregateState(): array
{
return $this->aggregateState;
}
- public function sequenceNumber(): SequenceNumber
+ /**
+ * Returns the aggregate version.
+ *
+ * @return AggregateVersion The aggregate version captured with the snapshot.
+ */
+ public function aggregateVersion(): AggregateVersion
{
- return $this->sequenceNumber;
+ return $this->aggregateVersion;
}
}
diff --git a/src/Snapshot/SnapshotCondition.php b/src/Snapshot/SnapshotCondition.php
index d1ddbf7..87699f5 100644
--- a/src/Snapshot/SnapshotCondition.php
+++ b/src/Snapshot/SnapshotCondition.php
@@ -9,15 +9,15 @@
/**
* Strategy that decides when a snapshot of an event-sourced aggregate should be taken.
*
- *
Typical implementations check the aggregate's sequence number against a threshold (for example, take
- * a snapshot every N events) or combine sequence checks with a time-based policy. Keeping the
+ *
Typical implementations check the aggregate's version against a threshold (for example, take a
+ * snapshot every N events) or combine version checks with a time-based policy. Keeping the
* decision behind a strategy lets consumers mix and match policies per aggregate type without branching
* inside the snapshotter.
*/
interface SnapshotCondition
{
/**
- * Decides whether a snapshot of the given aggregate should be taken now.
+ * Tells whether a snapshot of the given aggregate should be taken now.
*
* @param EventSourcingRoot $aggregate The aggregate under evaluation.
* @return bool True when a snapshot should be taken.
diff --git a/src/Snapshot/SnapshotData.php b/src/Snapshot/SnapshotData.php
deleted file mode 100644
index 99cb3dd..0000000
--- a/src/Snapshot/SnapshotData.php
+++ /dev/null
@@ -1,22 +0,0 @@
-payload;
- }
-}
diff --git a/src/Snapshot/SnapshotEvery.php b/src/Snapshot/SnapshotEvery.php
index b8c98d1..d20352a 100644
--- a/src/Snapshot/SnapshotEvery.php
+++ b/src/Snapshot/SnapshotEvery.php
@@ -5,7 +5,7 @@
namespace TinyBlocks\BuildingBlocks\Snapshot;
use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidSnapshotCount;
+use TinyBlocks\BuildingBlocks\Exceptions\InvalidSnapshotCount;
final readonly class SnapshotEvery implements SnapshotCondition
{
@@ -16,6 +16,13 @@ private function __construct(private int $count)
}
}
+ /**
+ * Creates a SnapshotEvery condition that triggers every N events.
+ *
+ * @param int $count The number of events between snapshots. Must be at least 1.
+ * @return SnapshotEvery The created condition.
+ * @throws InvalidSnapshotCount If the count is less than 1.
+ */
public static function events(int $count): SnapshotEvery
{
return new SnapshotEvery(count: $count);
@@ -23,7 +30,7 @@ public static function events(int $count): SnapshotEvery
public function shouldSnapshot(EventSourcingRoot $aggregate): bool
{
- $value = $aggregate->sequenceNumber()->value;
+ $value = $aggregate->aggregateVersion()->value;
return $value > 0 && $value % $this->count === 0;
}
diff --git a/src/Snapshot/SnapshotNever.php b/src/Snapshot/SnapshotNever.php
index 5b5004c..30a42e5 100644
--- a/src/Snapshot/SnapshotNever.php
+++ b/src/Snapshot/SnapshotNever.php
@@ -12,6 +12,11 @@ private function __construct()
{
}
+ /**
+ * Creates a SnapshotNever condition that never triggers a snapshot.
+ *
+ * @return SnapshotNever The created condition.
+ */
public static function create(): SnapshotNever
{
return new SnapshotNever();
diff --git a/src/Snapshot/SnapshotterBehavior.php b/src/Snapshot/SnapshotterBehavior.php
index 0ca59f6..728865c 100644
--- a/src/Snapshot/SnapshotterBehavior.php
+++ b/src/Snapshot/SnapshotterBehavior.php
@@ -13,5 +13,14 @@ public function take(EventSourcingRoot $aggregate): void
$this->persist(snapshot: Snapshot::fromAggregate(aggregate: $aggregate));
}
+ /**
+ * Persists the given snapshot.
+ *
+ *
Implemented by the consumer. Invoked once per call to {@see take()} with the snapshot already
+ * captured from the aggregate. Storage format and location are entirely up to the implementation.
+ * This hook simply hands over the captured snapshot.
+ *
+ * @param Snapshot $snapshot The snapshot to persist.
+ */
abstract protected function persist(Snapshot $snapshot): void;
}
diff --git a/src/Upcast/DefaultValues.php b/src/Upcast/DefaultValues.php
index f8c32c1..ac09b59 100644
--- a/src/Upcast/DefaultValues.php
+++ b/src/Upcast/DefaultValues.php
@@ -4,8 +4,13 @@
namespace TinyBlocks\BuildingBlocks\Upcast;
-final readonly class DefaultValues
+final class DefaultValues
{
+ /**
+ * Returns the DefaultValues as an associative array keyed by primitive type.
+ *
+ * @return array Zero-value for each primitive type.
+ */
public static function get(): array
{
return [
diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php
index 95a68e5..2035e84 100644
--- a/src/Upcast/IntermediateEvent.php
+++ b/src/Upcast/IntermediateEvent.php
@@ -35,6 +35,12 @@ public function equals(ValueObject $other): bool
&& $this->serializedEvent === $other->serializedEvent;
}
+ /**
+ * Returns a copy of the IntermediateEvent with the revision replaced.
+ *
+ * @param Revision $revision The replacement revision.
+ * @return IntermediateEvent A new instance carrying the given revision.
+ */
public function withRevision(Revision $revision): IntermediateEvent
{
return new IntermediateEvent(
@@ -44,6 +50,12 @@ public function withRevision(Revision $revision): IntermediateEvent
);
}
+ /**
+ * Returns a copy of the IntermediateEvent with the serialized payload replaced.
+ *
+ * @param array $serializedEvent The replacement payload.
+ * @return IntermediateEvent A new instance carrying the given payload.
+ */
public function withSerializedEvent(array $serializedEvent): IntermediateEvent
{
return new IntermediateEvent(
diff --git a/src/Upcast/SingleUpcasterBehavior.php b/src/Upcast/SingleUpcasterBehavior.php
index 6ad0b41..36d6a6d 100644
--- a/src/Upcast/SingleUpcasterBehavior.php
+++ b/src/Upcast/SingleUpcasterBehavior.php
@@ -23,5 +23,15 @@ public function upcast(IntermediateEvent $event): IntermediateEvent
->withRevision(revision: Revision::of(value: static::TO_REVISION));
}
+ /**
+ * Rewrites the serialized payload of an event being upcast.
+ *
+ *
Implemented by the consumer. Invoked by {@see upcast()} only when the event's type and revision
+ * match this upcaster's declared (type, from-revision) pair. The returned array becomes the new
+ * serialized payload at the upcaster's to-revision.
+ *
+ * @param array $payload The serialized event payload.
+ * @return array The rewritten payload.
+ */
abstract protected function rewrite(array $payload): array;
}
diff --git a/src/Upcast/Upcasters.php b/src/Upcast/Upcasters.php
index 2dd8d93..185fb68 100644
--- a/src/Upcast/Upcasters.php
+++ b/src/Upcast/Upcasters.php
@@ -8,12 +8,25 @@
final class Upcasters extends Collection
{
+ /**
+ * Folds every upcaster in this collection over the given event, returning the resulting event.
+ *
+ *
Each upcaster either advances the event by one (type, revision) step or returns it unchanged.
+ * Apply order follows the collection's iteration order, so callers must register upcasters in the
+ * order they should run.
+ *
+ * @param IntermediateEvent $event The event entering the chain.
+ * @return IntermediateEvent The event after every upcaster in the chain has been applied.
+ */
public function chain(IntermediateEvent $event): IntermediateEvent
{
$upcast = static function (IntermediateEvent $carried, Upcaster $upcaster): IntermediateEvent {
return $upcaster->upcast(event: $carried);
};
- return $this->reduce(accumulator: $upcast, initial: $event);
+ /** @var IntermediateEvent $upcasted */
+ $upcasted = $this->reduce(accumulator: $upcast, initial: $event);
+
+ return $upcasted;
}
}
From f6bcf829751c0cff98cc5103d97f4f2c26ae1df0 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Tue, 19 May 2026 00:33:56 -0300
Subject: [PATCH 4/5] test: Reorganize tests under tests/Unit and refresh
fixtures.
---
.../EventualAggregateRootBehaviorTest.php | 175 -------------
tests/Aggregate/ModelVersionTest.php | 95 -------
tests/Entity/CompoundIdentityBehaviorTest.php | 68 -----
tests/Event/SequenceNumberTest.php | 198 ---------------
tests/Models/AppointmentSlot.php | 18 ++
tests/Models/Cart.php | 4 -
tests/Models/CartWithLogger.php | 13 +-
tests/Models/ExplicitCart.php | 6 +-
tests/Models/Order.php | 24 +-
tests/Models/Reservation.php | 41 +++
tests/Models/ReservationBooked.php | 13 +
tests/Models/ReservationConfirmed.php | 13 +
tests/Models/ReservationId.php | 17 ++
tests/Snapshot/SnapshotDataTest.php | 65 -----
.../Aggregate/AggregateRootBehaviorTest.php | 56 +++--
tests/Unit/Aggregate/AggregateVersionTest.php | 234 ++++++++++++++++++
.../EventSourcingRootBehaviorTest.php | 52 ++--
.../EventualAggregateRootBehaviorTest.php | 224 +++++++++++++++++
tests/Unit/Aggregate/ModelVersionTest.php | 180 ++++++++++++++
.../Entity/CompoundIdentityBehaviorTest.php | 148 +++++++++++
.../{ => Unit}/Entity/EntityBehaviorTest.php | 12 +-
.../Entity/SingleIdentityBehaviorTest.php | 6 +-
.../Event/DomainEventBehaviorTest.php | 2 +-
tests/{ => Unit}/Event/EventRecordTest.php | 184 +++++++-------
tests/{ => Unit}/Event/EventRecordsTest.php | 27 +-
tests/{ => Unit}/Event/EventTypeTest.php | 19 +-
tests/{ => Unit}/Event/RevisionTest.php | 15 +-
tests/{Mocks => Unit}/FileSnapshotter.php | 2 +-
.../Snapshot/SnapshotConditionTest.php | 2 +-
.../{ => Unit}/Snapshot/SnapshotEveryTest.php | 44 ++--
.../{ => Unit}/Snapshot/SnapshotNeverTest.php | 14 +-
tests/{ => Unit}/Snapshot/SnapshotTest.php | 44 ++--
.../Snapshot/SnapshotterBehaviorTest.php | 36 ++-
tests/{ => Unit}/Upcast/DefaultValuesTest.php | 8 +-
.../Upcast/IntermediateEventTest.php | 30 ++-
.../Upcast/SingleUpcasterBehaviorTest.php | 2 +-
tests/{ => Unit}/Upcast/UpcastersTest.php | 2 +-
37 files changed, 1204 insertions(+), 889 deletions(-)
delete mode 100644 tests/Aggregate/EventualAggregateRootBehaviorTest.php
delete mode 100644 tests/Aggregate/ModelVersionTest.php
delete mode 100644 tests/Entity/CompoundIdentityBehaviorTest.php
delete mode 100644 tests/Event/SequenceNumberTest.php
create mode 100644 tests/Models/AppointmentSlot.php
create mode 100644 tests/Models/Reservation.php
create mode 100644 tests/Models/ReservationBooked.php
create mode 100644 tests/Models/ReservationConfirmed.php
create mode 100644 tests/Models/ReservationId.php
delete mode 100644 tests/Snapshot/SnapshotDataTest.php
rename tests/{ => Unit}/Aggregate/AggregateRootBehaviorTest.php (52%)
create mode 100644 tests/Unit/Aggregate/AggregateVersionTest.php
rename tests/{ => Unit}/Aggregate/EventSourcingRootBehaviorTest.php (88%)
create mode 100644 tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php
create mode 100644 tests/Unit/Aggregate/ModelVersionTest.php
create mode 100644 tests/Unit/Entity/CompoundIdentityBehaviorTest.php
rename tests/{ => Unit}/Entity/EntityBehaviorTest.php (94%)
rename tests/{ => Unit}/Entity/SingleIdentityBehaviorTest.php (89%)
rename tests/{ => Unit}/Event/DomainEventBehaviorTest.php (94%)
rename tests/{ => Unit}/Event/EventRecordTest.php (52%)
rename tests/{ => Unit}/Event/EventRecordsTest.php (72%)
rename tests/{ => Unit}/Event/EventTypeTest.php (85%)
rename tests/{ => Unit}/Event/RevisionTest.php (91%)
rename tests/{Mocks => Unit}/FileSnapshotter.php (91%)
rename tests/{ => Unit}/Snapshot/SnapshotConditionTest.php (97%)
rename tests/{ => Unit}/Snapshot/SnapshotEveryTest.php (74%)
rename tests/{ => Unit}/Snapshot/SnapshotNeverTest.php (72%)
rename tests/{ => Unit}/Snapshot/SnapshotTest.php (85%)
rename tests/{ => Unit}/Snapshot/SnapshotterBehaviorTest.php (66%)
rename tests/{ => Unit}/Upcast/DefaultValuesTest.php (80%)
rename tests/{ => Unit}/Upcast/IntermediateEventTest.php (92%)
rename tests/{ => Unit}/Upcast/SingleUpcasterBehaviorTest.php (98%)
rename tests/{ => Unit}/Upcast/UpcastersTest.php (98%)
diff --git a/tests/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Aggregate/EventualAggregateRootBehaviorTest.php
deleted file mode 100644
index 4e93ac2..0000000
--- a/tests/Aggregate/EventualAggregateRootBehaviorTest.php
+++ /dev/null
@@ -1,175 +0,0 @@
-sequenceNumber();
-
- /** @Then the sequence number is 1 */
- self::assertSame(1, $sequenceNumber->value);
- }
-
- public function testSequenceNumberAdvancesOnEverySubsequentEvent(): void
- {
- /** @Given a placed order */
- $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen');
-
- /** @And a shipping event emitted after placement */
- $order->ship(carrier: 'DHL');
-
- /** @When retrieving the sequence number */
- $sequenceNumber = $order->sequenceNumber();
-
- /** @Then the sequence number reflects every emitted event */
- self::assertSame(2, $sequenceNumber->value);
- }
-
- public function testRecordedEventsCountMatchesEmittedEvents(): void
- {
- /** @Given a placed order */
- $order = Order::place(orderId: new OrderId(value: 'ord-3'), item: 'lamp');
-
- /** @And a shipping event emitted after placement */
- $order->ship(carrier: 'FedEx');
-
- /** @When retrieving recorded events */
- $records = $order->recordedEvents();
-
- /** @Then the count matches the number of events */
- self::assertSame(2, $records->count());
- }
-
- public function testFirstRecordedEventCarriesPlacementMetadata(): void
- {
- /** @Given an identity for the placed order */
- $orderId = new OrderId(value: 'ord-4');
-
- /** @And a placed order emitting an OrderPlaced event */
- $order = Order::place(orderId: $orderId, item: 'chair');
-
- /** @When inspecting the first recorded record */
- $record = $order->recordedEvents()->first();
-
- /** @Then the envelope carries the placement metadata */
- self::assertSame('OrderPlaced', $record->type->value);
- self::assertSame(1, $record->revision->value);
- self::assertSame(1, $record->sequenceNumber->value);
- self::assertSame('Order', $record->aggregateType);
- self::assertInstanceOf(OrderPlaced::class, $record->event);
- self::assertSame($orderId, $record->identity);
- self::assertSame('chair', $record->event->item);
- }
-
- public function testSecondRecordedEventCarriesShippingMetadata(): void
- {
- /** @Given a placed order */
- $order = Order::place(orderId: new OrderId(value: 'ord-4b'), item: 'chair');
-
- /** @And a shipping event emitted after placement */
- $order->ship(carrier: 'UPS');
-
- /** @When inspecting the last recorded record */
- $record = $order->recordedEvents()->last();
-
- /** @Then the envelope carries the shipping metadata */
- self::assertSame('OrderShipped', $record->type->value);
- self::assertSame(2, $record->sequenceNumber->value);
- self::assertInstanceOf(OrderShipped::class, $record->event);
- self::assertSame('UPS', $record->event->carrier);
- }
-
- public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void
- {
- /** @Given an order with one recorded event */
- $order = Order::place(orderId: new OrderId(value: 'ord-6'), item: 'mug');
-
- /** @And an external mutation applied to the first retrieved copy */
- $order->recordedEvents()->add($order->recordedEvents()->first());
-
- /** @When retrieving the recorded events again */
- $secondCopy = $order->recordedEvents();
-
- /** @Then the aggregate's own buffer is unaffected by the external mutation */
- self::assertSame(1, $secondCopy->count());
- }
-
- public function testBufferAccumulatesAcrossOperationsWithoutClearing(): void
- {
- /** @Given a placed order whose events are still buffered */
- $order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle');
-
- /** @And the buffer drained without clearing, simulating a save that reads but does not reset */
- $firstBatch = $order->recordedEvents();
-
- /** @When a second operation emits a further event on the same instance */
- $order->ship(carrier: 'DHL');
-
- /** @Then the buffer accumulates events from both operations */
- self::assertSame(2, $order->recordedEvents()->count());
- self::assertSame(1, $firstBatch->count());
- }
-
- public function testSnapshotDataCapturesDomainStateOnEveryEvent(): void
- {
- /** @Given an order that transitioned to 'placed' */
- $order = Order::place(orderId: new OrderId(value: 'ord-9'), item: 'tray');
-
- /** @When inspecting the snapshot data carried by the event record */
- $state = $order->recordedEvents()->first()->snapshotData->toArray();
-
- /** @Then the domain status field is captured with its current value */
- self::assertSame('placed', $state['status']);
- }
-
- public function testSnapshotDataOmitsTransientRecordedEventsBuffer(): void
- {
- /** @Given an order that emits a placement event */
- $order = Order::place(orderId: new OrderId(value: 'ord-10'), item: 'tray');
-
- /** @When inspecting the persistable state attached to the record */
- $state = $order->recordedEvents()->first()->snapshotData->toArray();
-
- /** @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
deleted file mode 100644
index 7ae91ad..0000000
--- a/tests/Aggregate/ModelVersionTest.php
+++ /dev/null
@@ -1,95 +0,0 @@
-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
deleted file mode 100644
index 517bc2e..0000000
--- a/tests/Entity/CompoundIdentityBehaviorTest.php
+++ /dev/null
@@ -1,68 +0,0 @@
-identityValue();
-
- /** @Then both fields are returned in an associative array */
- self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value);
- }
-
- public function testEqualsReturnsTrueForIdenticalCompoundIdentities(): void
- {
- /** @Given two compound identities with identical field values */
- $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
-
- /** @And a matching counterpart */
- $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
-
- /** @When comparing them */
- $areEqual = $first->equals(other: $second);
-
- /** @Then they are considered equal */
- self::assertTrue($areEqual);
- }
-
- public function testEqualsReturnsFalseWhenTenantDiffers(): void
- {
- /** @Given two compound identities differing on the tenant */
- $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
-
- /** @And a counterpart with a different tenant */
- $second = new AppointmentId(tenantId: 'tenant-2', appointmentId: 'apt-1');
-
- /** @When comparing them */
- $areEqual = $first->equals(other: $second);
-
- /** @Then they are not equal */
- self::assertFalse($areEqual);
- }
-
- public function testEqualsReturnsFalseWhenAppointmentDiffers(): void
- {
- /** @Given two compound identities differing on the appointment */
- $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
-
- /** @And a counterpart with a different appointment */
- $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-2');
-
- /** @When comparing them */
- $areEqual = $first->equals(other: $second);
-
- /** @Then they are not equal */
- self::assertFalse($areEqual);
- }
-}
diff --git a/tests/Event/SequenceNumberTest.php b/tests/Event/SequenceNumberTest.php
deleted file mode 100644
index 75b30de..0000000
--- a/tests/Event/SequenceNumberTest.php
+++ /dev/null
@@ -1,198 +0,0 @@
-isPrivate());
- }
-
- public function testInitialYieldsZero(): void
- {
- /** @Given the initial-sequence factory */
- /** @When requesting the initial sequence number */
- $sequenceNumber = SequenceNumber::initial();
-
- /** @Then the value is zero */
- self::assertSame(0, $sequenceNumber->value);
- }
-
- public function testFirstYieldsOne(): void
- {
- /** @Given the first-sequence factory */
- /** @When requesting the first sequence number */
- $sequenceNumber = SequenceNumber::first();
-
- /** @Then the value is one */
- self::assertSame(1, $sequenceNumber->value);
- }
-
- public function testOfReturnsSequenceNumberWithGivenValue(): void
- {
- /** @Given a valid sequence number value */
- /** @When requesting a sequence number of that value */
- $sequenceNumber = SequenceNumber::of(value: 5);
-
- /** @Then the value matches */
- self::assertSame(5, $sequenceNumber->value);
- }
-
- public function testNextYieldsTheFollowingValue(): void
- {
- /** @Given a sequence number of 5 */
- $sequenceNumber = SequenceNumber::of(value: 5);
-
- /** @When advancing to the next */
- $next = $sequenceNumber->next();
-
- /** @Then the value is 6 */
- self::assertSame(6, $next->value);
- }
-
- public function testNextDoesNotMutateTheSource(): void
- {
- /** @Given a sequence number of 5 */
- $sequenceNumber = SequenceNumber::of(value: 5);
-
- /** @When advancing */
- $sequenceNumber->next();
-
- /** @Then the original is unchanged */
- self::assertSame(5, $sequenceNumber->value);
- }
-
- public function testIsAfterReturnsTrueWhenStrictlyGreater(): void
- {
- /** @Given a larger sequence number */
- $larger = SequenceNumber::of(value: 10);
-
- /** @And a smaller counterpart */
- $smaller = SequenceNumber::of(value: 5);
-
- /** @When checking if the larger is after the smaller */
- $isAfter = $larger->isAfter(other: $smaller);
-
- /** @Then the result is true */
- self::assertTrue($isAfter);
- }
-
- public function testIsAfterReturnsFalseWhenEqual(): void
- {
- /** @Given two equal sequence numbers */
- $first = SequenceNumber::of(value: 3);
-
- /** @And a counterpart with the same value */
- $second = SequenceNumber::of(value: 3);
-
- /** @When checking if one is strictly after the other */
- $isAfter = $first->isAfter(other: $second);
-
- /** @Then the result is false */
- self::assertFalse($isAfter);
- }
-
- public function testIsAfterReturnsFalseWhenStrictlySmaller(): void
- {
- /** @Given a smaller sequence number */
- $smaller = SequenceNumber::of(value: 2);
-
- /** @And a larger counterpart */
- $larger = SequenceNumber::of(value: 8);
-
- /** @When checking if the smaller is after the larger */
- $isAfter = $smaller->isAfter(other: $larger);
-
- /** @Then the result is false */
- self::assertFalse($isAfter);
- }
-
- public function testEqualsReturnsTrueForSameValue(): void
- {
- /** @Given two sequence numbers with the same value */
- $first = SequenceNumber::of(value: 7);
-
- /** @And a matching counterpart */
- $second = SequenceNumber::of(value: 7);
-
- /** @When comparing them */
- $areEqual = $first->equals(other: $second);
-
- /** @Then they are equal */
- self::assertTrue($areEqual);
- }
-
- public function testEqualsReturnsFalseForDifferentValues(): void
- {
- /** @Given two sequence numbers with different values */
- $first = SequenceNumber::of(value: 1);
-
- /** @And a distinct counterpart */
- $second = SequenceNumber::of(value: 2);
-
- /** @When comparing them */
- $areEqual = $first->equals(other: $second);
-
- /** @Then they are not equal */
- self::assertFalse($areEqual);
- }
-
- #[DataProvider('negativeValues')]
- public function testOfRejectsNegativeValue(int $negativeValue): void
- {
- /** @Given a value that violates the sequence-number invariant */
- /** @Then an InvalidSequenceNumber exception carrying the invalid value is thrown */
- $this->expectException(InvalidSequenceNumber::class);
- $this->expectExceptionMessage((string)$negativeValue);
-
- /** @When constructing with a negative value */
- SequenceNumber::of(value: $negativeValue);
- }
-
- public function testInvalidSequenceNumberIsCatchableAsInvalidArgumentException(): void
- {
- /** @Given consumer code catching the PHP-standard InvalidArgumentException */
- /** @Then InvalidSequenceNumber is caught by the standard exception type */
- $this->expectException(InvalidArgumentException::class);
-
- /** @When constructing with a negative value */
- SequenceNumber::of(value: -1);
- }
-
- public function testInvalidSequenceNumberMessageMentionsTheMinimumAllowed(): void
- {
- /** @Given a consumer inspecting the exception message */
- /** @Then the message mentions the minimum allowed value */
- $this->expectException(InvalidSequenceNumber::class);
- $this->expectExceptionMessage('greater than or equal to 0');
-
- /** @When constructing with a negative value */
- SequenceNumber::of(value: -1);
- }
-
- /**
- * @return array
- */
- public static function negativeValues(): array
- {
- return [
- 'minus one' => [-1],
- 'minus ten' => [-10]
- ];
- }
-}
diff --git a/tests/Models/AppointmentSlot.php b/tests/Models/AppointmentSlot.php
new file mode 100644
index 0000000..528e731
--- /dev/null
+++ b/tests/Models/AppointmentSlot.php
@@ -0,0 +1,18 @@
+ */
private array $productIds = [];
public static function withProducts(CartId $cartId, int $count): Cart
@@ -39,9 +38,6 @@ public function applySnapshot(Snapshot $snapshot): void
$this->productIds = $state['productIds'] ?? [];
}
- /**
- * @return list
- */
public function productIds(): array
{
return $this->productIds;
diff --git a/tests/Models/CartWithLogger.php b/tests/Models/CartWithLogger.php
index 242ece4..dbcb2fa 100644
--- a/tests/Models/CartWithLogger.php
+++ b/tests/Models/CartWithLogger.php
@@ -16,12 +16,11 @@ final class CartWithLogger implements EventSourcingRoot
private string $logBuffer = '';
- /** @var list */
private array $productIds = [];
public function addProduct(string $productId): void
{
- $this->logBuffer .= "Added: {$productId}";
+ $this->logBuffer .= "Added: $productId";
$this->when(event: new ProductAdded(productId: $productId));
}
@@ -33,19 +32,11 @@ public function applySnapshot(Snapshot $snapshot): void
public function snapshotState(): array
{
$state = get_object_vars($this);
- unset($state['recordedEvents'], $state['sequenceNumber'], $state['logBuffer']);
+ unset($state['recordedEvents'], $state['aggregateVersion'], $state['logBuffer']);
return $state;
}
- /**
- * @return list
- */
- public function productIds(): array
- {
- return $this->productIds;
- }
-
protected function identityProperty(): string
{
return 'cartId';
diff --git a/tests/Models/ExplicitCart.php b/tests/Models/ExplicitCart.php
index 14352e1..ddc9702 100644
--- a/tests/Models/ExplicitCart.php
+++ b/tests/Models/ExplicitCart.php
@@ -14,7 +14,6 @@ final class ExplicitCart implements EventSourcingRoot
private CartId $cartId;
- /** @var list */
private array $productIds = [];
public function addProduct(string $productId): void
@@ -35,7 +34,7 @@ public function applySnapshot(Snapshot $snapshot): void
public function eventHandlers(): array
{
return [
- ProductAdded::class => function (ProductAdded $event): void {
+ ProductAdded::class => function (ProductAdded $event): void {
$this->productIds[] = $event->productId;
},
ProductAddedV2::class => function (ProductAddedV2 $event): void {
@@ -44,9 +43,6 @@ public function eventHandlers(): array
];
}
- /**
- * @return list
- */
public function productIds(): array
{
return $this->productIds;
diff --git a/tests/Models/Order.php b/tests/Models/Order.php
index 45798ee..97ecee4 100644
--- a/tests/Models/Order.php
+++ b/tests/Models/Order.php
@@ -4,9 +4,11 @@
namespace Test\TinyBlocks\BuildingBlocks\Models;
+use InvalidArgumentException;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot;
use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
+use TinyBlocks\BuildingBlocks\Entity\Identity;
final class Order implements EventualAggregateRoot
{
@@ -14,14 +16,24 @@ final class Order implements EventualAggregateRoot
private string $status = 'draft';
- private function __construct(private OrderId $id)
+ private function __construct(private readonly OrderId $id)
{
}
- public static function reconstitute(OrderId $orderId, SequenceNumber $sequenceNumber): Order
- {
- $order = new Order(id: $orderId);
- $order->reconstituteSequenceNumber(sequenceNumber: $sequenceNumber);
+ public static function reconstitute(
+ Identity $identity,
+ AggregateVersion $aggregateVersion,
+ array $state = []
+ ): static {
+ if (!$identity instanceof OrderId) {
+ $template = 'Expected identity of type <%s>, got <%s>.';
+
+ throw new InvalidArgumentException(message: sprintf($template, OrderId::class, $identity::class));
+ }
+
+ $order = new Order(id: $identity);
+ $order->aggregateVersion = $aggregateVersion;
+
return $order;
}
diff --git a/tests/Models/Reservation.php b/tests/Models/Reservation.php
new file mode 100644
index 0000000..85d7933
--- /dev/null
+++ b/tests/Models/Reservation.php
@@ -0,0 +1,41 @@
+status = $status;
+ }
+
+ public static function book(ReservationId $id): Reservation
+ {
+ $reservation = new Reservation(id: $id, status: 'pending');
+ $reservation->push(event: new ReservationBooked());
+
+ return $reservation;
+ }
+
+ public function confirm(): void
+ {
+ if ($this->status !== 'pending') {
+ $template = 'Cannot confirm reservation in status <%s>.';
+
+ throw new RuntimeException(message: sprintf($template, $this->status));
+ }
+
+ $this->status = 'confirmed';
+ $this->push(event: new ReservationConfirmed());
+ }
+}
diff --git a/tests/Models/ReservationBooked.php b/tests/Models/ReservationBooked.php
new file mode 100644
index 0000000..b125d0d
--- /dev/null
+++ b/tests/Models/ReservationBooked.php
@@ -0,0 +1,13 @@
+ '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/Aggregate/AggregateRootBehaviorTest.php b/tests/Unit/Aggregate/AggregateRootBehaviorTest.php
similarity index 52%
rename from tests/Aggregate/AggregateRootBehaviorTest.php
rename to tests/Unit/Aggregate/AggregateRootBehaviorTest.php
index 24d681b..0e75b9b 100644
--- a/tests/Aggregate/AggregateRootBehaviorTest.php
+++ b/tests/Unit/Aggregate/AggregateRootBehaviorTest.php
@@ -2,27 +2,27 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Aggregate;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Aggregate;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\Cart;
use Test\TinyBlocks\BuildingBlocks\Models\CartId;
use Test\TinyBlocks\BuildingBlocks\Models\Order;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
final class AggregateRootBehaviorTest extends TestCase
{
- public function testSequenceNumberIsZeroForBlankAggregate(): void
+ public function testAggregateVersionIsZeroForBlankAggregate(): 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->sequenceNumber();
+ /** @When retrieving the aggregate version */
+ $aggregateVersion = $cart->aggregateVersion();
/** @Then it is zero */
- self::assertSame(0, $sequenceNumber->value);
+ self::assertSame(0, $aggregateVersion->value);
}
public function testModelVersionReflectsDeclaredValue(): void
@@ -49,51 +49,57 @@ public function testModelVersionDefaultsToZeroWhenUndefined(): void
self::assertSame(0, $version->value);
}
- public function testAggregateNameForEventSourcedAggregate(): void
+ public function testAggregateTypeForEventSourcedAggregate(): void
{
/** @Given a Cart aggregate */
$cart = Cart::blank(identity: new CartId(value: 'cart-3'));
- /** @When retrieving the aggregate name */
- $name = $cart->aggregateName();
+ /** @When retrieving the aggregate type */
+ $aggregateType = $cart->aggregateType();
/** @Then it matches the short class name */
- self::assertSame('Cart', $name);
+ self::assertSame('Cart', $aggregateType);
}
- public function testAggregateNameForOutboxAggregate(): void
+ public function testAggregateTypeForOutboxAggregate(): void
{
/** @Given an Order aggregate */
$order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'lamp');
- /** @When retrieving the aggregate name */
- $name = $order->aggregateName();
+ /** @When retrieving the aggregate type */
+ $aggregateType = $order->aggregateType();
/** @Then it matches the short class name */
- self::assertSame('Order', $name);
+ self::assertSame('Order', $aggregateType);
}
- public function testReconstitutedSequenceNumberMatchesPersistedValue(): void
+ public function testReconstitutedAggregateVersionMatchesPersistedValue(): void
{
- /** @Given an Order reconstituted with a persisted sequence number of 5 */
- $order = Order::reconstitute(orderId: new OrderId(value: 'ord-3'), sequenceNumber: SequenceNumber::of(value: 5));
+ /** @Given an Order reconstituted with a persisted aggregate version of 5 */
+ $order = Order::reconstitute(
+ identity: new OrderId(value: 'ord-3'),
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
- /** @When retrieving the sequence number */
- $sequenceNumber = $order->sequenceNumber();
+ /** @When retrieving the aggregate version */
+ $aggregateVersion = $order->aggregateVersion();
/** @Then it matches the persisted value */
- self::assertSame(5, $sequenceNumber->value);
+ self::assertSame(5, $aggregateVersion->value);
}
- public function testPushAfterReconstituteAdvancesSequenceByOne(): void
+ public function testPushAfterReconstituteAdvancesVersionByOne(): void
{
- /** @Given an Order reconstituted with a persisted sequence number of 5 */
- $order = Order::reconstitute(orderId: new OrderId(value: 'ord-4'), sequenceNumber: SequenceNumber::of(value: 5));
+ /** @Given an Order reconstituted with a persisted aggregate version of 5 */
+ $order = Order::reconstitute(
+ identity: new OrderId(value: 'ord-4'),
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
/** @When pushing a new event */
$order->ship(carrier: 'FedEx');
- /** @Then the sequence number advances by one */
- self::assertSame(6, $order->sequenceNumber()->value);
+ /** @Then the aggregate version advances by one */
+ self::assertSame(6, $order->aggregateVersion()->value);
}
}
diff --git a/tests/Unit/Aggregate/AggregateVersionTest.php b/tests/Unit/Aggregate/AggregateVersionTest.php
new file mode 100644
index 0000000..b34a884
--- /dev/null
+++ b/tests/Unit/Aggregate/AggregateVersionTest.php
@@ -0,0 +1,234 @@
+isPrivate());
+ }
+
+ public function testInitialYieldsZero(): void
+ {
+ /** @When requesting the initial aggregate version */
+ $aggregateVersion = AggregateVersion::initial();
+
+ /** @Then the value is zero */
+ self::assertSame(0, $aggregateVersion->value);
+ }
+
+ public function testFirstYieldsOne(): void
+ {
+ /** @When requesting the first aggregate version */
+ $aggregateVersion = AggregateVersion::first();
+
+ /** @Then the value is one */
+ self::assertSame(1, $aggregateVersion->value);
+ }
+
+ public function testOfReturnsAggregateVersionWithGivenValue(): void
+ {
+ /** @When requesting an aggregate version of that value */
+ $aggregateVersion = AggregateVersion::of(value: 5);
+
+ /** @Then the value matches */
+ self::assertSame(5, $aggregateVersion->value);
+ }
+
+ public function testNextYieldsTheFollowingValue(): void
+ {
+ /** @Given an aggregate version of 5 */
+ $aggregateVersion = AggregateVersion::of(value: 5);
+
+ /** @When advancing to the next */
+ $next = $aggregateVersion->next();
+
+ /** @Then the value is 6 */
+ self::assertSame(6, $next->value);
+ }
+
+ public function testNextDoesNotMutateTheSource(): void
+ {
+ /** @Given an aggregate version of 5 */
+ $aggregateVersion = AggregateVersion::of(value: 5);
+
+ /** @When advancing */
+ $aggregateVersion->next();
+
+ /** @Then the original is unchanged */
+ self::assertSame(5, $aggregateVersion->value);
+ }
+
+ public function testIsAfterReturnsTrueWhenStrictlyGreater(): void
+ {
+ /** @Given a larger aggregate version */
+ $larger = AggregateVersion::of(value: 10);
+
+ /** @And a smaller counterpart */
+ $smaller = AggregateVersion::of(value: 5);
+
+ /** @When checking if the larger is after the smaller */
+ $isAfter = $larger->isAfter(other: $smaller);
+
+ /** @Then the result is true */
+ self::assertTrue($isAfter);
+ }
+
+ public function testIsAfterReturnsFalseWhenEqual(): void
+ {
+ /** @Given two equal aggregate versions */
+ $first = AggregateVersion::of(value: 3);
+
+ /** @And a counterpart with the same value */
+ $second = AggregateVersion::of(value: 3);
+
+ /** @When checking if one is strictly after the other */
+ $isAfter = $first->isAfter(other: $second);
+
+ /** @Then the result is false */
+ self::assertFalse($isAfter);
+ }
+
+ public function testIsAfterReturnsFalseWhenStrictlySmaller(): void
+ {
+ /** @Given a smaller aggregate version */
+ $smaller = AggregateVersion::of(value: 2);
+
+ /** @And a larger counterpart */
+ $larger = AggregateVersion::of(value: 8);
+
+ /** @When checking if the smaller is after the larger */
+ $isAfter = $smaller->isAfter(other: $larger);
+
+ /** @Then the result is false */
+ self::assertFalse($isAfter);
+ }
+
+ public function testIsBeforeReturnsTrueWhenValueIsLess(): void
+ {
+ /** @Given a smaller aggregate version */
+ $smaller = AggregateVersion::of(value: 4);
+
+ /** @And a larger counterpart */
+ $larger = AggregateVersion::of(value: 9);
+
+ /** @When checking if the smaller is before the larger */
+ $isBefore = $smaller->isBefore(other: $larger);
+
+ /** @Then the result is true */
+ self::assertTrue($isBefore);
+ }
+
+ public function testIsBeforeReturnsFalseWhenValuesAreEqual(): void
+ {
+ /** @Given two equal aggregate versions */
+ $first = AggregateVersion::of(value: 6);
+
+ /** @And a counterpart with the same value */
+ $second = AggregateVersion::of(value: 6);
+
+ /** @When checking if one is strictly before the other */
+ $isBefore = $first->isBefore(other: $second);
+
+ /** @Then the result is false */
+ self::assertFalse($isBefore);
+ }
+
+ public function testIsBeforeReturnsFalseWhenValueIsGreater(): void
+ {
+ /** @Given a larger aggregate version */
+ $larger = AggregateVersion::of(value: 12);
+
+ /** @And a smaller counterpart */
+ $smaller = AggregateVersion::of(value: 3);
+
+ /** @When checking if the larger is before the smaller */
+ $isBefore = $larger->isBefore(other: $smaller);
+
+ /** @Then the result is false */
+ self::assertFalse($isBefore);
+ }
+
+ public function testEqualsReturnsTrueForSameValue(): void
+ {
+ /** @Given two aggregate versions with the same value */
+ $first = AggregateVersion::of(value: 7);
+
+ /** @And a matching counterpart */
+ $second = AggregateVersion::of(value: 7);
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($areEqual);
+ }
+
+ public function testEqualsReturnsFalseForDifferentValues(): void
+ {
+ /** @Given two aggregate versions with different values */
+ $first = AggregateVersion::of(value: 1);
+
+ /** @And a distinct counterpart */
+ $second = AggregateVersion::of(value: 2);
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ #[DataProvider('negativeValues')]
+ public function testOfRejectsNegativeValue(int $negativeValue): void
+ {
+ /** @Then an InvalidAggregateVersion exception carrying the invalid value is thrown */
+ $this->expectException(InvalidAggregateVersion::class);
+ $this->expectExceptionMessage((string)$negativeValue);
+
+ /** @When constructing with a negative value */
+ AggregateVersion::of(value: $negativeValue);
+ }
+
+ public function testInvalidAggregateVersionIsCatchableAsInvalidArgumentException(): void
+ {
+ /** @Then InvalidAggregateVersion is caught by the standard exception type */
+ $this->expectException(InvalidArgumentException::class);
+
+ /** @When constructing with a negative value */
+ AggregateVersion::of(value: -1);
+ }
+
+ public function testInvalidAggregateVersionMessageMentionsTheMinimumAllowed(): void
+ {
+ /** @Then the message mentions the minimum allowed value */
+ $this->expectException(InvalidAggregateVersion::class);
+ $this->expectExceptionMessage('greater than or equal to 0');
+
+ /** @When constructing with a negative value */
+ AggregateVersion::of(value: -1);
+ }
+
+ public static function negativeValues(): array
+ {
+ return [
+ 'minus one' => [-1],
+ 'minus ten' => [-10]
+ ];
+ }
+}
diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php
similarity index 88%
rename from tests/Aggregate/EventSourcingRootBehaviorTest.php
rename to tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php
index 9e7479e..d8be17a 100644
--- a/tests/Aggregate/EventSourcingRootBehaviorTest.php
+++ b/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Aggregate;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Aggregate;
use LogicException;
use PHPUnit\Framework\TestCase;
@@ -18,7 +18,7 @@
final class EventSourcingRootBehaviorTest extends TestCase
{
- public function testBlankAggregateStartsWithInitialSequenceNumber(): void
+ public function testBlankAggregateStartsWithInitialAggregateVersion(): void
{
/** @Given a cart identity */
$cartId = new CartId(value: 'cart-1');
@@ -26,8 +26,8 @@ public function testBlankAggregateStartsWithInitialSequenceNumber(): void
/** @When creating a blank cart */
$cart = Cart::blank(identity: $cartId);
- /** @Then the aggregate starts at sequence number zero */
- self::assertSame(0, $cart->sequenceNumber()->value);
+ /** @Then the aggregate starts at aggregate version zero */
+ self::assertSame(0, $cart->aggregateVersion()->value);
}
public function testBlankAggregateStartsWithEmptyDomainState(): void
@@ -78,7 +78,7 @@ public function testDomainOperationAppliesStateFromEmittedEvent(): void
self::assertSame(['prod-1'], $cart->productIds());
}
- public function testDomainOperationAdvancesSequenceNumber(): void
+ public function testDomainOperationAdvancesAggregateVersion(): void
{
/** @Given a blank cart */
$cart = Cart::blank(identity: new CartId(value: 'cart-3'));
@@ -89,8 +89,8 @@ public function testDomainOperationAdvancesSequenceNumber(): void
/** @And adding a second product */
$cart->addProduct(productId: 'prod-2');
- /** @Then the sequence number equals the number of events */
- self::assertSame(2, $cart->sequenceNumber()->value);
+ /** @Then the aggregate version equals the number of events */
+ self::assertSame(2, $cart->aggregateVersion()->value);
}
public function testDomainOperationAppendsToRecordedEvents(): void
@@ -120,12 +120,12 @@ public function testFirstRecordedEventCarriesEnvelopeMetadata(): void
$record = $cart->recordedEvents()->first();
/** @Then the envelope carries the expected metadata */
- self::assertSame('ProductAdded', $record->type->value);
+ self::assertSame('ProductAdded', $record->eventType->value);
self::assertSame(1, $record->revision->value);
- self::assertSame(1, $record->sequenceNumber->value);
+ self::assertSame(1, $record->aggregateVersion->value);
self::assertSame('Cart', $record->aggregateType);
self::assertInstanceOf(ProductAdded::class, $record->event);
- self::assertSame($cartId, $record->identity);
+ self::assertSame($cartId, $record->aggregateId);
self::assertSame('prod-abc', $record->event->productId);
}
@@ -168,7 +168,7 @@ public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream
self::assertSame(['zebra', 'apple', 'mango'], $reconstituted->productIds());
}
- public function testReconstituteAdvancesSequenceNumberToLastEvent(): void
+ public function testReconstituteAdvancesAggregateVersionToLastEvent(): void
{
/** @Given a cart identity */
$cartId = new CartId(value: 'cart-6c');
@@ -179,8 +179,8 @@ public function testReconstituteAdvancesSequenceNumberToLastEvent(): void
/** @When reconstituting from the event stream */
$reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
- /** @Then the sequence number equals the last event's */
- self::assertSame(2, $reconstituted->sequenceNumber()->value);
+ /** @Then the aggregate version equals the last event's */
+ self::assertSame(2, $reconstituted->aggregateVersion()->value);
}
public function testReconstituteWithEmptyStreamYieldsBlankState(): void
@@ -195,7 +195,7 @@ public function testReconstituteWithEmptyStreamYieldsBlankState(): void
self::assertSame([], $reconstituted->productIds());
}
- public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): void
+ public function testReconstituteWithEmptyStreamYieldsInitialAggregateVersion(): void
{
/** @Given a cart identity and no events */
$cartId = new CartId(value: 'cart-7b');
@@ -203,8 +203,8 @@ public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): vo
/** @When reconstituting with no events */
$reconstituted = Cart::reconstitute(identity: $cartId, records: []);
- /** @Then the sequence number remains at the initial value */
- self::assertSame(0, $reconstituted->sequenceNumber()->value);
+ /** @Then the aggregate version remains at the initial value */
+ self::assertSame(0, $reconstituted->aggregateVersion()->value);
}
public function testReconstituteFromSnapshotRestoresDomainState(): void
@@ -228,7 +228,7 @@ public function testReconstituteFromSnapshotRestoresDomainState(): void
self::assertSame(['prod-snapshot'], $reconstituted->productIds());
}
- public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): void
+ public function testReconstituteFromSnapshotAppliesTheSnapshotAggregateVersion(): void
{
/** @Given a cart identity */
$cartId = new CartId(value: 'cart-8b');
@@ -245,8 +245,8 @@ public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber():
/** @When reconstituting from the snapshot only */
$reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
- /** @Then the sequence number matches the snapshot's */
- self::assertSame(1, $reconstituted->sequenceNumber()->value);
+ /** @Then the aggregate version matches the snapshot's */
+ self::assertSame(1, $reconstituted->aggregateVersion()->value);
}
public function testReconstituteCombinesSnapshotWithLaterEvents(): void
@@ -268,8 +268,8 @@ 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->sequenceNumber()
+ predicates: static fn($record): bool => $record->aggregateVersion->isAfter(
+ other: $snapshot->aggregateVersion()
)
);
@@ -280,7 +280,7 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void
self::assertSame(['prod-1', 'prod-2'], $reconstituted->productIds());
}
- public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequence(): void
+ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesAggregateVersion(): void
{
/** @Given a cart identity */
$cartId = new CartId(value: 'cart-8d');
@@ -299,16 +299,16 @@ 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->sequenceNumber()
+ predicates: static fn($record): bool => $record->aggregateVersion->isAfter(
+ other: $snapshot->aggregateVersion()
)
);
/** @When reconstituting from the snapshot and the later records */
$reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot);
- /** @Then the sequence number reflects the last applied event */
- self::assertSame(2, $reconstituted->sequenceNumber()->value);
+ /** @Then the aggregate version reflects the last applied event */
+ self::assertSame(2, $reconstituted->aggregateVersion()->value);
}
public function testReconstitutedAggregateHasNoRecordedEvents(): void
diff --git a/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php
new file mode 100644
index 0000000..a37daf4
--- /dev/null
+++ b/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php
@@ -0,0 +1,224 @@
+aggregateVersion();
+
+ /** @Then the aggregate version is 1 */
+ self::assertSame(1, $aggregateVersion->value);
+ }
+
+ public function testAggregateVersionAdvancesOnEverySubsequentEvent(): void
+ {
+ /** @Given a placed order */
+ $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen');
+
+ /** @And a shipping event emitted after placement */
+ $order->ship(carrier: 'DHL');
+
+ /** @When retrieving the aggregate version */
+ $aggregateVersion = $order->aggregateVersion();
+
+ /** @Then the aggregate version reflects every emitted event */
+ self::assertSame(2, $aggregateVersion->value);
+ }
+
+ public function testRecordedEventsCountMatchesEmittedEvents(): void
+ {
+ /** @Given a placed order */
+ $order = Order::place(orderId: new OrderId(value: 'ord-3'), item: 'lamp');
+
+ /** @And a shipping event emitted after placement */
+ $order->ship(carrier: 'FedEx');
+
+ /** @When retrieving recorded events */
+ $records = $order->recordedEvents();
+
+ /** @Then the count matches the number of events */
+ self::assertSame(2, $records->count());
+ }
+
+ public function testFirstRecordedEventCarriesPlacementMetadata(): void
+ {
+ /** @Given an identity for the placed order */
+ $orderId = new OrderId(value: 'ord-4');
+
+ /** @And a placed order emitting an OrderPlaced event */
+ $order = Order::place(orderId: $orderId, item: 'chair');
+
+ /** @When inspecting the first recorded record */
+ $record = $order->recordedEvents()->first();
+
+ /** @Then the envelope carries the placement metadata */
+ self::assertSame('OrderPlaced', $record->eventType->value);
+ self::assertSame(1, $record->revision->value);
+ self::assertSame(1, $record->aggregateVersion->value);
+ self::assertSame('Order', $record->aggregateType);
+ self::assertInstanceOf(OrderPlaced::class, $record->event);
+ self::assertSame($orderId, $record->aggregateId);
+ self::assertSame('chair', $record->event->item);
+ }
+
+ public function testSecondRecordedEventCarriesShippingMetadata(): void
+ {
+ /** @Given a placed order */
+ $order = Order::place(orderId: new OrderId(value: 'ord-4b'), item: 'chair');
+
+ /** @And a shipping event emitted after placement */
+ $order->ship(carrier: 'UPS');
+
+ /** @When inspecting the last recorded record */
+ $record = $order->recordedEvents()->last();
+
+ /** @Then the envelope carries the shipping metadata */
+ self::assertSame('OrderShipped', $record->eventType->value);
+ self::assertSame(2, $record->aggregateVersion->value);
+ self::assertInstanceOf(OrderShipped::class, $record->event);
+ self::assertSame('UPS', $record->event->carrier);
+ }
+
+ public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void
+ {
+ /** @Given an order with one recorded event */
+ $order = Order::place(orderId: new OrderId(value: 'ord-6'), item: 'mug');
+
+ /** @And an external mutation applied to the first retrieved copy */
+ $order->recordedEvents()->add($order->recordedEvents()->first());
+
+ /** @When retrieving the recorded events again */
+ $secondCopy = $order->recordedEvents();
+
+ /** @Then the aggregate's own buffer is unaffected by the external mutation */
+ self::assertSame(1, $secondCopy->count());
+ }
+
+ public function testBufferAccumulatesAcrossOperationsWithoutClearing(): void
+ {
+ /** @Given a placed order whose events are still buffered */
+ $order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle');
+
+ /** @And the buffer drained without clearing, simulating a save that reads but does not reset */
+ $firstBatch = $order->recordedEvents();
+
+ /** @When a second operation emits a further event on the same instance */
+ $order->ship(carrier: 'DHL');
+
+ /** @Then the buffer accumulates events from both operations */
+ self::assertSame(2, $order->recordedEvents()->count());
+ self::assertSame(1, $firstBatch->count());
+ }
+
+ public function testReconstituteRestoresIdentityWhenNoStateIsProvided(): void
+ {
+ /** @Given an identity to assign to the reconstituted aggregate */
+ $reservationId = new ReservationId(value: 'res-1');
+
+ /** @When reconstituting via the trait default with no state */
+ $reservation = Reservation::reconstitute(
+ identity: $reservationId,
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
+
+ /** @Then the identity is restored on the reconstituted aggregate */
+ self::assertTrue($reservation->identity()->equals(other: $reservationId));
+ }
+
+ public function testReconstituteRestoresAggregateVersionForNextEvent(): void
+ {
+ /** @Given a reservation reconstituted at version 5 with pending status */
+ $reservation = Reservation::reconstitute(
+ identity: new ReservationId(value: 'res-1'),
+ aggregateVersion: AggregateVersion::of(value: 5),
+ state: ['status' => 'pending']
+ );
+
+ /** @When confirming the reservation */
+ $reservation->confirm();
+
+ /** @Then the next recorded event carries version 6 */
+ self::assertSame(6, $reservation->recordedEvents()->first()->aggregateVersion->value);
+ }
+
+ public function testReconstituteHydratesStateSoCommandsBehaveCorrectly(): void
+ {
+ /** @Given a reservation reconstituted in confirmed status */
+ $reservation = Reservation::reconstitute(
+ identity: new ReservationId(value: 'res-1'),
+ aggregateVersion: AggregateVersion::of(value: 5),
+ state: ['status' => 'confirmed']
+ );
+
+ /** @Then a RuntimeException is raised because state was correctly restored */
+ $this->expectException(RuntimeException::class);
+
+ /** @When attempting to confirm an already confirmed reservation */
+ $reservation->confirm();
+ }
+
+ public function testReconstituteSilentlyIgnoresUnknownStateKeys(): void
+ {
+ /** @Given an identity for the aggregate */
+ $reservationId = new ReservationId(value: 'res-1');
+
+ /** @When reconstituting with a state map carrying a key absent from the aggregate */
+ $reservation = Reservation::reconstitute(
+ identity: $reservationId,
+ aggregateVersion: AggregateVersion::of(value: 5),
+ state: ['status' => 'pending', 'unknownProperty' => 'value']
+ );
+
+ /** @Then no exception is raised and the known identity is still restored */
+ self::assertTrue($reservation->identity()->equals(other: $reservationId));
+ }
+
+ public function testOrderReconstituteRejectsForeignIdentityType(): void
+ {
+ /** @Given an identity belonging to a foreign aggregate type */
+ $foreignIdentity = new ReservationId(value: 'res-1');
+
+ /** @Then an exception indicating the wrong identity type should be thrown */
+ $this->expectException(InvalidArgumentException::class);
+
+ /** @When reconstituting an Order with a non-OrderId identity */
+ Order::reconstitute(identity: $foreignIdentity, aggregateVersion: AggregateVersion::of(value: 1));
+ }
+
+ public function testOrderReconstituteRestoresVersionForNextEvent(): void
+ {
+ /** @Given an Order reconstituted at version 9 */
+ $order = Order::reconstitute(
+ identity: new OrderId(value: 'ord-rec-1'),
+ aggregateVersion: AggregateVersion::of(value: 9)
+ );
+
+ /** @And a shipping event emitted on the reconstituted instance */
+ $order->ship(carrier: 'DHL');
+
+ /** @When inspecting the recorded event */
+ $record = $order->recordedEvents()->first();
+
+ /** @Then the event carries version 10 */
+ self::assertSame(10, $record->aggregateVersion->value);
+ }
+}
diff --git a/tests/Unit/Aggregate/ModelVersionTest.php b/tests/Unit/Aggregate/ModelVersionTest.php
new file mode 100644
index 0000000..d10e859
--- /dev/null
+++ b/tests/Unit/Aggregate/ModelVersionTest.php
@@ -0,0 +1,180 @@
+value);
+ }
+
+ public function testOfReturnsVersionWithGivenValue(): void
+ {
+ /** @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 testIsAfterReturnsTrueWhenValueIsGreater(): void
+ {
+ /** @Given a larger model version */
+ $larger = ModelVersion::of(value: 5);
+
+ /** @And a smaller counterpart */
+ $smaller = ModelVersion::of(value: 2);
+
+ /** @When checking if the larger is after the smaller */
+ $isAfter = $larger->isAfter(other: $smaller);
+
+ /** @Then the result is true */
+ self::assertTrue($isAfter);
+ }
+
+ public function testIsAfterReturnsFalseWhenValuesAreEqual(): void
+ {
+ /** @Given two equal model versions */
+ $first = ModelVersion::of(value: 4);
+
+ /** @And a counterpart with the same value */
+ $second = ModelVersion::of(value: 4);
+
+ /** @When checking if one is strictly after the other */
+ $isAfter = $first->isAfter(other: $second);
+
+ /** @Then the result is false */
+ self::assertFalse($isAfter);
+ }
+
+ public function testIsAfterReturnsFalseWhenValueIsLess(): void
+ {
+ /** @Given a smaller model version */
+ $smaller = ModelVersion::of(value: 1);
+
+ /** @And a larger counterpart */
+ $larger = ModelVersion::of(value: 8);
+
+ /** @When checking if the smaller is after the larger */
+ $isAfter = $smaller->isAfter(other: $larger);
+
+ /** @Then the result is false */
+ self::assertFalse($isAfter);
+ }
+
+ public function testIsBeforeReturnsTrueWhenValueIsLess(): void
+ {
+ /** @Given a smaller model version */
+ $smaller = ModelVersion::of(value: 3);
+
+ /** @And a larger counterpart */
+ $larger = ModelVersion::of(value: 7);
+
+ /** @When checking if the smaller is before the larger */
+ $isBefore = $smaller->isBefore(other: $larger);
+
+ /** @Then the result is true */
+ self::assertTrue($isBefore);
+ }
+
+ public function testIsBeforeReturnsFalseWhenValuesAreEqual(): void
+ {
+ /** @Given two equal model versions */
+ $first = ModelVersion::of(value: 9);
+
+ /** @And a counterpart with the same value */
+ $second = ModelVersion::of(value: 9);
+
+ /** @When checking if one is strictly before the other */
+ $isBefore = $first->isBefore(other: $second);
+
+ /** @Then the result is false */
+ self::assertFalse($isBefore);
+ }
+
+ public function testIsBeforeReturnsFalseWhenValueIsGreater(): void
+ {
+ /** @Given a larger model version */
+ $larger = ModelVersion::of(value: 10);
+
+ /** @And a smaller counterpart */
+ $smaller = ModelVersion::of(value: 2);
+
+ /** @When checking if the larger is before the smaller */
+ $isBefore = $larger->isBefore(other: $smaller);
+
+ /** @Then the result is false */
+ self::assertFalse($isBefore);
+ }
+
+ public function testOfRejectsNegativeValue(): void
+ {
+ /** @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
+ {
+ /** @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
+ {
+ /** @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/Unit/Entity/CompoundIdentityBehaviorTest.php b/tests/Unit/Entity/CompoundIdentityBehaviorTest.php
new file mode 100644
index 0000000..60b9936
--- /dev/null
+++ b/tests/Unit/Entity/CompoundIdentityBehaviorTest.php
@@ -0,0 +1,148 @@
+identityValue();
+
+ /** @Then both fields are returned in an associative array */
+ self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $identityValue);
+ }
+
+ public function testEqualsReturnsTrueForIdenticalCompoundIdentities(): void
+ {
+ /** @Given two compound identities with identical field values */
+ $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @And a matching counterpart */
+ $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are considered equal */
+ self::assertTrue($areEqual);
+ }
+
+ public function testEqualsReturnsFalseWhenTenantDiffers(): void
+ {
+ /** @Given two compound identities differing on the tenant */
+ $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @And a counterpart with a different tenant */
+ $second = new AppointmentId(tenantId: 'tenant-2', appointmentId: 'apt-1');
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ public function testEqualsReturnsFalseWhenAppointmentDiffers(): void
+ {
+ /** @Given two compound identities differing on the appointment */
+ $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @And a counterpart with a different appointment */
+ $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-2');
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ public function testIdentityValueReturnsFieldsPreservingScalarAndObjectTypes(): void
+ {
+ /** @Given a revision for the slot */
+ $revision = Revision::of(value: 7);
+
+ /** @And a compound identity with mixed scalar and object fields */
+ $slot = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: $revision);
+
+ /** @When retrieving the identity value */
+ $identityValue = $slot->identityValue();
+
+ /** @Then each field is present preserving its original scalar and object types */
+ self::assertSame(
+ ['tenantId' => 'tenant-1', 'practitionerId' => 42, 'revision' => $revision],
+ $identityValue
+ );
+ }
+
+ public function testEqualsReturnsTrueForCompoundIdentitiesWithIdenticalMixedFields(): void
+ {
+ /** @Given a compound identity with mixed types */
+ $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7));
+
+ /** @And a counterpart with identical field values */
+ $second = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7));
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are considered equal */
+ self::assertTrue($areEqual);
+ }
+
+ public function testEqualsReturnsFalseWhenObjectFieldDiffers(): void
+ {
+ /** @Given a compound identity */
+ $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7));
+
+ /** @And a counterpart differing only on the object-typed field */
+ $second = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 8));
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ public function testEqualsReturnsFalseWhenIntegerFieldDiffers(): void
+ {
+ /** @Given a compound identity */
+ $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7));
+
+ /** @And a counterpart differing only on the integer field */
+ $second = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 43, revision: Revision::of(value: 7));
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ public function testEqualsReturnsFalseWhenStringFieldDiffers(): void
+ {
+ /** @Given a compound identity */
+ $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7));
+
+ /** @And a counterpart differing only on the string field */
+ $second = new AppointmentSlot(tenantId: 'tenant-2', practitionerId: 42, revision: Revision::of(value: 7));
+
+ /** @When comparing them */
+ $areEqual = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+}
diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Unit/Entity/EntityBehaviorTest.php
similarity index 94%
rename from tests/Entity/EntityBehaviorTest.php
rename to tests/Unit/Entity/EntityBehaviorTest.php
index 3988b07..de52d5f 100644
--- a/tests/Entity/EntityBehaviorTest.php
+++ b/tests/Unit/Entity/EntityBehaviorTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Entity;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Entity;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\AppointmentId;
@@ -11,7 +11,7 @@
use Test\TinyBlocks\BuildingBlocks\Models\Order;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
use Test\TinyBlocks\BuildingBlocks\Models\OrderWithMissingIdentityProperty;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty;
+use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty;
final class EntityBehaviorTest extends TestCase
{
@@ -60,10 +60,10 @@ public function testIdentityValueReturnsScalarForSingleIdentity(): void
$order = Order::place(orderId: new OrderId(value: 'ord-42'), item: 'pen');
/** @When retrieving the identity value */
- $value = $order->identityValue();
+ $identityValue = $order->identityValue();
/** @Then the raw scalar is returned */
- self::assertSame('ord-42', $value);
+ self::assertSame('ord-42', $identityValue);
}
public function testIdentityValueReturnsAssociativeArrayForCompoundIdentity(): void
@@ -72,10 +72,10 @@ public function testIdentityValueReturnsAssociativeArrayForCompoundIdentity(): v
$appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
/** @When retrieving the identity value */
- $value = $appointmentId->identityValue();
+ $identityValue = $appointmentId->identityValue();
/** @Then an associative array with all fields is returned */
- self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value);
+ self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $identityValue);
}
public function testSameIdentityOfReturnsTrueForAggregatesWithEqualIdentity(): void
diff --git a/tests/Entity/SingleIdentityBehaviorTest.php b/tests/Unit/Entity/SingleIdentityBehaviorTest.php
similarity index 89%
rename from tests/Entity/SingleIdentityBehaviorTest.php
rename to tests/Unit/Entity/SingleIdentityBehaviorTest.php
index cd3fe2d..a833481 100644
--- a/tests/Entity/SingleIdentityBehaviorTest.php
+++ b/tests/Unit/Entity/SingleIdentityBehaviorTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Entity;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Entity;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
@@ -15,10 +15,10 @@ public function testIdentityValueReturnsTheSingleScalarField(): void
$orderId = new OrderId(value: 'ord-1');
/** @When retrieving the identity value */
- $value = $orderId->identityValue();
+ $identityValue = $orderId->identityValue();
/** @Then the scalar value is returned as-is */
- self::assertSame('ord-1', $value);
+ self::assertSame('ord-1', $identityValue);
}
public function testEqualsReturnsTrueForIdenticalSingleIdentities(): void
diff --git a/tests/Event/DomainEventBehaviorTest.php b/tests/Unit/Event/DomainEventBehaviorTest.php
similarity index 94%
rename from tests/Event/DomainEventBehaviorTest.php
rename to tests/Unit/Event/DomainEventBehaviorTest.php
index ff7f40e..bf60f75 100644
--- a/tests/Event/DomainEventBehaviorTest.php
+++ b/tests/Unit/Event/DomainEventBehaviorTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Event;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
diff --git a/tests/Event/EventRecordTest.php b/tests/Unit/Event/EventRecordTest.php
similarity index 52%
rename from tests/Event/EventRecordTest.php
rename to tests/Unit/Event/EventRecordTest.php
index a0aab13..763685b 100644
--- a/tests/Event/EventRecordTest.php
+++ b/tests/Unit/Event/EventRecordTest.php
@@ -2,92 +2,111 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Event;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
final class EventRecordTest extends TestCase
{
public function testEventRecordExposesEveryConstructorField(): void
{
- /** @Given every required field for an EventRecord */
+ /** @Given an event identifier */
$id = Uuid::uuid4();
+
+ /** @And an aggregate identity */
$orderId = new OrderId(value: 'ord-1');
+
+ /** @And a domain event */
$placedEvent = new OrderPlaced(item: 'book');
+
+ /** @And the matching event type */
$eventType = EventType::fromString(value: 'OrderPlaced');
+
+ /** @And the initial revision */
$revision = Revision::initial();
- $occurredOn = Instant::now();
- $snapshotData = new SnapshotData(payload: ['status' => 'placed']);
- $sequenceNumber = SequenceNumber::first();
+
+ /** @And the occurrence timestamp */
+ $occurredAt = Instant::now();
+
+ /** @And the first aggregate version */
+ $aggregateVersion = AggregateVersion::first();
/** @When constructing the EventRecord */
$record = new EventRecord(
id: $id,
- type: $eventType,
event: $placedEvent,
- identity: $orderId,
revision: $revision,
- occurredOn: $occurredOn,
- snapshotData: $snapshotData,
+ eventType: $eventType,
+ occurredAt: $occurredAt,
+ aggregateId: $orderId,
aggregateType: 'Order',
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @Then each public field is accessible with the expected value */
self::assertSame($id, $record->id);
- self::assertSame($eventType, $record->type);
+ self::assertSame($eventType, $record->eventType);
self::assertSame($placedEvent, $record->event);
- self::assertSame($orderId, $record->identity);
+ self::assertSame($orderId, $record->aggregateId);
self::assertSame($revision, $record->revision);
- self::assertSame($occurredOn, $record->occurredOn);
- self::assertSame($snapshotData, $record->snapshotData);
+ self::assertSame($occurredAt, $record->occurredAt);
self::assertSame('Order', $record->aggregateType);
- self::assertSame($sequenceNumber, $record->sequenceNumber);
+ self::assertSame($aggregateVersion, $record->aggregateVersion);
}
public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void
{
- /** @Given shared values for two EventRecord instances */
+ /** @Given an event identifier */
$id = Uuid::uuid4();
+
+ /** @And an aggregate identity */
$orderId = new OrderId(value: 'ord-1');
+
+ /** @And a domain event */
$placedEvent = new OrderPlaced(item: 'book');
+
+ /** @And the matching event type */
$eventType = EventType::fromString(value: 'OrderPlaced');
+
+ /** @And the initial revision */
$revision = Revision::initial();
- $occurredOn = Instant::now();
- $snapshotData = new SnapshotData(payload: []);
- $sequenceNumber = SequenceNumber::first();
- /** @And two records constructed from those identical values */
+ /** @And the occurrence timestamp */
+ $occurredAt = Instant::now();
+
+ /** @And the first aggregate version */
+ $aggregateVersion = AggregateVersion::first();
+
+ /** @And a first record built from those values */
$first = new EventRecord(
id: $id,
- type: $eventType,
event: $placedEvent,
- identity: $orderId,
revision: $revision,
- occurredOn: $occurredOn,
- snapshotData: $snapshotData,
+ eventType: $eventType,
+ occurredAt: $occurredAt,
+ aggregateId: $orderId,
aggregateType: 'Order',
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
+
+ /** @And a second record built from the same values */
$second = new EventRecord(
id: $id,
- type: $eventType,
event: $placedEvent,
- identity: $orderId,
revision: $revision,
- occurredOn: $occurredOn,
- snapshotData: $snapshotData,
+ eventType: $eventType,
+ occurredAt: $occurredAt,
+ aggregateId: $orderId,
aggregateType: 'Order',
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @When comparing them */
@@ -99,37 +118,46 @@ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void
public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void
{
- /** @Given shared values except the identifier */
+ /** @Given an aggregate identity */
$orderId = new OrderId(value: 'ord-1');
+
+ /** @And a domain event */
$placedEvent = new OrderPlaced(item: 'book');
+
+ /** @And the matching event type */
$eventType = EventType::fromString(value: 'OrderPlaced');
+
+ /** @And the initial revision */
$revision = Revision::initial();
- $occurredOn = Instant::now();
- $snapshotData = new SnapshotData(payload: []);
- $sequenceNumber = SequenceNumber::first();
- /** @And two records with different UUIDs */
+ /** @And the occurrence timestamp */
+ $occurredAt = Instant::now();
+
+ /** @And the first aggregate version */
+ $aggregateVersion = AggregateVersion::first();
+
+ /** @And a first record with a unique identifier */
$first = new EventRecord(
id: Uuid::uuid4(),
- type: $eventType,
event: $placedEvent,
- identity: $orderId,
revision: $revision,
- occurredOn: $occurredOn,
- snapshotData: $snapshotData,
+ eventType: $eventType,
+ occurredAt: $occurredAt,
+ aggregateId: $orderId,
aggregateType: 'Order',
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
+
+ /** @And a second record with a different identifier */
$second = new EventRecord(
id: Uuid::uuid4(),
- type: $eventType,
event: $placedEvent,
- identity: $orderId,
revision: $revision,
- occurredOn: $occurredOn,
- snapshotData: $snapshotData,
+ eventType: $eventType,
+ occurredAt: $occurredAt,
+ aggregateId: $orderId,
aggregateType: 'Order',
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @When comparing them */
@@ -141,71 +169,61 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void
public function testOfFactoryBuildsRecordWithRequiredFields(): void
{
- /** @Given required fields for a record built via the factory */
+ /** @Given an aggregate identity */
$orderId = new OrderId(value: 'ord-of-1');
+
+ /** @And a domain event */
$placedEvent = new OrderPlaced(item: 'notebook');
- $sequenceNumber = SequenceNumber::first();
+
+ /** @And the first aggregate version */
+ $aggregateVersion = AggregateVersion::first();
/** @When building the record via the factory */
$record = EventRecord::of(
event: $placedEvent,
- identity: $orderId,
+ aggregateId: $orderId,
aggregateType: 'Order',
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @Then the envelope carries the expected metadata */
- self::assertSame('OrderPlaced', $record->type->value);
+ self::assertSame('OrderPlaced', $record->eventType->value);
self::assertSame(1, $record->revision->value);
self::assertSame($placedEvent, $record->event);
- self::assertSame($orderId, $record->identity);
+ self::assertSame($orderId, $record->aggregateId);
self::assertSame('Order', $record->aggregateType);
- self::assertSame($sequenceNumber, $record->sequenceNumber);
+ self::assertSame($aggregateVersion, $record->aggregateVersion);
}
public function testOfFactoryUsesProvidedOptionalFields(): void
{
- /** @Given a specific id, timestamp, and snapshot data */
+ /** @Given an explicit identifier */
$id = Uuid::uuid4();
+
+ /** @And an aggregate identity */
$orderId = new OrderId(value: 'ord-of-2');
+
+ /** @And a domain event */
$placedEvent = new OrderPlaced(item: 'pen');
- $occurredOn = Instant::now();
- $snapshotData = new SnapshotData(payload: ['status' => 'placed']);
- $sequenceNumber = SequenceNumber::first();
+
+ /** @And an explicit occurrence timestamp */
+ $occurredAt = Instant::now();
+
+ /** @And the first aggregate version */
+ $aggregateVersion = AggregateVersion::first();
/** @When building the record via the factory with all optional fields */
$record = EventRecord::of(
event: $placedEvent,
- identity: $orderId,
+ aggregateId: $orderId,
aggregateType: 'Order',
- sequenceNumber: $sequenceNumber,
+ aggregateVersion: $aggregateVersion,
id: $id,
- occurredOn: $occurredOn,
- snapshotData: $snapshotData
+ occurredAt: $occurredAt
);
/** @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());
+ self::assertSame($occurredAt, $record->occurredAt);
}
}
diff --git a/tests/Event/EventRecordsTest.php b/tests/Unit/Event/EventRecordsTest.php
similarity index 72%
rename from tests/Event/EventRecordsTest.php
rename to tests/Unit/Event/EventRecordsTest.php
index c67fc2f..d84065c 100644
--- a/tests/Event/EventRecordsTest.php
+++ b/tests/Unit/Event/EventRecordsTest.php
@@ -2,18 +2,17 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Event;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
-use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData;
use TinyBlocks\Time\Instant;
final class EventRecordsTest extends TestCase
@@ -38,14 +37,13 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void
/** @And a freshly built event record */
$record = new EventRecord(
id: Uuid::uuid4(),
- type: EventType::fromString(value: 'OrderPlaced'),
event: new OrderPlaced(item: 'book'),
- identity: new OrderId(value: 'ord-1'),
revision: Revision::initial(),
- occurredOn: Instant::now(),
- snapshotData: new SnapshotData(payload: []),
+ eventType: EventType::fromString(value: 'OrderPlaced'),
+ occurredAt: Instant::now(),
+ aggregateId: new OrderId(value: 'ord-1'),
aggregateType: 'Order',
- sequenceNumber: SequenceNumber::first()
+ aggregateVersion: AggregateVersion::first()
);
/** @When adding the record */
@@ -57,18 +55,19 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void
public function testFirstElementRoundTripsTheAddedRecord(): void
{
- /** @Given a record added to an empty EventRecords collection */
+ /** @Given a freshly built event record */
$record = new EventRecord(
id: Uuid::uuid4(),
- type: EventType::fromString(value: 'OrderPlaced'),
event: new OrderPlaced(item: 'book'),
- identity: new OrderId(value: 'ord-1'),
revision: Revision::initial(),
- occurredOn: Instant::now(),
- snapshotData: new SnapshotData(payload: []),
+ eventType: EventType::fromString(value: 'OrderPlaced'),
+ occurredAt: Instant::now(),
+ aggregateId: new OrderId(value: 'ord-1'),
aggregateType: 'Order',
- sequenceNumber: SequenceNumber::first()
+ aggregateVersion: AggregateVersion::first()
);
+
+ /** @And a collection carrying that record */
$records = EventRecords::createFromEmpty()->add($record);
/** @When retrieving the first element */
diff --git a/tests/Event/EventTypeTest.php b/tests/Unit/Event/EventTypeTest.php
similarity index 85%
rename from tests/Event/EventTypeTest.php
rename to tests/Unit/Event/EventTypeTest.php
index 537479c..070fbb7 100644
--- a/tests/Event/EventTypeTest.php
+++ b/tests/Unit/Event/EventTypeTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Event;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -10,7 +10,7 @@
use ReflectionMethod;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
use TinyBlocks\BuildingBlocks\Event\EventType;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidEventType;
+use TinyBlocks\BuildingBlocks\Exceptions\InvalidEventType;
final class EventTypeTest extends TestCase
{
@@ -38,7 +38,6 @@ public function testFromEventUsesTheShortClassNameOfTheDomainEvent(): void
public function testFromStringAcceptsValidPascalCase(): void
{
- /** @Given a valid PascalCase string */
/** @When creating an EventType from the string */
$eventType = EventType::fromString(value: 'OrderShipped');
@@ -79,7 +78,6 @@ public function testEqualsReturnsFalseForDifferentValues(): void
#[DataProvider('invalidPatterns')]
public function testFromStringRejectsValuesNotMatchingPattern(string $invalidValue): void
{
- /** @Given a value that violates the event-type pattern */
/** @Then an InvalidEventType exception mentioning the pattern is thrown */
$this->expectException(InvalidEventType::class);
$this->expectExceptionMessage('does not match the required pattern');
@@ -90,7 +88,6 @@ public function testFromStringRejectsValuesNotMatchingPattern(string $invalidVal
public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): void
{
- /** @Given consumer code catching the PHP-standard InvalidArgumentException */
/** @Then InvalidEventType is caught by the standard exception type */
$this->expectException(InvalidArgumentException::class);
@@ -100,7 +97,6 @@ public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): voi
public function testInvalidEventTypeCarriesTheOffendingValue(): void
{
- /** @Given a consumer inspecting the exception message */
/** @Then the offending value is included in the message */
$this->expectException(InvalidEventType::class);
$this->expectExceptionMessage('lowercaseStart');
@@ -109,17 +105,14 @@ public function testInvalidEventTypeCarriesTheOffendingValue(): void
EventType::fromString(value: 'lowercaseStart');
}
- /**
- * @return array
- */
public static function invalidPatterns(): array
{
return [
- 'lowercase start' => ['orderPlaced'],
- 'contains spaces' => ['Order Placed'],
- 'empty string' => [''],
+ 'lowercase start' => ['orderPlaced'],
+ 'contains spaces' => ['Order Placed'],
+ 'empty string' => [''],
'contains underscore' => ['Order_Placed'],
- 'single character' => ['O']
+ 'single character' => ['O']
];
}
}
diff --git a/tests/Event/RevisionTest.php b/tests/Unit/Event/RevisionTest.php
similarity index 91%
rename from tests/Event/RevisionTest.php
rename to tests/Unit/Event/RevisionTest.php
index 31d3b4c..4413fb0 100644
--- a/tests/Event/RevisionTest.php
+++ b/tests/Unit/Event/RevisionTest.php
@@ -2,14 +2,14 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Event;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use TinyBlocks\BuildingBlocks\Event\Revision;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidRevision;
+use TinyBlocks\BuildingBlocks\Exceptions\InvalidRevision;
final class RevisionTest extends TestCase
{
@@ -25,7 +25,6 @@ public function testConstructorIsPrivate(): void
public function testInitialReturnsRevisionOfOne(): void
{
- /** @Given the initial revision factory */
/** @When requesting the initial revision */
$revision = Revision::initial();
@@ -35,7 +34,6 @@ public function testInitialReturnsRevisionOfOne(): void
public function testOfReturnsRevisionWithGivenValue(): void
{
- /** @Given a valid revision value */
/** @When requesting a revision of that value */
$revision = Revision::of(value: 42);
@@ -45,7 +43,6 @@ public function testOfReturnsRevisionWithGivenValue(): void
public function testOfStoresTheMinimumValidValue(): void
{
- /** @Given the minimum valid revision value */
/** @When constructing the revision via factory */
$revision = Revision::of(value: 1);
@@ -176,7 +173,6 @@ public function testIsBeforeReturnsFalseWhenValueIsGreater(): void
#[DataProvider('invalidValues')]
public function testOfRejectsNonPositiveValue(int $invalidValue): void
{
- /** @Given a value that violates the revision invariant */
/** @Then an InvalidRevision exception carrying the invalid value is thrown */
$this->expectException(InvalidRevision::class);
$this->expectExceptionMessage((string)$invalidValue);
@@ -187,7 +183,6 @@ public function testOfRejectsNonPositiveValue(int $invalidValue): void
public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void
{
- /** @Given consumer code catching the PHP-standard InvalidArgumentException */
/** @Then InvalidRevision is caught by the standard exception type */
$this->expectException(InvalidArgumentException::class);
@@ -197,7 +192,6 @@ public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void
public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void
{
- /** @Given a consumer inspecting the exception message */
/** @Then the message mentions the minimum allowed value */
$this->expectException(InvalidRevision::class);
$this->expectExceptionMessage('greater than or equal to 1');
@@ -206,13 +200,10 @@ public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void
Revision::of(value: 0);
}
- /**
- * @return array
- */
public static function invalidValues(): array
{
return [
- 'zero' => [0],
+ 'zero' => [0],
'negative one' => [-1],
'negative ten' => [-10]
];
diff --git a/tests/Mocks/FileSnapshotter.php b/tests/Unit/FileSnapshotter.php
similarity index 91%
rename from tests/Mocks/FileSnapshotter.php
rename to tests/Unit/FileSnapshotter.php
index 6dd157c..d01fae3 100644
--- a/tests/Mocks/FileSnapshotter.php
+++ b/tests/Unit/FileSnapshotter.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Mocks;
+namespace Test\TinyBlocks\BuildingBlocks\Unit;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshotter;
diff --git a/tests/Snapshot/SnapshotConditionTest.php b/tests/Unit/Snapshot/SnapshotConditionTest.php
similarity index 97%
rename from tests/Snapshot/SnapshotConditionTest.php
rename to tests/Unit/Snapshot/SnapshotConditionTest.php
index d8caa58..a8dfd79 100644
--- a/tests/Snapshot/SnapshotConditionTest.php
+++ b/tests/Unit/Snapshot/SnapshotConditionTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Snapshot;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\Cart;
diff --git a/tests/Snapshot/SnapshotEveryTest.php b/tests/Unit/Snapshot/SnapshotEveryTest.php
similarity index 74%
rename from tests/Snapshot/SnapshotEveryTest.php
rename to tests/Unit/Snapshot/SnapshotEveryTest.php
index 3d7b1d3..6db41b9 100644
--- a/tests/Snapshot/SnapshotEveryTest.php
+++ b/tests/Unit/Snapshot/SnapshotEveryTest.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Snapshot;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\Cart;
use Test\TinyBlocks\BuildingBlocks\Models\CartId;
-use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidSnapshotCount;
+use TinyBlocks\BuildingBlocks\Exceptions\InvalidSnapshotCount;
use TinyBlocks\BuildingBlocks\Snapshot\SnapshotEvery;
final class SnapshotEveryTest extends TestCase
@@ -18,10 +18,10 @@ public function testReturnsFalseForBlankAggregateWithCountHundred(): void
$cart = Cart::blank(identity: new CartId(value: 'cart-snap-1'));
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is false because sequence zero is excluded */
- self::assertFalse($result);
+ self::assertFalse($shouldSnapshot);
}
public function testReturnsTrueAtSequenceHundred(): void
@@ -30,10 +30,10 @@ public function testReturnsTrueAtSequenceHundred(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-2'), count: 100);
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is true */
- self::assertTrue($result);
+ self::assertTrue($shouldSnapshot);
}
public function testReturnsTrueAtSequenceTwoHundred(): void
@@ -42,10 +42,10 @@ public function testReturnsTrueAtSequenceTwoHundred(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-3'), count: 200);
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is true */
- self::assertTrue($result);
+ self::assertTrue($shouldSnapshot);
}
public function testReturnsTrueAtSequenceThreeHundred(): void
@@ -54,10 +54,10 @@ public function testReturnsTrueAtSequenceThreeHundred(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-4'), count: 300);
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is true */
- self::assertTrue($result);
+ self::assertTrue($shouldSnapshot);
}
public function testReturnsFalseAtSequenceOne(): void
@@ -66,10 +66,10 @@ public function testReturnsFalseAtSequenceOne(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-5'), count: 1);
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is false */
- self::assertFalse($result);
+ self::assertFalse($shouldSnapshot);
}
public function testReturnsFalseAtSequenceNinetyNine(): void
@@ -78,10 +78,10 @@ public function testReturnsFalseAtSequenceNinetyNine(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-6'), count: 99);
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is false */
- self::assertFalse($result);
+ self::assertFalse($shouldSnapshot);
}
public function testReturnsFalseAtSequenceHundredOne(): void
@@ -90,10 +90,10 @@ public function testReturnsFalseAtSequenceHundredOne(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-7'), count: 101);
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is false */
- self::assertFalse($result);
+ self::assertFalse($shouldSnapshot);
}
public function testReturnsFalseAtSequenceOneNinetyNine(): void
@@ -102,10 +102,10 @@ public function testReturnsFalseAtSequenceOneNinetyNine(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-8'), count: 199);
/** @When asking a count-100 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart);
/** @Then the result is false */
- self::assertFalse($result);
+ self::assertFalse($shouldSnapshot);
}
public function testReturnsTrueForCountOneAtSequenceOne(): void
@@ -114,10 +114,10 @@ public function testReturnsTrueForCountOneAtSequenceOne(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-9'), count: 1);
/** @When asking a count-1 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart);
/** @Then the result is true */
- self::assertTrue($result);
+ self::assertTrue($shouldSnapshot);
}
public function testReturnsTrueForCountOneAtSequenceTwo(): void
@@ -126,10 +126,10 @@ public function testReturnsTrueForCountOneAtSequenceTwo(): void
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-10'), count: 2);
/** @When asking a count-1 condition whether to snapshot */
- $result = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart);
/** @Then the result is true */
- self::assertTrue($result);
+ self::assertTrue($shouldSnapshot);
}
public function testThrowsWhenCountIsZero(): void
diff --git a/tests/Snapshot/SnapshotNeverTest.php b/tests/Unit/Snapshot/SnapshotNeverTest.php
similarity index 72%
rename from tests/Snapshot/SnapshotNeverTest.php
rename to tests/Unit/Snapshot/SnapshotNeverTest.php
index b2e2f6d..5fa4f19 100644
--- a/tests/Snapshot/SnapshotNeverTest.php
+++ b/tests/Unit/Snapshot/SnapshotNeverTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Snapshot;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\Cart;
@@ -17,22 +17,22 @@ public function testReturnsFalseForBlankAggregate(): void
$cart = Cart::blank(identity: new CartId(value: 'cart-never-1'));
/** @When asking the SnapshotNever condition whether to snapshot */
- $result = SnapshotNever::create()->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotNever::create()->shouldSnapshot(aggregate: $cart);
/** @Then the result is always false */
- self::assertFalse($result);
+ self::assertFalse($shouldSnapshot);
}
- public function testReturnsFalseForAggregateAtHighSequenceNumber(): void
+ public function testReturnsFalseForAggregateAtHighAggregateVersion(): void
{
- /** @Given a cart at sequence 1000 */
+ /** @Given a cart at aggregate version 1000 */
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-never-2'), count: 1000);
/** @When asking the SnapshotNever condition whether to snapshot */
- $result = SnapshotNever::create()->shouldSnapshot(aggregate: $cart);
+ $shouldSnapshot = SnapshotNever::create()->shouldSnapshot(aggregate: $cart);
/** @Then the result is always false */
- self::assertFalse($result);
+ self::assertFalse($shouldSnapshot);
}
public function testTwoInstancesAreEqualUnderLooseComparison(): void
diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Unit/Snapshot/SnapshotTest.php
similarity index 85%
rename from tests/Snapshot/SnapshotTest.php
rename to tests/Unit/Snapshot/SnapshotTest.php
index b21852f..9f78b87 100644
--- a/tests/Snapshot/SnapshotTest.php
+++ b/tests/Unit/Snapshot/SnapshotTest.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Snapshot;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\Cart;
use Test\TinyBlocks\BuildingBlocks\Models\CartId;
use Test\TinyBlocks\BuildingBlocks\Models\CartWithLogger;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
use TinyBlocks\Time\Instant;
@@ -25,8 +25,8 @@ public function testFromAggregateCapturesAggregateType(): void
/** @When taking a snapshot */
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
- /** @Then the type matches the aggregate's short class name */
- self::assertSame('Cart', $snapshot->type());
+ /** @Then the aggregate type matches the aggregate's short class name */
+ self::assertSame('Cart', $snapshot->aggregateType());
}
public function testFromAggregateCapturesAggregateId(): void
@@ -41,7 +41,7 @@ public function testFromAggregateCapturesAggregateId(): void
self::assertSame('cart-id-42', $snapshot->aggregateId());
}
- public function testFromAggregateCapturesSequenceNumber(): void
+ public function testFromAggregateCapturesAggregateVersion(): void
{
/** @Given a cart with two products added */
$cart = Cart::withProducts(cartId: new CartId(value: 'cart-2'), count: 2);
@@ -49,8 +49,8 @@ public function testFromAggregateCapturesSequenceNumber(): void
/** @When taking a snapshot */
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
- /** @Then the sequence number is captured */
- self::assertSame(2, $snapshot->sequenceNumber()->value);
+ /** @Then the aggregate version is captured */
+ self::assertSame(2, $snapshot->aggregateVersion()->value);
}
public function testFromAggregateCapturesCreatedAt(): void
@@ -95,7 +95,7 @@ public function testFromAggregateStateOmitsRecordedEventsBuffer(): void
self::assertArrayNotHasKey('recordedEvents', $state);
}
- public function testFromAggregateStateOmitsSequenceNumber(): void
+ public function testFromAggregateStateOmitsAggregateVersion(): void
{
/** @Given a blank cart */
$cart = Cart::blank(identity: new CartId(value: 'cart-6'));
@@ -106,8 +106,8 @@ public function testFromAggregateStateOmitsSequenceNumber(): void
/** @When taking a snapshot */
$state = Snapshot::fromAggregate(aggregate: $cart)->aggregateState();
- /** @Then the sequence number is not duplicated into the state */
- self::assertArrayNotHasKey('sequenceNumber', $state);
+ /** @Then the aggregate version is not duplicated into the state */
+ self::assertArrayNotHasKey('aggregateVersion', $state);
}
public function testRoundTripThroughSnapshotRestoresDomainState(): void
@@ -169,28 +169,28 @@ public function testFromAggregateWithOverriddenSnapshotStateExcludesInfrastructu
public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void
{
- /** @Given a sequence number at its first value */
- $sequenceNumber = SequenceNumber::first();
+ /** @Given an aggregate version at its first value */
+ $aggregateVersion = AggregateVersion::first();
/** @And a known creation timestamp */
$createdAt = Instant::now();
/** @And the first snapshot built from those fields */
$first = Snapshot::restore(
- type: 'Cart',
+ aggregateType: 'Cart',
createdAt: $createdAt,
aggregateId: 'cart-1',
aggregateState: ['productIds' => []],
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @And the second snapshot built from the same fields */
$second = Snapshot::restore(
- type: 'Cart',
+ aggregateType: 'Cart',
createdAt: $createdAt,
aggregateId: 'cart-1',
aggregateState: ['productIds' => []],
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @When comparing them */
@@ -202,28 +202,28 @@ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void
public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void
{
- /** @Given a sequence number at its first value */
- $sequenceNumber = SequenceNumber::first();
+ /** @Given an aggregate version at its first value */
+ $aggregateVersion = AggregateVersion::first();
/** @And a known creation timestamp */
$createdAt = Instant::now();
/** @And the first snapshot with type Cart */
$first = Snapshot::restore(
- type: 'Cart',
+ aggregateType: 'Cart',
createdAt: $createdAt,
aggregateId: 'cart-1',
aggregateState: [],
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @And the second snapshot with type Order */
$second = Snapshot::restore(
- type: 'Order',
+ aggregateType: 'Order',
createdAt: $createdAt,
aggregateId: 'cart-1',
aggregateState: [],
- sequenceNumber: $sequenceNumber
+ aggregateVersion: $aggregateVersion
);
/** @When comparing them */
diff --git a/tests/Snapshot/SnapshotterBehaviorTest.php b/tests/Unit/Snapshot/SnapshotterBehaviorTest.php
similarity index 66%
rename from tests/Snapshot/SnapshotterBehaviorTest.php
rename to tests/Unit/Snapshot/SnapshotterBehaviorTest.php
index fb13d67..f35eb9a 100644
--- a/tests/Snapshot/SnapshotterBehaviorTest.php
+++ b/tests/Unit/Snapshot/SnapshotterBehaviorTest.php
@@ -2,21 +2,25 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Snapshot;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\Cart;
use Test\TinyBlocks\BuildingBlocks\Models\CartId;
-use Test\TinyBlocks\BuildingBlocks\Mocks\FileSnapshotter;
+use Test\TinyBlocks\BuildingBlocks\Unit\FileSnapshotter;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
final class SnapshotterBehaviorTest extends TestCase
{
public function testTakePersistsASnapshotForTheAggregate(): void
{
- /** @Given a cart with state and a fresh snapshotter */
+ /** @Given a blank cart */
$cart = Cart::blank(identity: new CartId(value: 'cart-1'));
+
+ /** @And a product added */
$cart->addProduct(productId: 'prod-1');
+
+ /** @And a fresh snapshotter */
$snapshotter = new FileSnapshotter();
/** @When taking a snapshot */
@@ -28,42 +32,52 @@ public function testTakePersistsASnapshotForTheAggregate(): void
public function testPersistedSnapshotReflectsTheAggregateType(): void
{
- /** @Given a cart and a fresh snapshotter */
+ /** @Given a blank cart */
$cart = Cart::blank(identity: new CartId(value: 'cart-2'));
+
+ /** @And a fresh snapshotter */
$snapshotter = new FileSnapshotter();
/** @When taking a snapshot */
$snapshotter->take(aggregate: $cart);
/** @Then the persisted snapshot carries the aggregate's type */
- self::assertSame('Cart', $snapshotter->lastSnapshot()->type());
+ self::assertSame('Cart', $snapshotter->lastSnapshot()?->aggregateType());
}
- public function testPersistedSnapshotReflectsTheAggregateSequenceNumber(): void
+ public function testPersistedSnapshotReflectsTheAggregateVersion(): void
{
- /** @Given a cart advanced to sequence number 2 */
+ /** @Given a blank cart */
$cart = Cart::blank(identity: new CartId(value: 'cart-3'));
+
+ /** @And a first product added */
$cart->addProduct(productId: 'prod-1');
+
+ /** @And a second product added */
$cart->addProduct(productId: 'prod-2');
+
+ /** @And a fresh snapshotter */
$snapshotter = new FileSnapshotter();
/** @When taking a snapshot */
$snapshotter->take(aggregate: $cart);
- /** @Then the persisted snapshot carries the aggregate's sequence number */
- self::assertSame(2, $snapshotter->lastSnapshot()->sequenceNumber()->value);
+ /** @Then the persisted snapshot carries the aggregate's version */
+ self::assertSame(2, $snapshotter->lastSnapshot()?->aggregateVersion()->value);
}
public function testPersistedSnapshotReflectsTheAggregateIdentity(): void
{
- /** @Given a cart with a known identity */
+ /** @Given a blank cart with a known identity */
$cart = Cart::blank(identity: new CartId(value: 'cart-4'));
+
+ /** @And a fresh snapshotter */
$snapshotter = new FileSnapshotter();
/** @When taking a snapshot */
$snapshotter->take(aggregate: $cart);
/** @Then the persisted snapshot carries the aggregate id */
- self::assertSame('cart-4', $snapshotter->lastSnapshot()->aggregateId());
+ self::assertSame('cart-4', $snapshotter->lastSnapshot()?->aggregateId());
}
}
diff --git a/tests/Upcast/DefaultValuesTest.php b/tests/Unit/Upcast/DefaultValuesTest.php
similarity index 80%
rename from tests/Upcast/DefaultValuesTest.php
rename to tests/Unit/Upcast/DefaultValuesTest.php
index e2af751..330982d 100644
--- a/tests/Upcast/DefaultValuesTest.php
+++ b/tests/Unit/Upcast/DefaultValuesTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Upcast;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast;
use PHPUnit\Framework\TestCase;
use TinyBlocks\BuildingBlocks\Upcast\DefaultValues;
@@ -11,8 +11,7 @@ final class DefaultValuesTest extends TestCase
{
public function testDefaultsMapYieldsTheZeroValueForEachPrimitiveType(): void
{
- /** @Given the primitive defaults map */
- /** @When retrieving it */
+ /** @When retrieving the primitive defaults map */
$defaults = DefaultValues::get();
/** @Then each primitive type maps to its zero-value */
@@ -25,8 +24,7 @@ public function testDefaultsMapYieldsTheZeroValueForEachPrimitiveType(): void
public function testDefaultsMapContainsOnlyPrimitiveTypeKeys(): void
{
- /** @Given the primitive defaults map */
- /** @When retrieving it */
+ /** @When retrieving the primitive defaults map */
$defaults = DefaultValues::get();
/** @Then only primitive type keys are present */
diff --git a/tests/Upcast/IntermediateEventTest.php b/tests/Unit/Upcast/IntermediateEventTest.php
similarity index 92%
rename from tests/Upcast/IntermediateEventTest.php
rename to tests/Unit/Upcast/IntermediateEventTest.php
index 900df90..eb67bf6 100644
--- a/tests/Upcast/IntermediateEventTest.php
+++ b/tests/Unit/Upcast/IntermediateEventTest.php
@@ -2,21 +2,25 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Upcast;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast;
use PHPUnit\Framework\TestCase;
+use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
-use TinyBlocks\BuildingBlocks\Event\SequenceNumber;
use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent;
final class IntermediateEventTest extends TestCase
{
public function testIntermediateEventExposesEveryConstructorField(): void
{
- /** @Given every required field for an IntermediateEvent */
+ /** @Given an event type */
$eventType = EventType::fromString(value: 'ProductAdded');
+
+ /** @And the initial revision */
$revision = Revision::initial();
+
+ /** @And a serialized event payload */
$serializedEvent = ['productId' => 'prod-1'];
/** @When constructing the intermediate event */
@@ -113,13 +117,19 @@ public function testWithSerializedEventPreservesTheTypeAndRevision(): void
public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void
{
- /** @Given shared fields for two intermediate events */
+ /** @Given an event type */
$eventType = EventType::fromString(value: 'ProductAdded');
+
+ /** @And the initial revision */
$revision = Revision::initial();
+
+ /** @And a serialized event payload */
$payload = ['productId' => 'prod-1'];
- /** @And two intermediate events with identical values */
+ /** @And a first intermediate event built from those values */
$first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload);
+
+ /** @And a second intermediate event built from the same values */
$second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload);
/** @When comparing them */
@@ -131,12 +141,16 @@ public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void
public function testEqualsReturnsFalseForDifferentPayloads(): void
{
- /** @Given two intermediate events with different payloads */
+ /** @Given an event type */
$eventType = EventType::fromString(value: 'ProductAdded');
+
+ /** @And the initial revision */
$revision = Revision::initial();
- /** @And the two events constructed accordingly */
+ /** @And a first event carrying payload a */
$first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']);
+
+ /** @And a second event carrying payload b */
$second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'b']);
/** @When comparing them */
@@ -208,7 +222,7 @@ public function testEqualsReturnsFalseWhenOtherIsDifferentValueObjectType(): voi
);
/** @And a value object of a different class */
- $otherValueObject = SequenceNumber::first();
+ $otherValueObject = AggregateVersion::first();
/** @When comparing them */
$areEqual = $event->equals(other: $otherValueObject);
diff --git a/tests/Upcast/SingleUpcasterBehaviorTest.php b/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php
similarity index 98%
rename from tests/Upcast/SingleUpcasterBehaviorTest.php
rename to tests/Unit/Upcast/SingleUpcasterBehaviorTest.php
index 1b09712..ec7e298 100644
--- a/tests/Upcast/SingleUpcasterBehaviorTest.php
+++ b/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Upcast;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\ProductV1Upcaster;
diff --git a/tests/Upcast/UpcastersTest.php b/tests/Unit/Upcast/UpcastersTest.php
similarity index 98%
rename from tests/Upcast/UpcastersTest.php
rename to tests/Unit/Upcast/UpcastersTest.php
index 665c1ee..fa0c6ed 100644
--- a/tests/Upcast/UpcastersTest.php
+++ b/tests/Unit/Upcast/UpcastersTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\BuildingBlocks\Upcast;
+namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\BuildingBlocks\Models\ProductV1Upcaster;
From d13728f36d9d3bab285289feffcbaf5dbadf309b Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Tue, 19 May 2026 00:34:03 -0300
Subject: [PATCH 5/5] docs: Expand README and add repository templates.
---
.github/ISSUE_TEMPLATE/bug_report.md | 29 ++
.github/ISSUE_TEMPLATE/feature_request.md | 17 ++
.github/PULL_REQUEST_TEMPLATE.md | 16 +
README.md | 357 +++++++++++++++++++---
SECURITY.md | 12 +
5 files changed, 390 insertions(+), 41 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md
create mode 100644 .github/PULL_REQUEST_TEMPLATE.md
create mode 100644 SECURITY.md
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..8ddd1db
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,29 @@
+---
+name: Bug report
+about: Report a bug to help improve the library
+labels: bug
+---
+
+## Description
+
+A clear and concise description of the bug.
+
+## Steps to reproduce
+
+1.
+2.
+3.
+
+## Expected behavior
+
+What should happen.
+
+## Actual behavior
+
+What actually happens.
+
+## Environment
+
+- PHP version:
+- Library version:
+- OS:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..b344d9e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,17 @@
+---
+name: Feature request
+about: Suggest a feature for the library
+labels: enhancement
+---
+
+## Problem
+
+What problem does this feature solve?
+
+## Proposed solution
+
+How should the feature work?
+
+## Alternatives considered
+
+Other approaches considered.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..7a2c836
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,16 @@
+> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md).
+
+## Summary
+
+What this pull request does.
+
+## Related issue
+
+Closes #...
+
+## Checklist
+
+- [ ] Tests added or updated.
+- [ ] Documentation updated when applicable.
+- [ ] `composer review` passes.
+- [ ] `composer tests` passes.
diff --git a/README.md b/README.md
index 6b7dcf8..3bee14d 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,29 @@
* [Installation](#installation)
* [How to use](#how-to-use)
+ [Entity](#entity)
+ - [Single-field identity](#single-field-identity)
+ - [Compound identity](#compound-identity)
+ - [Identity access on entities](#identity-access-on-entities)
+ [Aggregate](#aggregate)
+ [Domain events with transactional outbox](#domain-events-with-transactional-outbox)
+ - [Declaring events](#declaring-events)
+ - [Emitting events from the aggregate](#emitting-events-from-the-aggregate)
+ - [Draining events](#draining-events)
+ - [Restoring aggregate version on reload](#restoring-aggregate-version-on-reload)
+ - [Constructing event records directly](#constructing-event-records-directly)
+ [Event sourcing](#event-sourcing)
+ - [Applying events to state](#applying-events-to-state)
+ - [Creating a blank aggregate](#creating-a-blank-aggregate)
+ - [Replaying an event stream](#replaying-an-event-stream)
+ [Snapshots](#snapshots)
+ - [Capturing aggregate state](#capturing-aggregate-state)
+ - [Taking a snapshot](#taking-a-snapshot)
+ - [Persisting snapshots](#persisting-snapshots)
+ - [Built-in conditions](#built-in-conditions)
+ [Upcasting](#upcasting)
+ - [Defining an upcaster](#defining-an-upcaster)
+ - [Chaining upcasters](#chaining-upcasters)
+ - [Default values for new fields](#default-values-for-new-fields)
* [FAQ](#faq)
* [License](#license)
* [Contributing](#contributing)
@@ -21,6 +39,13 @@ The `Building Blocks` library provides the tactical design building blocks of Do
`Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox
or an event-sourced store.
+This library implements the tactical patterns from Evans (Entity, Identity, Aggregate Root, Value Object) and Vernon
+(Domain Event) together with pragmatic extensions that production code needs but the original DDD literature does
+not address: aggregate versioning for optimistic offline locking (Fowler PEAA), model versioning and rolling
+snapshots for event-sourced aggregates (Greg Young), event upcasting for schema evolution (Greg Young), and an
+event envelope decoupling domain events from infrastructure metadata (Hohpe/Woolf EIP). Every extension is annotated
+in its own PHPDoc with its source.
+
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.
@@ -53,6 +78,10 @@ differently named property override `identityProperty()`.
* `SingleIdentity`: identity backed by a single scalar value (UUID, auto-increment integer, slug).
```php
+ identityValue();
```
@@ -98,6 +132,10 @@ differently named property override `identityProperty()`.
entity that declares its identity property.
```php
+ sequenceNumber();
+ $user->aggregateVersion();
+ ```
+
+ ```php
+ isAfter(other: $previous); # true
+ $previous->isBefore(other: $current); # true
```
* `modelVersion()`: typed as `ModelVersion`. Defaults to `ModelVersion::initial()` (value `0`). Override on
- aggregates that have a versioned schema.
+ aggregates that have a versioned schema. `ModelVersion::isAfter()` and `ModelVersion::isBefore()` compare two
+ schema versions during migration logic.
```php
+ modelVersion();
```
-* `aggregateName()`: short class name, used as the aggregate type identifier on each `EventRecord`.
+ ```php
+ isAfter(other: $previous); # true
+ $previous->isBefore(other: $current); # true
+ ```
+
+* `aggregateType()`: short class name, used as the aggregate type identifier on each `EventRecord`.
```php
- $user->aggregateName();
+ $user->aggregateType();
```
### Domain events with transactional outbox
@@ -182,6 +265,10 @@ fails by design with a duplicate-event error from the outbox.
defaulted to `Revision::initial()` by `DomainEventBehavior`. Override only when bumping the event schema.
```php
+ recordedEvents() as $record) {
@@ -264,22 +373,107 @@ fails by design with a duplicate-event error from the outbox.
}
```
+#### Restoring aggregate version on reload
+
+* `reconstitute()`: static factory that state-based repositories invoke when rehydrating an
+ `EventualAggregateRoot` from persistence. The default implementation provided by
+ `EventualAggregateRootBehavior` instantiates the aggregate without invoking its constructor, assigns the
+ identity to the property declared by `identityProperty()`, hydrates the remaining state by reflection
+ from the `$state` map (entries with keys absent from the aggregate are silently ignored), and assigns
+ the aggregate version so subsequent events advance from the correct value. The buffer of recorded events
+ starts empty, the use-once contract still holds for any new operation.
+
+ ```php
+ 'pending']
+ );
+ ```
+
+ Aggregates may override the factory to enforce a concrete identity type at the entry point. The static
+ signature cannot narrow the parameter type per LSP, so the override keeps `Identity` in the signature
+ and guards with `instanceof` inside:
+
+ ```php
+ , got <%s>.';
+
+ throw new InvalidArgumentException(message: sprintf($template, OrderId::class, $orderId::class));
+ }
+
+ $order = new Order(id: $orderId);
+ $order->aggregateVersion = $aggregateVersion;
+
+ return $order;
+ }
+ }
+ ```
+
#### Constructing event records directly
+Every envelope carries `$id`, `$event`, `$revision`, `$eventType`, `$occurredAt`, `$aggregateId`,
+`$aggregateType`, and `$aggregateVersion`. The aggregate normally builds the record, so consumers
+read these fields off `EventRecord` directly without instantiating one.
+
* `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.
+ from a wire format. The `id` and `occurredAt` parameters fall back to sensible defaults (`Uuid::uuid4()` and
+ `Instant::now()`) when omitted.
+
+ | Parameter | Type | Required | Description |
+ |--------------------|--------------------|----------|--------------------------------------------------------------------|
+ | `id` | `?UuidInterface` | No | Explicit envelope identifier; defaults to a fresh `Uuid::uuid4()`. |
+ | `event` | `DomainEvent` | Yes | The event being recorded. |
+ | `occurredAt` | `?Instant` | No | Explicit occurrence timestamp; defaults to `Instant::now()`. |
+ | `aggregateId` | `Identity` | Yes | The aggregate identity that produced the event. |
+ | `aggregateType` | `string` | Yes | The short class name of the aggregate. |
+ | `aggregateVersion` | `AggregateVersion` | Yes | The aggregate version assigned to this envelope. |
```php
+ ` method by reflection.
```php
+ 'placed']);
- $data->toArray();
- ```
+ declare(strict_types=1);
-* 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).
+ use Psr\Log\LoggerInterface;
+ use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
+ use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior;
- ```php
final class CartWithLogger implements EventSourcingRoot
{
use EventSourcingRootBehavior;
@@ -402,7 +610,7 @@ Snapshots let the event store skip replay of early events when reconstituting a
private array $productIds = [];
private LoggerInterface $logger;
- protected function getSnapshotState(): array
+ public function snapshotState(): array
{
return ['id' => $this->id, 'productIds' => $this->productIds];
}
@@ -411,14 +619,18 @@ Snapshots let the event store skip replay of early events when reconstituting a
#### Taking a snapshot
-* `Snapshot::fromAggregate()`: captures the aggregate's current state via the `getSnapshotState()` hook.
+* `Snapshot::fromAggregate()`: captures the aggregate's current state via the `snapshotState()` hook.
```php
+ aggregateState();
- $snapshot->sequenceNumber();
+ $snapshot->aggregateVersion();
```
#### Persisting snapshots
@@ -427,6 +639,10 @@ Snapshots let the event store skip replay of early events when reconstituting a
storage to a `persist` hook implemented by the consumer.
```php
+ Alistair Cockburn, *Hexagonal Architecture* (alistair.cockburn.us, 2005).
-### 07. What is the difference between `ModelVersion` and `SequenceNumber`?
+### 07. What is the difference between `ModelVersion` and `AggregateVersion`?
-`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.
+`AggregateVersion` counts events per aggregate instance. It is the basis for optimistic concurrency control: a
+save fails if the aggregate version in storage differs from the in-memory version the aggregate believed it had.
`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
@@ -606,7 +845,7 @@ The two are different concepts that happen to share an integer representation. T
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.
+> Lock", source of `AggregateVersion` semantics.
> Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017), source of `ModelVersion` semantics.
### 08. Why is the `EventualAggregateRoot` use-once?
@@ -635,9 +874,45 @@ No. These three concerns live elsewhere:
A `DomainEvent` that grows methods like these duplicates envelope data already on the `EventRecord` and pulls
infrastructure into the domain layer.
+### 10. Why does the library include `AggregateVersion` and `ModelVersion` if Evans never mentioned them?
+
+Evans defined the tactical patterns of DDD, but optimistic concurrency control and aggregate schema evolution
+are concerns that emerged later in mainstream production code. `AggregateVersion` carries the optimistic offline
+lock formalized by Fowler in PEAA: the value travels with the aggregate, the persistence adapter compares the
+in-memory value against the stored one, and a mismatch raises a concurrency exception instead of overwriting
+another process's change. `ModelVersion` carries Greg Young's schema versioning for aggregate types, so migration
+code has a single source of truth to branch on when older shapes show up in storage.
+
+> Martin Fowler, *Patterns of Enterprise Application Architecture* (Addison-Wesley, 2002), "Optimistic Offline
+> Lock".
+> Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017).
+
+### 11. Why is `reconstitute()` static on the interface even though PHP's polymorphism for static methods is limited?
+
+The interface declaration documents the contract: every `EventualAggregateRoot` exposes a static factory with the
+shape `(Identity, AggregateVersion, array): static` that repositories can call. PHP does not dispatch static calls
+through interfaces at runtime, so the consumer always names the concrete class (`Order::reconstitute(...)`,
+`Reservation::reconstitute(...)`). The interface still earns its keep: it forces aggregates to expose the factory,
+the trait default provides one for free, and overrides remain bound to the declared signature. The parameter name
+is free per LSP, so an override can rename `$identity` to `$orderId` for readability, but the type must remain
+`Identity` — narrowing to a concrete identity class would break LSP. Concrete types are enforced inside the
+override with `instanceof`.
+
+> Barbara Liskov and Jeannette Wing, *A Behavioral Notion of Subtyping* (ACM TOPLAS, 1994).
+
+### 12. Why was `reconstituteAggregateVersion()` removed?
+
+It was never part of the external contract. The only caller was the trait's own `reconstitute()` factory, which
+needed to set the aggregate version on the instance it had just built. Exposing that internal step as a public
+instance method invited misuse (repositories calling it on aggregates they had not just reconstituted) without
+adding any expressiveness over assigning the property directly. The factory now writes `$aggregate->aggregateVersion`
+directly inside the trait, which is legal because the assignment happens in the static method of the same class
+after the trait flattens into the aggregate. Eliminating the public method tightens the surface and removes the
+documentation burden of explaining when calling it is correct.
+
## License
-Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE).
+Building Blocks is licensed under [MIT](LICENSE).
## Contributing
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..4464a1f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,12 @@
+# Security Policy
+
+## Supported versions
+
+Only the latest release receives security updates.
+
+## Reporting a vulnerability
+
+Report security vulnerabilities privately via
+[GitHub Security Advisories](https://github.com/tiny-blocks/building-blocks/security/advisories/new).
+
+Please do not disclose the vulnerability publicly until it has been addressed.