Phase 6 Domain layer: constructor invariants, FieldValueBag, domain events - #6
Merged
Conversation
Phase 6 — domain models grow real semantics. Invariants on Category, Field, Item: - Non-null ids must be >= 1; categoryId must be >= 1. - Names and slugs cannot be empty (whitespace-trimmed). - Position and timestamps cannot be negative. - Violations raise \InvalidArgumentException — caller programming error, not a runtime/user-input concern (those are still routed through ValidationException at the storage / Sanitizer boundary). FieldValueBag: - Replaces Item->data's bare array<string, mixed> with an immutable typed wrapper. - Provides has / get / with / without / merge / toArray / isEmpty / count, with `with`/`without`/`merge` returning a new instance every time. - Distinguishes "key absent" from "value is null" so a Field plugin can legitimately store null values without confusing default-resolution. - Item's constructor still accepts FieldValueBag|array for ergonomic test setups; the property type is always FieldValueBag. Domain events: - DomainEvent marker interface + nine concrete events (Category/Field/Item × Created/Updated/Deleted) as final readonly data records. No dispatcher yet — that lands together with Scriptor's hook system in a later phase. - Updated events carry both `previous` and `current` so listeners can diff. Deleted events carry enough context (ids, name) to react without re-fetching the now-missing record. Storage adapters updated: - InMemoryStorage::fieldValue() and SqliteItemRepository's encode/hydrate paths read/write through FieldValueBag::toArray(). - Phase 5's Query layer keeps working because the structural-vs-JSON switch already routed dynamic field reads through a single helper. Tests: 266 / 567 assertions; PHPStan 8 + Psalm 3 clean. (Per discussion: typed *Id value objects intentionally NOT introduced in this phase — three aggregates make the boilerplate cost outweigh the type-safety benefit. We can adopt them surgically later if a hot spot calls for it.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements Phase 6 of the iManager 2.0 plan — turning the Phase 3 anemic DTOs into proper domain models.
What's in
Constructor invariants on Category, Field, Item
Phase 3 deliberately accepted any input. Phase 6 enforces structural sanity:
idmust be>= 1.Field.categoryIdandItem.categoryIdmust be>= 1(a field/item without an owning category is meaningless).Category.name,Category.slug,Field.namecannot be empty (whitespace-trimmed).positionand timestamps cannot be negative.Violations raise
\InvalidArgumentException— these are caller programming errors. Real user-input validation still flows throughValidationExceptionat the storage boundary.FieldValueBagReplaces
Item->data's barearray<string, mixed>with an immutable typed wrapper:Two subtle decisions worth noting:
has()distinguishes "key absent" from "value is null" — so a Field plugin can legitimately store a null value without colliding with default resolution.Item's constructor acceptsFieldValueBag|arrayso existing callers/tests don't have to wrap manually. The property type is alwaysFieldValueBag— there's no escape hatch from the typed form once anItemexists.Domain events
Nine
final readonlydata records underImanager\Domain\Event\:CategoryCreatedCategoryUpdatedCategoryDeletedFieldCreatedFieldUpdatedFieldDeletedItemCreatedItemUpdatedItemDeletedAll implement
DomainEventwithoccurredAt(): int(Unix timestamp). Updated events carry bothpreviousandcurrentso listeners can diff. Deleted events carry enough context (fieldId+categoryId+name, etc.) to react without re-fetching the now-missing record.No dispatcher yet — that lands together with Scriptor's hook system in a later phase. The events ship as data so we can name them precisely, write listener tests, and bolt the bus on later without changing the event surface.
Storage adapter touch-ups
InMemoryStorage::fieldValue()— reads dynamic fields throughFieldValueBag::get()instead of$item->data[$field] ?? null.SqliteItemRepository—encodeData()calls$item->data->toArray()beforejson_encode;hydrate()continues to feed the array intoItem's constructor (which wraps it).What's deliberately not in this phase
Typed
*Idvalue objects (CategoryId,FieldId,ItemId). After discussion: with three aggregates the boilerplate cost outweighs the type-safety benefit, and the boundary work (URL parsing, JSON serialization, generated-column lookups) would noticeably grow. We keep the door open by isolating ID-related invariants in a single place per class so the future migrationis mechanical.
Acceptance criteria (from plan §7 Phase 6)
Category,Field,Itemas final readonly value objects with promoted propertiesextends FieldMapperfor Item (already true since Phase 3)imanager()calls in domain code (already true since Phase 3)Item->dataas a typedFieldValueBagwithget(string $field): mixed*Idvalue objects — deferred (see above)Verification
Files
src/Domain/{Category,Field,Item}.php(modified — add invariants)src/Domain/FieldValueBag.php(new)src/Domain/Event/{DomainEvent,CategoryCreated,CategoryUpdated,CategoryDeleted,FieldCreated,FieldUpdated,FieldDeleted,ItemCreated,ItemUpdated,ItemDeleted}.php(new)src/Storage/InMemory/InMemoryStorage.php,src/Storage/Sqlite/SqliteItemRepository.php(FieldValueBag plumbing)tests/Unit/Domain/{CategoryTest,FieldTest,ItemTest,FieldValueBagTest}.php+tests/Unit/Domain/Event/DomainEventsTest.phptests/Unit/Storage/ItemRepositoryContract.php— assertions read->data->toArray()