# 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).