Skip to content

Phase 6 Domain layer: constructor invariants, FieldValueBag, domain events - #6

Merged
bigin merged 1 commit into
mainfrom
phase-6-domain
May 2, 2026
Merged

Phase 6 Domain layer: constructor invariants, FieldValueBag, domain events#6
bigin merged 1 commit into
mainfrom
phase-6-domain

Conversation

@bigin

@bigin bigin commented May 2, 2026

Copy link
Copy Markdown
Owner

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:

  • Non-null id must be >= 1.
  • Field.categoryId and Item.categoryId must be >= 1 (a field/item without an owning category is meaningless).
  • Category.name, Category.slug, Field.name cannot be empty (whitespace-trimmed).
  • position and timestamps cannot be negative.

Violations raise \InvalidArgumentException — these are caller programming errors. Real user-input validation still flows through ValidationException at the storage boundary.

FieldValueBag

Replaces Item->data's bare array<string, mixed> with an immutable typed wrapper:

$bag = new FieldValueBag(['title' => 'Hello']);                                                                                                                                                                                                                                                                                                                                          
$bag->has('title');           // true                                                                                                                                                                                                                                                                                                                                                    
$bag->get('title');           // 'Hello'                                                                                                                                                                                                                                                                                                                                                 
$bag->get('missing', '');    // '–' (default)                                                                                                                                                                                                                                                                                                                                           
$bag->with('subtitle', 'Hi'); // new bag, original untouched                                                                                                                                                                                                                                                                                                                             
$bag->without('title');       // new bag without the key                                                                                                                                                                                                                                                                                                                                 
$bag->merge(['extra' => 1]);  // accepts arrays or another bag                                                                                                                                                                                                                                                                                                                           
$bag->toArray();              // back to plain array for the JSON boundary                                                                                                                                                                                                                                                                                                               

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 accepts FieldValueBag|array so existing callers/tests don't have to wrap manually. The property type is always FieldValueBag — there's no escape hatch from the typed form once an Item exists.

Domain events

Nine final readonly data records under Imanager\Domain\Event\:

Aggregate Created Updated Deleted
Category CategoryCreated CategoryUpdated CategoryDeleted
Field FieldCreated FieldUpdated FieldDeleted
Item ItemCreated ItemUpdated ItemDeleted

All implement DomainEvent with occurredAt(): int (Unix timestamp). Updated events carry both previous and current so 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 through FieldValueBag::get() instead of $item->data[$field] ?? null.
  • SqliteItemRepositoryencodeData() calls $item->data->toArray() before json_encode; hydrate() continues to feed the array into Item's constructor (which wraps it).
  • The Phase 5 query layer keeps working without changes because the structural-vs-JSON switch already routed dynamic field reads through a single helper.

What's deliberately not in this phase

Typed *Id value 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 migration
is mechanical.

Acceptance criteria (from plan §7 Phase 6)

  • Category, Field, Item as final readonly value objects with promoted properties
  • No extends FieldMapper for Item (already true since Phase 3)
  • No imanager() calls in domain code (already true since Phase 3)
  • Item->data as a typed FieldValueBag with get(string $field): mixed
  • Domain events for Category / Field / Item × Created / Updated / Deleted
  • *Id value objects — deferred (see above)

Verification

Check Result
PHP-CS-Fixer 0 issues
PHPStan level 8 no errors
Psalm level 3 no errors, 99.31% type inference
PHPUnit 266 tests, 567 assertions (229 from Phase 5 + 37 new)

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.php
  • tests/Unit/Storage/ItemRepositoryContract.php — assertions read ->data->toArray()

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.)
@bigin
bigin merged commit c763a1f into main May 2, 2026
4 checks passed
@bigin
bigin deleted the phase-6-domain branch May 2, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant