22 KiB
Architektur – svc-essensplanung
Konkretisierung der ADR-007 für diese App. Referenz: 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) | <Model>Status |
MahlzeitStatus (Geplant, Gegessen) |
| Domain-Enum (Wert) | Substantiv, Werte kurz | Wochentag (Mo…So) |
| DTOs | <Model><Aktion>{Request,Response} |
MahlzeitCreateRequest, MahlzeitListResponse |
| Fabrik (DTO→Model) | <Model>Factory |
MahlzeitFactory |
| UseCase | <Model><Aktion>UseCase |
MahlzeitCreateUseCase |
| Read-Query | <Model><Aktion>Query |
MahlzeitListQuery |
| Manager | <Model>Manager |
MahlzeitManager |
| Provider (Read) | <Model>Provider(Interface) |
MahlzeitProvider |
| Processor (Write) | <Model>Processor(Interface) |
MahlzeitProcessor |
| Mapper | <Model>Mapper |
MahlzeitMapper |
| Entity | <Model>Entity |
MahlzeitEntity |
| Exception | <Model>…Exception |
MahlzeitNotFoundException |
| Events | <Model><Aktion>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):
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):
- Syntaktische Validierung im UI-Layer (Controller/Command) als rein PHP (keine Symfony-Validator-Constraints auf DTOs).
- Business-Validierung im UseCase + im Model-Konstruktor (Invarianzen).
- Persistence im UseCase via
MahlzeitManager→MahlzeitProcessor(Write); Cache-Sidecar-Invalidierung im Manager. - Event-Dispatch nach erfolgreichem Commit (Bridge in Data-Layer).
- Response-DTO (z. B.
MahlzeitUpdateResponse) zurück an Controller.
Beispiel (UseCase MahlzeitCreateUseCase):
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):
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):
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<Mahlzeit> */
public function listGegessen(): array;
/** @return list<Mahlzeit> */
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.phpsrc/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):
#[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): MahlzeitcreateEntity(Mahlzeit): MahlzeitEntityupdateEntity(Mahlzeit, MahlzeitEntity): void
Processor (Write):
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
- Neue Domäne: Neues Ordner-Paar
src/Logic/{Modul}+src/Data/{Modul}(gleicher Name, z. B.Einkaufsliste). - Neues Business-Model: Model-Ordner, eigene Manager-/Provider-/-Processor,
eigene Exceptions, eigenes
*ModelFactory. - Neuer UseCase: Immer unter
src/Logic/{Modul}/{Feature}/UseCase/, DTOs imDto/, Read inQuery/. - Neue Schnittstelle (Provider/Processor): Interface in Logic,
Implementation in Data; Autowiring via Symfony (nichts in
services.yaml, es sei denn mehrere Implementierungen). - Neue Business-Regel: Im Model-Konstruktor oder in einer State-Transition- Methode – nie im UseCase, wenn sie das Model selbst betrifft.
- Neue Konsole/CLI:
src/UI/Console/, UseCase-Delegation wie HTTP. - Neues Event: In
src/Logic/{Modul}/{Feature}/Event/, DTO-Struktur, State-Carry (nur was der Subscriber braucht), kein ORM-Wissen. - 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.
- ❌
EntityManagerInterfacein 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.yamlfür Inspiration-Gewichte (ADR-006). Default 0.5/0.3/0.2.MahlzeitHistoryProviderkonkret (für Inspiration, zählt Auftritte pro Name).GET /api/todayfür HA-Consumer (Phase 2, ADR-004).- Optional:
Push-Benachrichtigung per HA-Notify (Phase 2). - Optional: Cache-Aside im
MahlzeitManager(vorerst ohne, Phase 2).