gwoe-antragspruefer/docs/adr/0008-ddd-lightweight-migration.md
Dotty Dotter 8f0f6d6e32 refactor(#136): DDD-Lightweight Tag 1-4 (Ports, Adapter, Repositories, Domain-Verhalten)
ADR 0008: Lightweight-Migration ohne Package-Split

- ports/llm_bewerter.py: Protocol + LlmRequest-Dataclass
- adapters/qwen_bewerter.py: Qwen/DashScope-Adapter mit Retry-Loop
- repositories/{antrag,bewertung,abonnement}_repository.py: Protocol + Sqlite-Impl + InMemory-Fake
- analyzer.py refactored: nimmt Optional[LlmBewerter], AsyncOpenAI-Import raus
- models.py: 5 Domain-Methoden auf Bewertung/MatrixEntry
  (ist_ablehnung, hat_fundamental_kritisches_feld, verletzt_score_cap, ...)
- analyzer loggt WARNING wenn LLM Score-Cap-Invariante verletzt

Folge-PR: Callsite-Migration in main.py (~21 direkte database.*-Aufrufe)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:16 +02:00

6.6 KiB
Raw Blame History

0008 — DDD-Lightweight-Migration

Status accepted
Datum 2026-04-20
Refs Issue #136, docs/analysen/ddd-bewertung.md Kap. 7 + 10
Related ADR 0001 (Citation-Binding), ADR 0002 (Adapter-Architektur)

Kontext

Nach 18 Monaten Entwicklung verteilt der Code sich auf 23 Dateien in einem flachen app/-Verzeichnis (LOC-Inventar in ddd-bewertung.md Kap. 1.1). Die Analyse in diesem Dokument zeigt fünf konkrete DDD-Verletzungen (AE, ebenda Kap. 1.2):

  • A Infrastructure-Leak: analyze_antrag instanziiert direkt einen AsyncOpenAI-Client.
  • B Retry-Loop, JSON-Parsing und Pydantic in einer 60-Zeilen-Kaskade.
  • C Anämisches Modell: MatrixEntry / Assessment tragen keine Invarianten; die Score-Cap-Regel („rating ≤ -4 ⇒ score ≤ 3") lebt nur im LLM-System-Prompt.
  • D Kein Repository-Pattern: database.py (909 LOC) wird von sechs Modulen direkt aufgerufen.
  • E Adapter-Contract nur informell (abhandelbar in späterem PR).

Issue #136 fragte nach einer DDD-Umstellung. Die Analyse zeigte zwei realistische Optionen (Kap. 7 des Bewertungsdokuments).

Optionen

Option A — Voll-DDD mit Package-Split

Getrennte Packages antragsbewertung/, parlamentsintegration/, wahlprogramm_wissensbasis/, publikation/, benutzer_abo/, monitoring/, jeweils mit domain/application/infrastructure/- Struktur. Ports und Adapter überall, Anti-Corruption-Layer pro BL.

Vorteile: Maximale Testbarkeit, klare Bounded-Contexts, mehrere Devs können parallel arbeiten, Ubiquitous-Language konsequent.

Nachteile: 4-8 Wochen netto Umbau, Test-Suite muss migriert werden, hohes Regressionsrisiko während der Migration, für Solo-Projekt dieser Größe ein schlechtes Kosten-Nutzen-Verhältnis.

Option B — DDD-Lightweight

Drei gezielte DDD-Prinzipien ohne Package-Split: Repository-Pattern, LLM-Port, Domain-Verhalten auf den bestehenden Pydantic-Modellen. Dateien bleiben flach in app/ liegen, nur drei neue Unterordner: repositories/, ports/, adapters/.

Vorteile: Adressiert die schmerzhaftesten Punkte (A, B, C, D) in 5-8 Tagen. Keine API-Breaking-Changes. Callsites müssen nicht alle gleichzeitig migrieren — die Repositories delegieren an die alten database.py-Funktionen, alte Calls laufen weiter.

Nachteile: Parlaments-Adapter bleiben als eine 3397-LOC-Datei (parlamente.py). Ubiquitous-Language bleibt halb Deutsch / halb Englisch. Bounded-Contexts sind nur konzeptuell, nicht physisch separiert.

Entscheidung

Option B. Konkret:

  1. Repository-Pattern für assessments, assessment_versions und email_subscriptionsapp/repositories/, drei Module.
  2. LLM-Port + Qwen-Adapterapp/ports/llm_bewerter.py, app/adapters/qwen_bewerter.py. analyze_antrag nimmt ein LlmBewerter-Argument (Default: QwenBewerter()), direkt oder über FastAPI-Depends.
  3. Domain-Verhalten auf Assessment und MatrixEntry: verletzt_score_cap(), ist_ablehnung(), ist_uneingeschraenkt_unterstuetzend(), hat_fundamental_kritisches_feld(), MatrixEntry.ist_fundamental_kritisch(), MatrixEntry.to_symbol(). analyzer.py loggt bei Verletzung der Score-Cap-Invariante eine Warning, wirft aber nicht — das LLM soll lernen, der Produktiv- betrieb soll nicht brechen.

Nicht Teil dieser Entscheidung (Folge-PRs):

  • Migration aller database.*-Callsites in main.py (21 Stellen) auf Repository-Depends. Der Repository-Layer ist dafür bereitgelegt; die Umstellung selbst ist mechanisch, aber wegen 1746 LOC in einer Datei mit Merge-Konflikt-Risiko verbunden.
  • ZitatVerifier-Port (Tag 5 der Roadmap).
  • Frozen-Dataclass-Domain-Objekte in app/domain/ (Tag 6/7).
  • Bounded-Context-Package-Split (explizit ausgeschlossen).

Konsequenzen

Positiv

  • Tests ohne OpenAI-Stub. conftest.py::_stub("openai") ist nicht mehr zwingend; Tests reichen einen FakeLlmBewerter-Objekt. Der umgebaute test_bug_regressions.py::test_analyzer_user_prompt_... ist exakt dieses Muster.
  • Adapter-Wechsel trivial. Ein zweiter LLM-Provider (Claude, Gemini) ist eine neue Klasse in app/adapters/, ohne Änderung in analyzer.py.
  • Server-seitige Score-Cap-Erkennung. Verstöße gegen die fundamental-kritisch-Regel aus dem System-Prompt werden jetzt in Logs sichtbar (analyzer.py), können in einem Folge-Issue zu harten Rejects hochgezogen werden, wenn die Qualitäts-Daten stabil aussehen.
  • Test-Fakes für DB-Zugriff. Application-Logik-Tests brauchen keine SQLite-Datei mehr; InMemoryAntragRepository reicht. Die 76 neuen Tests in tests/test_*_repository.py, test_llm_bewerter.py und test_domain_behavior.py demonstrieren das.

Negativ

  • Zwei Zugangswege zur DB parallel. Solange main.py noch database.get_assessment direkt aufruft, gibt es doppelten Zugriff. Folge-PR räumt auf.
  • analyzer.py importiert noch check_missing_programmes direkt. Auch das ist Infrastructure, liegt aber außerhalb der drei migrierten Schnitte. Nächster Schritt bei Bedarf.
  • Logger-Warning ist weich. Wer die Logs nicht liest, bemerkt Score-Cap-Verletzungen nicht. Ein Dashboard-Panel auf logger.name == "app.analyzer" + Severity ≥ WARNING gehört in die Monitoring-Erweiterung (#135-Folge).

Folgen für andere ADRs

  • ADR 0001 (Citation-Binding) bleibt unverändert gültig — die Post-LLM-Rekonstruktion läuft weiter nach bewerter.bewerte().
  • ADR 0002 (Adapter-Architektur für Parlamente) bleibt unverändert; die 17 Adapter in parlamente.py werden hier nicht in app/adapters/ gezogen. Ein Folge-ADR (0009?) könnte den Package- Split für Parlaments-Adapter beschließen, wenn die Datei über 5000 LOC wächst (wie in ADR 0002 angekündigt).
  • ADR 0003 (Citation-Property-Tests) bleibt unverändert; die Tests hängen an der Assessment-Schnittstelle, die sich hier nicht geändert hat.

Nach-Migration-Regeln

Zur Orientierung bei neuen Beiträgen:

  1. Kein direktes database.* in neuer Application-Logik. Immer über das passende Repository.
  2. Kein AsyncOpenAI außerhalb app/adapters/qwen_bewerter.py. Neue LLM-Provider bekommen einen eigenen Adapter im gleichen Ordner.
  3. Invarianten auf dem Domain-Modell, nicht im Prompt. Wenn eine Regel im LLM-System-Prompt steht und vom LLM potenziell verletzt werden kann, gehört eine Prüfmethode auf Assessment oder MatrixEntry und ein Warning-Log in analyzer.py.
  4. Tests mit InMemory*Repository und FakeLlmBewerter. Keine Monkeypatches auf app.analyzer.AsyncOpenAI mehr; keine temporären SQLite-Dateien für Unit-Tests.