From 00b5591b3557d04ee261899944c447c6d7ac7899 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 18 Apr 2026 16:10:11 -0300 Subject: [PATCH] feat: Add initial implementation of domain models and event handling. --- .claude/CLAUDE.md | 30 ++ .claude/rules/documentation.md | 37 ++ .claude/rules/github-workflows.md | 78 +++ .claude/rules/php-code-style.md | 121 +++++ .claude/rules/php-domain.md | 96 ++++ .claude/rules/php-testing.md | 120 +++++ .editorconfig | 18 + .gitattributes | 30 ++ .github/copilot-instructions.md | 11 + .github/dependabot.yml | 31 ++ .github/workflows/auto-assign.yml | 25 + .github/workflows/ci.yml | 87 ++++ .github/workflows/codeql.yml | 35 ++ .gitignore | 7 + LICENSE | 21 + Makefile | 73 +++ README.md | 457 +++++++++++++++++- composer.json | 70 +++ infection.json.dist | 24 + phpstan.neon.dist | 14 + phpunit.xml | 37 ++ src/Aggregate/AggregateRoot.php | 64 +++ src/Aggregate/AggregateRootBehavior.php | 70 +++ src/Aggregate/EventSourcingRoot.php | 73 +++ src/Aggregate/EventSourcingRootBehavior.php | 78 +++ src/Aggregate/EventualAggregateRoot.php | 43 ++ .../EventualAggregateRootBehavior.php | 35 ++ src/Entity/CompoundIdentity.php | 21 + src/Entity/CompoundIdentityBehavior.php | 17 + src/Entity/Entity.php | 68 +++ src/Entity/EntityBehavior.php | 46 ++ src/Entity/Identity.php | 31 ++ src/Entity/SingleIdentity.php | 21 + src/Entity/SingleIdentityBehavior.php | 19 + src/Event/DomainEvent.php | 23 + src/Event/EventRecord.php | 29 ++ src/Event/EventRecords.php | 11 + src/Event/EventType.php | 34 ++ src/Event/Revision.php | 21 + src/Event/SequenceNumber.php | 41 ++ src/Event/SnapshotData.php | 27 ++ src/Internal/Exceptions/InvalidEventType.php | 17 + src/Internal/Exceptions/InvalidRevision.php | 17 + .../Exceptions/InvalidSequenceNumber.php | 17 + .../Exceptions/MissingIdentityConstant.php | 15 + .../Exceptions/MissingIdentityProperty.php | 21 + src/Snapshot/Snapshot.php | 71 +++ src/Snapshot/SnapshotCondition.php | 26 + src/Snapshot/Snapshotter.php | 29 ++ src/Snapshot/SnapshotterBehavior.php | 17 + src/Upcast/DefaultValues.php | 19 + src/Upcast/IntermediateEvent.php | 40 ++ src/Upcast/SingleUpcasterBehavior.php | 27 ++ src/Upcast/Upcaster.php | 32 ++ tests/Aggregate/AggregateRootBehaviorTest.php | 74 +++ .../EventSourcingRootBehaviorTest.php | 289 +++++++++++ .../EventualAggregateRootBehaviorTest.php | 161 ++++++ tests/Entity/CompoundIdentityBehaviorTest.php | 68 +++ tests/Entity/EntityBehaviorTest.php | 154 ++++++ tests/Entity/SingleIdentityBehaviorTest.php | 53 ++ tests/Event/EventRecordTest.php | 141 ++++++ tests/Event/EventRecordsTest.php | 80 +++ tests/Event/EventTypeTest.php | 114 +++++ tests/Event/RevisionTest.php | 109 +++++ tests/Event/SequenceNumberTest.php | 177 +++++++ tests/Event/SnapshotDataTest.php | 103 ++++ tests/Models/AppointmentId.php | 17 + tests/Models/Cart.php | 47 ++ tests/Models/CartId.php | 17 + tests/Models/CartWithoutIdentityConstant.php | 18 + tests/Models/EveryTwoEvents.php | 16 + tests/Models/FileSnapshotter.php | 26 + tests/Models/Order.php | 42 ++ tests/Models/OrderId.php | 17 + tests/Models/OrderPlaced.php | 14 + tests/Models/OrderShipped.php | 14 + .../OrderWithMissingIdentityProperty.php | 21 + tests/Models/OrderWithoutIdentityConstant.php | 19 + tests/Models/ProductAdded.php | 14 + tests/Models/ProductV1Upcaster.php | 22 + tests/Snapshot/SnapshotConditionTest.php | 67 +++ tests/Snapshot/SnapshotTest.php | 178 +++++++ tests/Snapshot/SnapshotterBehaviorTest.php | 69 +++ tests/Upcast/DefaultValuesTest.php | 35 ++ tests/Upcast/IntermediateEventTest.php | 147 ++++++ tests/Upcast/SingleUpcasterBehaviorTest.php | 78 +++ 86 files changed, 4941 insertions(+), 2 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/rules/documentation.md create mode 100644 .claude/rules/github-workflows.md create mode 100644 .claude/rules/php-code-style.md create mode 100644 .claude/rules/php-domain.md create mode 100644 .claude/rules/php-testing.md create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/copilot-instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/auto-assign.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 composer.json create mode 100644 infection.json.dist create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml create mode 100644 src/Aggregate/AggregateRoot.php create mode 100644 src/Aggregate/AggregateRootBehavior.php create mode 100644 src/Aggregate/EventSourcingRoot.php create mode 100644 src/Aggregate/EventSourcingRootBehavior.php create mode 100644 src/Aggregate/EventualAggregateRoot.php create mode 100644 src/Aggregate/EventualAggregateRootBehavior.php create mode 100644 src/Entity/CompoundIdentity.php create mode 100644 src/Entity/CompoundIdentityBehavior.php create mode 100644 src/Entity/Entity.php create mode 100644 src/Entity/EntityBehavior.php create mode 100644 src/Entity/Identity.php create mode 100644 src/Entity/SingleIdentity.php create mode 100644 src/Entity/SingleIdentityBehavior.php create mode 100644 src/Event/DomainEvent.php create mode 100644 src/Event/EventRecord.php create mode 100644 src/Event/EventRecords.php create mode 100644 src/Event/EventType.php create mode 100644 src/Event/Revision.php create mode 100644 src/Event/SequenceNumber.php create mode 100644 src/Event/SnapshotData.php create mode 100644 src/Internal/Exceptions/InvalidEventType.php create mode 100644 src/Internal/Exceptions/InvalidRevision.php create mode 100644 src/Internal/Exceptions/InvalidSequenceNumber.php create mode 100644 src/Internal/Exceptions/MissingIdentityConstant.php create mode 100644 src/Internal/Exceptions/MissingIdentityProperty.php create mode 100644 src/Snapshot/Snapshot.php create mode 100644 src/Snapshot/SnapshotCondition.php create mode 100644 src/Snapshot/Snapshotter.php create mode 100644 src/Snapshot/SnapshotterBehavior.php create mode 100644 src/Upcast/DefaultValues.php create mode 100644 src/Upcast/IntermediateEvent.php create mode 100644 src/Upcast/SingleUpcasterBehavior.php create mode 100644 src/Upcast/Upcaster.php create mode 100644 tests/Aggregate/AggregateRootBehaviorTest.php create mode 100644 tests/Aggregate/EventSourcingRootBehaviorTest.php create mode 100644 tests/Aggregate/EventualAggregateRootBehaviorTest.php create mode 100644 tests/Entity/CompoundIdentityBehaviorTest.php create mode 100644 tests/Entity/EntityBehaviorTest.php create mode 100644 tests/Entity/SingleIdentityBehaviorTest.php create mode 100644 tests/Event/EventRecordTest.php create mode 100644 tests/Event/EventRecordsTest.php create mode 100644 tests/Event/EventTypeTest.php create mode 100644 tests/Event/RevisionTest.php create mode 100644 tests/Event/SequenceNumberTest.php create mode 100644 tests/Event/SnapshotDataTest.php create mode 100644 tests/Models/AppointmentId.php create mode 100644 tests/Models/Cart.php create mode 100644 tests/Models/CartId.php create mode 100644 tests/Models/CartWithoutIdentityConstant.php create mode 100644 tests/Models/EveryTwoEvents.php create mode 100644 tests/Models/FileSnapshotter.php create mode 100644 tests/Models/Order.php create mode 100644 tests/Models/OrderId.php create mode 100644 tests/Models/OrderPlaced.php create mode 100644 tests/Models/OrderShipped.php create mode 100644 tests/Models/OrderWithMissingIdentityProperty.php create mode 100644 tests/Models/OrderWithoutIdentityConstant.php create mode 100644 tests/Models/ProductAdded.php create mode 100644 tests/Models/ProductV1Upcaster.php create mode 100644 tests/Snapshot/SnapshotConditionTest.php create mode 100644 tests/Snapshot/SnapshotTest.php create mode 100644 tests/Snapshot/SnapshotterBehaviorTest.php create mode 100644 tests/Upcast/DefaultValuesTest.php create mode 100644 tests/Upcast/IntermediateEventTest.php create mode 100644 tests/Upcast/SingleUpcasterBehaviorTest.php diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..3e73900 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,30 @@ +# Project + +PHP microservices platform. Hexagonal architecture (ports & adapters), DDD, CQRS. + +## Rules + +All coding standards, architecture, naming, testing, documentation, and OpenAPI 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). diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..64587c9 --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,37 @@ +--- +description: Standards for README files and all project documentation in PHP libraries. +paths: + - "**/*.md" +--- + +# Documentation + +## 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 **badges** section (license, build status, coverage, latest version, PHP version). +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 `rules/code-style.md` American English section for spelling conventions. + +## 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. + +## Style + +1. Keep language concise and scannable. +2. Never include placeholder content (`TODO`, `TBD`). +3. Code examples must be syntactically correct and self-contained. +4. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 0000000..a369ba4 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,78 @@ +--- +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-code-style.md b/.claude/rules/php-code-style.md new file mode 100644 index 0000000..59323ba --- /dev/null +++ b/.claude/rules/php-code-style.md @@ -0,0 +1,121 @@ +--- +description: Pre-output checklist, naming, typing, comparisons, and PHPDoc rules for all PHP files in libraries. +paths: + - "src/**/*.php" + - "tests/**/*.php" +--- + +# 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 `rules/domain.md` for domain modeling rules. + +## 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 `tiny-blocks/collection` + (`Collection`, `Collectible`) instead of raw `array` for any list of domain objects. Raw arrays are acceptable + only for primitive configuration data, variadic pass-through, or interop at system boundaries. +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. The same rule applies to named arguments at call sites. + Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). +13. No O(N²) or worse complexity exists. +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 `{` goes on the same line as the closing parenthesis `)` for constructors, methods, and + closures: `): ReturnType {` — not `): ReturnType\n {`. Parameters with default values go last. + +## Casing conventions + +- Internal code (variables, methods, classes): **`camelCase`**. +- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. + +## Naming + +- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. +- Generic technical verbs (`process`, `handle`, `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`, + `check`, `verify`, `assert`, `transform`, `parse`, `compute`, `sanitize`, `normalize`) **should be avoided**. + Prefer names that describe the domain operation. +- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural: `$orders`, `$lines`. +- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. + +## 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)`. + +## 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`). + +## PHPDoc + +- PHPDoc is restricted to interfaces only, documenting obligations and `@throws`. +- Never add PHPDoc to concrete classes. + +## Collection usage + +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions. + +**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** + +```php +$names = array_map( + static fn(Element $element): string => $element->name(), + iterator_to_array($collection) +); +``` + +**Correct — fluent chain with `map()` + `toArray()`:** + +```php +$names = $collection + ->map(transformations: static fn(Element $element): string => $element->name()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` + +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. diff --git a/.claude/rules/php-domain.md b/.claude/rules/php-domain.md new file mode 100644 index 0000000..f3b0eea --- /dev/null +++ b/.claude/rules/php-domain.md @@ -0,0 +1,96 @@ +--- +description: Domain modeling rules for PHP libraries — folder structure, naming, value objects, exceptions, enums, and SOLID. +paths: + - "src/**/*.php" +--- + +# Domain modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. +Refer to `rules/code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +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) +``` + +**Public API boundary:** 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. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Never use `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **domain concept** the library represents. + A math library uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection + library uses `Collectible`, `Order`. +2. Never use generic technical names: `Manager`, `Helper`, `Processor`, `Data`, `Info`, `Utils`, + `Item`, `Record`, `Entity`, `Exception`, `Ensure`, `Validate`, `Check`, `Verify`, + `Assert`, `Transform`, `Parse`, `Compute`, `Sanitize`, or `Normalize` as class suffixes or prefixes. +3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +4. Name methods after the operation in domain terms: `add()`, `convertTo()`, `splitAt()` — not `process()`, + `handle()`, `execute()`, `manage()`, `ensure()`, `validate()`, `check()`, `verify()`, `assert()`, + `transform()`, `parse()`, `compute()`, `sanitize()`, or `normalize()`. + +## Value objects + +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. + +## Exceptions + +1. Extend native PHP exceptions (`DomainException`, `InvalidArgumentException`, `OverflowException`, etc.). +2. Are pure: no formatted `code`/`message` for HTTP responses. +3. Signal invariant violations only. +4. Name after the invariant violated, never after the technical type: + `PrecisionOutOfRange` — not `InvalidPrecisionException`. + `CurrencyMismatch` — not `BadCurrencyException`. + `ContainerWaitTimeout` — not `TimeoutException`. +5. Create the exception class directly with the invariant name and the appropriate native parent. The exception + is dedicated by definition when its name describes the specific invariant it guards. + +## Enums + +1. Are PHP backed enums. +2. Include domain-meaningful methods when needed (e.g., `Order::ASCENDING_KEY`). + +## 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. + +## Principles + +- **Immutability**: all models and value objects adopt immutability. Operations return new instances. +- **Zero dependencies**: the library's core has no dependency on frameworks, databases, or I/O. +- **Small surface area**: expose only what consumers need. Hide implementation in `Internal/`. + +## SOLID reference + +| Principle | Failure signal | +|---------------------------|---------------------------------------------| +| S — Single responsibility | Class does two unrelated things | +| O — Open/closed | Adding a feature requires editing internals | +| L — Liskov substitution | Subclass throws on parent method | +| I — Interface segregation | Interface has unused methods | +| D — Dependency inversion | Constructor uses `new ConcreteClass()` | diff --git a/.claude/rules/php-testing.md b/.claude/rules/php-testing.md new file mode 100644 index 0000000..7bd9e68 --- /dev/null +++ b/.claude/rules/php-testing.md @@ -0,0 +1,120 @@ +--- +description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +paths: + - "tests/**/*.php" +--- + +# Testing conventions + +Framework: **PHPUnit**. Refer to `rules/code-style.md` for the code style checklist, which also applies to test files. + +## Structure: Given/When/Then (BDD) + +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. + +### Happy path example + +```php +public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void +{ + /** @Given two money instances in the same currency */ + $ten = Money::of(amount: 1000, currency: Currency::BRL); + $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()); +} +``` + +### Exception example + +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); + $usd = Money::of(amount: 500, currency: Currency::USD); + + /** @Then an exception indicating currency mismatch should be thrown */ + $this->expectException(CurrencyMismatch::class); + + /** @When trying to add money with different currencies */ + $brl->add(other: $usd); +} +``` + +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. For exception tests, place `@Then` (expectException) before `@When`. +9. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts + (e.g., `Amount`, `Invoice`, `Order`). +10. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +11. 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. +12. Never use `/** @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. + +## 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 + +``` +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 +``` + +- `tests/` or `tests/Unit/`: pure unit tests exercising the library's public API. +- `tests/Integration/`: tests requiring real external resources (e.g., Docker containers, databases). + Only present when the library interacts with infrastructure. +- `tests/Models/`: domain-specific fixture classes reused across test files. +- `tests/Mocks/` or `tests/Unit/Mocks/`: test doubles for system boundaries. + +## Coverage and mutation testing + +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. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..73e3c9a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..28337dc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Auto detect text files and perform LF normalization +* text=auto eol=lf + +# ─── Diff drivers ──────────────────────────────────────────── +*.php diff=php +*.md diff=markdown + +# ─── Force LF ──────────────────────────────────────────────── +*.sh text eol=lf +Makefile text eol=lf + +# ─── Generated (skip diff and GitHub stats) ────────────────── +composer.lock -diff linguist-generated + +# ─── Export ignore (excluded from dist archive) ────────────── +/tests export-ignore +/vendor export-ignore +/rules export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore + +/CLAUDE.md export-ignore +/LICENSE export-ignore +/Makefile export-ignore +/README.md export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..77c2bb8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +# Copilot instructions + +## Context + +PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. + +## 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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0ce8fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 + +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "php" + - "security" + - "dependencies" + groups: + php-security: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "build" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..d0ba49e --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,25 @@ +name: Auto assign issues and pull requests + +on: + issues: + types: + - opened + pull_request: + types: + - opened + +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Assign issues and pull requests + uses: gustavofreze/auto-assign@2.1.0 + with: + assignees: '${{ vars.ASSIGNEES }}' + github_token: '${{ secrets.GITHUB_TOKEN }}' + allow_self_assign: 'true' + allow_no_assignees: 'true' + assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..71e59e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +env: + PHP_VERSION: '8.5' + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - 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 + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - 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 + runs-on: ubuntu-latest + needs: auto-review + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - 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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c6d7f7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: Security checks + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "0 0 * * *" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ "actions" ] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85fc064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea + +vendor +report + +*.lock +.phpunit.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b630e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Tiny Blocks + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..07acc3b --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +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 update --optimize-autoloader + @${DOCKER_RUN} composer normalize + +.PHONY: test +test: ## Run all 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 + @${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 + +.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 report 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):.*?## .*$$' $(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) \ + | 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/README.md b/README.md index a7bdf22..036b9d6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,455 @@ -# building-blocks -Tactical DDD building blocks for PHP: Entity, Aggregate Root, and domain events with transactional outbox and event sourcing support. Persistence-agnostic and PSR-14 friendly. +# Building Blocks + +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE) + +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) + + [Entity](#entity) + + [Aggregate](#aggregate) + + [Domain events with transactional outbox](#domain-events-with-transactional-outbox) + + [Event sourcing](#event-sourcing) + + [Snapshots](#snapshots) + + [Upcasting](#upcasting) +* [FAQ](#faq) +* [License](#license) +* [Contributing](#contributing) + +## Overview + +The `Building Blocks` library provides the tactical design building blocks of Domain-Driven Design: `Entity`, +`Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox +or an event-sourced store. + +It is persistence-agnostic and framework-agnostic. It depends only on the other `tiny-blocks` primitives +(`immutable-object`, `value-object`, `collection`, `time`) and `ramsey/uuid` for event identifiers. + +Domain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not +replace PSR-14; it defines what flows through it. + +## Installation + +``` +composer require tiny-blocks/building-blocks +``` + +## How to use + +The library exposes three styles of aggregate modeling through sibling interfaces: + +* `AggregateRoot` for plain DDD modeling without events. +* `EventualAggregateRoot` for aggregates that persist state and emit events as side effects via a transactional + outbox. +* `EventSourcingRoot` for aggregates whose state is derived entirely from their ordered event stream. + +### Entity + +Every entity declares an `IDENTITY` constant pointing to the property that holds its `Identity`. + +#### Single-field identity + +* `SingleIdentity`: identity backed by a single scalar value (UUID, auto-increment integer, etc.). + + ```php + use TinyBlocks\BuildingBlocks\Entity\SingleIdentity; + use TinyBlocks\BuildingBlocks\Entity\SingleIdentityBehavior; + + final readonly class OrderId implements SingleIdentity + { + use SingleIdentityBehavior; + + public function __construct(public string $value) + { + } + } + + $orderId = new OrderId(value: 'ord-1'); + $orderId->getIdentityValue(); + ``` + +#### Compound identity + +* `CompoundIdentity`: identity composed of multiple fields treated as a tuple. + + ```php + use TinyBlocks\BuildingBlocks\Entity\CompoundIdentity; + use TinyBlocks\BuildingBlocks\Entity\CompoundIdentityBehavior; + + final readonly class AppointmentId implements CompoundIdentity + { + use CompoundIdentityBehavior; + + public function __construct( + public string $tenantId, + public string $appointmentId + ) { + } + } + + $appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); + $appointmentId->getIdentityValue(); + ``` + +#### Identity access + +* `getIdentity`, `getIdentityValue`, `sameIdentityOf`, `identityEquals`: provided by `EntityBehavior` for any entity + that declares the `IDENTITY` constant. + + ```php + use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; + use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior; + + final class User implements AggregateRoot + { + use AggregateRootBehavior; + + private const string IDENTITY = 'userId'; + + private function __construct(private UserId $userId, private string $email) + { + } + } + + $user->sameIdentityOf(other: $otherUser); + $user->identityEquals(other: new UserId(value: 'usr-1')); + ``` + +### Aggregate + +`AggregateRoot` adds two pragmatic fields to Evans' aggregate: a monotonic `SequenceNumber` for optimistic concurrency +control and a `ModelVersion` for schema evolution of the aggregate type itself. + +* `getSequenceNumber`: the current sequence number, starting at zero for a blank aggregate. + + ```php + use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; + use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior; + + final class User implements AggregateRoot + { + use AggregateRootBehavior; + + private const string IDENTITY = 'userId'; + } + + $user->getSequenceNumber(); + ``` + +* `getModelVersion`: resolved from the optional `MODEL_VERSION` class constant, defaults to zero when absent. + + ```php + final class Cart implements AggregateRoot + { + use AggregateRootBehavior; + + private const string IDENTITY = 'cartId'; + private const int MODEL_VERSION = 1; + } + + $cart->getModelVersion(); + ``` + +* `buildAggregateName`: short class name, used as the aggregate type identifier on each `EventRecord`. + + ```php + $user->buildAggregateName(); + ``` + +### Domain events with transactional outbox + +`EventualAggregateRoot` records domain events during the unit of work. State is the source of truth; events are +emitted as side effects and must be delivered at-least-once. + +#### Declaring events + +* `DomainEvent`: empty marker interface. A domain event is a plain PHP object. + + ```php + use TinyBlocks\BuildingBlocks\Event\DomainEvent; + + final readonly class OrderPlaced implements DomainEvent + { + public function __construct(public string $item) + { + } + } + ``` + +#### Emitting events from the aggregate + +* `pushEvent`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a + fully-built `EventRecord` to the recorded buffer. The `Revision` is provided on the call site, so the event class + stays pure. + + ```php + use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot; + use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior; + use TinyBlocks\BuildingBlocks\Event\Revision; + + final class Order implements EventualAggregateRoot + { + use EventualAggregateRootBehavior; + + private const string IDENTITY = 'orderId'; + + private function __construct(private OrderId $orderId) + { + } + + public static function place(OrderId $orderId, string $item): Order + { + $order = new Order(orderId: $orderId); + $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1)); + + return $order; + } + } + ``` + +#### Draining events in the repository + +* `recordedEvents`: returns a fresh copy of the buffer, safe to iterate without mutating the aggregate. +* `clearRecordedEvents`: discards the buffer, typically called after persisting the events. + + ```php + $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book'); + + foreach ($order->recordedEvents() as $record) { + $outbox->append(record: $record); + } + + $order->clearRecordedEvents(); + ``` + +### Event sourcing + +`EventSourcingRoot` stores no state of its own; state is derived by replaying the event stream. + +#### Applying events to state + +* `when`: protected method that records the event and immediately applies it to state by dispatching to a + `when` method by reflection. + + ```php + use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; + use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior; + use TinyBlocks\BuildingBlocks\Event\Revision; + use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; + + final class Cart implements EventSourcingRoot + { + use EventSourcingRootBehavior; + + private const string IDENTITY = 'cartId'; + + private CartId $cartId; + private array $productIds = []; + + public function addProduct(string $productId): void + { + $this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1)); + } + + public function applySnapshot(Snapshot $snapshot): void + { + $this->productIds = $snapshot->getAggregateState()['productIds'] ?? []; + } + + protected function whenProductAdded(ProductAdded $event): void + { + $this->productIds[] = $event->productId; + } + } + ``` + +#### Creating a blank aggregate + +* `blank`: factory that instantiates the aggregate without invoking its constructor. All state must come from events + or from a snapshot. + + ```php + $cart = Cart::blank(identity: new CartId(value: 'cart-1')); + ``` + +#### Replaying an event stream + +* `reconstitute`: replays an ordered stream of `EventRecord` instances, optionally starting from a snapshot to skip + earlier events. When a snapshot is provided, its sequence number is authoritative. + + ```php + $cart = Cart::reconstitute(identity: new CartId(value: 'cart-1'), records: $records); + ``` + + ```php + $cart = Cart::reconstitute( + identity: new CartId(value: 'cart-1'), + records: $laterRecords, + snapshot: $snapshot + ); + ``` + +### Snapshots + +Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate. + +#### Capturing a snapshot + +* `Snapshot::fromAggregate`: reads all declared properties except `recordedEvents` and `sequenceNumber`. Both are + tracked outside `aggregateState` because the snapshot has dedicated fields for them. + + ```php + use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; + + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + ``` + +#### Persisting a snapshot + +* `Snapshotter`: port for snapshot persistence. The `SnapshotterBehavior` trait captures the snapshot and delegates + storage to a concrete `persist` hook. + + ```php + use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; + use TinyBlocks\BuildingBlocks\Snapshot\Snapshotter; + use TinyBlocks\BuildingBlocks\Snapshot\SnapshotterBehavior; + + final class FileSnapshotter implements Snapshotter + { + use SnapshotterBehavior; + + protected function persist(Snapshot $snapshot): void + { + file_put_contents('/var/snapshots/cart.json', $snapshot->getAggregateState()); + } + } + + $snapshotter = new FileSnapshotter(); + $snapshotter->take(aggregate: $cart); + ``` + +#### Deciding when to snapshot + +* `SnapshotCondition`: strategy for deciding whether a snapshot should be taken at a given point. + + ```php + use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; + use TinyBlocks\BuildingBlocks\Snapshot\SnapshotCondition; + + final class EveryHundredEvents implements SnapshotCondition + { + public function shouldSnapshot(EventSourcingRoot $aggregate): bool + { + return $aggregate->getSequenceNumber()->value % 100 === 0; + } + } + ``` + +### Upcasting + +Upcasters migrate serialized events across schema changes without touching the event classes. + +#### Defining an upcaster + +* `Upcaster`: transforms one `(type, revision)` pair forward by one step. Chains of upcasters handle multistep + evolution. The `SingleUpcasterBehavior` trait binds the upcaster to a specific migration via three class constants. + + ```php + use TinyBlocks\BuildingBlocks\Upcast\SingleUpcasterBehavior; + use TinyBlocks\BuildingBlocks\Upcast\Upcaster; + + final class ProductV1Upcaster implements Upcaster + { + use SingleUpcasterBehavior; + + private const string EXPECTED_EVENT_TYPE = 'ProductAdded'; + private const int FROM_REVISION = 1; + private const int TO_REVISION = 2; + + protected function doUpcast(array $data): array + { + return [...$data, 'quantity' => 1]; + } + } + ``` + +#### Upcasting an event + +* `upcast`: transforms the event if it matches the expected `(type, revision)`, otherwise returns it unchanged. + + ```php + use TinyBlocks\BuildingBlocks\Event\EventType; + use TinyBlocks\BuildingBlocks\Event\Revision; + use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; + + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 1), + serializedEvent: ['productId' => 'prod-1'] + ); + + $upcasted = new ProductV1Upcaster()->upcast(event: $event); + ``` + +#### Default values for new fields + +* `DefaultValues`: type-to-default-value map for common primitive types, used when an upcast introduces a new field. + + ```php + use TinyBlocks\BuildingBlocks\Upcast\DefaultValues; + + $defaults = DefaultValues::get(); + ``` + +## FAQ + +### 01. Why is `DomainEvent` an empty marker interface? + +A domain event is a fact about something that happened in the domain. It has no technical contract beyond being that +fact. Persistence and transport concerns (type name, revision, aggregate identity) belong to `EventRecord`, not to +the event itself. Keeping the event pure prevents infrastructure concerns from leaking into the domain model. + +### 02. Why does `EventualAggregateRoot` store `EventRecord` instead of `DomainEvent`? + +Only the aggregate has the context needed to build the complete envelope: identity, sequence number, aggregate type +name. Storing raw events and wrapping them later would either duplicate that context or require a second pass. +`pushEvent` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation. + +### 03. Why are `EventualAggregateRoot` and `EventSourcingRoot` siblings instead of a hierarchy? + +Outbox and event sourcing are mutually exclusive persistence strategies. An aggregate either persists its state and +emits events as side effects, or persists only its events as the source of truth. A common base beyond `AggregateRoot` +would imply the two patterns can coexist on the same aggregate, which they cannot. + +### 04. Why does `Revision` live on the call site instead of on the event class? + +Keeping `Revision` on the `pushEvent` or `when` call site makes the aggregate the author of schema evolution. The +event class stays pure. Bumping the revision of an existing event does not require creating a new class. + +### 05. Why does `blank` skip the constructor? + +`EventSourcingRootBehavior::blank` instantiates the aggregate via reflection without invoking its constructor because +all aggregate state in an event-sourced model must come from events or from a snapshot. Any invariants established by +the constructor would contradict that principle. Concrete aggregates should treat their constructor as private and +reserved for internal use. + +### 06. Why are `recordedEvents` and `sequenceNumber` excluded from `Snapshot::aggregateState`? + +`recordedEvents` belongs to the current unit of work, not to the aggregate's intrinsic state. `sequenceNumber` is +already carried by the snapshot as a first-class field, so duplicating it inside `aggregateState` would force +consumers to decide which copy is authoritative. + +### 07. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace? + +Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, `MissingIdentityConstant` +and `MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or +`RuntimeException` from the PHP standard library, so consumers that catch the broad standard types continue to work; +consumers that need precise handling can catch the specific classes. + +## License + +Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE). + +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a343f8e --- /dev/null +++ b/composer.json @@ -0,0 +1,70 @@ +{ + "name": "tiny-blocks/building-blocks", + "description": "Tactical DDD building blocks for PHP: Entity, Aggregate Root, and domain events with transactional outbox and event sourcing support. Persistence-agnostic and PSR-14 friendly.", + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "homepage": "https://github.com/tiny-blocks/building-blocks", + "support": { + "issues": "https://github.com/tiny-blocks/building-blocks/issues", + "source": "https://github.com/tiny-blocks/building-blocks" + }, + "require": { + "php": "^8.5", + "ramsey/uuid": "^4.9", + "tiny-blocks/collection": "^2.3", + "tiny-blocks/immutable-object": "^1.1", + "tiny-blocks/time": "^1.5", + "tiny-blocks/value-object": "^3.2" + }, + "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\\BuildingBlocks\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\TinyBlocks\\BuildingBlocks\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "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", + "review": [ + "@phpcs", + "@phpstan" + ], + "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", + "tests": [ + "@test", + "@mutation-test" + ], + "tests-no-coverage": [ + "@test-no-coverage" + ] + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..ee435dd --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,24 @@ +{ + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, + "tmpDir": "report/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true, + "ProtectedVisibility": false + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..6115fe6 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +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>#' + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..40c80a2 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,37 @@ + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + diff --git a/src/Aggregate/AggregateRoot.php b/src/Aggregate/AggregateRoot.php new file mode 100644 index 0000000..ddbc59c --- /dev/null +++ b/src/Aggregate/AggregateRoot.php @@ -0,0 +1,64 @@ +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.
  • + *
  • modelVersion for aggregate schema evolution.
  • + *
