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

22 KiB
Raw Blame History

type id status owner last_update
ADR ADR-0016 ACTIVE Lead Architect 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):

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

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

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

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

// 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

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

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

// 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