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); + } +}