Status: Accepted Datum: 2026-01-08 Entscheider: Entwicklungsteam Bezieht sich auf: ADR-018 Domain-Driven Design
Kontext
Im Rahmen der DDD-Migration (siehe Issue #38) haben wir festgestellt, dass unsere API-Responses zu eng an die SQLAlchemy ORM-Modelle gekoppelt sind:
Probleme der aktuellen Implementierung:
- Response-Schemas verwenden
ConfigDict(from_attributes=True)→ Direkte ORM-Kopplung - Keine klare Trennung zwischen Domain-Modellen und API-Kontrakten
- Computed Fields (z.B.
insurance_ok) werden inkonsistent berechnet - Schwierige Testbarkeit durch ORM-Abhängigkeiten
- Eager Loading wird nicht explizit dokumentiert
Beispiel (Problem):
# app/kind/schemas.py
class Kind(BaseModel):
id: int
vorname: str
# ... weitere Felder
model_config = ConfigDict(from_attributes=True) # ⚠️ ORM-Kopplung!
Entscheidung
Wir implementieren das DTO/Mapper Pattern zur Entkopplung von Domain-Modellen und API-Responses.
Architektur-Übersicht
┌─────────────────────────────────────────────────────┐
│ API Layer │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Router │───────→│ DTO │ │
│ └─────────────┘ └─────────────┘ │
│ ↓ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Mapper │───────→│ Domain │ │
│ └─────────────┘ │ Models │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────┘
Komponenten
- DTOs (Data Transfer Objects): Reine Datencontainer für API-Responses
- Mappers: Konvertierungsfunktionen zwischen ORM-Modellen und DTOs
- Schemas: Bleiben bestehen für Request-Validierung (Create/Update)
Implementierung
1. DTOs erstellen
DTOs sind reine Pydantic-Modelle ohne ORM-Kopplung:
# app/kind/dtos.py
from pydantic import BaseModel, Field
from datetime import date
from typing import Optional
class VereinDTO(BaseModel):
"""DTO für Verein-Daten in Kind-Responses."""
id: int
name: str
ort: str
register_id: str
contact: str
class KindDTO(BaseModel):
"""Data Transfer Object für Kind API-Responses.
Entkoppelt von der Datenbank-Schicht, definiert API-Kontrakt.
"""
id: int
vorname: str
nachname: str
geburtsdatum: date
geschlecht: Optional[str] = None
verein_id: Optional[int] = None
# Nested entities (eagerly loaded)
verein: Optional[VereinDTO] = None
verband: Optional[VerbandDTO] = None
versicherung: Optional[VersicherungDTO] = None
# KEINE from_attributes - reine DTOs!
2. Mapper-Funktionen
Mapper konvertieren ORM → DTO und handhaben Eager Loading:
# app/kind/mappers.py
from app import models
from app.kind.dtos import KindDTO, VereinDTO
def map_verein_to_dto(verein: Optional[models.Verein]) -> Optional[VereinDTO]:
"""Map Verein ORM model to DTO."""
if not verein:
return None
return VereinDTO(
id=verein.id,
name=verein.name,
ort=verein.ort,
register_id=verein.register_id,
contact=verein.contact
)
def map_kind_to_dto(kind: models.Kind) -> KindDTO:
"""Map Kind ORM model to DTO.
Mapped nested entities if eager-loaded using hasattr().
"""
return KindDTO(
id=kind.id,
vorname=kind.vorname,
nachname=kind.nachname,
geburtsdatum=kind.geburtsdatum,
geschlecht=kind.geschlecht,
verein_id=kind.verein_id,
# Map nested entities if eager-loaded
verein=map_verein_to_dto(kind.verein) if hasattr(kind, 'verein') else None,
verband=map_verband_to_dto(kind.verband) if hasattr(kind, 'verband') else None,
versicherung=map_versicherung_to_dto(kind.versicherung) if hasattr(kind, 'versicherung') else None
)
def map_kinder_to_dtos(kinder: List[models.Kind]) -> List[KindDTO]:
"""Map list of Kind ORM models to DTOs."""
return [map_kind_to_dto(kind) for kind in kinder]
3. Router Integration
Router verwenden DTOs als response_model und rufen Mapper auf:
# app/kind/router.py
from app.kind.dtos import KindDTO
from app.kind.mappers import map_kind_to_dto, map_kinder_to_dtos
@router.get("/kind", response_model=List[KindDTO])
def list_kind(
skip: int = 0,
limit: int = 20,
service: KindService = Depends(get_kind_service)
):
"""Get list of all children."""
results, total_count = service.search_kinder(skip=skip, limit=limit)
# Map ORM models to DTOs
return map_kinder_to_dtos(results)
@router.post("/kind", response_model=KindDTO, status_code=201)
def create_kind(kind: kind_schemas.KindCreate, service: KindService = Depends(get_kind_service)):
"""Create a new child."""
created_kind = service.create_kind(kind)
# Map ORM model to DTO
return map_kind_to_dto(created_kind)
4. Eager Loading in Repositories
Repositories müssen nested entities eager loaden für vollständige DTOs:
# app/anmeldung/repository.py
def list(self, skip: int = 0, limit: int = 100) -> List[models.Anmeldung]:
"""List all Anmeldungen with eager loading."""
return self.db.query(models.Anmeldung).options(
# Load Kind with nested entities
joinedload(models.Anmeldung.kind).joinedload(models.Kind.verein),
joinedload(models.Anmeldung.kind).joinedload(models.Kind.verband),
joinedload(models.Anmeldung.kind).joinedload(models.Kind.versicherung)
).offset(skip).limit(limit).all()
5. Computed Fields
Computed Fields werden in Mappern berechnet:
# app/anmeldung/mappers.py
def map_anmeldung_to_dto(anmeldung: models.Anmeldung) -> AnmeldungDTO:
"""Map Anmeldung ORM model to DTO."""
# Compute insurance_ok if Kind is available
insurance_ok = False
if hasattr(anmeldung, 'kind') and anmeldung.kind:
insurance_ok = kind_has_insurance(anmeldung.kind)
return AnmeldungDTO(
id=anmeldung.id,
kind_id=anmeldung.kind_id,
wettkampf_id=anmeldung.wettkampf_id,
startnummer=anmeldung.startnummer,
insurance_ok=insurance_ok, # Computed field
# ... weitere Felder
)
Pattern-Konventionen
Naming Conventions
| Komponente | Naming | Beispiel |
|---|---|---|
| DTO | {Entity}DTO |
KindDTO, AnmeldungDTO |
| Nested DTO (simplified) | {Entity}DTOSimple |
KindDTOSimple (in Anmeldung) |
| Mapper (single) | map_{entity}_to_dto |
map_kind_to_dto |
| Mapper (list) | map_{entities}_to_dtos |
map_kinder_to_dtos |
| File (DTOs) | dtos.py |
app/kind/dtos.py |
| File (Mappers) | mappers.py |
app/kind/mappers.py |
Eager Loading Pattern
# Repository: Define what to load
def list(self):
return query.options(
joinedload(Model.relation)
).all()
# Mapper: Check if loaded with hasattr()
def map_to_dto(model):
return DTO(
nested=map_nested(model.relation) if hasattr(model, 'relation') else None
)
Simplified DTOs für Nested Responses
Um tiefe Verschachtelungen zu vermeiden:
# app/anmeldung/dtos.py
class KindDTOSimple(BaseModel):
"""Simplified Kind DTO for nested responses in Anmeldung."""
id: int
vorname: str
nachname: str
# Includes nested entities but avoids deep nesting
verein: Optional[VereinDTOSimple] = None
Konsequenzen
Positiv ✅
- Klare Trennung: API-Kontrakte unabhängig von Datenbank-Schema
- Testbarkeit: DTOs sind reine Datenklassen, leicht zu testen
- Flexibilität: API-Responses können unabhängig von DB-Struktur angepasst werden
- Explizite Mappings: Dokumentiert durch Code, was im Response enthalten ist
- Computed Fields: Einheitliche Stelle für Berechnungen (Mapper)
- JSON-Kompatibilität: API-Kontrakt bleibt stabil
Negativ ⚠️
- Boilerplate: Mehr Code (DTOs + Mappers zusätzlich zu Schemas)
- Duplizierung: DTOs ähneln oft stark den Schemas
- Wartungsaufwand: Bei Schema-Änderungen müssen DTOs und Mapper angepasst werden
- Performance: Zusätzlicher Mapping-Overhead (minimal)
Risiken & Mitigationen
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|---|---|---|---|
| DTOs und Schemas werden inkonsistent | Mittel | Mittel | Tests mit vollständigen API-Aufrufen |
| Vergessene Eager Loading führt zu N+1 | Mittel | Hoch | Tests prüfen Eager Loading explizit |
| Zu viele DTO-Varianten (Simple/Full) | Niedrig | Niedrig | Nur bei Bedarf erstellen |
Validierung
Test-Strategie
- Integration Tests: Prüfen vollständige API-Responses inkl. nested entities
- Mapper Tests: Unit-Tests für Mapper-Funktionen
- Eager Loading Tests: Explizite Tests für N+1-Vermeidung
# tests/integration/test_business_rules.py
def test_eager_loading_kind_in_anmeldung(client, db):
"""Verify Kind data is eagerly loaded with Anmeldung."""
response = client.get("/api/anmeldung")
anmeldungen = response.json()
# Assert nested entities are present
assert anmeldungen[0]["kind"] is not None
assert anmeldungen[0]["kind"]["verein"] is not None # Nested!
Success Criteria
- ✅ Alle Tests bestanden (77 passed)
- ✅ JSON-Response-Struktur unverändert (Kompatibilität)
- ✅ Keine N+1 Queries (Eager Loading funktioniert)
- ✅ DTOs haben keine ORM-Kopplung (
from_attributesentfernt) - ✅ Computed Fields werden korrekt berechnet
Migration-Pfad
Für neue Domains:
- DTOs erstellen in
{domain}/dtos.py - Mapper erstellen in
{domain}/mappers.py - Router anpassen:
response_model=DTO+ Mapper-Aufruf - Repository: Eager Loading für nested entities
- Tests: Integration-Tests für vollständige Responses
Bestehende Domains migrieren bei nächster Änderung (opportunistic refactoring).
Referenzen
- GitHub Issue: #38 - DDD Migration Step 6
- Implementierte Domains:
app/kind/,app/anmeldung/ - Test-Coverage:
tests/mappers/,tests/integration/test_business_rules.py
Historie
| Datum | Änderung | Autor |
|---|---|---|
| 2026-01-08 | Initiale Version | Team |