+ * + *

Three sibling variants build on this base: plain aggregates without events, {@see EventualAggregateRoot} + * for the transactional outbox pattern, and {@see EventSourcingRoot} for the event-sourced style.

+ * + * @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. + * @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 getSequenceNumber(): SequenceNumber; + + /** + * Returns the schema version of this aggregate type. + * + *

Resolved from the optional MODEL_VERSION class constant, defaults to 0 + * when the constant is not declared. Used by consumers to migrate aggregate schemas when loading older + * persisted state.

+ * + * @return SequenceNumber The declared model version, or 0 when undefined. + */ + public function getModelVersion(): SequenceNumber; + + /** + * Returns the short class name of this aggregate. + * + *

Used as the aggregate type identifier on each produced + * {@see \TinyBlocks\BuildingBlocks\Event\EventRecord}.

+ * + * @return string The short class name. + */ + public function buildAggregateName(): string; +} diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php new file mode 100644 index 0000000..6cb166b --- /dev/null +++ b/src/Aggregate/AggregateRootBehavior.php @@ -0,0 +1,70 @@ +sequenceNumber ?? SequenceNumber::initial(); + } + + public function getModelVersion(): SequenceNumber + { + if (!defined('static::MODEL_VERSION')) { + return new SequenceNumber(value: 0); + } + + return new SequenceNumber(value: static::MODEL_VERSION); + } + + public function buildAggregateName(): string + { + return new ReflectionClass(objectOrClass: static::class)->getShortName(); + } + + protected function nextSequenceNumber(): void + { + $this->sequenceNumber = $this->getSequenceNumber()->next(); + } + + protected function buildEventRecord(DomainEvent $event, Revision $revision): EventRecord + { + return new EventRecord( + id: Uuid::uuid4(), + type: EventType::fromEvent(event: $event), + event: $event, + identity: $this->getIdentity(), + revision: $revision, + occurredOn: Instant::now(), + snapshotData: $this->generateSnapshotData(), + aggregateType: $this->buildAggregateName(), + sequenceNumber: $this->getSequenceNumber() + ); + } + + protected function generateSnapshotData(): SnapshotData + { + $state = get_object_vars($this); + unset($state['recordedEvents']); + + return new SnapshotData(data: $state); + } +} diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php new file mode 100644 index 0000000..7e19e9c --- /dev/null +++ b/src/Aggregate/EventSourcingRoot.php @@ -0,0 +1,73 @@ +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.

