--- 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` | | GET | `/pferde/{lebensnummer}` | Pferd per Lebensnummer laden | `PferdDto` | | GET | `/pferde/search?name=&reiter=` | Pferde suchen (für Nennungs-Maske) | `List` | | 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 ) 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` | | 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` | | 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 ) 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 // ["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` | | 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 // 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() 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 } // 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)