meldestelle/docs/01_Architecture/adr/0016-api-design-acl-de.md
Stefan Mogeritsch 354bd49de6 feat: integrate new desktop shell and extend backend & ADRs
- Added `meldestelle-desktop` module using JVM/Compose Desktop, registered in `settings.gradle.kts`.
- Integrated new screens and desktop navigation into core: `Veranstaltungen`, `TurnierDetail`, etc.
- Expanded backend with `ExposedFunktionaerRepository` in `officials-infrastructure`.
- Completed ADRs for bounded context mapping (`ADR-0014`) and context map (`ADR-0015`).
- Updated and extended project documentation with session logs and architecture decisions.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-24 18:22:15 +01:00

562 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
type: ADR
id: ADR-0016
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-24
---
# ADR-0016: API-Design & Anti-Corruption Layer (ACL)
## Status
Akzeptiert
## Kontext
Die 6 Bounded Contexts (ADR-0014) kommunizieren über definierte Schnittstellen (ADR-0015).
Dieses ADR konkretisiert:
1. **Welche Daten** über Kontextgrenzen fließen (DTOs / Query-Objekte)
2. **Wie** die ACL-Schicht technisch implementiert wird (Ports & Adapters)
3. **Welche REST-Endpunkte** die P1-Contexts nach außen exponieren
4. **Welche Domain Events** asynchron publiziert werden
Grundprinzip: **Kein Context kennt die internen Modelle eines anderen Context.**
Jeder Context übersetzt eingehende Daten in seine eigene Ubiquitous Language.
---
## Entscheidung
### 1. Architektur-Muster: Ports & Adapters (Hexagonal)
Jeder Context implementiert:
```
┌─────────────────────────────────────────────────────┐
│ [Context X] │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Inbound │ │ Domain │ │ Outbound │ │
│ │ Port │───▶│ Model │───▶│ Port │ │
│ │(REST/UI) │ │ (Aggregate) │ │(ACL/Event)│ │
│ └──────────┘ └──────────────┘ └───────────┘ │
│ ▲ │ │
│ │DTO DTO │ │
└───────┼────────────────────────────────────┼─────────┘
│ │
[Client / [Anderer
Frontend] Context]
```
**Regel:** DTOs sind flache, serialisierbare Datenstrukturen ohne Domänen-Logik.
Domain-Objekte verlassen den Context **niemals**.
---
### 2. Schnittstellen-Katalog: P1-Contexts
#### 2.1 `actor-context` → Inbound REST API
**Base-URL:** `/api/v1/actors`
| Methode | Pfad | Beschreibung | Response-DTO |
|---------|--------------------------------|------------------------------------|--------------------------|
| GET | `/reiter/{satznummer}` | Reiter per Satznummer laden | `ReiterDto` |
| GET | `/reiter/search?name=&verein=` | Reiter suchen (für Nennungs-Maske) | `List<ReiterSummaryDto>` |
| GET | `/pferde/{lebensnummer}` | Pferd per Lebensnummer laden | `PferdDto` |
| GET | `/pferde/search?name=&reiter=` | Pferde suchen (für Nennungs-Maske) | `List<PferdSummaryDto>` |
| GET | `/funktionaere/{id}` | Funktionär laden | `FunktionaerDto` |
| GET | `/vereine/{vereinsnummer}` | Verein laden | `VereinDto` |
| POST | `/reiter` | Reiter anlegen (TBA-Workflow) | `ReiterDto` |
| PUT | `/reiter/{satznummer}` | Reiter aktualisieren | `ReiterDto` |
**DTOs (actor-context → outbound):**
```kotlin
// Vollständiges Reiter-Objekt (für Detail-Ansicht)
data class ReiterDto(
val satznummer: String, // OEPS-Satznummer (eindeutig)
val vorname: String,
val nachname: String,
val geburtsdatum: LocalDate,
val lizenzklasse: String, // "A", "B", "C", "AMATEUR" etc.
val vereinsnummer: String,
val vereinsname: String,
val startkarte: Boolean,
val znsId: String? // ZNS-Referenz (nullable, Offline-Fall)
)
// Kompaktes Objekt für Suchergebnisse / Dropdown
data class ReiterSummaryDto(
val satznummer: String,
val vollname: String, // "Nachname, Vorname"
val lizenzklasse: String,
val vereinsname: String
)
// Vollständiges Pferd-Objekt
data class PferdDto(
val lebensnummer: String, // FEI-Lebensnummer (eindeutig)
val name: String,
val kopfnummer: String?, // Turnier-Kopfnummer (optional)
val satznummer: String?, // OEPS-Satznummer des Besitzers
val rasse: String?,
val farbe: String?,
val geburtsjahr: Int?
)
data class PferdSummaryDto(
val lebensnummer: String,
val name: String,
val kopfnummer: String?
)
data class FunktionaerDto(
val id: String,
val vorname: String,
val nachname: String,
val rolle: String, // "RICHTER", "PARCOURSCHEF" etc.
val qualifikationen: List<String>
)
data class VereinDto(
val vereinsnummer: String,
val name: String,
val oepsNummer: String
)
```
---
#### 2.2 `event-management-context` → Inbound REST API
**Base-URL:** `/api/v1/events`
| Methode | Pfad | Beschreibung | Response-DTO |
|---------|-------------------------------------|-------------------------------------|---------------------------------|
| GET | `/veranstaltungen` | Alle Veranstaltungen (paginiert) | `Page<VeranstaltungSummaryDto>` |
| GET | `/veranstaltungen/{id}` | Veranstaltung mit Turnieren laden | `VeranstaltungDetailDto` |
| GET | `/turniere/{turniernummer}` | Turnier per OEPS-Nummer laden | `TurnierDto` |
| GET | `/turniere/{turniernummer}/bewerbe` | Bewerbe eines Turniers | `List<BewerbSummaryDto>` |
| POST | `/veranstaltungen` | Neue Veranstaltung anlegen | `VeranstaltungDto` |
| POST | `/veranstaltungen/{id}/turniere` | Turnier zu Veranstaltung hinzufügen | `TurnierDto` |
**DTOs (event-management-context → outbound):**
```kotlin
data class VeranstaltungSummaryDto(
val id: String,
val bezeichnung: String,
val datum: LocalDate,
val ort: String,
val veranstalterVereinsnummer: String,
val status: String // "GEPLANT", "AKTIV", "ABGESCHLOSSEN"
)
data class VeranstaltungDetailDto(
val id: String,
val bezeichnung: String,
val datum: LocalDate,
val ort: String,
val veranstalterVereinsnummer: String,
val status: String,
val turniere: List<TurnierSummaryDto>
)
data class TurnierDto(
val turniernummer: String, // OEPS-vergebene Nummer
val veranstaltungId: String, // Referenz auf interne Veranstaltung
val bezeichnung: String,
val datum: LocalDate,
val kategorie: String, // "LT", "RT", "BT", "ST" etc.
val sparten: List<String> // ["DRESSUR", "SPRINGEN"] etc.
)
data class TurnierSummaryDto(
val turniernummer: String,
val bezeichnung: String,
val datum: LocalDate,
val kategorie: String
)
// Wird vom registration-context konsumiert (ACL-Übersetzung)
data class BewerbSummaryDto(
val bewerbId: String,
val bewerbsnummer: String, // z.B. "1", "2A", "2B"
val bezeichnung: String,
val sparte: String,
val klasse: String,
val maxStarter: Int?
)
```
---
#### 2.3 `registration-context` → Inbound REST API
**Base-URL:** `/api/v1/registrations`
| Methode | Pfad | Beschreibung | Response-DTO |
|---------|-------------------------------------------------|-------------------------------------|---------------------------|
| GET | `/nennungen?turniernummer=&status=` | Nennungen filtern | `List<NennungSummaryDto>` |
| GET | `/nennungen/{nennungsId}` | Einzelne Nennung laden | `NennungDetailDto` |
| POST | `/nennungen` | Neue Nennung einreichen | `NennungDetailDto` |
| PUT | `/nennungen/{nennungsId}/status` | Status ändern (Storno, Bestätigung) | `NennungDetailDto` |
| POST | `/nennungen/{nennungsId}/transfer` | Nennung transferieren | `NennungsTransferDto` |
| GET | `/nennungen/{nennungsId}/transfer/{transferId}` | Transfer-Status abfragen | `NennungsTransferDto` |
**DTOs (registration-context):**
```kotlin
// Eingehend: Neue Nennung (Command)
data class NennungErstellenCommand(
val turniernummer: String,
val bewerbId: String,
val reiterSatznummer: String,
val pferdLebensnummer: String,
val startwunsch: String?, // "FRUEH", "SPAET", "EGAL"
val bemerkung: String?
)
// Ausgehend: Kompakte Nennung (für Listen)
data class NennungSummaryDto(
val nennungsId: String,
val turniernummer: String,
val bewerbBezeichnung: String,
val reiterName: String, // Denormalisiert für Performance
val pferdName: String, // Denormalisiert für Performance
val status: String, // "EINGEREICHT", "BESTAETIGT", "STORNIERT"
val eingereichtAm: LocalDateTime
)
// Ausgehend: Vollständige Nennung (für Detail-Ansicht)
data class NennungDetailDto(
val nennungsId: String,
val turniernummer: String,
val bewerbId: String,
val bewerbBezeichnung: String,
val reiterSatznummer: String,
val reiterName: String,
val pferdLebensnummer: String,
val pferdName: String,
val startwunsch: String?,
val status: String,
val bemerkung: String?,
val eingereichtAm: LocalDateTime,
val letzteAenderung: LocalDateTime
)
// Transfer-DTO
data class NennungsTransferDto(
val transferId: String,
val quellNennungsId: String,
val zielBewerbId: String,
val zielReiterSatznummer: String?, // null = gleicher Reiter
val zielPferdLebensnummer: String?, // null = gleiches Pferd
val status: String, // "BEANTRAGT", "GENEHMIGT", "ABGELEHNT"
val guthabenErhalten: Boolean
)
```
---
### 3. ACL-Implementierung: registration-context konsumiert actor-context
Der `registration-context` benötigt Reiter- und Pferd-Daten, darf aber **nicht** direkt auf
die Datenbank des `actor-context` zugreifen.
**ACL-Port (Interface im registration-context):**
```kotlin
// Port-Interface (Teil des registration-context Domain-Layers)
interface AktorReferenzPort {
fun ladeReiter(satznummer: String): ReiterReferenz?
fun ladePferd(lebensnummer: String): PferdReferenz?
fun validiereReiterPferdKombination(
reiterSatznummer: String,
pferdLebensnummer: String
): ValidiertesReiterPferdPaar?
}
// Interne Referenz-Objekte (registration-context eigene Sprache)
data class ReiterReferenz(
val satznummer: String,
val vollname: String,
val lizenzklasse: LizenzKlasseE, // Eigener Enum des registration-context
val vereinsnummer: String,
val istStartberechtigt: Boolean // Abgeleitetes Feld (Startkarte + Lizenz aktiv)
)
data class PferdReferenz(
val lebensnummer: String,
val name: String,
val kopfnummer: String?
)
data class ValidiertesReiterPferdPaar(
val reiter: ReiterReferenz,
val pferd: PferdReferenz,
val paarungGueltig: Boolean,
val warnungen: List<String> // z.B. "Pferd hat keine aktive Kopfnummer"
)
```
**ACL-Adapter (Infrastruktur-Layer, implementiert den Port):**
```kotlin
// Adapter übersetzt actor-context DTO → registration-context Referenz-Objekt
@Component
class AktorReferenzAdapter(
private val aktorClient: AktorContextClient // HTTP-Client oder direkte Bean
) : AktorReferenzPort {
override fun ladeReiter(satznummer: String): ReiterReferenz? {
val dto = aktorClient.getReiter(satznummer) ?: return null
// ACL-Übersetzung: ReiterDto → ReiterReferenz
return ReiterReferenz(
satznummer = dto.satznummer,
vollname = "${dto.nachname}, ${dto.vorname}",
lizenzklasse = LizenzKlasseE.valueOf(dto.lizenzklasse),
vereinsnummer = dto.vereinsnummer,
istStartberechtigt = dto.startkarte
)
}
override fun ladePferd(lebensnummer: String): PferdReferenz? {
val dto = aktorClient.getPferd(lebensnummer) ?: return null
return PferdReferenz(
lebensnummer = dto.lebensnummer,
name = dto.name,
kopfnummer = dto.kopfnummer
)
}
override fun validiereReiterPferdKombination(
reiterSatznummer: String,
pferdLebensnummer: String
): ValidiertesReiterPferdPaar? {
val reiter = ladeReiter(reiterSatznummer) ?: return null
val pferd = ladePferd(pferdLebensnummer) ?: return null
val warnungen = mutableListOf<String>()
if (pferd.kopfnummer == null) warnungen.add("Pferd hat keine aktive Kopfnummer")
if (!reiter.istStartberechtigt) warnungen.add("Reiter hat keine gültige Startkarte")
return ValidiertesReiterPferdPaar(reiter, pferd, warnungen.isEmpty(), warnungen)
}
}
```
---
### 4. ACL-Implementierung: registration-context konsumiert event-management-context
```kotlin
// Port-Interface
interface TurnierReferenzPort {
fun ladeTurnier(turniernummer: String): TurnierReferenz?
fun ladeBewerb(bewerbId: String): BewerbReferenz?
fun ladeBewerbeDesTurniers(turniernummer: String): List<BewerbReferenz>
}
// Interne Referenz-Objekte (registration-context Sprache)
data class TurnierReferenz(
val turniernummer: String,
val bezeichnung: String,
val datum: LocalDate,
val kategorie: TurnierkategorieE, // Eigener Enum
val istNennungMoeglich: Boolean // Abgeleitetes Feld (Datum + Status)
)
data class BewerbReferenz(
val bewerbId: String,
val bewerbsnummer: String,
val bezeichnung: String,
val sparte: SparteE, // Eigener Enum
val klasse: String,
val maxStarter: Int?
)
```
---
### 5. Domain Events (Asynchrone Kommunikation)
Folgende Events werden über den internen Event-Bus publiziert:
#### 5.1 `actor-context` publiziert
| Event | Payload (Schlüsselfelder) | Konsumenten |
|----------------------|--------------------------------------------|----------------------------------------------|
| `ReiterAktualisiert` | `satznummer`, `lizenzklasse`, `startkarte` | `registration-context` (Cache-Invalidierung) |
| `PferdAktualisiert` | `lebensnummer`, `kopfnummer` | `registration-context` (Cache-Invalidierung) |
| `ReiterGesperrt` | `satznummer`, `grund` | `registration-context` (Warn-Logik) |
#### 5.2 `registration-context` publiziert
| Event | Payload (Schlüsselfelder) | Konsumenten |
|-----------------------|------------------------------------------------------------------------------------|------------------------------------------|
| `NennungEingereicht` | `nennungsId`, `turniernummer`, `bewerbId`, `reiterSatznummer`, `pferdLebensnummer` | `billing-context`, `competition-context` |
| `NennungStorniert` | `nennungsId`, `turniernummer`, `grund` | `billing-context` |
| `NennungTransferiert` | `transferId`, `quellNennungsId`, `zielBewerbId` | `billing-context` |
#### 5.3 `event-management-context` publiziert
| Event | Payload (Schlüsselfelder) | Konsumenten |
|---------------------------|-------------------------------------|--------------------------------------------|
| `TurnierEroeffnet` | `turniernummer`, `datum`, `sparten` | `registration-context` (Nennungs-Freigabe) |
| `NennungsschlussErreicht` | `turniernummer`, `zeitpunkt` | `registration-context` (Sperr-Logik) |
| `TurnierAbgesagt` | `turniernummer`, `grund` | `registration-context`, `billing-context` |
**Event-Struktur (Basis):**
```kotlin
// Basis-Event (alle Domain Events erben davon)
abstract class DomainEvent(
val eventId: String = UUID.randomUUID().toString(),
val occurredAt: Instant = Instant.now(),
val contextSource: String // z.B. "actor-context"
)
// Beispiel: NennungEingereicht
data class NennungEingereichtEvent(
val nennungsId: String,
val turniernummer: String,
val bewerbId: String,
val reiterSatznummer: String,
val pferdLebensnummer: String,
val eingereichtVon: String // Benutzer-ID (identity-context Referenz)
) : DomainEvent(contextSource = "registration-context")
```
---
### 6. Offline-First: Lokale Referenz-Caches
Da die Desktop-App offline-fähig sein muss, cachen die ACL-Adapter Referenz-Daten lokal:
```
┌─────────────────────────────────────────────────────────┐
│ registration-context (Desktop) │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ AktorReferenzAdapter │ │
│ │ │ │
│ │ ladeReiter() ──▶ [Lokaler Cache (SQLite)] │ │
│ │ │ │ │
│ │ ▼ Cache-Miss │ │
│ │ [HTTP → actor-context] │ │
│ │ │ │ │
│ │ ▼ Offline │ │
│ │ [Fehler: ReiterNichtGefunden] │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
**Cache-Strategie:**
- **Reiter/Pferd-Daten:** Werden beim Turnier-Download vollständig gecacht (Bulk-Sync)
- **Cache-Invalidierung:** Via `ReiterAktualisiert`-Event (wenn online) oder manueller Sync
- **Offline-Fallback:** Gecachte Daten sind gültig; neue Reiter können nicht angelegt werden
---
### 7. ZNS-Schnittstelle (Externer Context)
Der `actor-context` implementiert eine ACL zum externen ZNS (Zentrales Nennungs-System):
```kotlin
// Port (actor-context → ZNS)
interface ZnsPort {
fun ladeReiterAusSatz(satznummer: String): ZnsReiterSatz?
fun ladePferdAusSatz(lebensnummer: String): ZnsPferdSatz?
fun synchronisiereStammdaten(turniernummer: String): ZnsSyncErgebnis
}
// ZNS A-Satz (Reiter-Stammdaten)
data class ZnsReiterSatz(
val satznummer: String,
val vorname: String,
val nachname: String,
val geburtsdatum: LocalDate,
val lizenzklasse: String, // ZNS-Kodierung → wird in LizenzKlasseE übersetzt
val vereinsNummer: String,
val startkarte: Boolean
)
// ZNS B-Satz (Pferd-Stammdaten)
data class ZnsPferdSatz(
val lebensnummer: String,
val name: String,
val satznummer: String?, // Besitzer-Satznummer
val rasse: String?,
val farbe: String?
)
```
---
## Konsequenzen
### Positiv
- **Klare Kontraktgrenzen:** Jeder Context hat explizite, versionierbare APIs
- **Unabhängige Deployments:** Contexts können unabhängig deployed werden
- **Testbarkeit:** ACL-Ports können einfach gemockt werden (Unit-Tests)
- **Offline-Fähigkeit:** Lokale Caches ermöglichen Offline-Betrieb ohne Architektur-Bruch
- **ZNS-Isolation:** Änderungen am ZNS-Format betreffen nur den `ZnsAdapter`
### Negativ / Risiken
- **Datenduplizierung:** Denormalisierte Felder in DTOs (z.B. `reiterName` in `NennungSummaryDto`)
- **Cache-Konsistenz:** Lokale Caches können veralten (akzeptiertes Risiko, Warn-Logik)
- **Initialer Aufwand:** ACL-Adapter müssen für jeden Context implementiert werden
### Neutral
- **DTO-Versionierung:** Bei Breaking Changes muss API-Version erhöht werden (`/api/v2/...`)
- **Event-Ordering:** Domain Events sind best-effort; kritische Operationen bleiben synchron
---
## Abgelehnte Alternativen
### Shared Domain Model
Alle Contexts teilen ein gemeinsames Domain-Modell (z.B. `DomReiter` überall).
**Abgelehnt:** Führt zu starker Kopplung; Änderungen an `DomReiter` brechen alle Contexts.
### GraphQL Federation
Einheitliches GraphQL-Schema über alle Contexts.
**Abgelehnt:** Zu komplex für MVP; REST + Domain Events reicht für die aktuelle Skalierung.
### Direkte Datenbank-Joins
`registration-context` liest direkt aus der `actor-context`-Datenbank.
**Abgelehnt:** Verletzt SCS-Prinzip; verhindert unabhängige Deployments und Skalierung.
---
## Implementierungs-Reihenfolge (P1-Priorität)
1. **`actor-context` REST API** (`/api/v1/actors`) Basis für alle anderen Contexts
2. **`event-management-context` REST API** (`/api/v1/events`) Turnier/Bewerb-Referenzen
3. **ACL-Adapter im `registration-context`** `AktorReferenzAdapter` + `TurnierReferenzAdapter`
4. **`registration-context` REST API** (`/api/v1/registrations`) Kern-Use-Cases
5. **Domain Events** `NennungEingereicht` als erstes Event (für `billing-context`)
6. **Offline-Cache** Bulk-Sync beim Turnier-Download
---
## Referenzen
- [ADR-0014: Bounded Context Mapping](0014-bounded-context-mapping-de.md)
- [ADR-0015: Context Map & Integration Patterns](0015-context-map-de.md)
- [ADR-0004: Event-Driven Communication](0004-event-driven-communication-de.md)
- [ADR-0001: Modular Architecture](0001-modular-architecture-de.md)
- [Ubiquitous Language](../../03_Domain/01_Glossary/Ubiquitous_Language.md)
- [MASTER_ROADMAP](../MASTER_ROADMAP.md)
- Vaughn Vernon: „Implementing Domain-Driven Design", Kapitel 13 (Integrating Bounded Contexts)
- ÖTO 2026, ZNS-Schnittstellen-Spezifikation (A-Satz / B-Satz)