diff --git a/Doku/adr/007-architekturrichtlinien.md b/Doku/adr/007-architekturrichtlinien.md new file mode 100644 index 0000000..6a38ea9 --- /dev/null +++ b/Doku/adr/007-architekturrichtlinien.md @@ -0,0 +1,31 @@ +# 007 – Architekturrichtlinien + +**Status:** accepted + +**Kontext.** Das Projekt soll eine moderne Symfony-Anwendung mit sauberer +schichtbasierter Architektur haben. Als Referenz dient das interne Repo +[`jens/symfony-architektur`](https://git.ljh31.de/jens/symfony-architektur), +dort insbesondere `architektur.md` (13 Sektionen) und `AGENTS.md`. + +**Entscheidung.** Die Codebasis unter `src/` wird in genau drei primäre +Layer gegliedert, strikt nach Abhängigkeitsrichtung (UI → Logic → +Interface → Data-Implementierung): + +- **`src/UI`** – Controller, Formulare, Templates, Twig-Views, HTTP-Adaption. +- **`src/Logic`** – Reine PHP (keine Framework-Abhängigkeiten). UseCases für + Writes, Querien für Reads, Manager pro Model, Provider-/Processor-Interfaces, + Models, DTOs, Domain-Exceptions, Event-Veröffentlichung. +- **`src/Data`** – Doctrine-Entities, Provider-/Processor-Implementierungen, + Mappers, Transaction-Manager-Bridge, Bridge zum Symfony EventDispatcher. + +**Konsequenzen.** +- + Business-Logik ist 100 % framework-unabhängig und in Unit-Tests ohne Kernel lauffähig. +- + Data-Implementierungen sind austauschbar; z. B. SQLite ↔ Postgres nur im Data-Layer. +- + Die UI kann auch als HA-REST-Consumer konsumiert werden (`/api/today`, `/api/dishes`). +- − Mehr Boilerplate als eine anemic-Entity-Architektur; kompensiert durch + konventionelle Naming- & Verzeichnis-Regeln und Symfony-Autowiring. +- − Symfony-Forms nutzen wir **nicht** (htmx + eigene Validierung aus ADR-002 + bleibt; Forms wären eine zusätzliche Framework-Abhängigkeit, die gegen + die UI-Logik-Regel in der Referenz-Architektur verstoßen würde). Wir + validieren im Controller per reinem PHP, wie Sektion 4 der Referenz das + vorsieht. diff --git a/Doku/architektur.md b/Doku/architektur.md new file mode 100644 index 0000000..acfad46 --- /dev/null +++ b/Doku/architektur.md @@ -0,0 +1,525 @@ +# Architektur – svc-essensplanung + +> Konkretisierung der ADR-007 für diese App. Referenz: +> [jens/symfony-architektur](https://git.ljh31.de/jens/symfony-architektur). + +## 0. Die drei Layer (Kurzreferenz) + +``` ++-------------+ +--------------------------+ +--------------------------------------+ +| UI | | Logic | | Data | +| (src/UI) | --> | (src/Logic) | --> | (src/Data) | +├-------------┤ ├──────────────────────────┤ ├────────────────────────────────────────┤ +│ Controllers │ │ UseCases (Writes) | │ Entities (Doctrine) │ +│ Commands │ │ Queries (Reads) | │ Mappers (Entity <-> Model) │ +│ Templates | │ Managers (per Model) | │ Providers (Read) │ +│ (Twig) | │ Models (POPO, Rich) | │ Processors (Write) │ +│ Exception | │ DTOs (readonly) | │ Transaction-Manager-Bridge │ +│ Subscriber | │ Exceptions (Domain) | │ EventDispatcher-Bridge │ +└-------------┘ │ Mapping (Model-Factory) | │ │ + | Provider/Processor Ifcs | │ │ + └──────────────────────────┘ └────────────────────────────────────────┘ +``` + +**Abhängigkeitsregel (Dependency Inversion):** +UI → Logic; Logic → Interface (Definition im Logic); Data → Interface +(Definition im Data). Die Data-Layer implementiert Interfaces aus der +Logic-Layer. **Keine** Umkehr. + +**Namenskonvention (Kurz):** +- Interface: Suffixe `Interface` (z. B. `DishProviderInterface`). +- UseCase: Suffix `UseCase` (z. B. `CreateDishUseCase`). +- Query: Suffix `Query` (z. B. `ListDishesQuery`). +- Manager: Suffix `Manager` (z. B. `DishManager`). +- Provider/Processor: Suffix `Provider`/`Processor` (z. B. `DishProvider`). +- Mapper: Suffix `Mapper` (z. B. `DishMapper`). +- Model-Factory: Suffix `Factory` (z. B. `DishModelFactory`). +- DTO: Suffix `Request` bzw. `Response`. +- Exception: Suffix `Exception`. +- Entity (Data): Suffix `Entity` (z. B. `DishEntity`). + +## 1. Domain-Modell (Logik) + +**Eine** Modul-Domäne: `Essen`. Darin ein Business-Model: `Dish` +(Gericht/Eintrag). Wichtiger Hinweis: Die "Archivsicht" ist **kein** +abgesonderter Business-Model, sondern eine **Volltext-Sicht** (Read-Query) mit +dem Filter `status = gegessen`. Das reduziert die Komplexität. + +### 1.1. Das Business-Model `Dish` + +`src/Logic/Essen/Model/Dish.php` – **Rich Domain Model** (POPO, readonly, +kein Framework): + +```php +namespace App\Logic\Essen\Model; + +class Dish +{ + public function __construct( + public readonly ?int $id, + public string $name, + public ?string $notiz, + /** @var DishDay[] */ + public readonly array $wochentage, + public readonly int $sort_order, + public DishStatus $status, + public readonly ?\DateTimeImmutable $gegessen_am, + public readonly \DateTimeImmutable $erstellt_am, + public readonly \DateTimeImmutable $aktualisiert_am, + ) {} + + // State transitions: kapseln eigene Statusänderungen + public function markAsGegessen(\DateTimeImmutable $now): self { + if ($this->status === DishStatus::GEGESSEN) { + throw new BusinessRuleViolationException('Bereits gegessen.'); + } + return new self($this->id, $this->name, $this->notiz, $this->wochentage, + $this->sort_order, DishStatus::GEGESSEN, $now, $this->erstellt_am, $now); + } + + public function markAsGeplant(\DateTimeImmutable $now): self { /* symmetrisch */ } +} +``` + +**Enums** in `src/Logic/Essen/Model/`: +- `DishStatus` (`GEPLANT`, `GEGESSEN`) +- `DishDay` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`) + +### 1.2. DTOs + +``` +src/Logic/Essen/Dto/ +├── CreateDishRequest.php // name, notiz?, wochentag[]? +├── UpdateDishRequest.php // id, name, notiz?, wochentag[]?, sort_order? +├── DeleteDishRequest.php // id +├── MoveDishRequest.php // id, position (int) +├── ToggleDishStatusRequest.php // id +├── ListDishesRequest.php // status? (optional), sort='sort_order'|'gegessen_am' +├── ListDishesResponse.php // items: Dish[] +├── InspirationRequest.php // limit=5, offset=0, excludeIds: int[] +├── InspirationResponse.php // suggestions: Dish[] +└── TodayResponse.php // dishes: Dish[] // optional HA-Consumer +``` + +Alle `readonly`, native Typisierung, **keine** Attribute/Constraints. + +### 1.3. UseCases (Writes) + +``` +src/Logic/Essen/UseCase/ +├── CreateDishUseCase.php +├── UpdateDishUseCase.php +├── DeleteDishUseCase.php +├── MoveDishUseCase.php // ändert sort_order (bzw. sort_order-Reihenfolge) +└── ToggleDishStatusUseCase.php // markAsGegessen / markAsGeplant +``` + +**Verfahren (schlau aus Referenz Sektion 1 & 3):** +1. Syntaktische Validierung im UI-Layer (Controller/Command) als **rein + PHP** (keine Symfony-Validator-Constraints auf DTOs). +2. Business-Validierung im UseCase + im Model-Konstruktor (Invarianzen). +3. Persistence im UseCase via `DishManager` → `DishProcessor` + (Write); Cache-Sidecar-Invalidierung im Manager. +4. Event-Dispatch nach erfolgreichem Commit (Bridge in Data-Layer). +5. Response-DTO (z. B. `UpdateDishResponse`) zurück an Controller. + +**Beispiel** (UseCase `CreateDishUseCase`): + +```php +namespace App\Logic\Essen\UseCase; + +readonly class CreateDishUseCase +{ + public function __construct( + private TransactionManagerInterface $tx, + private DishManagerInterface $dishes, + private DishModelFactory $factory, + private EventDispatcherInterface $events, + ) {} + + public function execute(CreateDishRequest $req): CreateDishResponse + { + return $this->tx->execute(function() use ($req): CreateDishResponse { + $dish = $this->factory->createFromRequest($req); // DTO -> Model + $saved = $this->dishes->create($dish); // Model -> Processor + $this->events->dispatch(new DishCreatedEvent(id: $saved->id)); + return new CreateDishResponse(id: $saved->id, dish: $saved); + }); + } +} +``` + +### 1.4. Queries (Reads) + +``` +src/Logic/Essen/Query/ +├── ListDishesQuery.php // -> ListDishesResponse +├── GetDishQuery.php // -> Dish (optional) +├── TodayQuery.php // -> TodayResponse (HA-Consumer / optional) +└── InspirationQuery.php // -> InspirationResponse (5er-Batch + Offset) +``` + +**InspirationQuery** (Algorithmus, ADR-006): + +```php +readonly class InspirationQuery +{ + public function __construct( + private InspirationWeightSettings $settings, // w_slow/w_rare/w_hot + private DishManagerInterface $dishes, + private DishHistoryProviderInterface $history, // aus Data-Layer + ) {} + + public function execute(InspirationRequest $req): InspirationResponse + { + $all = $this->dishes->listGegessen(); + $history = $this->history->historyByDishName(); + + $scored = $all + ->filter(fn(Dish $d) => !in_array($d->id, $req->excludeIds, true)) + ->map(fn(Dish $d) => new Scored( + dish: $d, + score: $this->score($d, $history), + )) + ->sortDescByScore() + ->slice($req->offset, $req->limit); + + return new InspirationResponse(suggestions: $scored->map(fn($s)=>$s->dish)); + } + + private function score(Dish $d, array $h): float { + // w_slow * staleness + w_rare * rarity + w_hot * popularity + // (Details: ADR-006) + } +} +``` + +### 1.5. Manager (pro Business-Model) + +``` +src/Logic/Essen/Manager/ +├── DishManagerInterface.php +└── DishManager.php // Read + Write-Koordination, Cache-Aside (vorerst + // ohne Cache, Platzhalter für Phase 2) +``` + +Interface (in Logic): + +```php +interface DishManagerInterface +{ + public function create(Dish $dish): Dish; + public function update(Dish $dish): Dish; + public function delete(int $id): void; + public function move(Dish $dish, int $newSortOrder): void; + public function find(int $id): Dish; + /** @return Dish[] */ + public function listGegessen(): array; + /** @return Dish[] */ + public function listGeplant(): array; + public function invalidateCache(): void; +} +``` + +### 1.6. Provider-/Processor-Interfaces (in Logic) + +``` +src/Logic/Essen/ +├── DishProviderInterface.php // Read: find, listGegessen, listGeplant, countByName... +└── DishProcessorInterface.php // Write: create, save, delete; move (sort_order) +``` + +Implementierungen liegen in `src/Data/Essen/Provider/` bzw. `Processor/`. + +### 1.7. Model-Factory (DTO -> Model) + +``` +src/Logic/Essen/Mapping/DishModelFactory.php +├── createFromRequest(CreateDishRequest): Dish +├── updateFromRequest(UpdateDishRequest): Dish +└── rebuildFromPartialUpdate(Dish $current, UpdateDishRequest $req): Dish +``` + +### 1.8. Domänen-Exceptions + +``` +src/Logic/Common/Exception/ +├── DomainException.php // abstract base (RuntimeException) +├── ResourceNotFoundException.php // -> 404 +├── BusinessRuleViolationException.php // -> 422 +├── ConcurrencyException.php // -> 409 (falls Locking) +├── AccessDeniedException.php // -> 403 +└── InfrastructureException.php // -> 503 + +src/Logic/Essen/Exception/ +└── DishNotFoundException.php // erbt ResourceNotFoundException +``` + +**HTTP-Mapping** erfolgt zentral in `src/UI/Http/ExceptionSubscriber.php` +(keine try-catch in jedem Controller). + +### 1.9. Gemeinsame Schnittstellen in `src/Logic/Common/` + +``` +src/Logic/Common/ +├── TransactionManagerInterface.php // execute(callable): mixed +├── EventDispatcherInterface.php // dispatch(object $event): void +└── Exception/ (wie oben) +``` + +**Bridges in Data** (siehe Sektion 1.10): +- `src/Data/Infrastructure/Doctrine/EntityManagerTransactionManager.php` +- `src/Data/Infrastructure/SymfonyEventDispatcherBridge.php` + +### 1.10. Data-Layer (Konkrete Implementierung) + +``` +src/Data/Essen/ +├── Entity/DishEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt! +├── Provider/DishProvider.php // implementiert DishProviderInterface +├── Processor/DishProcessor.php // implementiert DishProcessorInterface +├── Mapper/DishMapper.php // toModel / createEntity / updateEntity +└── HistoryProvider/DishHistoryProvider.php // (optional, für Inspiration) + +src/Data/Infrastructure/ +├── Doctrine/EntityManagerTransactionManager.php +└── SymfonyEventDispatcherBridge.php +``` + +**Doctrine Entity** (`DishEntity`): + +```php +#[ORM\Entity, ORM\Table(name: 'essens_dishes')] +class DishEntity +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name = ''; + // (notiz, wochentage (set), sort_order, status, gegessen_am, timestamps) + + // nur Getter/Setter, keine Logik +} +``` + +**Mapper** (stateless): +- `toModel(DishEntity): Dish` +- `createEntity(Dish): DishEntity` +- `updateEntity(Dish, DishEntity): void` + +**Processor** (Write): + +```php +readonly class DishProcessor implements DishProcessorInterface +{ + public function __construct( + private EntityManagerInterface $em, + private DishMapper $mapper, + ) {} + + public function create(Dish $model): Dish { + $entity = $this->mapper->createEntity($model); + $this->em->persist($entity); + $this->em->flush(); + return $this->mapper->toModel($entity); // mit id aus DB + } + + public function save(Dish $model): Dish { + $entity = $this->em->getRepository(DishEntity::class)->find($model->id); + if ($entity === null) throw new DishNotFoundException($model->id); + $this->mapper->updateEntity($model, $entity); + $this->em->flush(); + return $model; + } + // ... +} +``` + +### 1.11. UI-Layer + +``` +src/UI/Http/Essen/ +├── DishController.php // CRUD, toggle, move (sort_order) +├── InspirationController.php // 5er-Batch +├── TodayController.php // optional: HA-Consumer (GET /api/today) +└── ExceptionSubscriber.php // zentrales Domain-Exception → HTTP-Mapping + +src/UI/Twig/ // Twig-Templates (htmx-Fragmente) +├── dish/ +│ ├── index.html.twig // Planungsliste + Archiv-Tab +│ ├── form.html.twig // Erstellen/Bearbeiten +│ └── item.html.twig // Eintrag (li) – SortableJS +├── inspiration/ +│ └── panel.html.twig +└── layout/base.html.twig +``` + +**htmx** wird als statische Datei ausgeliefert +(`public/js/htmx.min.js`, `public/js/sortable.min.js`) und in `layout` +eingeblendet. Kein Build-Step. + +**Antwort-Vertrag (htmx):** +- Alle Writes geben ein HTML-Fragment zurück (aktualisierte Liste/Tab), oder + `Hix-Response-Header: `HX-Reswap` etc. für Fragment-Austausch. +- Für API-Consumer (HA) parallel ein JSON-Endpunkt `GET /api/today`. + +### 1.12. Testing + +``` +tests/ +├── Unit/Logic/ // Pure PHP, ohne Kernel (schnell) +│ ├── Essen/Model/DishTest.php +│ ├── Essen/UseCase/CreateDishUseCaseTest.php +│ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider +│ └── Essen/Mapping/DishModelFactoryTest.php +├── Integration/Data/ // Test-Env + SQLite (oder in-memory) +│ ├── Essen/DishProviderTest.php +│ ├── Essen/DishProcessorTest.php +│ └── Essen/DishMapperTest.php +├── Functional/UI/ // Symfony WebTestCase, isolierte Endpunkte +│ ├── Essen/DishControllerTest.php +│ └── Essen/ExceptionSubscriberTest.php +└── Functional/Scenarios/ // User-Flows + └── EssenDishLifecycleTest.php // create → toggle → archive → restore +``` + +**Mocking-Richtlinien** (aus Referenz Sektion 13): +- Mocke **Interface**, nie die konkrete Implementierung. +- Mocke **kein** Business-Model/DTO (eigentlich instanziert). +- Mocke **keine** 3rd-Party-Libs; brüche in Interfaces. + +## 2. Directory Structure (End-to-End) + +``` +svc-essensplanung/ +├── bin/ +│ └── console +├── config/ +│ ├── bundles.php +│ ├── packages/ +│ │ ├── doctrine.yaml +│ │ ├── framework.yaml +│ │ └── routing.yaml +│ ├── routes/ +│ │ ├── essen.yaml +│ │ └── api.yaml # /api/today (HA-Consumer) +│ ├── services.yaml # (fast nur auto-wire, wenige explizite) +│ └── inspiration_settings.yaml // w_slow/w_rare/w_hot (ADR-006) +├── docker/ +│ └── prod_entrypoint.sh +├── public/ +│ ├── index.php +│ ├── js/htmx.min.js +│ ├── js/sortable.min.js +│ └── images/icon.svg // PWA +├── src/ +│ ├── Logic/ +│ │ ├── Common/ +│ │ │ ├── Exception/ (6 Klassen, s. 1.8) +│ │ │ ├── TransactionManagerInterface.php +│ │ │ └── EventDispatcherInterface.php +│ │ └── Essen/ +│ │ ├── Model/ (Dish, DishDay, DishStatus) +│ │ ├── Dto/ (10 Dateien) +│ │ ├── UseCase/ (5) +│ │ ├── Query/ (4) +│ │ ├── Manager/ (interface + impl) +│ │ ├── DishProviderInterface.php +│ │ ├── DishProcessorInterface.php +│ │ ├── Mapping/DishModelFactory.php +│ │ └── Event/DishCreatedEvent.php, DishArchivedEvent.php, ... +│ ├── Data/ +│ │ ├── Infrastructure/ +│ │ │ ├── Doctrine/EntityManagerTransactionManager.php +│ │ │ └── SymfonyEventDispatcherBridge.php +│ │ └── Essen/ +│ │ ├── Entity/DishEntity.php +│ │ ├── Provider/DishProvider.php +│ │ ├── Processor/DishProcessor.php +│ │ ├── Mapper/DishMapper.php +│ │ └── HistoryProvider/DishHistoryProvider.php +│ └── UI/ +│ ├── Http/Essen/ (3 Controllers + ExceptionSubscriber) +│ └── Twig/ (Layout + Fragmente) +├── templates/ // (oder src/UI/Twig – siehe oben) +├── migrations/ // (Doctrine Migrations, Auto-Gen) +├── tests/ +├── public/js/htmx.min.js +├── public/js/sortable.min.js +├── docker-compose.yaml // FrankenPHP + Volume /db +├── Dockerfile +├── .env.dist +├── composer.json +├── symfony.lock +├── phpunit.xml +└── README.md +``` + +## 3. Dependency Graph (konkret) + +``` +UI: DishController + -> Logic: CreateDishUseCase + -> Logic: MoveDishUseCase + -> Logic: ToggleDishStatusUseCase + -> Logic: ListDishesQuery + -> Logic: InspirationQuery + -> Logic DTOs (CreateDishRequest, MoveDishRequest, ...) + +Logic: CreateDishUseCase + -> Logic: Common TransactionManagerInterface + -> Logic: Essen DishManagerInterface + -> Logic: Essen Mapping/DishModelFactory + -> Logic: Common EventDispatcherInterface + -> Logic: Essen/Event DishCreatedEvent + +Logic: DishManager + -> Logic: Essen DishProviderInterface (Read) + -> Logic: Essen DishProcessorInterface (Write) + +Data: DishProvider implements Logic:DishProviderInterface +Data: DishProcessor implements Logic:DishProcessorInterface +Data: DishMapper (Entity <-> Model, stateless) +Data: EntityManagerTransactionManager implements Logic:Common TransactionManagerInterface +Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherInterface +``` + +## 4. Konventionen für neue Module/Features + +1. **Neue Domäne**: Neues Ordner-Paar `src/Logic/{Modul}` + + `src/Data/{Modul}` (gleicher Name, z. B. `Einkaufsliste`). +2. **Neues Business-Model**: Model-Ordner, eigene Manager-/Provider-/-Processor, + eigene Exceptions, eigenes `*ModelFactory`. +3. **Neuer UseCase**: Immer unter `src/Logic/{Modul}/{Feature}/UseCase/`, + DTOs im `Dto/`, Read in `Query/`. +4. **Neue Schnittstelle (Provider/Processor)**: Interface in Logic, + Implementation in Data; Autowiring via Symfony (nichts in `services.yaml`, + es sei denn mehrere Implementierungen). +5. **Neue Business-Regel**: Im Model-Konstruktor oder in einer State-Transition- + Methode – nie im UseCase, wenn sie das Model selbst betrifft. +6. **Neue Konsole/CLI**: `src/UI/Console/`, UseCase-Delegation wie HTTP. +7. **Neues Event**: In `src/Logic/{Modul}/{Feature}/Event/`, DTO-Struktur, + State-Carry (nur was der Subscriber braucht), kein ORM-Wissen. +8. **Testing**: Jede Unit in `tests/Unit/{Modul}/`, je Layer ein Testfall. + **Kein** Mock auf konkrete Klassen. + +## 5. Anti-Pattern (bewusst vermieden) + +- ❌ Symfony-Validator-Constraints auf Logik-DTOs. +- ❌ `EntityManagerInterface` in der Logik-Layer. +- ❌ Doctrine-Entities als Rückgabewert aus der Logic-Layer. +- ❌ HTTP-Header/Routing-Knowledge in der Logik-Layer. +- ❌ Try-catch in jedem Controller (zentraler Exception-Subscriber). +- ❌ Anemic-Entities in der Data-Layer, *die* die Geschäftsregeln tragen. +- ❌ Mehrere Implementierungen eines Interfaces ohne explizite Bindung + in `services.yaml` (Ambiguität). +- ❌ „Service" als catch-all-Name (immer UseCase/Query/Manager). + +## 6. Offene / Phase-2-Punkte + +- [ ] `settings.yaml` für Inspiration-Gewichte (ADR-006). Default 0.5/0.3/0.2. +- [ ] `DishHistoryProvider` konkret (für Inspiration, zählt Auftritte pro Name). +- [ ] `GET /api/today` für HA-Consumer (Phase 2, ADR-004). +- [ ] Optional: `Push`-Benachrichtigung per HA-Notify (Phase 2). +- [ ] Optional: Cache-Aside im `DishManager` (vorerst ohne, Phase 2).