+ * + *

Sibling of {@see EventualAggregateRoot}, not a parent. The default implementation instantiates + * aggregates via reflection without invoking the constructor, so implementations must derive all state + * from events or from a snapshot, never from constructor logic.

+ * + * @see Greg Young, CQRS Documents (2010), "Event Sourcing". + * @see Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8, + * "Event Sourcing" section. + */ +interface EventSourcingRoot extends AggregateRoot +{ + /** + * 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. + * + *

The constructor is not invoked. All state must come from events or from a snapshot.

+ * + * @param Identity $identity The identity to assign to the new aggregate. + * @return static A new aggregate in its initial state. + * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. + */ + 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 + * replayed.

+ * + * @param Identity $identity The identity of the aggregate. + * @param iterable $records The event stream to replay, ordered by sequence number. + * @param Snapshot|null $snapshot Optional snapshot to restore from before replay. + * @return static The reconstituted aggregate. + * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. + */ + public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static; + + /** + * Restores aggregate state from the given snapshot. + * + *

Implementations read {@see Snapshot::getAggregateState()} and copy the relevant fields into + * their own properties. The sequence number is applied automatically by + * reconstitute(); implementations should not touch it.

+ * + * @param Snapshot $snapshot The snapshot to restore from. + */ + public function applySnapshot(Snapshot $snapshot): void; +} diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php new file mode 100644 index 0000000..fa02b38 --- /dev/null +++ b/src/Aggregate/EventSourcingRootBehavior.php @@ -0,0 +1,78 @@ +recordedEvents ?? EventRecords::createFromEmpty(); + + return EventRecords::createFrom(elements: $records); + } + + public static function blank(Identity $identity): static + { + if (!defined('static::IDENTITY')) { + throw new MissingIdentityConstant(className: static::class); + } + + $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor(); + new ReflectionProperty($aggregate, static::IDENTITY)->setValue(objectOrValue: $aggregate, value: $identity); + $aggregate->sequenceNumber = SequenceNumber::initial(); + $aggregate->recordedEvents = EventRecords::createFromEmpty(); + + return $aggregate; + } + + public static function reconstitute( + Identity $identity, + iterable $records, + ?Snapshot $snapshot = null + ): static { + $aggregate = static::blank(identity: $identity); + + if (!is_null($snapshot)) { + $aggregate->applySnapshot(snapshot: $snapshot); + $aggregate->sequenceNumber = $snapshot->getSequenceNumber(); + } + + foreach ($records as $record) { + $aggregate->applyEvent(record: $record); + } + + return $aggregate; + } + + protected function when(DomainEvent $event, Revision $revision): void + { + $this->nextSequenceNumber(); + $record = $this->buildEventRecord(event: $event, revision: $revision); + $this->applyEvent(record: $record); + $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())->add($record); + } + + protected function applyEvent(EventRecord $record): void + { + $methodName = 'when' . new ReflectionClass(objectOrClass: $record->event)->getShortName(); + $this->{$methodName}($record->event); + $this->sequenceNumber = $record->sequenceNumber; + } +} diff --git a/src/Aggregate/EventualAggregateRoot.php b/src/Aggregate/EventualAggregateRoot.php new file mode 100644 index 0000000..2a8fc9f --- /dev/null +++ b/src/Aggregate/EventualAggregateRoot.php @@ -0,0 +1,43 @@ +State is persisted as the source of truth; events are emitted as side effects and delivered + * at-least-once to external consumers. The repository is expected to drain + * recordedEvents() after persisting the aggregate state and then call + * clearRecordedEvents() to reset the buffer for the next unit of work.

