Files

22 KiB
Raw Permalink Blame History

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):

  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):

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

#[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):

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