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/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/.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/.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
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/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.
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 @@
-
-
-
-
+
+
+
+
-
+
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;
}
}
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;