- 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>
22 KiB
| 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:
- Welche Daten über Kontextgrenzen fließen (DTOs / Query-Objekte)
- Wie die ACL-Schicht technisch implementiert wird (Ports & Adapters)
- Welche REST-Endpunkte die P1-Contexts nach außen exponieren
- 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.
reiterNameinNennungSummaryDto) - 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)
actor-contextREST API (/api/v1/actors) – Basis für alle anderen Contextsevent-management-contextREST API (/api/v1/events) – Turnier/Bewerb-Referenzen- ACL-Adapter im
registration-context–AktorReferenzAdapter+TurnierReferenzAdapter registration-contextREST API (/api/v1/registrations) – Kern-Use-Cases- Domain Events –
NennungEingereichtals erstes Event (fürbilling-context) - Offline-Cache – Bulk-Sync beim Turnier-Download
Referenzen
- ADR-0014: Bounded Context Mapping
- ADR-0015: Context Map & Integration Patterns
- ADR-0004: Event-Driven Communication
- ADR-0001: Modular Architecture
- Ubiquitous Language
- MASTER_ROADMAP
- Vaughn Vernon: „Implementing Domain-Driven Design", Kapitel 13 (Integrating Bounded Contexts)
- ÖTO 2026, ZNS-Schnittstellen-Spezifikation (A-Satz / B-Satz)