Files
svc-essensplanung/Doku/architektur.md
T

526 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).