+ * + *

Sibling of {@see EventSourcingRoot}, not a parent. Outbox and event sourcing are mutually exclusive + * persistence strategies: an aggregate either persists its state and emits events as side effects, or + * persists only its events as the source of truth.

+ * + * @see Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8 + * "Domain Events". + * @see Chris Richardson, Microservices Patterns (Manning, 2018), Chapter 3, "Transactional Outbox". + */ +interface EventualAggregateRoot extends AggregateRoot +{ + /** + * Returns a copy of the events recorded since the last clear. + * + *

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. + */ + public function recordedEvents(): EventRecords; + + /** + * Discards all recorded events. + * + *

Typically called by the repository after the events have been persisted to the outbox.

+ */ + public function clearRecordedEvents(): void; +} diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php new file mode 100644 index 0000000..8a30a79 --- /dev/null +++ b/src/Aggregate/EventualAggregateRootBehavior.php @@ -0,0 +1,35 @@ +recordedEvents ?? EventRecords::createFromEmpty(); + + return EventRecords::createFrom(elements: $records); + } + + public function clearRecordedEvents(): void + { + $this->recordedEvents = EventRecords::createFromEmpty(); + } + + protected function pushEvent(DomainEvent $event, Revision $revision): void + { + $this->nextSequenceNumber(); + $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty()) + ->add($this->buildEventRecord(event: $event, revision: $revision)); + } +} diff --git a/src/Entity/CompoundIdentity.php b/src/Entity/CompoundIdentity.php new file mode 100644 index 0000000..df8d82c --- /dev/null +++ b/src/Entity/CompoundIdentity.php @@ -0,0 +1,21 @@ +Pragmatic extension for the common case of identifiers that require more than one field to be + * unique (for example (tenantId, appointmentId) in multi-tenant contexts). Not a concept + * from Evans.

+ * + *

All declared properties participate in the identity: getIdentityValue() returns them + * as an associative array keyed by property name.

+ */ +interface CompoundIdentity extends Identity, ValueObject +{ +} diff --git a/src/Entity/CompoundIdentityBehavior.php b/src/Entity/CompoundIdentityBehavior.php new file mode 100644 index 0000000..55bf559 --- /dev/null +++ b/src/Entity/CompoundIdentityBehavior.php @@ -0,0 +1,17 @@ +An entity is distinguished not by its attributes but by a thread of identity that remains stable + * across distinct representations and lifecycle transitions. Two entities are equal when their + * identities are equal, regardless of attribute differences.

+ * + *

Concrete entities declare the IDENTITY class constant pointing to the property that + * holds their {@see Identity}. The default behavior uses reflection to resolve and compare it.

+ * + * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software + * (Addison-Wesley, 2003), Chapter 5 "Entities (a.k.a. Reference Objects)". + */ +interface Entity +{ + /** + * Returns the Identity that uniquely identifies this entity. + * + * @return Identity The identity instance held by this entity. + * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. + * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist. + */ + public function getIdentity(): Identity; + + /** + * Returns the name of the property that holds this entity's Identity. + * + * @return string The property name, resolved from the IDENTITY class constant. + * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. + * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist. + */ + public function getIdentityName(): string; + + /** + * Returns the raw value of this entity's identity. + * + *

The shape of the returned value depends on the kind of identity held: a scalar for + * {@see SingleIdentity}, an associative array for {@see CompoundIdentity}.

+ * + * @return mixed The raw identity value. + */ + public function getIdentityValue(): mixed; + + /** + * Checks 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. + */ + public function sameIdentityOf(Entity $other): bool; + + /** + * Checks 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. + */ + public function identityEquals(Identity $other): bool; +} diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php new file mode 100644 index 0000000..5cccb42 --- /dev/null +++ b/src/Entity/EntityBehavior.php @@ -0,0 +1,46 @@ +{$this->getIdentityName()}; + } + + public function getIdentityValue(): mixed + { + return $this->getIdentity()->getIdentityValue(); + } + + public function sameIdentityOf(Entity $other): bool + { + return $this->identityEquals(other: $other->getIdentity()); + } + + public function identityEquals(Identity $other): bool + { + return $this->getIdentity() == $other; + } +} diff --git a/src/Entity/Identity.php b/src/Entity/Identity.php new file mode 100644 index 0000000..d6f1255 --- /dev/null +++ b/src/Entity/Identity.php @@ -0,0 +1,31 @@ +Identity is the stable thread that allows an Entity to be recognized across distinct representations + * and lifecycle states. Being {@see Immutable}, it cannot change once constructed: a new identity must be + * created for a new entity.

+ * + *

Implementations are expected to also be value objects for equality purposes. See the two shipped + * variants: {@see SingleIdentity} for scalar-backed identifiers and {@see CompoundIdentity} for + * multi-field tuples.

+ * + * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software + * (Addison-Wesley, 2003), Chapter 5 "Entities". + */ +interface Identity extends Immutable +{ + /** + * Returns the raw value of this identity. + * + * @return mixed A scalar value for single-field identities, an associative array for composite ones. + */ + public function getIdentityValue(): mixed; +} diff --git a/src/Entity/SingleIdentity.php b/src/Entity/SingleIdentity.php new file mode 100644 index 0000000..e9d0c9b --- /dev/null +++ b/src/Entity/SingleIdentity.php @@ -0,0 +1,21 @@ +Pragmatic extension for the common case of identifiers backed by a single field (UUID string, + * 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 + * reads it by reflection and returns it from getIdentityValue().

+ */ +interface SingleIdentity extends Identity, ValueObject +{ +} diff --git a/src/Entity/SingleIdentityBehavior.php b/src/Entity/SingleIdentityBehavior.php new file mode 100644 index 0000000..4430a9e --- /dev/null +++ b/src/Entity/SingleIdentityBehavior.php @@ -0,0 +1,19 @@ +A Domain Event is a fact that domain experts care about. It has no technical contract beyond being + * that fact: persistence and transport concerns such as type name, revision, or envelope metadata belong + * to {@see EventRecord}, not here. Keeping the event pure prevents infrastructure concerns from leaking + * into the domain model.

+ * + *

Being a plain PHP object, any implementation is compatible with PSR-14 + * (Psr\EventDispatcher\EventDispatcherInterface) without additional adaptation.

+ * + * @see Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8 + * "Domain Events". + */ +interface DomainEvent +{ +} diff --git a/src/Event/EventRecord.php b/src/Event/EventRecord.php new file mode 100644 index 0000000..d8f19e8 --- /dev/null +++ b/src/Event/EventRecord.php @@ -0,0 +1,29 @@ +getShortName()); + } + + public static function fromString(string $value): EventType + { + return new EventType(value: $value); + } +} diff --git a/src/Event/Revision.php b/src/Event/Revision.php new file mode 100644 index 0000000..51f0fce --- /dev/null +++ b/src/Event/Revision.php @@ -0,0 +1,21 @@ +value + 1); + } + + public function isAfter(SequenceNumber $other): bool + { + return $this->value > $other->value; + } +} diff --git a/src/Event/SnapshotData.php b/src/Event/SnapshotData.php new file mode 100644 index 0000000..dded14b --- /dev/null +++ b/src/Event/SnapshotData.php @@ -0,0 +1,27 @@ +data; + } + + public function toJson(int $flags = JSON_PRESERVE_ZERO_FRACTION): string + { + return json_encode(value: $this->data, flags: $flags | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Internal/Exceptions/InvalidEventType.php b/src/Internal/Exceptions/InvalidEventType.php new file mode 100644 index 0000000..edc61c3 --- /dev/null +++ b/src/Internal/Exceptions/InvalidEventType.php @@ -0,0 +1,17 @@ + does not match the required pattern <%s>.', $value, $pattern) + ); + } +} diff --git a/src/Internal/Exceptions/InvalidRevision.php b/src/Internal/Exceptions/InvalidRevision.php new file mode 100644 index 0000000..df12692 --- /dev/null +++ b/src/Internal/Exceptions/InvalidRevision.php @@ -0,0 +1,17 @@ +.', $value) + ); + } +} diff --git a/src/Internal/Exceptions/InvalidSequenceNumber.php b/src/Internal/Exceptions/InvalidSequenceNumber.php new file mode 100644 index 0000000..8258fcf --- /dev/null +++ b/src/Internal/Exceptions/InvalidSequenceNumber.php @@ -0,0 +1,17 @@ +.', $value) + ); + } +} diff --git a/src/Internal/Exceptions/MissingIdentityConstant.php b/src/Internal/Exceptions/MissingIdentityConstant.php new file mode 100644 index 0000000..3327ea7 --- /dev/null +++ b/src/Internal/Exceptions/MissingIdentityConstant.php @@ -0,0 +1,15 @@ +.', $className)); + } +} diff --git a/src/Internal/Exceptions/MissingIdentityProperty.php b/src/Internal/Exceptions/MissingIdentityProperty.php new file mode 100644 index 0000000..2faebea --- /dev/null +++ b/src/Internal/Exceptions/MissingIdentityProperty.php @@ -0,0 +1,21 @@ + referenced by IDENTITY constant does not exist in <%s>.', + $propertyName, + $className + ) + ); + } +} diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php new file mode 100644 index 0000000..bdaac1c --- /dev/null +++ b/src/Snapshot/Snapshot.php @@ -0,0 +1,71 @@ +getProperties() as $property) { + if (!in_array(needle: $property->getName(), haystack: ['recordedEvents', 'sequenceNumber'], strict: true)) { + $aggregateState[$property->getName()] = $property->getValue($aggregate); + } + } + + return new Snapshot( + type: $aggregate->buildAggregateName(), + createdAt: Instant::now(), + aggregateId: $aggregate->getIdentityValue(), + aggregateState: $aggregateState, + sequenceNumber: $aggregate->getSequenceNumber() + ); + } + + public function getType(): string + { + return $this->type; + } + + public function getCreatedAt(): Instant + { + return $this->createdAt; + } + + public function getAggregateId(): mixed + { + return $this->aggregateId; + } + + public function getAggregateState(): array + { + return $this->aggregateState; + } + + public function getSequenceNumber(): SequenceNumber + { + return $this->sequenceNumber; + } +} diff --git a/src/Snapshot/SnapshotCondition.php b/src/Snapshot/SnapshotCondition.php new file mode 100644 index 0000000..d1ddbf7 --- /dev/null +++ b/src/Snapshot/SnapshotCondition.php @@ -0,0 +1,26 @@ +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 + * 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. + * + * @param EventSourcingRoot $aggregate The aggregate under evaluation. + * @return bool True when a snapshot should be taken. + */ + public function shouldSnapshot(EventSourcingRoot $aggregate): bool; +} diff --git a/src/Snapshot/Snapshotter.php b/src/Snapshot/Snapshotter.php new file mode 100644 index 0000000..ce67677 --- /dev/null +++ b/src/Snapshot/Snapshotter.php @@ -0,0 +1,29 @@ +Infrastructure adapters implement this interface to store snapshots in whatever backend is + * appropriate: a relational database table keyed by aggregate id, a document store, object storage, or + * even an in-process cache. The domain layer depends only on this contract and remains unaware of the + * underlying mechanism.

+ * + *

