Doku: ADR-007 + konkrete Architektur (Layer, Naming, Patterns, Tests)

This commit is contained in:
2026-09-13 11:30:15 +02:00
parent 99348661df
commit 830cfc4cee
2 changed files with 556 additions and 0 deletions
+525
View File
@@ -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).