# Architektur – svc-essensplanung > Konkretisierung der ADR-007 für diese App. Referenz: > [jens/symfony-architektur](https://git.ljh31.de/jens/symfony-architektur). ## 0.1 Namenskonzept (2026-09-13 final) **Regel: Domäne deutsch, technische Begriffe / Suffixe englisch.** | Rolle | Name | Beispiel | |---|---|---| | Rich-Modell | Substantiv | `Mahlzeit` | | Domain-Enum (Status) | `Status` | `MahlzeitStatus` (`Geplant`, `Gegessen`) | | Domain-Enum (Wert) | Substantiv, Werte kurz | `Wochentag` (`Mo`…`So`) | | DTOs | `{Request,Response}` | `MahlzeitCreateRequest`, `MahlzeitListResponse` | | Fabrik (DTO→Model) | `Factory` | `MahlzeitFactory` | | UseCase | `UseCase` | `MahlzeitCreateUseCase` | | Read-Query | `Query` | `MahlzeitListQuery` | | Manager | `Manager` | `MahlzeitManager` | | Provider (Read) | `Provider(Interface)` | `MahlzeitProvider` | | Processor (Write) | `Processor(Interface)` | `MahlzeitProcessor` | | Mapper | `Mapper` | `MahlzeitMapper` | | Entity | `Entity` | `MahlzeitEntity` | | Exception | `…Exception` | `MahlzeitNotFoundException` | | Events | `Event` | `MahlzeitCreatedEvent` | Methode-Suffixe in Interfaces bleiben kurz/englisch (`create`, `save`, `delete`, `find`, `list`), da sie technische Konvention sind — nur die **Klassen-/Modellnamen** sind domänendeutsch. ## 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)** — siehe §0.1 Namenskonzept: `MahlzeitProviderInterface`, `MahlzeitCreateUseCase`, `MahlzeitListQuery`, `Manager`, `Provider`/`Processor`, `Mapper`, `MahlzeitFactory` (statt `*ModelFactory`), `Create/Update/Delete/…Request`, `…Response`, Exception, `Entity`. ## 1. Domain-Modell (Logik) **Eine** Modul-Domäne: `Essen`. Darin ein Business-Model: `Mahlzeit` (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 `Mahlzeit` `src/Logic/Essen/Model/Mahlzeit.php` – **Rich Domain Model** (POPO, readonly, kein Framework): ```php namespace App\Logic\Essen\Model; class Mahlzeit { public function __construct( public readonly ?int $id, public string $name, public ?string $notiz, /** @var Wochentag[] */ public readonly array $wochentage, public readonly int $sort_order, public MahlzeitStatus $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 === MahlzeitStatus::GEGESSEN) { throw new BusinessRuleViolationException('Bereits gegessen.'); } return new self($this->id, $this->name, $this->notiz, $this->wochentage, $this->sort_order, MahlzeitStatus::GEGESSEN, $now, $this->erstellt_am, $now); } public function markAsGeplant(\DateTimeImmutable $now): self { /* symmetrisch */ } } ``` **Enums** in `src/Logic/Essen/Model/`: - `MahlzeitStatus` (`GEPLANT`, `GEGESSEN`) - `Wochentag` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`) ### 1.2. DTOs ``` src/Logic/Essen/Dto/ ├── MahlzeitCreateRequest.php // name, notiz?, wochentage?: Wochentag[] ├── MahlzeitUpdateRequest.php // name, notiz?, wochentage? (sort_order via M3-Reorder) ├── MahlzeitMoveRequest.php // id, position (int) ├── MahlzeitToggleStatusRequest.php // id ├── MahlzeitListRequest.php // status? (optional), sort='sort_order'|'gegessen_am' ├── MahlzeitListResponse.php // items: Mahlzeit[] ├── MahlzeitCreateResponse.php // id, mahlzeit ├── MahlzeitUpdateResponse.php // id, mahlzeit ├── InspirationRequest.php // limit=5, offset=0, excludeIds: int[] ├── InspirationResponse.php // suggestions: Mahlzeit[] └── TodayResponse.php // mahlzeiten: Mahlzeit[] // optional HA-Consumer ``` Alle `readonly`, native Typisierung, **keine** Attribute/Constraints. ### 1.3. UseCases (Writes) ``` src/Logic/Essen/UseCase/ ├── MahlzeitCreateUseCase.php // execute(MahlzeitCreateRequest): MahlzeitCreateResponse ├── MahlzeitUpdateUseCase.php // execute(int $id, MahlzeitUpdateRequest): MahlzeitUpdateResponse ├── MahlzeitDeleteUseCase.php // execute(int $id): void ├── MahlzeitMoveUseCase.php // ändert sort_order └── MahlzeitToggleStatusUseCase.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 `MahlzeitManager` → `MahlzeitProcessor` (Write); Cache-Sidecar-Invalidierung im Manager. 4. Event-Dispatch nach erfolgreichem Commit (Bridge in Data-Layer). 5. Response-DTO (z. B. `MahlzeitUpdateResponse`) zurück an Controller. **Beispiel** (UseCase `MahlzeitCreateUseCase`): ```php namespace App\Logic\Essen\UseCase; readonly class MahlzeitCreateUseCase { public function __construct( private TransactionManagerInterface $tx, private MahlzeitManagerInterface $mahlzeiten, private MahlzeitFactory $factory, private EventDispatcherInterface $events, ) {} public function execute(MahlzeitCreateRequest $req): MahlzeitCreateResponse { return $this->tx->execute(function() use ($req): MahlzeitCreateResponse { $mahlzeit = $this->factory->createFromCreateRequest($req); // DTO -> Model $gespeichert = $this->mahlzeiten->create($mahlzeit); // Model -> Processor $this->events->dispatch(new MahlzeitCreatedEvent(id: $gespeichert->id)); return new MahlzeitCreateResponse(id: $gespeichert->id, mahlzeit: $gespeichert); }); } } ``` ### 1.4. Queries (Reads) ``` src/Logic/Essen/Query/ ├── MahlzeitListQuery.php // -> MahlzeitListResponse ├── MahlzeitGetQuery.php // -> Mahlzeit (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 MahlzeitManagerInterface $mahlzeiten, private MahlzeitHistoryProviderInterface $history, // aus Data-Layer ) {} public function execute(InspirationRequest $req): InspirationResponse { $all = $this->mahlzeiten->listGegessen(); $history = $this->history->historyByMahlzeitName(); $scored = $all ->filter(fn(Mahlzeit $m) => !in_array($m->id, $req->excludeIds, true)) ->map(fn(Mahlzeit $m) => new Scored( mahlzeit: $m, score: $this->score($m, $history), )) ->sortDescByScore() ->slice($req->offset, $req->limit); return new InspirationResponse(suggestions: $scored->map(fn($s)=>$s->mahlzeit)); } private function score(Mahlzeit $m, 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/ ├── MahlzeitManagerInterface.php └── MahlzeitManager.php // Read + Write-Koordination, Cache-Aside (vorerst // ohne Cache, Platzhalter für Phase 2) ``` Interface (in Logic): ```php interface MahlzeitManagerInterface { public function create(Mahlzeit $mahlzeit): Mahlzeit; public function update(Mahlzeit $mahlzeit): Mahlzeit; public function delete(int $id): void; public function move(Mahlzeit $mahlzeit, int $newSortOrder): void; public function find(int $id): Mahlzeit; // -> MahlzeitNotFoundException (404) /** @return list */ public function listGegessen(): array; /** @return list */ public function listGeplant(): array; public function invalidateCache(): void; } ``` ### 1.6. Provider-/Processor-Interfaces (in Logic) ``` src/Logic/Essen/ ├── MahlzeitProviderInterface.php // Read: find, listGegessen, listGeplant, countByName... └── MahlzeitProcessorInterface.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/MahlzeitFactory.php ├── createFromCreateRequest(MahlzeitCreateRequest): Mahlzeit ├── updateFromUpdateRequest(Mahlzeit, MahlzeitUpdateRequest): Mahlzeit └── ... ``` ### 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/ └── MahlzeitNotFoundException.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/MahlzeitEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt! ├── Provider/MahlzeitProvider.php // implementiert MahlzeitProviderInterface ├── Processor/MahlzeitProcessor.php // implementiert MahlzeitProcessorInterface ├── Mapper/MahlzeitMapper.php // toModel / createEntity / updateEntity └── HistoryProvider/MahlzeitHistoryProvider.php // (optional, für Inspiration) src/Data/Infrastructure/ ├── Doctrine/EntityManagerTransactionManager.php └── SymfonyEventDispatcherBridge.php ``` **Doctrine Entity** (`MahlzeitEntity`): ```php #[ORM\Entity, ORM\Table(name: 'essens_mahlzeiten')] class MahlzeitEntity { #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] private ?int $id = null; #[ORM\Column(type: 'string', length: 255)] private string $name = ''; // (notiz, wochentage (CSV-string „mo,di,fr", nullable), sort_order, status (enum), gegessen_am, timestamps) // nur Getter/Setter, keine Logik } ``` **Mapper** (stateless): - `toModel(MahlzeitEntity): Mahlzeit` - `createEntity(Mahlzeit): MahlzeitEntity` - `updateEntity(Mahlzeit, MahlzeitEntity): void` **Processor** (Write): ```php readonly class MahlzeitProcessor implements MahlzeitProcessorInterface { public function __construct( private EntityManagerInterface $em, private MahlzeitMapper $mapper, ) {} public function create(Mahlzeit $model): Mahlzeit { $entity = $this->mapper->createEntity($model); $this->em->persist($entity); $this->em->flush(); return $this->mapper->toModel($entity); // mit id aus DB } public function save(Mahlzeit $model): Mahlzeit { $entity = $this->em->getRepository(MahlzeitEntity::class)->find($model->id); if ($entity === null) throw new MahlzeitNotFoundException($model->id); $this->mapper->updateEntity($model, $entity); $this->em->flush(); return $model; } // ... } ``` ### 1.11. UI-Layer ``` src/UI/Http/Essen/ ├── MahlzeitController.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 templates/ // Twig-Templates (htmx-Fragmente) ├── mahlzeiten/ │ ├── 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/MahlzeitTest.php │ ├── Essen/UseCase/MahlzeitCreateUseCaseTest.php │ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider │ └── Essen/Mapping/MahlzeitFactoryTest.php ├── Integration/Data/ // Test-Env + file-basierte SQLite (var/test.db) │ ├── Essen/MahlzeitProviderTest.php │ ├── Essen/MahlzeitProcessorTest.php │ └── Essen/MahlzeitMapperTest.php ├── Functional/UI/ // Symfony WebTestCase, isolierte Endpunkte │ ├── Essen/MahlzeitControllerTest.php │ └── Essen/ExceptionSubscriberTest.php └── Functional/Scenarios/ // User-Flows └── MahlzeitLifecycleTest.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/ (Mahlzeit, Wochentag, MahlzeitStatus) │ │ ├── Dto/ (10 Dateien) │ │ ├── UseCase/ (5) │ │ ├── Query/ (4) │ │ ├── Manager/ (interface + impl) │ │ ├── MahlzeitProviderInterface.php │ │ ├── MahlzeitProcessorInterface.php │ │ ├── Mapping/MahlzeitFactory.php │ │ └── Event/MahlzeitCreatedEvent.php, MahlzeitArchivedEvent.php, ... │ ├── Data/ │ │ ├── Infrastructure/ │ │ │ ├── Doctrine/EntityManagerTransactionManager.php │ │ │ └── SymfonyEventDispatcherBridge.php │ │ └── Essen/ │ │ ├── Entity/MahlzeitEntity.php │ │ ├── Provider/MahlzeitProvider.php │ │ ├── Processor/MahlzeitProcessor.php │ │ ├── Mapper/MahlzeitMapper.php │ │ └── HistoryProvider/MahlzeitHistoryProvider.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: MahlzeitController -> Logic: MahlzeitCreateUseCase -> Logic: MahlzeitMoveUseCase -> Logic: MahlzeitToggleStatusUseCase -> Logic: MahlzeitListQuery -> Logic: InspirationQuery -> Logic DTOs (MahlzeitCreateRequest, MahlzeitMoveRequest, ...) Logic: MahlzeitCreateUseCase -> Logic: Common TransactionManagerInterface -> Logic: Essen MahlzeitManagerInterface -> Logic: Essen Mapping/MahlzeitFactory -> Logic: Common EventDispatcherInterface -> Logic: Essen/Event MahlzeitCreatedEvent Logic: MahlzeitManager -> Logic: Essen MahlzeitProviderInterface (Read) -> Logic: Essen MahlzeitProcessorInterface (Write) Data: MahlzeitProvider implements Logic:MahlzeitProviderInterface Data: MahlzeitProcessor implements Logic:MahlzeitProcessorInterface Data: MahlzeitMapper (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. - [ ] `MahlzeitHistoryProvider` 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 `MahlzeitManager` (vorerst ohne, Phase 2).