Skip to content

Commit c763a1f

Browse files
authored
Phase 6 Domain layer: constructor invariants, FieldValueBag, domain events #6
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.)
1 parent 09967a2 commit c763a1f

23 files changed

Lines changed: 658 additions & 24 deletions

docs/imanager-2.0-plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,8 @@ können parallel laufen, sobald ihre Vorbedingungen erfüllt sind.
273273
| 2 | Architektur-Foundations | ✅ done | `phase-2-foundations` (PR #1, squashed → main) |
274274
| 3 | Storage-Abstraktion | ✅ done | `phase-3-storage-iface` (PR #2, squashed → main) |
275275
| 4 | SQLite-Implementierung | ✅ done | `phase-4-sqlite` (PR #4, squashed → main) |
276-
| 5 | Query-Builder & Selector-DSL | 🟡 in progress | `phase-5-query` |
277-
| 6 | Domain-Models neu | ⬜ todo | `phase-6-domain` |
276+
| 5 | Query-Builder & Selector-DSL | ✅ done | `phase-5-query` (PR #5, squashed → main) |
277+
| 6 | Domain-Models neu | 🟡 in progress | `phase-6-domain` |
278278
| 7 | Field-Type-System | ⬜ todo | `phase-7-fields` |
279279
| 8 | Volltextsuche (FTS5) | ⬜ todo | `phase-8-fts` |
280280
| 9 | Migration-Tool (1.x → 2.0) | ⬜ todo | `phase-9-migration` |

src/Domain/Category.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
namespace Imanager\Domain;
66

77
/**
8-
* Anemic data carrier for a category.
8+
* A content category — the top-level grouping that owns fields and items.
99
*
10-
* Phase 3 keeps this intentionally lightweight; Phase 6 will enrich it with
11-
* domain-level invariants, factory methods, and `CategoryId` value-object
12-
* wrappers (if we decide to introduce them).
10+
* `Category` is a value object. Constructor invariants enforce structural
11+
* sanity (non-empty name and slug, non-negative position, monotonically
12+
* non-negative timestamps); business rules like uniqueness or slug format
13+
* live at the storage / Sanitizer boundary, not here.
1314
*/
1415
final readonly class Category
1516
{
@@ -20,7 +21,23 @@ public function __construct(
2021
public int $position = 0,
2122
public int $created = 0,
2223
public int $updated = 0,
23-
) {}
24+
) {
25+
if ($id !== null && $id < 1) {
26+
throw new \InvalidArgumentException('Category id, when set, must be >= 1');
27+
}
28+
if (trim($name) === '') {
29+
throw new \InvalidArgumentException('Category name must not be empty');
30+
}
31+
if (trim($slug) === '') {
32+
throw new \InvalidArgumentException('Category slug must not be empty');
33+
}
34+
if ($position < 0) {
35+
throw new \InvalidArgumentException('Category position must be >= 0');
36+
}
37+
if ($created < 0 || $updated < 0) {
38+
throw new \InvalidArgumentException('Category timestamps must be >= 0');
39+
}
40+
}
2441

2542
public function withId(int $id): self
2643
{
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
use Imanager\Domain\Category;
8+
9+
final readonly class CategoryCreated implements DomainEvent
10+
{
11+
public function __construct(
12+
public Category $category,
13+
public int $occurredAt,
14+
) {}
15+
16+
public function occurredAt(): int
17+
{
18+
return $this->occurredAt;
19+
}
20+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
final readonly class CategoryDeleted implements DomainEvent
8+
{
9+
public function __construct(
10+
public int $categoryId,
11+
public int $occurredAt,
12+
) {}
13+
14+
public function occurredAt(): int
15+
{
16+
return $this->occurredAt;
17+
}
18+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
use Imanager\Domain\Category;
8+
9+
final readonly class CategoryUpdated implements DomainEvent
10+
{
11+
public function __construct(
12+
public Category $previous,
13+
public Category $current,
14+
public int $occurredAt,
15+
) {}
16+
17+
public function occurredAt(): int
18+
{
19+
return $this->occurredAt;
20+
}
21+
}

src/Domain/Event/DomainEvent.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
/**
8+
* Marker interface for every domain event raised by the iManager core.
9+
*
10+
* Phase 6 ships only the data classes — they're pure records carrying the
11+
* state changes that storage operations produced. A dispatcher and listener
12+
* registry land alongside Scriptor's hook system in a later phase; this
13+
* interface plus the concrete events are everything that needs to exist
14+
* in iManager itself.
15+
*/
16+
interface DomainEvent
17+
{
18+
/**
19+
* Unix timestamp (seconds) at which the event happened.
20+
*/
21+
public function occurredAt(): int;
22+
}

src/Domain/Event/FieldCreated.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
use Imanager\Domain\Field;
8+
9+
final readonly class FieldCreated implements DomainEvent
10+
{
11+
public function __construct(
12+
public Field $field,
13+
public int $occurredAt,
14+
) {}
15+
16+
public function occurredAt(): int
17+
{
18+
return $this->occurredAt;
19+
}
20+
}

src/Domain/Event/FieldDeleted.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
final readonly class FieldDeleted implements DomainEvent
8+
{
9+
public function __construct(
10+
public int $fieldId,
11+
public int $categoryId,
12+
public string $name,
13+
public int $occurredAt,
14+
) {}
15+
16+
public function occurredAt(): int
17+
{
18+
return $this->occurredAt;
19+
}
20+
}

src/Domain/Event/FieldUpdated.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
use Imanager\Domain\Field;
8+
9+
final readonly class FieldUpdated implements DomainEvent
10+
{
11+
public function __construct(
12+
public Field $previous,
13+
public Field $current,
14+
public int $occurredAt,
15+
) {}
16+
17+
public function occurredAt(): int
18+
{
19+
return $this->occurredAt;
20+
}
21+
}

src/Domain/Event/ItemCreated.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Imanager\Domain\Event;
6+
7+
use Imanager\Domain\Item;
8+
9+
final readonly class ItemCreated implements DomainEvent
10+
{
11+
public function __construct(
12+
public Item $item,
13+
public int $occurredAt,
14+
) {}
15+
16+
public function occurredAt(): int
17+
{
18+
return $this->occurredAt;
19+
}
20+
}

0 commit comments

Comments
 (0)