The shipped {@see SnapshotterBehavior} trait captures the snapshot via {@see Snapshot::fromAggregate} + * and delegates the storage step to a concrete persist() hook, leaving adapters with only + * the storage concern to implement.

+ */ +interface Snapshotter +{ + /** + * Captures and persists a snapshot of the given aggregate. + * + * @param EventSourcingRoot $aggregate The aggregate to snapshot. + */ + public function take(EventSourcingRoot $aggregate): void; +} diff --git a/src/Snapshot/SnapshotterBehavior.php b/src/Snapshot/SnapshotterBehavior.php new file mode 100644 index 0000000..0ca59f6 --- /dev/null +++ b/src/Snapshot/SnapshotterBehavior.php @@ -0,0 +1,17 @@ +persist(snapshot: Snapshot::fromAggregate(aggregate: $aggregate)); + } + + abstract protected function persist(Snapshot $snapshot): void; +} diff --git a/src/Upcast/DefaultValues.php b/src/Upcast/DefaultValues.php new file mode 100644 index 0000000..f8c32c1 --- /dev/null +++ b/src/Upcast/DefaultValues.php @@ -0,0 +1,19 @@ + 0, + 'bool' => false, + 'array' => [], + 'float' => 0.0, + 'string' => '' + ]; + } +} diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php new file mode 100644 index 0000000..12da42f --- /dev/null +++ b/src/Upcast/IntermediateEvent.php @@ -0,0 +1,40 @@ +type, + revision: $revision, + serializedEvent: $this->serializedEvent + ); + } + + public function withSerializedEvent(array $serializedEvent): IntermediateEvent + { + return new IntermediateEvent( + type: $this->type, + revision: $this->revision, + serializedEvent: $serializedEvent + ); + } +} diff --git a/src/Upcast/SingleUpcasterBehavior.php b/src/Upcast/SingleUpcasterBehavior.php new file mode 100644 index 0000000..f19511f --- /dev/null +++ b/src/Upcast/SingleUpcasterBehavior.php @@ -0,0 +1,27 @@ +type->value !== static::EXPECTED_EVENT_TYPE) { + return $event; + } + + if ($event->revision->value !== static::FROM_REVISION) { + return $event; + } + + return $event + ->withSerializedEvent(serializedEvent: $this->doUpcast(data: $event->serializedEvent)) + ->withRevision(revision: new Revision(value: static::TO_REVISION)); + } + + abstract protected function doUpcast(array $data): array; +} diff --git a/src/Upcast/Upcaster.php b/src/Upcast/Upcaster.php new file mode 100644 index 0000000..70c3e5a --- /dev/null +++ b/src/Upcast/Upcaster.php @@ -0,0 +1,32 @@ +A single upcaster advances exactly one (type, revision) pair forward by one step. + * Chains of upcasters handle multistep schema evolution: each upcaster in the chain either transforms + * the event when the type and revision match, or returns it unchanged. This preserves compatibility with + * historical events already stored at older revisions without requiring retroactive event rewrites.

+ * + *

The shipped {@see SingleUpcasterBehavior} trait binds an upcaster to a specific + * (EXPECTED_EVENT_TYPE, FROM_REVISION, TO_REVISION) triple through class constants and + * delegates the payload transformation to an abstract doUpcast() hook.

+ * + * @see Greg Young, Versioning in an Event Sourced System (Leanpub, 2017), + * "Basic Type Based Conversion" and "Upcasting". + */ +interface Upcaster +{ + /** + * Transforms the given intermediate event if it matches the expected type and revision. + * + * @param IntermediateEvent $event The event to transform. + * @return IntermediateEvent The transformed event, or the input unchanged when this upcaster does + * not apply. + */ + public function upcast(IntermediateEvent $event): IntermediateEvent; +} diff --git a/tests/Aggregate/AggregateRootBehaviorTest.php b/tests/Aggregate/AggregateRootBehaviorTest.php new file mode 100644 index 0000000..0bfaab2 --- /dev/null +++ b/tests/Aggregate/AggregateRootBehaviorTest.php @@ -0,0 +1,74 @@ +getSequenceNumber(); + + /** @Then it is zero */ + self::assertSame(0, $sequenceNumber->value); + } + + public function testGetModelVersionReflectsDeclaredConstant(): void + { + /** @Given an aggregate declaring MODEL_VERSION = 1 */ + $cart = Cart::blank(identity: new CartId(value: 'cart-2')); + + /** @When retrieving the model version */ + $version = $cart->getModelVersion(); + + /** @Then the version reflects the constant */ + self::assertSame(1, $version->value); + } + + public function testGetModelVersionDefaultsToZeroWhenUndefined(): void + { + /** @Given an aggregate without MODEL_VERSION constant */ + $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen'); + + /** @When retrieving the model version */ + $version = $order->getModelVersion(); + + /** @Then the default is zero */ + self::assertSame(0, $version->value); + } + + public function testBuildAggregateNameForEventSourcedAggregate(): void + { + /** @Given a Cart aggregate */ + $cart = Cart::blank(identity: new CartId(value: 'cart-3')); + + /** @When building the aggregate name */ + $name = $cart->buildAggregateName(); + + /** @Then it matches the short class name */ + self::assertSame('Cart', $name); + } + + public function testBuildAggregateNameForOutboxAggregate(): void + { + /** @Given an Order aggregate */ + $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'lamp'); + + /** @When building the aggregate name */ + $name = $order->buildAggregateName(); + + /** @Then it matches the short class name */ + self::assertSame('Order', $name); + } +} diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Aggregate/EventSourcingRootBehaviorTest.php new file mode 100644 index 0000000..b6c4251 --- /dev/null +++ b/tests/Aggregate/EventSourcingRootBehaviorTest.php @@ -0,0 +1,289 @@ +getSequenceNumber()->value); + } + + public function testBlankAggregateStartsWithEmptyDomainState(): void + { + /** @Given a cart identity */ + $cartId = new CartId(value: 'cart-1b'); + + /** @When creating a blank cart */ + $cart = Cart::blank(identity: $cartId); + + /** @Then the aggregate's domain state is empty */ + self::assertSame([], $cart->getProductIds()); + } + + public function testBlankAggregateCarriesTheGivenIdentity(): void + { + /** @Given a cart identity */ + $cartId = new CartId(value: 'cart-1c'); + + /** @When creating a blank cart */ + $cart = Cart::blank(identity: $cartId); + + /** @Then the aggregate exposes the given identity */ + self::assertSame($cartId, $cart->getIdentity()); + } + + public function testBlankAggregateStartsWithNoRecordedEvents(): void + { + /** @Given a cart identity */ + $cartId = new CartId(value: 'cart-1d'); + + /** @When creating a blank cart */ + $cart = Cart::blank(identity: $cartId); + + /** @Then the recorded events buffer is empty */ + self::assertTrue($cart->recordedEvents()->isEmpty()); + } + + public function testDomainOperationAppliesStateFromEmittedEvent(): void + { + /** @Given a blank cart */ + $cart = Cart::blank(identity: new CartId(value: 'cart-2')); + + /** @When adding a product */ + $cart->addProduct(productId: 'prod-1'); + + /** @Then the domain state reflects the event */ + self::assertSame(['prod-1'], $cart->getProductIds()); + } + + public function testDomainOperationAdvancesSequenceNumber(): void + { + /** @Given a blank cart */ + $cart = Cart::blank(identity: new CartId(value: 'cart-3')); + + /** @And two products added in sequence */ + $cart->addProduct(productId: 'prod-1'); + $cart->addProduct(productId: 'prod-2'); + + /** @When retrieving the sequence number */ + $sequenceNumber = $cart->getSequenceNumber(); + + /** @Then the sequence number equals the number of events */ + self::assertSame(2, $sequenceNumber->value); + } + + public function testDomainOperationAppendsToRecordedEvents(): void + { + /** @Given a blank cart */ + $cart = Cart::blank(identity: new CartId(value: 'cart-4')); + + /** @When adding a product */ + $cart->addProduct(productId: 'prod-1'); + + /** @Then one event is recorded */ + self::assertSame(1, $cart->recordedEvents()->count()); + } + + public function testFirstRecordedEventCarriesEnvelopeMetadata(): void + { + /** @Given a blank cart with a known identity */ + $cartId = new CartId(value: 'cart-5'); + + /** @And a product added to the cart */ + $cart = Cart::blank(identity: $cartId); + $cart->addProduct(productId: 'prod-abc'); + + /** @When inspecting the first recorded record */ + $record = $cart->recordedEvents()->first(); + + /** @Then the envelope carries the expected metadata */ + self::assertSame('ProductAdded', $record->type->value); + self::assertSame(1, $record->revision->value); + self::assertSame(1, $record->sequenceNumber->value); + self::assertSame('Cart', $record->aggregateType); + self::assertInstanceOf(ProductAdded::class, $record->event); + self::assertSame($cartId, $record->identity); + self::assertSame('prod-abc', $record->event->productId); + } + + public function testReconstituteReplaysEventsInOrder(): void + { + /** @Given a cart with two products added */ + $cartId = new CartId(value: 'cart-6'); + $original = Cart::blank(identity: $cartId); + $original->addProduct(productId: 'prod-1'); + $original->addProduct(productId: 'prod-2'); + + /** @When reconstituting from the event stream */ + $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents()); + + /** @Then the replayed state preserves event order */ + self::assertSame(['prod-1', 'prod-2'], $reconstituted->getProductIds()); + } + + public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream(): void + { + /** @Given a cart that received products in a distinctive order */ + $cartId = new CartId(value: 'cart-6b'); + $original = Cart::blank(identity: $cartId); + $original->addProduct(productId: 'zebra'); + $original->addProduct(productId: 'apple'); + $original->addProduct(productId: 'mango'); + + /** @When reconstituting from the event stream */ + $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents()); + + /** @Then the replayed state preserves the exact insertion order */ + self::assertSame(['zebra', 'apple', 'mango'], $reconstituted->getProductIds()); + } + + public function testReconstituteAdvancesSequenceNumberToLastEvent(): void + { + /** @Given a cart with two products added */ + $cartId = new CartId(value: 'cart-6c'); + $original = Cart::blank(identity: $cartId); + $original->addProduct(productId: 'prod-1'); + $original->addProduct(productId: 'prod-2'); + + /** @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->getSequenceNumber()->value); + } + + public function testReconstituteWithEmptyStreamYieldsBlankState(): void + { + /** @Given a cart identity and no events */ + $cartId = new CartId(value: 'cart-7'); + + /** @When reconstituting with no events */ + $reconstituted = Cart::reconstitute(identity: $cartId, records: []); + + /** @Then the state matches a blank aggregate */ + self::assertSame([], $reconstituted->getProductIds()); + } + + public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): void + { + /** @Given a cart identity and no events */ + $cartId = new CartId(value: 'cart-7b'); + + /** @When reconstituting with no events */ + $reconstituted = Cart::reconstitute(identity: $cartId, records: []); + + /** @Then the sequence number remains at the initial value */ + self::assertSame(0, $reconstituted->getSequenceNumber()->value); + } + + public function testReconstituteFromSnapshotRestoresDomainState(): void + { + /** @Given a cart with one product and a snapshot taken at that point */ + $cartId = new CartId(value: 'cart-8'); + $cart = Cart::blank(identity: $cartId); + $cart->addProduct(productId: 'prod-snapshot'); + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @When reconstituting from the snapshot only */ + $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot); + + /** @Then the domain state is fully restored */ + self::assertSame(['prod-snapshot'], $reconstituted->getProductIds()); + } + + public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): void + { + /** @Given a cart with one product and a snapshot taken at that point */ + $cartId = new CartId(value: 'cart-8b'); + $cart = Cart::blank(identity: $cartId); + $cart->addProduct(productId: 'prod-snapshot'); + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @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->getSequenceNumber()->value); + } + + public function testReconstituteCombinesSnapshotWithLaterEvents(): void + { + /** @Given a cart snapshotted after one product, then more events after the snapshot */ + $cartId = new CartId(value: 'cart-8c'); + $cart = Cart::blank(identity: $cartId); + $cart->addProduct(productId: 'prod-1'); + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + $cart->addProduct(productId: 'prod-2'); + $laterRecords = $cart->recordedEvents()->filter( + predicates: static fn($record): bool => $record->sequenceNumber->isAfter(other: $snapshot->getSequenceNumber()) + ); + + /** @When reconstituting from the snapshot and the later records */ + $reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot); + + /** @Then the full state is restored */ + self::assertSame(['prod-1', 'prod-2'], $reconstituted->getProductIds()); + } + + public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequence(): void + { + /** @Given a cart snapshotted after one product, then more events after the snapshot */ + $cartId = new CartId(value: 'cart-8d'); + $cart = Cart::blank(identity: $cartId); + $cart->addProduct(productId: 'prod-1'); + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + $cart->addProduct(productId: 'prod-2'); + $laterRecords = $cart->recordedEvents()->filter( + predicates: static fn($record): bool => $record->sequenceNumber->isAfter(other: $snapshot->getSequenceNumber()) + ); + + /** @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->getSequenceNumber()->value); + } + + public function testBlankThrowsWhenIdentityConstantIsMissing(): void + { + /** @Given an event-sourced aggregate class omitting the IDENTITY constant */ + /** @Then a MissingIdentityConstant exception carrying the class name is thrown */ + $this->expectException(MissingIdentityConstant::class); + $this->expectExceptionMessage(CartWithoutIdentityConstant::class); + + /** @When creating a blank aggregate */ + CartWithoutIdentityConstant::blank(identity: new CartId(value: 'cart-missing')); + } + + public function testReconstitutedAggregateHasNoRecordedEvents(): void + { + /** @Given a cart with one recorded event */ + $cartId = new CartId(value: 'cart-9'); + $original = Cart::blank(identity: $cartId); + $original->addProduct(productId: 'prod-1'); + + /** @When reconstituting from that event stream */ + $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents()); + + /** @Then the reconstituted aggregate has no fresh recorded events */ + self::assertTrue($reconstituted->recordedEvents()->isEmpty()); + } +} diff --git a/tests/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Aggregate/EventualAggregateRootBehaviorTest.php new file mode 100644 index 0000000..2db78e8 --- /dev/null +++ b/tests/Aggregate/EventualAggregateRootBehaviorTest.php @@ -0,0 +1,161 @@ +getSequenceNumber(); + + /** @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->getSequenceNumber(); + + /** @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 testClearRecordedEventsResetsTheBuffer(): void + { + /** @Given an order with recorded events */ + $order = Order::place(orderId: new OrderId(value: 'ord-5'), item: 'desk'); + + /** @When clearing the buffer */ + $order->clearRecordedEvents(); + + /** @Then no events remain */ + self::assertTrue($order->recordedEvents()->isEmpty()); + } + + 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 testRecordedEventsIsEmptyAfterClear(): void + { + /** @Given an order that was placed and immediately cleared */ + $order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle'); + + /** @And the buffer cleared */ + $order->clearRecordedEvents(); + + /** @When retrieving recorded events */ + $records = $order->recordedEvents(); + + /** @Then the collection is empty */ + self::assertTrue($records->isEmpty()); + } + + 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); + } +} diff --git a/tests/Entity/CompoundIdentityBehaviorTest.php b/tests/Entity/CompoundIdentityBehaviorTest.php new file mode 100644 index 0000000..11ad0c3 --- /dev/null +++ b/tests/Entity/CompoundIdentityBehaviorTest.php @@ -0,0 +1,68 @@ +getIdentityValue(); + + /** @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 */ + $result = $first->equals(other: $second); + + /** @Then they are considered equal */ + self::assertTrue($result); + } + + 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 */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } + + 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 */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } +} diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Entity/EntityBehaviorTest.php new file mode 100644 index 0000000..38da3fd --- /dev/null +++ b/tests/Entity/EntityBehaviorTest.php @@ -0,0 +1,154 @@ +getIdentity(); + + /** @Then the same identity instance is returned */ + self::assertSame($orderId, $identity); + } + + public function testGetIdentityNameReturnsPropertyName(): void + { + /** @Given an order aggregate */ + $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen'); + + /** @When retrieving the identity property name */ + $name = $order->getIdentityName(); + + /** @Then it matches the IDENTITY constant value */ + self::assertSame('orderId', $name); + } + + public function testGetIdentityValueReturnsScalarForSingleIdentity(): void + { + /** @Given an order whose identity is a single-value identifier */ + $order = Order::place(orderId: new OrderId(value: 'ord-42'), item: 'pen'); + + /** @When retrieving the identity value */ + $value = $order->getIdentityValue(); + + /** @Then the raw scalar is returned */ + self::assertSame('ord-42', $value); + } + + public function testGetIdentityValueReturnsAssociativeArrayForCompoundIdentity(): void + { + /** @Given a compound identity */ + $appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); + + /** @When retrieving the identity value */ + $value = $appointmentId->getIdentityValue(); + + /** @Then an associative array with all fields is returned */ + self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value); + } + + public function testSameIdentityOfReturnsTrueForAggregatesWithEqualIdentity(): void + { + /** @Given two orders sharing the same identity value */ + $first = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book'); + + /** @And a second order with the same identity value */ + $second = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen'); + + /** @When comparing their identities */ + $result = $first->sameIdentityOf(other: $second); + + /** @Then the comparison yields true */ + self::assertTrue($result); + } + + public function testSameIdentityOfReturnsFalseForAggregatesWithDifferentIdentity(): void + { + /** @Given two orders with different identities */ + $first = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book'); + + /** @And a second order with a different identity */ + $second = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen'); + + /** @When comparing their identities */ + $result = $first->sameIdentityOf(other: $second); + + /** @Then the comparison yields false */ + self::assertFalse($result); + } + + public function testIdentityEqualsReturnsTrueForEqualIdentity(): void + { + /** @Given an order and an identity with the same value */ + $order = Order::place(orderId: new OrderId(value: 'ord-5'), item: 'lamp'); + + /** @And a separately constructed identity of equal value */ + $sameIdentity = new OrderId(value: 'ord-5'); + + /** @When comparing the identity */ + $result = $order->identityEquals(other: $sameIdentity); + + /** @Then the comparison yields true */ + self::assertTrue($result); + } + + public function testIdentityEqualsReturnsFalseForDifferentIdentity(): void + { + /** @Given an order and an identity with a different value */ + $order = Order::place(orderId: new OrderId(value: 'ord-5'), item: 'lamp'); + + /** @And a different identity value */ + $otherIdentity = new OrderId(value: 'ord-9'); + + /** @When comparing the identity */ + $result = $order->identityEquals(other: $otherIdentity); + + /** @Then the comparison yields false */ + self::assertFalse($result); + } + + public function testShipThrowsWhenIdentityConstantIsMissing(): void + { + /** @Given an aggregate whose class omits the IDENTITY constant */ + $order = new OrderWithoutIdentityConstant(); + + /** @Then a MissingIdentityConstant exception carrying the class name is thrown */ + $this->expectException(MissingIdentityConstant::class); + $this->expectExceptionMessage(OrderWithoutIdentityConstant::class); + + /** @When shipping the order and indirectly reaching identity resolution */ + $order->ship(); + } + + public function testShipThrowsWhenIdentityPropertyIsMissing(): void + { + /** @Given an aggregate whose IDENTITY points to a non-existent property */ + $order = new OrderWithMissingIdentityProperty(); + + /** @Then a MissingIdentityProperty exception carrying the property name is thrown */ + $this->expectException(MissingIdentityProperty::class); + $this->expectExceptionMessage('nonExistentProperty'); + + /** @When shipping the order and indirectly reaching identity resolution */ + $order->ship(); + } +} diff --git a/tests/Entity/SingleIdentityBehaviorTest.php b/tests/Entity/SingleIdentityBehaviorTest.php new file mode 100644 index 0000000..aea3603 --- /dev/null +++ b/tests/Entity/SingleIdentityBehaviorTest.php @@ -0,0 +1,53 @@ +getIdentityValue(); + + /** @Then the scalar value is returned as-is */ + self::assertSame('ord-1', $value); + } + + public function testEqualsReturnsTrueForIdenticalSingleIdentities(): void + { + /** @Given two single identities with the same value */ + $first = new OrderId(value: 'ord-1'); + + /** @And a matching counterpart */ + $second = new OrderId(value: 'ord-1'); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are considered equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseForDifferentSingleIdentities(): void + { + /** @Given two single identities with different values */ + $first = new OrderId(value: 'ord-1'); + + /** @And a distinct counterpart */ + $second = new OrderId(value: 'ord-2'); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } +} diff --git a/tests/Event/EventRecordTest.php b/tests/Event/EventRecordTest.php new file mode 100644 index 0000000..d61f8c5 --- /dev/null +++ b/tests/Event/EventRecordTest.php @@ -0,0 +1,141 @@ + 'placed']); + $sequenceNumber = new SequenceNumber(value: 1); + + /** @When constructing the EventRecord */ + $record = new EventRecord( + id: $id, + type: $eventType, + event: $placedEvent, + identity: $orderId, + revision: $revision, + occurredOn: $occurredOn, + snapshotData: $snapshotData, + aggregateType: 'Order', + sequenceNumber: $sequenceNumber + ); + + /** @Then each public field is accessible with the expected value */ + self::assertSame($id, $record->id); + self::assertSame($eventType, $record->type); + self::assertSame($placedEvent, $record->event); + self::assertSame($orderId, $record->identity); + self::assertSame($revision, $record->revision); + self::assertSame($occurredOn, $record->occurredOn); + self::assertSame($snapshotData, $record->snapshotData); + self::assertSame('Order', $record->aggregateType); + self::assertSame($sequenceNumber, $record->sequenceNumber); + } + + public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void + { + /** @Given shared values for two EventRecord instances */ + $id = Uuid::uuid4(); + $orderId = new OrderId(value: 'ord-1'); + $placedEvent = new OrderPlaced(item: 'book'); + $eventType = EventType::fromString(value: 'OrderPlaced'); + $revision = new Revision(value: 1); + $occurredOn = Instant::now(); + $snapshotData = new SnapshotData(data: []); + $sequenceNumber = new SequenceNumber(value: 1); + + /** @And two records constructed from those identical values */ + $first = new EventRecord( + id: $id, + type: $eventType, + event: $placedEvent, + identity: $orderId, + revision: $revision, + occurredOn: $occurredOn, + snapshotData: $snapshotData, + aggregateType: 'Order', + sequenceNumber: $sequenceNumber + ); + $second = new EventRecord( + id: $id, + type: $eventType, + event: $placedEvent, + identity: $orderId, + revision: $revision, + occurredOn: $occurredOn, + snapshotData: $snapshotData, + aggregateType: 'Order', + sequenceNumber: $sequenceNumber + ); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void + { + /** @Given shared values except the identifier */ + $orderId = new OrderId(value: 'ord-1'); + $placedEvent = new OrderPlaced(item: 'book'); + $eventType = EventType::fromString(value: 'OrderPlaced'); + $revision = new Revision(value: 1); + $occurredOn = Instant::now(); + $snapshotData = new SnapshotData(data: []); + $sequenceNumber = new SequenceNumber(value: 1); + + /** @And two records with different UUIDs */ + $first = new EventRecord( + id: Uuid::uuid4(), + type: $eventType, + event: $placedEvent, + identity: $orderId, + revision: $revision, + occurredOn: $occurredOn, + snapshotData: $snapshotData, + aggregateType: 'Order', + sequenceNumber: $sequenceNumber + ); + $second = new EventRecord( + id: Uuid::uuid4(), + type: $eventType, + event: $placedEvent, + identity: $orderId, + revision: $revision, + occurredOn: $occurredOn, + snapshotData: $snapshotData, + aggregateType: 'Order', + sequenceNumber: $sequenceNumber + ); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } +} diff --git a/tests/Event/EventRecordsTest.php b/tests/Event/EventRecordsTest.php new file mode 100644 index 0000000..2f0c6d4 --- /dev/null +++ b/tests/Event/EventRecordsTest.php @@ -0,0 +1,80 @@ +isEmpty(); + + /** @Then the collection is empty */ + self::assertTrue($result); + } + + public function testAddingARecordYieldsACollectionOfOneElement(): void + { + /** @Given an empty EventRecords collection */ + $records = EventRecords::createFromEmpty(); + + /** @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: new Revision(value: 1), + occurredOn: Instant::now(), + snapshotData: new SnapshotData(data: []), + aggregateType: 'Order', + sequenceNumber: new SequenceNumber(value: 1) + ); + + /** @When adding the record */ + $updated = $records->add($record); + + /** @Then the count is one */ + self::assertSame(1, $updated->count()); + } + + public function testFirstElementRoundTripsTheAddedRecord(): void + { + /** @Given a record added to an empty EventRecords collection */ + $record = new EventRecord( + id: Uuid::uuid4(), + type: EventType::fromString(value: 'OrderPlaced'), + event: new OrderPlaced(item: 'book'), + identity: new OrderId(value: 'ord-1'), + revision: new Revision(value: 1), + occurredOn: Instant::now(), + snapshotData: new SnapshotData(data: []), + aggregateType: 'Order', + sequenceNumber: new SequenceNumber(value: 1) + ); + $records = EventRecords::createFromEmpty()->add($record); + + /** @When retrieving the first element */ + $first = $records->first(); + + /** @Then it matches the record added */ + self::assertSame($record, $first); + } +} diff --git a/tests/Event/EventTypeTest.php b/tests/Event/EventTypeTest.php new file mode 100644 index 0000000..7f7b63c --- /dev/null +++ b/tests/Event/EventTypeTest.php @@ -0,0 +1,114 @@ +value); + } + + public function testFromStringAcceptsValidPascalCase(): void + { + /** @Given a valid PascalCase string */ + /** @When creating an EventType from the string */ + $eventType = EventType::fromString(value: 'OrderShipped'); + + /** @Then the value is stored */ + self::assertSame('OrderShipped', $eventType->value); + } + + public function testEqualsReturnsTrueForSameValue(): void + { + /** @Given two event types with the same name */ + $first = EventType::fromString(value: 'OrderPlaced'); + + /** @And a matching counterpart */ + $second = EventType::fromString(value: 'OrderPlaced'); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseForDifferentValues(): void + { + /** @Given two event types with different names */ + $first = EventType::fromString(value: 'OrderPlaced'); + + /** @And a distinct counterpart */ + $second = EventType::fromString(value: 'OrderShipped'); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } + + #[DataProvider('invalidPatterns')] + public function testConstructorRejectsValuesNotMatchingPattern(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'); + + /** @When constructing with the invalid value */ + new EventType(value: $invalidValue); + } + + 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); + + /** @When constructing with an invalid value */ + new EventType(value: 'invalid'); + } + + 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'); + + /** @When constructing with an invalid value */ + new EventType(value: 'lowercaseStart'); + } + + /** + * @return array + */ + public static function invalidPatterns(): array + { + return [ + 'lowercase start' => ['orderPlaced'], + 'contains spaces' => ['Order Placed'], + 'empty string' => [''], + 'contains underscore' => ['Order_Placed'], + 'single character' => ['O'] + ]; + } +} diff --git a/tests/Event/RevisionTest.php b/tests/Event/RevisionTest.php new file mode 100644 index 0000000..b20f308 --- /dev/null +++ b/tests/Event/RevisionTest.php @@ -0,0 +1,109 @@ +value); + } + + public function testRevisionStoresAHigherValidValue(): void + { + /** @Given a larger valid revision value */ + /** @When constructing the revision */ + $revision = new Revision(value: 42); + + /** @Then the value is stored verbatim */ + self::assertSame(42, $revision->value); + } + + public function testEqualsReturnsTrueForSameRevision(): void + { + /** @Given two revisions with the same value */ + $first = new Revision(value: 2); + + /** @And a matching counterpart */ + $second = new Revision(value: 2); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseForDifferentRevisions(): void + { + /** @Given two revisions with different values */ + $first = new Revision(value: 1); + + /** @And a distinct counterpart */ + $second = new Revision(value: 2); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } + + #[DataProvider('invalidValues')] + public function testConstructorRejectsNonPositiveValue(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); + + /** @When constructing with a non-positive value */ + new Revision(value: $invalidValue); + } + + 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); + + /** @When constructing with an invalid value */ + new Revision(value: 0); + } + + 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'); + + /** @When constructing with an invalid value */ + new Revision(value: 0); + } + + /** + * @return array + */ + public static function invalidValues(): array + { + return [ + 'zero' => [0], + 'negative one' => [-1], + 'negative ten' => [-10] + ]; + } +} diff --git a/tests/Event/SequenceNumberTest.php b/tests/Event/SequenceNumberTest.php new file mode 100644 index 0000000..9d9782f --- /dev/null +++ b/tests/Event/SequenceNumberTest.php @@ -0,0 +1,177 @@ +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 testNextYieldsTheFollowingValue(): void + { + /** @Given a sequence number of 5 */ + $sequenceNumber = new SequenceNumber(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 = new SequenceNumber(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 = new SequenceNumber(value: 10); + + /** @And a smaller counterpart */ + $smaller = new SequenceNumber(value: 5); + + /** @When checking if the larger is after the smaller */ + $result = $larger->isAfter(other: $smaller); + + /** @Then the result is true */ + self::assertTrue($result); + } + + public function testIsAfterReturnsFalseWhenEqual(): void + { + /** @Given two equal sequence numbers */ + $first = new SequenceNumber(value: 3); + + /** @And a counterpart with the same value */ + $second = new SequenceNumber(value: 3); + + /** @When checking if one is strictly after the other */ + $result = $first->isAfter(other: $second); + + /** @Then the result is false */ + self::assertFalse($result); + } + + public function testIsAfterReturnsFalseWhenStrictlySmaller(): void + { + /** @Given a smaller sequence number */ + $smaller = new SequenceNumber(value: 2); + + /** @And a larger counterpart */ + $larger = new SequenceNumber(value: 8); + + /** @When checking if the smaller is after the larger */ + $result = $smaller->isAfter(other: $larger); + + /** @Then the result is false */ + self::assertFalse($result); + } + + public function testEqualsReturnsTrueForSameValue(): void + { + /** @Given two sequence numbers with the same value */ + $first = new SequenceNumber(value: 7); + + /** @And a matching counterpart */ + $second = new SequenceNumber(value: 7); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseForDifferentValues(): void + { + /** @Given two sequence numbers with different values */ + $first = new SequenceNumber(value: 1); + + /** @And a distinct counterpart */ + $second = new SequenceNumber(value: 2); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } + + #[DataProvider('negativeValues')] + public function testConstructorRejectsNegativeValue(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 */ + new SequenceNumber(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 */ + new SequenceNumber(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 */ + new SequenceNumber(value: -1); + } + + /** + * @return array + */ + public static function negativeValues(): array + { + return [ + 'minus one' => [-1], + 'minus ten' => [-10] + ]; + } +} diff --git a/tests/Event/SnapshotDataTest.php b/tests/Event/SnapshotDataTest.php new file mode 100644 index 0000000..8295140 --- /dev/null +++ b/tests/Event/SnapshotDataTest.php @@ -0,0 +1,103 @@ + 'placed', 'amount' => 100]); + + /** @When converting to array */ + $result = $snapshotData->toArray(); + + /** @Then the original data is returned */ + self::assertSame(['status' => 'placed', 'amount' => 100], $result); + } + + public function testToJsonProducesValidJson(): void + { + /** @Given snapshot data with a simple payload */ + $snapshotData = new SnapshotData(data: ['status' => 'shipped']); + + /** @When converting to JSON */ + $json = $snapshotData->toJson(); + + /** @Then the result is valid JSON */ + self::assertSame('{"status":"shipped"}', $json); + } + + public function testToJsonPreservesZeroFractionOnFloats(): void + { + /** @Given snapshot data with a float value */ + $snapshotData = new SnapshotData(data: ['amount' => 1.0]); + + /** @When converting to JSON with default flags */ + $json = $snapshotData->toJson(); + + /** @Then the float zero fraction is preserved */ + self::assertSame('{"amount":1.0}', $json); + } + + public function testToJsonHonorsAdditionalFlags(): void + { + /** @Given snapshot data with a nested payload */ + $snapshotData = new SnapshotData(data: ['amount' => 1.0]); + + /** @When converting to JSON with an additional pretty-print flag */ + $json = $snapshotData->toJson(flags: JSON_PRESERVE_ZERO_FRACTION | JSON_PRETTY_PRINT); + + /** @Then the output reflects the requested formatting */ + self::assertStringContainsString("\n", $json); + self::assertStringContainsString('"amount": 1.0', $json); + } + + public function testToJsonThrowsForNonSerializableValue(): void + { + /** @Given snapshot data containing a non-JSON-serializable value */ + $snapshotData = new SnapshotData(data: ['infinity' => INF]); + + /** @Then a JsonException is thrown */ + $this->expectException(JsonException::class); + + /** @When converting to JSON */ + $snapshotData->toJson(); + } + + public function testEqualsReturnsTrueForIdenticalPayloads(): void + { + /** @Given two snapshot data instances with identical payloads */ + $first = new SnapshotData(data: ['status' => 'placed']); + + /** @And a matching counterpart */ + $second = new SnapshotData(data: ['status' => 'placed']); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseForDifferentPayloads(): void + { + /** @Given two snapshot data instances with different payloads */ + $first = new SnapshotData(data: ['status' => 'placed']); + + /** @And a distinct counterpart */ + $second = new SnapshotData(data: ['status' => 'shipped']); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } +} diff --git a/tests/Models/AppointmentId.php b/tests/Models/AppointmentId.php new file mode 100644 index 0000000..d19aff7 --- /dev/null +++ b/tests/Models/AppointmentId.php @@ -0,0 +1,17 @@ + */ + private array $productIds = []; + + public function addProduct(string $productId): void + { + $this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1)); + } + + public function applySnapshot(Snapshot $snapshot): void + { + $state = $snapshot->getAggregateState(); + $this->productIds = $state['productIds'] ?? []; + } + + /** + * @return list + */ + public function getProductIds(): array + { + return $this->productIds; + } + + protected function whenProductAdded(ProductAdded $event): void + { + $this->productIds[] = $event->productId; + } +} diff --git a/tests/Models/CartId.php b/tests/Models/CartId.php new file mode 100644 index 0000000..65e2eae --- /dev/null +++ b/tests/Models/CartId.php @@ -0,0 +1,17 @@ +getSequenceNumber()->value % 2 === 0; + } +} diff --git a/tests/Models/FileSnapshotter.php b/tests/Models/FileSnapshotter.php new file mode 100644 index 0000000..20bca88 --- /dev/null +++ b/tests/Models/FileSnapshotter.php @@ -0,0 +1,26 @@ +latest; + } + + protected function persist(Snapshot $snapshot): void + { + $this->latest = $snapshot; + } +} diff --git a/tests/Models/Order.php b/tests/Models/Order.php new file mode 100644 index 0000000..d4f919f --- /dev/null +++ b/tests/Models/Order.php @@ -0,0 +1,42 @@ +status = 'placed'; + $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1)); + + return $order; + } + + public function ship(string $carrier): void + { + $this->status = 'shipped'; + $this->pushEvent(event: new OrderShipped(carrier: $carrier), revision: new Revision(value: 1)); + } + + public function getStatus(): string + { + return $this->status; + } +} diff --git a/tests/Models/OrderId.php b/tests/Models/OrderId.php new file mode 100644 index 0000000..4e6c583 --- /dev/null +++ b/tests/Models/OrderId.php @@ -0,0 +1,17 @@ +pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1)); + } +} diff --git a/tests/Models/OrderWithoutIdentityConstant.php b/tests/Models/OrderWithoutIdentityConstant.php new file mode 100644 index 0000000..cd73ce8 --- /dev/null +++ b/tests/Models/OrderWithoutIdentityConstant.php @@ -0,0 +1,19 @@ +pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1)); + } +} diff --git a/tests/Models/ProductAdded.php b/tests/Models/ProductAdded.php new file mode 100644 index 0000000..073f9d5 --- /dev/null +++ b/tests/Models/ProductAdded.php @@ -0,0 +1,14 @@ + 1]; + } +} diff --git a/tests/Snapshot/SnapshotConditionTest.php b/tests/Snapshot/SnapshotConditionTest.php new file mode 100644 index 0000000..fc52258 --- /dev/null +++ b/tests/Snapshot/SnapshotConditionTest.php @@ -0,0 +1,67 @@ +shouldSnapshot(aggregate: $cart); + + /** @Then the condition holds because zero is divisible by two */ + self::assertTrue($result); + } + + public function testConditionDoesNotHoldAfterOneEvent(): void + { + /** @Given a cart advanced to sequence number one */ + $cart = Cart::blank(identity: new CartId(value: 'cart-2')); + $cart->addProduct(productId: 'prod-1'); + + /** @When asking the condition whether to snapshot */ + $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + + /** @Then the condition does not hold */ + self::assertFalse($result); + } + + public function testConditionHoldsAgainAfterTwoEvents(): void + { + /** @Given a cart advanced to sequence number two */ + $cart = Cart::blank(identity: new CartId(value: 'cart-3')); + $cart->addProduct(productId: 'prod-1'); + $cart->addProduct(productId: 'prod-2'); + + /** @When asking the condition whether to snapshot */ + $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + + /** @Then the condition holds again at the next even step */ + self::assertTrue($result); + } + + public function testConditionDoesNotHoldAfterThreeEvents(): void + { + /** @Given a cart advanced to sequence number three */ + $cart = Cart::blank(identity: new CartId(value: 'cart-4')); + $cart->addProduct(productId: 'prod-1'); + $cart->addProduct(productId: 'prod-2'); + $cart->addProduct(productId: 'prod-3'); + + /** @When asking the condition whether to snapshot */ + $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + + /** @Then the condition does not hold at an odd step */ + self::assertFalse($result); + } +} diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Snapshot/SnapshotTest.php new file mode 100644 index 0000000..8ad8fbf --- /dev/null +++ b/tests/Snapshot/SnapshotTest.php @@ -0,0 +1,178 @@ +addProduct(productId: 'prod-1'); + + /** @When taking a snapshot */ + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @Then the type matches the aggregate's short class name */ + self::assertSame('Cart', $snapshot->getType()); + } + + public function testFromAggregateCapturesAggregateId(): void + { + /** @Given a cart with a known identity */ + $cart = Cart::blank(identity: new CartId(value: 'cart-id-42')); + + /** @When taking a snapshot */ + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @Then the aggregate id reflects the identity value */ + self::assertSame('cart-id-42', $snapshot->getAggregateId()); + } + + public function testFromAggregateCapturesSequenceNumber(): void + { + /** @Given a cart with two events applied */ + $cart = Cart::blank(identity: new CartId(value: 'cart-2')); + $cart->addProduct(productId: 'prod-1'); + $cart->addProduct(productId: 'prod-2'); + + /** @When taking a snapshot */ + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @Then the sequence number is captured */ + self::assertSame(2, $snapshot->getSequenceNumber()->value); + } + + public function testFromAggregateCapturesCreatedAt(): void + { + /** @Given a blank cart */ + $cart = Cart::blank(identity: new CartId(value: 'cart-3')); + + /** @When taking a snapshot */ + $snapshot = Snapshot::fromAggregate(aggregate: $cart); + + /** @Then the createdAt timestamp is set */ + self::assertInstanceOf(Instant::class, $snapshot->getCreatedAt()); + } + + public function testFromAggregateCarriesDomainFieldsInState(): void + { + /** @Given a cart with a product added */ + $cart = Cart::blank(identity: new CartId(value: 'cart-4')); + $cart->addProduct(productId: 'prod-x'); + + /** @When taking a snapshot */ + $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState(); + + /** @Then the state carries the domain fields */ + self::assertSame(['prod-x'], $state['productIds']); + } + + public function testFromAggregateStateOmitsRecordedEventsBuffer(): void + { + /** @Given a cart with a product added */ + $cart = Cart::blank(identity: new CartId(value: 'cart-5')); + $cart->addProduct(productId: 'prod-x'); + + /** @When taking a snapshot */ + $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState(); + + /** @Then the transient recording buffer is not part of the persisted state */ + self::assertArrayNotHasKey('recordedEvents', $state); + } + + public function testFromAggregateStateOmitsSequenceNumber(): void + { + /** @Given a cart with a product added */ + $cart = Cart::blank(identity: new CartId(value: 'cart-6')); + $cart->addProduct(productId: 'prod-x'); + + /** @When taking a snapshot */ + $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState(); + + /** @Then the sequence number is not duplicated into the state */ + self::assertArrayNotHasKey('sequenceNumber', $state); + } + + public function testRoundTripThroughSnapshotRestoresDomainState(): void + { + /** @Given a cart with a product added */ + $cartId = new CartId(value: 'cart-7'); + $original = Cart::blank(identity: $cartId); + $original->addProduct(productId: 'prod-roundtrip'); + + /** @When taking a snapshot and reconstituting a fresh aggregate from it */ + $snapshot = Snapshot::fromAggregate(aggregate: $original); + $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot); + + /** @Then the reconstituted aggregate carries the same domain state */ + self::assertSame(['prod-roundtrip'], $reconstituted->getProductIds()); + } + + public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void + { + /** @Given shared fields for two snapshots */ + $sequenceNumber = new SequenceNumber(value: 1); + $createdAt = Instant::now(); + + /** @And two snapshots built from those identical fields */ + $first = new Snapshot( + type: 'Cart', + createdAt: $createdAt, + aggregateId: 'cart-1', + aggregateState: ['productIds' => []], + sequenceNumber: $sequenceNumber + ); + $second = new Snapshot( + type: 'Cart', + createdAt: $createdAt, + aggregateId: 'cart-1', + aggregateState: ['productIds' => []], + sequenceNumber: $sequenceNumber + ); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void + { + /** @Given two snapshots that differ only by type */ + $sequenceNumber = new SequenceNumber(value: 1); + $createdAt = Instant::now(); + + /** @And the two snapshots constructed accordingly */ + $first = new Snapshot( + type: 'Cart', + createdAt: $createdAt, + aggregateId: 'cart-1', + aggregateState: [], + sequenceNumber: $sequenceNumber + ); + $second = new Snapshot( + type: 'Order', + createdAt: $createdAt, + aggregateId: 'cart-1', + aggregateState: [], + sequenceNumber: $sequenceNumber + ); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } +} diff --git a/tests/Snapshot/SnapshotterBehaviorTest.php b/tests/Snapshot/SnapshotterBehaviorTest.php new file mode 100644 index 0000000..9f9520b --- /dev/null +++ b/tests/Snapshot/SnapshotterBehaviorTest.php @@ -0,0 +1,69 @@ +addProduct(productId: 'prod-1'); + $snapshotter = new FileSnapshotter(); + + /** @When taking a snapshot */ + $snapshotter->take(aggregate: $cart); + + /** @Then a Snapshot is persisted */ + self::assertInstanceOf(Snapshot::class, $snapshotter->lastSnapshot()); + } + + public function testPersistedSnapshotReflectsTheAggregateType(): void + { + /** @Given a cart and a fresh snapshotter */ + $cart = Cart::blank(identity: new CartId(value: 'cart-2')); + $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()->getType()); + } + + public function testPersistedSnapshotReflectsTheAggregateSequenceNumber(): void + { + /** @Given a cart advanced to sequence number 2 */ + $cart = Cart::blank(identity: new CartId(value: 'cart-3')); + $cart->addProduct(productId: 'prod-1'); + $cart->addProduct(productId: 'prod-2'); + $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()->getSequenceNumber()->value); + } + + public function testPersistedSnapshotReflectsTheAggregateIdentity(): void + { + /** @Given a cart with a known identity */ + $cart = Cart::blank(identity: new CartId(value: 'cart-4')); + $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()->getAggregateId()); + } +} diff --git a/tests/Upcast/DefaultValuesTest.php b/tests/Upcast/DefaultValuesTest.php new file mode 100644 index 0000000..e2af751 --- /dev/null +++ b/tests/Upcast/DefaultValuesTest.php @@ -0,0 +1,35 @@ + 'prod-1']; + + /** @When constructing the intermediate event */ + $event = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $serializedEvent); + + /** @Then each public field is accessible */ + self::assertSame($eventType, $event->type); + self::assertSame($revision, $event->revision); + self::assertSame($serializedEvent, $event->serializedEvent); + } + + public function testWithRevisionOnlyReplacesTheRevision(): void + { + /** @Given an intermediate event at revision 1 */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 1), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When bumping to revision 2 */ + $bumped = $event->withRevision(revision: new Revision(value: 2)); + + /** @Then the revision changes */ + self::assertSame(2, $bumped->revision->value); + } + + public function testWithRevisionPreservesTheTypeAndPayload(): void + { + /** @Given an intermediate event at revision 1 */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 1), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When bumping to revision 2 */ + $bumped = $event->withRevision(revision: new Revision(value: 2)); + + /** @Then neither the type nor the payload are affected */ + self::assertSame('ProductAdded', $bumped->type->value); + self::assertSame(['productId' => 'prod-1'], $bumped->serializedEvent); + } + + public function testWithRevisionReturnsANewInstance(): void + { + /** @Given an intermediate event at revision 1 */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 1), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When bumping to revision 2 */ + $bumped = $event->withRevision(revision: new Revision(value: 2)); + + /** @Then the source event remains untouched */ + self::assertNotSame($event, $bumped); + self::assertSame(1, $event->revision->value); + } + + public function testWithSerializedEventOnlyReplacesThePayload(): void + { + /** @Given an intermediate event with an original payload */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 1), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When replacing the serialized payload */ + $updated = $event->withSerializedEvent(serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]); + + /** @Then the payload changes */ + self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $updated->serializedEvent); + } + + public function testWithSerializedEventPreservesTheTypeAndRevision(): void + { + /** @Given an intermediate event with an original payload */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 1), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When replacing the serialized payload */ + $updated = $event->withSerializedEvent(serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]); + + /** @Then neither the type nor the revision are affected */ + self::assertSame(1, $updated->revision->value); + self::assertSame('ProductAdded', $updated->type->value); + } + + public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void + { + /** @Given shared fields for two intermediate events */ + $eventType = EventType::fromString(value: 'ProductAdded'); + $revision = new Revision(value: 1); + $payload = ['productId' => 'prod-1']; + + /** @And two intermediate events with identical values */ + $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload); + $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($result); + } + + public function testEqualsReturnsFalseForDifferentPayloads(): void + { + /** @Given two intermediate events with different payloads */ + $eventType = EventType::fromString(value: 'ProductAdded'); + $revision = new Revision(value: 1); + + /** @And the two events constructed accordingly */ + $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']); + $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'b']); + + /** @When comparing them */ + $result = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($result); + } +} diff --git a/tests/Upcast/SingleUpcasterBehaviorTest.php b/tests/Upcast/SingleUpcasterBehaviorTest.php new file mode 100644 index 0000000..df0a744 --- /dev/null +++ b/tests/Upcast/SingleUpcasterBehaviorTest.php @@ -0,0 +1,78 @@ + 'prod-1'] + ); + + /** @When upcasting with ProductV1Upcaster */ + $upcasted = new ProductV1Upcaster()->upcast(event: $event); + + /** @Then the revision is bumped to the target value */ + self::assertSame(2, $upcasted->revision->value); + } + + public function testUpcastEnrichesThePayloadOfAMatchingEvent(): void + { + /** @Given a ProductAdded event at revision 1 */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 1), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When upcasting with ProductV1Upcaster */ + $upcasted = new ProductV1Upcaster()->upcast(event: $event); + + /** @Then the payload is enriched with the default quantity */ + self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $upcasted->serializedEvent); + } + + public function testUpcastReturnsUnchangedEventForMismatchedType(): void + { + /** @Given an event whose type is not the one the upcaster handles */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'OrderPlaced'), + revision: new Revision(value: 1), + serializedEvent: ['item' => 'book'] + ); + + /** @When applying the upcaster */ + $result = new ProductV1Upcaster()->upcast(event: $event); + + /** @Then the same instance is returned unchanged */ + self::assertSame($event, $result); + } + + public function testUpcastReturnsUnchangedEventForMismatchedRevision(): void + { + /** @Given a ProductAdded event at revision 2, past the upcaster's FROM_REVISION */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: new Revision(value: 2), + serializedEvent: ['productId' => 'prod-1', 'quantity' => 1] + ); + + /** @When applying the upcaster */ + $result = new ProductV1Upcaster()->upcast(event: $event); + + /** @Then the same instance is returned unchanged */ + self::assertSame($event, $result); + } +}