diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 8fe1baf9..b4a0e05e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -103,10 +103,38 @@ jobs: with: java-version: 21 distribution: 'temurin' - cache: gradle + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + with: + gradle-version: wrapper + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + cache-overwrite-existing: true + gradle-home-cache-includes: | + caches + notifications + jdks + wrapper - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Run integration tests - run: ./gradlew integrationTest + run: ./gradlew integrationTest --no-daemon --parallel + env: + # Environment variables for Redis connection + REDIS_HOST: localhost + REDIS_PORT: 6379 + # Spring profile for integration tests + SPRING_PROFILES_ACTIVE: integration-test + + - name: Upload test reports + uses: actions/upload-artifact@v3 + if: always() + with: + name: integration-test-reports + path: | + **/build/reports/tests/integrationTest/ + **/build/test-results/integrationTest/ + retention-days: 7 diff --git a/README.md b/README.md index 18a5b8fa..89063f36 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,8 @@ Es gibt noch einige offene Probleme, insbesondere bei den Client-Modulen, die Ko ### Entwicklungsrichtlinien - Verwenden Sie die in der Projektstruktur definierten Module -- Folgen Sie den Architekturentscheidungen (ADRs) im Verzeichnis `docs/architecture/adr` +- Folgen Sie den Architekturentscheidungen (ADRs) im Verzeichnis `docs/architecture/adr` (verfügbar in Deutsch mit Dateiendung `-de.md`) +- Verwenden Sie die C4-Diagramme im Verzeichnis `docs/architecture/c4` für einen Überblick über die Systemarchitektur (verfügbar in Deutsch mit Dateiendung `-de.puml`) - Verwenden Sie die Datenmodelle aus `docs/architecture/data-model` ### Tests ausführen diff --git a/build.gradle.kts b/build.gradle.kts index 36f2beaa..4ab706a6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,6 +50,37 @@ subprojects { // Optimize JVM args for tests jvmArgs = listOf("-Xmx512m", "-XX:+UseG1GC") } + + // Define a custom integrationTest task + tasks.register("integrationTest") { + description = "Runs integration tests." + group = "verification" + + // Use the same configuration as the test task + useJUnitPlatform() + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 + jvmArgs = listOf("-Xmx512m", "-XX:+UseG1GC") + + // Include all tests that have "Integration" in their name + include("**/*Integration*Test.kt") + + // Exclude tests that are not integration tests + exclude("**/*Test.kt") + + // Set system properties for integration tests + systemProperty("spring.profiles.active", "integration-test") + systemProperty("redis.host", "localhost") + systemProperty("redis.port", "6379") + + // Generate reports in a separate directory + reports { + html.required.set(true) + junitXml.required.set(true) + } + + // This task should run after the regular test task + // We don't use mustRunAfter here to avoid reference issues + } } // Wrapper task configuration for the root project diff --git a/config/application.yml b/config/application.yml new file mode 100644 index 00000000..e89da0e9 --- /dev/null +++ b/config/application.yml @@ -0,0 +1,53 @@ +spring: + application: + name: meldestelle + +# Redis configuration for cache +redis: + host: localhost + port: 6379 + password: # Leave empty for no password + database: 0 + connection-timeout: 2000 + read-timeout: 2000 + use-pooling: true + max-pool-size: 8 + min-pool-size: 2 + connection-check-interval: 10000 # 10 seconds + local-cache-cleanup-interval: 60000 # 1 minute + sync-interval: 300000 # 5 minutes + + # Redis configuration for event store + event-store: + host: localhost + port: 6379 + password: # Leave empty for no password + database: 1 # Use a different database for event store + connection-timeout: 2000 + read-timeout: 2000 + use-pooling: true + max-pool-size: 8 + min-pool-size: 2 + consumer-group: event-processors + consumer-name: + "${spring.application.name}-${random.uuid}" + stream-prefix: + "event-stream:" + all-events-stream: + "all-events" + claim-idle-timeout: 60000 # 1 minute + poll-timeout: 100 # 100 milliseconds + poll-interval: 100 # 100 milliseconds + max-batch-size: 100 + create-consumer-group-if-not-exists: true + +# Logging configuration +logging: + level: + root: INFO + at.mocode: DEBUG + org.springframework.data.redis: INFO + +# Server configuration +server: + port: 8080 diff --git a/docs/architecture/adr/0000-adr-template-de.md b/docs/architecture/adr/0000-adr-template-de.md new file mode 100644 index 00000000..c0a177e1 --- /dev/null +++ b/docs/architecture/adr/0000-adr-template-de.md @@ -0,0 +1,27 @@ +# ADR-0000: Vorlage für Architekturentscheidungsaufzeichnung + +## Status + +[Vorgeschlagen | Akzeptiert | Veraltet | Ersetzt] + +Falls ersetzt, fügen Sie einen Verweis auf die neue ADR ein: [ADR-XXXX](XXXX-filename.md) + +## Kontext + +Beschreiben Sie den Kontext und die Problemstellung, z.B. in freier Form mit zwei bis drei Sätzen. Sie können das Problem auch in Form einer Frage formulieren. + +## Entscheidung + +Beschreiben Sie die getroffene Entscheidung. + +## Konsequenzen + +Beschreiben Sie den resultierenden Kontext nach Anwendung der Entscheidung. Alle Konsequenzen sollten hier aufgeführt werden, nicht nur die "positiven". Eine bestimmte Entscheidung kann positive, negative und neutrale Konsequenzen haben, die alle das Team und das Projekt in der Zukunft beeinflussen. + +## Betrachtete Alternativen + +Welche anderen Optionen wurden in Betracht gezogen und warum wurden sie nicht gewählt? + +## Referenzen + +- [Link zu relevanter Dokumentation, Diskussionen usw.] diff --git a/docs/architecture/adr/0000-adr-template.md b/docs/architecture/adr/0000-adr-template.md new file mode 100644 index 00000000..7fa818e2 --- /dev/null +++ b/docs/architecture/adr/0000-adr-template.md @@ -0,0 +1,27 @@ +# ADR-0000: Architecture Decision Record Template + +## Status + +[Proposed | Accepted | Deprecated | Superseded] + +If superseded, include a reference to the new ADR: [ADR-XXXX](XXXX-filename.md) + +## Context + +Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question. + +## Decision + +Describe the decision that was made. + +## Consequences + +Describe the resulting context after applying the decision. All consequences should be listed here, not just the "positive" ones. A particular decision may have positive, negative, and neutral consequences, but all of them affect the team and project in the future. + +## Alternatives Considered + +What other options were considered, and why were they not chosen? + +## References + +- [Link to relevant documentation, discussions, etc.] diff --git a/docs/architecture/adr/0001-modular-architecture-de.md b/docs/architecture/adr/0001-modular-architecture-de.md new file mode 100644 index 00000000..9fa0b040 --- /dev/null +++ b/docs/architecture/adr/0001-modular-architecture-de.md @@ -0,0 +1,68 @@ +# ADR-0001: Modulare Architektur + +## Status + +Akzeptiert + +## Kontext + +Das Meldestelle-System wurde ursprünglich als monolithische Anwendung entwickelt. Mit zunehmender Komplexität und Größe des Systems traten mehrere Herausforderungen auf: + +1. Der Quellcode wurde schwer zu warten und zu verstehen +2. Entwicklungsteams mussten eng koordinieren, was die Entwicklung verlangsamte +3. Die gesamte Anwendung musste skaliert werden, auch wenn nur bestimmte Teile mehr Ressourcen benötigten +4. Technologieentscheidungen wurden durch die monolithische Architektur eingeschränkt + +Das Team musste entscheiden, ob es mit dem monolithischen Ansatz fortfahren oder zu einer modulareren Architektur migrieren sollte. + +## Entscheidung + +Wir haben uns entschieden, von einer monolithischen Struktur zu einer modularen Architektur zu migrieren und das System in die folgenden Module zu organisieren: + +- **core**: Gemeinsame Kernkomponenten +- **masterdata**: Stammdatenverwaltung +- **members**: Mitgliederverwaltung +- **horses**: Pferderegistrierung +- **events**: Veranstaltungsverwaltung +- **infrastructure**: Gemeinsame Infrastrukturkomponenten +- **client**: Client-Anwendungen + +Jedes Domänenmodul (masterdata, members, horses, events) folgt einem Clean-Architecture-Ansatz mit separaten API-, Anwendungs-, Domänen-, Infrastruktur- und Service-Schichten. + +## Konsequenzen + +### Positive + +- **Verbesserte Wartbarkeit**: Kleinere, fokussierte Module sind leichter zu verstehen und zu warten +- **Unabhängige Entwicklung**: Teams können an verschiedenen Modulen mit minimaler Koordination arbeiten +- **Selektive Skalierung**: Einzelne Module können basierend auf ihren spezifischen Anforderungen skaliert werden +- **Technologieflexibilität**: Verschiedene Module können je nach Bedarf unterschiedliche Technologien verwenden +- **Klare Grenzen**: Domänengrenzen sind explizit definiert, was die konzeptionelle Integrität des Systems verbessert + +### Negative + +- **Erhöhte Komplexität**: Die Gesamtsystemarchitektur ist komplexer +- **Deployment-Overhead**: Mehr Komponenten müssen bereitgestellt und verwaltet werden +- **Leistungsüberlegungen**: Modulübergreifende Kommunikation fügt Latenz hinzu +- **Migrationsaufwand**: Erheblicher Aufwand erforderlich, um von der monolithischen Struktur zu migrieren + +### Neutral + +- **Teamorganisation**: Teams müssen um Module statt um Features herum organisiert werden +- **Dokumentationsbedarf**: Umfassendere Dokumentation ist erforderlich, um das System als Ganzes zu verstehen + +## Betrachtete Alternativen + +### Erweiterter Monolith + +Wir haben in Betracht gezogen, die interne Struktur des Monolithen mit besseren Modulgrenzen zu verbessern, ihn aber als eine einzige bereitstellbare Einheit zu behalten. Dies wäre einfacher bereitzustellen gewesen, hätte aber die Probleme mit der Skalierung und Technologieflexibilität nicht gelöst. + +### Microservices + +Wir haben einen feingranulareren Microservices-Ansatz mit vielen kleineren Diensten in Betracht gezogen. Dies hätte maximale Flexibilität geboten, aber für unsere aktuellen Bedürfnisse übermäßige Komplexität und betrieblichen Overhead eingeführt. + +## Referenzen + +- [Migrationshinweise in README.md](../../../../README.md#aktuelle-migrationshinweise) +- [Modular Monoliths von Simon Brown](https://simonbrown.je/blog/modularity-and-microservices/) +- [Clean Architecture von Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) diff --git a/docs/architecture/adr/0001-modular-architecture.md b/docs/architecture/adr/0001-modular-architecture.md new file mode 100644 index 00000000..f01dba78 --- /dev/null +++ b/docs/architecture/adr/0001-modular-architecture.md @@ -0,0 +1,68 @@ +# ADR-0001: Modular Architecture + +## Status + +Accepted + +## Context + +The Meldestelle system was initially developed as a monolithic application. As the system grew in complexity and size, several challenges emerged: + +1. The codebase became difficult to maintain and understand +2. Development teams had to coordinate closely, slowing down development +3. Scaling the entire application was necessary even when only specific parts needed more resources +4. Technology choices were constrained by the monolithic architecture + +The team needed to decide whether to continue with the monolithic approach or migrate to a more modular architecture. + +## Decision + +We decided to migrate from a monolithic structure to a modular architecture, organizing the system into the following modules: + +- **core**: Shared core components +- **masterdata**: Master data management +- **members**: Member management +- **horses**: Horse registration +- **events**: Event management +- **infrastructure**: Shared infrastructure components +- **client**: Client applications + +Each domain module (masterdata, members, horses, events) follows a clean architecture approach with separate API, application, domain, infrastructure, and service layers. + +## Consequences + +### Positive + +- **Improved maintainability**: Smaller, focused modules are easier to understand and maintain +- **Independent development**: Teams can work on different modules with minimal coordination +- **Selective scaling**: Individual modules can be scaled based on their specific requirements +- **Technology flexibility**: Different modules can use different technologies as appropriate +- **Clear boundaries**: Domain boundaries are explicitly defined, improving the conceptual integrity of the system + +### Negative + +- **Increased complexity**: The overall system architecture is more complex +- **Deployment overhead**: More components to deploy and manage +- **Performance considerations**: Inter-module communication adds latency +- **Migration effort**: Significant effort required to migrate from the monolithic structure + +### Neutral + +- **Team organization**: Teams need to be reorganized around modules rather than features +- **Documentation needs**: More comprehensive documentation is required to understand the system as a whole + +## Alternatives Considered + +### Enhanced Monolith + +We considered improving the internal structure of the monolith with better module boundaries but keeping it as a single deployable unit. This would have been simpler to deploy but wouldn't have addressed the scaling and technology flexibility issues. + +### Microservices + +We considered a more fine-grained microservices approach with many smaller services. This would have provided maximum flexibility but introduced excessive complexity and operational overhead for our current needs. + +## References + +- [Migration notes in README.md](../../../../README.md#aktuelle-migrationshinweise) +- [Modular Monoliths by Simon Brown](https://simonbrown.je/blog/modularity-and-microservices/) +- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) diff --git a/docs/architecture/adr/0002-domain-driven-design-de.md b/docs/architecture/adr/0002-domain-driven-design-de.md new file mode 100644 index 00000000..2c31afff --- /dev/null +++ b/docs/architecture/adr/0002-domain-driven-design-de.md @@ -0,0 +1,68 @@ +# ADR-0002: Domain-Driven Design + +## Status + +Akzeptiert + +## Kontext + +Mit der Weiterentwicklung des Meldestelle-Systems zur Bewältigung komplexer Geschäftsregeln für die Verwaltung von Reitsportveranstaltungen standen wir vor folgenden Herausforderungen: + +1. Aufrechterhaltung einer klaren Trennung zwischen Geschäftslogik und technischen Belangen +2. Sicherstellung, dass das System das Verständnis der Domänenexperten vom Problemraum genau widerspiegelt +3. Schaffung einer gemeinsamen Sprache zwischen technischen und nicht-technischen Stakeholdern +4. Organisation des Codes in einer Weise, die die Geschäftsdomänen widerspiegelt + +Wir benötigten einen architektonischen Ansatz, der diese Herausforderungen adressiert und eine solide Grundlage für die in [ADR-0001](0001-modular-architecture-de.md) beschriebene modulare Architektur bietet. + +## Entscheidung + +Wir haben uns entschieden, Domain-Driven Design (DDD)-Prinzipien für die Organisation unseres Quellcodes und die Gestaltung unseres Systems zu übernehmen. Dies umfasst: + +1. **Ubiquitäre Sprache**: Entwicklung einer gemeinsamen Sprache, die von Domänenexperten und Entwicklern geteilt wird +2. **Bounded Contexts**: Definition expliziter Grenzen zwischen verschiedenen Domänenbereichen (masterdata, members, horses, events) +3. **Schichtenarchitektur**: Organisation jedes Domänenmoduls in Schichten: + - Domänenschicht: Enthält Domänenmodelle, Entitäten, Wertobjekte und Domänendienste + - Anwendungsschicht: Enthält Anwendungsdienste, Anwendungsfälle und Befehls-/Abfragehandler + - Infrastrukturschicht: Enthält technische Implementierungen von Repositories, Messaging usw. + - API-Schicht: Definiert die Schnittstellen für die Interaktion mit der Domäne +4. **Aggregate**: Identifizierung von Aggregat-Roots, die Konsistenzgrenzen aufrechterhalten +5. **Repositories**: Verwendung des Repository-Musters zur Abstraktion des Datenzugriffs +6. **Domänen-Events**: Verwendung von Events zur Kommunikation zwischen Bounded Contexts + +## Konsequenzen + +### Positive + +- **Business-Technologie-Ausrichtung**: Die Codestruktur spiegelt direkt die Geschäftsdomänen wider +- **Verbesserte Kommunikation**: Ubiquitäre Sprache erleichtert die Kommunikation zwischen technischen und nicht-technischen Stakeholdern +- **Wartbarkeit**: Klare Trennung der Belange macht den Code leichter zu warten +- **Testbarkeit**: Domänenlogik kann unabhängig von Infrastrukturbelangen getestet werden +- **Flexibilität**: Änderungen in einem Bounded Context haben minimale Auswirkungen auf andere + +### Negative + +- **Lernkurve**: DDD-Konzepte erfordern Zeit, um sie richtig zu erlernen und anzuwenden +- **Initialer Entwicklungsaufwand**: Mehr Vorabdesign und Diskussion ist erforderlich +- **Potenzielle Überentwicklung**: Risiko, komplexe DDD-Muster anzuwenden, wo einfachere Lösungen ausreichen würden + +### Neutral + +- **Teamorganisation**: Teams benötigen Domänenwissen sowie technische Fähigkeiten +- **Dokumentationsbedarf**: Domänenmodelle und Bounded Contexts müssen gut dokumentiert sein + +## Betrachtete Alternativen + +### Transaction Script Pattern + +Wir haben die Verwendung eines einfacheren Transaction Script Patterns in Betracht gezogen, bei dem die Geschäftslogik um Prozeduren statt um Domänenobjekte organisiert ist. Dies wäre anfänglich einfacher zu implementieren gewesen, wäre aber mit zunehmender Komplexität der Geschäftslogik schwieriger zu warten geworden. + +### Anemic Domain Model + +Wir haben die Verwendung eines anämischen Domänenmodells in Betracht gezogen, bei dem Domänenobjekte einfache Datencontainer sind und die Geschäftslogik in separaten Serviceklassen liegt. Dies wäre für Entwickler mit CRUD-basiertem Hintergrund vertrauter gewesen, hätte aber nicht die Vorteile der Kapselung und der reichhaltigen Domänenmodellierung geboten. + +## Referenzen + +- [Domain-Driven Design von Eric Evans](https://domainlanguage.com/ddd/) +- [Implementing Domain-Driven Design von Vaughn Vernon](https://vaughnvernon.co/?page_id=168) +- [Clean Architecture von Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) diff --git a/docs/architecture/adr/0002-domain-driven-design.md b/docs/architecture/adr/0002-domain-driven-design.md new file mode 100644 index 00000000..f7a013a1 --- /dev/null +++ b/docs/architecture/adr/0002-domain-driven-design.md @@ -0,0 +1,68 @@ +# ADR-0002: Domain-Driven Design + +## Status + +Accepted + +## Context + +As the Meldestelle system evolved to handle complex business rules for equestrian event management, we faced challenges in: + +1. Maintaining a clear separation between business logic and technical concerns +2. Ensuring that the system accurately reflects the domain expert's understanding of the problem space +3. Creating a shared language between technical and non-technical stakeholders +4. Organizing code in a way that reflects the business domains + +We needed an architectural approach that would address these challenges and provide a solid foundation for the modular architecture described in [ADR-0001](0001-modular-architecture.md). + +## Decision + +We decided to adopt Domain-Driven Design (DDD) principles for organizing our codebase and designing our system. This includes: + +1. **Ubiquitous Language**: Developing a common language shared by domain experts and developers +2. **Bounded Contexts**: Defining explicit boundaries between different domain areas (masterdata, members, horses, events) +3. **Layered Architecture**: Organizing each domain module into layers: + - Domain Layer: Contains domain models, entities, value objects, and domain services + - Application Layer: Contains application services, use cases, and command/query handlers + - Infrastructure Layer: Contains technical implementations of repositories, messaging, etc. + - API Layer: Defines the interfaces for interacting with the domain +4. **Aggregates**: Identifying aggregate roots that maintain consistency boundaries +5. **Repositories**: Using the repository pattern to abstract data access +6. **Domain Events**: Using events to communicate between bounded contexts + +## Consequences + +### Positive + +- **Business-technology alignment**: The code structure directly reflects the business domains +- **Improved communication**: Ubiquitous language facilitates communication between technical and non-technical stakeholders +- **Maintainability**: Clear separation of concerns makes the code easier to maintain +- **Testability**: Domain logic can be tested independently of infrastructure concerns +- **Flexibility**: Changes in one bounded context have minimal impact on others + +### Negative + +- **Learning curve**: DDD concepts require time to learn and apply correctly +- **Initial development overhead**: More upfront design and discussion is needed +- **Potential overengineering**: Risk of applying complex DDD patterns where simpler solutions would suffice + +### Neutral + +- **Team organization**: Teams need domain knowledge as well as technical skills +- **Documentation needs**: Domain models and bounded contexts need to be well-documented + +## Alternatives Considered + +### Transaction Script Pattern + +We considered using a simpler transaction script pattern where business logic is organized around procedures rather than domain objects. This would have been simpler to implement initially but would have become harder to maintain as the business logic grew more complex. + +### Anemic Domain Model + +We considered using an anemic domain model where domain objects are simple data containers and business logic is in separate service classes. This would have been more familiar to developers coming from a CRUD-based background but would not have provided the benefits of encapsulation and rich domain modeling. + +## References + +- [Domain-Driven Design by Eric Evans](https://domainlanguage.com/ddd/) +- [Implementing Domain-Driven Design by Vaughn Vernon](https://vaughnvernon.co/?page_id=168) +- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) diff --git a/docs/architecture/adr/0003-microservices-architecture-de.md b/docs/architecture/adr/0003-microservices-architecture-de.md new file mode 100644 index 00000000..8e99aedc --- /dev/null +++ b/docs/architecture/adr/0003-microservices-architecture-de.md @@ -0,0 +1,77 @@ +# ADR-0003: Microservices-Architektur + +## Status + +Akzeptiert + +## Kontext + +Nach der Entscheidung, eine modulare Architektur ([ADR-0001](0001-modular-architecture-de.md)) und Domain-Driven Design ([ADR-0002](0002-domain-driven-design-de.md)) zu übernehmen, mussten wir die Deployment-Strategie für unsere Module festlegen. Zu den wichtigsten Überlegungen gehörten: + +1. Unabhängige Skalierbarkeit verschiedener Teile des Systems +2. Deployment-Unabhängigkeit, um Teams zu ermöglichen, Änderungen ohne Koordination mit anderen Teams zu veröffentlichen +3. Technologieunabhängigkeit, um verschiedenen Diensten die Verwendung unterschiedlicher Technologien nach Bedarf zu ermöglichen +4. Resilienz, um sicherzustellen, dass Ausfälle in einem Teil des Systems nicht das gesamte System beeinträchtigen +5. Klare Zuständigkeitsgrenzen, die mit den Teamverantwortlichkeiten übereinstimmen + +## Entscheidung + +Wir haben uns entschieden, eine Microservices-Architektur zu implementieren, bei der jedes Domänenmodul als separater Dienst bereitgestellt wird: + +- **masterdata-service**: Verwaltet Stammdaten wie Standorte, Disziplinen usw. +- **members-service**: Verwaltet Mitgliederregistrierung und -profile +- **horses-service**: Verwaltet Pferderegistrierung und -informationen +- **events-service**: Verwaltet Veranstaltungserstellung, -planung und -anmeldungen + +Jeder Dienst: +- Hat sein eigenes Datenbankschema +- Ist unabhängig bereitstellbar +- Kommuniziert mit anderen Diensten über klar definierte APIs und nachrichtenbasierte Kommunikation +- Ist für seine eigene Domänenlogik gemäß DDD-Prinzipien verantwortlich + +Wir haben auch unterstützende Infrastrukturdienste implementiert: +- **gateway**: API-Gateway für Routing und Authentifizierung +- **auth**: Authentifizierungs- und Autorisierungsdienst (Keycloak) +- **cache**: Caching-Dienst (Redis) +- **messaging**: Message Broker für die Kommunikation zwischen Diensten (Kafka) +- **monitoring**: Überwachungs- und Beobachtbarkeitsdienste + +## Konsequenzen + +### Positive + +- **Unabhängige Skalierbarkeit**: Jeder Dienst kann basierend auf seinen spezifischen Lastanforderungen skaliert werden +- **Deployment-Unabhängigkeit**: Teams können Änderungen an ihren Diensten bereitstellen, ohne sich mit anderen Teams abstimmen zu müssen +- **Technologieflexibilität**: Verschiedene Dienste können je nach Bedarf unterschiedliche Technologien verwenden +- **Resilienz**: Ausfälle in einem Dienst beeinträchtigen nicht unbedingt andere +- **Klare Zuständigkeit**: Jeder Dienst hat klare Zuständigkeitsgrenzen, die mit den Teamverantwortlichkeiten übereinstimmen +- **Kleinere Codebasen**: Jeder Dienst hat eine kleinere, fokussiertere Codebasis + +### Negative + +- **Komplexität verteilter Systeme**: Microservices bringen die Herausforderungen verteilter Systeme mit sich +- **Betrieblicher Mehraufwand**: Mehr Dienste müssen bereitgestellt, überwacht und gewartet werden +- **Herausforderungen bei der Datenkonsistenz**: Die Aufrechterhaltung der Datenkonsistenz über Dienste hinweg erfordert sorgfältiges Design +- **Netzwerklatenz**: Die Kommunikation zwischen Diensten fügt Latenz hinzu +- **Testkomplexität**: End-to-End-Tests werden komplexer + +### Neutral + +- **Teamorganisation**: Teams müssen um Dienste statt um Features herum organisiert werden +- **Dokumentationsbedarf**: Dienstschnittstellen und -interaktionen müssen gut dokumentiert sein + +## Betrachtete Alternativen + +### Modularer Monolith + +Wir haben die Implementierung eines modularen Monolithen in Betracht gezogen, bei dem alle Module als eine einzige Anwendung bereitgestellt würden, jedoch mit klaren Modulgrenzen. Dies wäre einfacher bereitzustellen gewesen und hätte die Herausforderungen verteilter Systeme vermieden, hätte aber nicht die Vorteile der unabhängigen Skalierbarkeit und Bereitstellung geboten. + +### Service-basierte Architektur + +Wir haben eine dienstbasierte Architektur mit weniger, größeren Diensten in Betracht gezogen, die mehrere Domänenbereiche umfassen würden. Dies hätte den betrieblichen Overhead reduziert, aber es schwieriger gemacht, klare Domänengrenzen und unabhängige Skalierbarkeit aufrechtzuerhalten. + +## Referenzen + +- [Microservices von Martin Fowler](https://martinfowler.com/articles/microservices.html) +- [Building Microservices von Sam Newman](https://samnewman.io/books/building_microservices/) +- [Microservices Patterns von Chris Richardson](https://microservices.io/book) diff --git a/docs/architecture/adr/0003-microservices-architecture.md b/docs/architecture/adr/0003-microservices-architecture.md new file mode 100644 index 00000000..60b9707d --- /dev/null +++ b/docs/architecture/adr/0003-microservices-architecture.md @@ -0,0 +1,77 @@ +# ADR-0003: Microservices Architecture + +## Status + +Accepted + +## Context + +Following the decision to adopt a modular architecture ([ADR-0001](0001-modular-architecture.md)) and Domain-Driven Design ([ADR-0002](0002-domain-driven-design.md)), we needed to determine the deployment strategy for our modules. Key considerations included: + +1. Independent scalability of different parts of the system +2. Deployment independence to allow teams to release changes without coordinating with other teams +3. Technology independence to allow different services to use different technologies as appropriate +4. Resilience to ensure that failures in one part of the system don't bring down the entire system +5. Clear ownership boundaries aligned with team responsibilities + +## Decision + +We decided to implement a microservices architecture where each domain module is deployed as a separate service: + +- **masterdata-service**: Manages master data such as locations, disciplines, etc. +- **members-service**: Manages member registration and profiles +- **horses-service**: Manages horse registration and information +- **events-service**: Manages event creation, scheduling, and registrations + +Each service: +- Has its own database schema +- Is independently deployable +- Communicates with other services through well-defined APIs and message-based communication +- Is responsible for its own domain logic as per DDD principles + +We also implemented supporting infrastructure services: +- **gateway**: API Gateway for routing and authentication +- **auth**: Authentication and authorization service (Keycloak) +- **cache**: Caching service (Redis) +- **messaging**: Message broker for inter-service communication (Kafka) +- **monitoring**: Monitoring and observability services + +## Consequences + +### Positive + +- **Independent scalability**: Each service can be scaled based on its specific load requirements +- **Deployment independence**: Teams can deploy changes to their services without coordinating with other teams +- **Technology flexibility**: Different services can use different technologies as appropriate +- **Resilience**: Failures in one service don't necessarily affect others +- **Clear ownership**: Each service has clear ownership boundaries aligned with team responsibilities +- **Smaller codebases**: Each service has a smaller, more focused codebase + +### Negative + +- **Distributed system complexity**: Microservices introduce the challenges of distributed systems +- **Operational overhead**: More services to deploy, monitor, and maintain +- **Data consistency challenges**: Maintaining data consistency across services requires careful design +- **Network latency**: Inter-service communication adds latency +- **Testing complexity**: End-to-end testing becomes more complex + +### Neutral + +- **Team organization**: Teams need to be organized around services rather than features +- **Documentation needs**: Service interfaces and interactions need to be well-documented + +## Alternatives Considered + +### Modular Monolith + +We considered implementing a modular monolith where all modules would be deployed as a single application but with clear module boundaries. This would have been simpler to deploy and would have avoided the distributed system challenges, but would not have provided the independent scalability and deployment benefits. + +### Service-Based Architecture + +We considered a service-based architecture with fewer, larger services that would encompass multiple domain areas. This would have reduced the operational overhead but would have made it harder to maintain clear domain boundaries and independent scalability. + +## References + +- [Microservices by Martin Fowler](https://martinfowler.com/articles/microservices.html) +- [Building Microservices by Sam Newman](https://samnewman.io/books/building_microservices/) +- [Microservices Patterns by Chris Richardson](https://microservices.io/book) diff --git a/docs/architecture/adr/0004-event-driven-communication-de.md b/docs/architecture/adr/0004-event-driven-communication-de.md new file mode 100644 index 00000000..7c2c965c --- /dev/null +++ b/docs/architecture/adr/0004-event-driven-communication-de.md @@ -0,0 +1,75 @@ +# ADR-0004: Ereignisgesteuerte Kommunikation + +## Status + +Akzeptiert + +## Kontext + +Mit der Einführung einer Microservices-Architektur ([ADR-0003](0003-microservices-architecture-de.md)) mussten wir die effektivste Art der Kommunikation zwischen den Diensten bestimmen. Zu den wichtigsten Überlegungen gehörten: + +1. Lose Kopplung zwischen Diensten, um ihre Unabhängigkeit zu erhalten +2. Asynchrone Verarbeitungsfähigkeiten zur Verbesserung der Systemresilienz und Skalierbarkeit +3. Zuverlässige Kommunikation, um sicherzustellen, dass wichtige Informationen nicht verloren gehen +4. Unterstützung für komplexe Workflows, die mehrere Dienste umfassen +5. Fähigkeit, den Zustand des Systems für Audit- und Debugging-Zwecke zu rekonstruieren + +## Entscheidung + +Wir haben uns entschieden, ein ereignisgesteuertes Kommunikationsmuster mit Apache Kafka als Message Broker zu implementieren. Die wichtigsten Aspekte dieses Ansatzes umfassen: + +1. **Domänen-Ereignisse**: Dienste veröffentlichen Domänen-Ereignisse, wenn signifikante Zustandsänderungen auftreten +2. **Event Sourcing**: Für kritische Daten speichern wir alle Ereignisse, die zum aktuellen Zustand geführt haben +3. **Nachrichtenbasierte Kommunikation**: Dienste kommunizieren hauptsächlich über asynchrone Nachrichten +4. **Choreographie**: Komplexe Workflows werden durch Ereignis-Choreographie statt Orchestrierung implementiert +5. **Ereignis-Schema-Registry**: Wir führen eine Registry von Ereignis-Schemas, um Kompatibilität zu gewährleisten + +Die Implementierung umfasst: +- Kafka als zentraler Message Broker +- Schema-Registry zur Verwaltung von Ereignis-Schemas +- Ereignis-Handler in jedem Dienst zur Verarbeitung von Ereignissen aus anderen Diensten +- Ereignis-Publisher in jedem Dienst zur Veröffentlichung von Domänen-Ereignissen + +## Konsequenzen + +### Positive + +- **Lose Kopplung**: Dienste sind entkoppelt und teilen nur die Ereignis-Verträge +- **Skalierbarkeit**: Asynchrone Verarbeitung ermöglicht bessere Skalierbarkeit unter Last +- **Resilienz**: Dienste können weiter funktionieren, auch wenn andere Dienste nicht verfügbar sind +- **Audit-Trail**: Event Sourcing bietet einen vollständigen Audit-Trail aller Zustandsänderungen +- **Flexibilität**: Neue Konsumenten können hinzugefügt werden, ohne Publisher zu modifizieren + +### Negative + +- **Eventuelle Konsistenz**: Das System ist letztendlich konsistent, was schwer zu verstehen sein kann +- **Komplexität**: Ereignisgesteuerte Systeme sind komplexer zu entwerfen, zu implementieren und zu debuggen +- **Reihenfolgegarantien**: Die korrekte Reihenfolge von Ereignissen sicherzustellen kann herausfordernd sein +- **Idempotenz**: Dienste müssen doppelte Ereignisse korrekt behandeln +- **Lernkurve**: Entwickler müssen ereignisgesteuerte Muster und Praktiken erlernen + +### Neutral + +- **Überwachungsbedarf**: Umfassende Überwachung ist erforderlich, um den Ereignisfluss zu verfolgen +- **Testansatz**: Teststrategien müssen asynchrones Verhalten berücksichtigen + +## Betrachtete Alternativen + +### Synchrone REST-APIs + +Wir haben die Verwendung synchroner REST-APIs als primären Kommunikationsmechanismus in Betracht gezogen. Dies wäre einfacher zu implementieren und zu debuggen gewesen, hätte aber zu einer engeren Kopplung zwischen Diensten und verringerter Resilienz geführt. + +### Request-Response-Messaging + +Wir haben ein Request-Response-Messaging-Muster in Betracht gezogen, bei dem Dienste Anfragen senden und auf Antworten warten. Dies hätte einige der Vorteile asynchroner Kommunikation geboten und gleichzeitig ein vertrautes Request-Response-Modell beibehalten, hätte aber das Publish-Subscribe-Muster nicht so effektiv unterstützt. + +### GraphQL-Federation + +Wir haben die Verwendung von GraphQL-Federation zur Zusammensetzung von APIs aus mehreren Diensten in Betracht gezogen. Dies hätte eine einheitliche API für Clients geboten, hätte aber eine enge Kopplung zwischen Diensten beibehalten und asynchrone Workflows nicht so effektiv unterstützt. + +## Referenzen + +- [Enterprise Integration Patterns](https://www.enterpriseintegrationpatterns.com/) +- [Event-Driven Architecture von Martin Fowler](https://martinfowler.com/articles/201701-event-driven.html) +- [Apache Kafka Dokumentation](https://kafka.apache.org/documentation/) +- [Event Sourcing Pattern](https://docs.microsoft.com/de-de/azure/architecture/patterns/event-sourcing) diff --git a/docs/architecture/adr/0004-event-driven-communication.md b/docs/architecture/adr/0004-event-driven-communication.md new file mode 100644 index 00000000..8cfee278 --- /dev/null +++ b/docs/architecture/adr/0004-event-driven-communication.md @@ -0,0 +1,75 @@ +# ADR-0004: Event-Driven Communication + +## Status + +Accepted + +## Context + +With the adoption of a microservices architecture ([ADR-0003](0003-microservices-architecture.md)), we needed to determine the most effective way for services to communicate with each other. Key considerations included: + +1. Loose coupling between services to maintain their independence +2. Asynchronous processing capabilities to improve system resilience and scalability +3. Reliable communication to ensure that important information is not lost +4. Support for complex workflows that span multiple services +5. Ability to reconstruct the state of the system for auditing and debugging purposes + +## Decision + +We decided to implement an event-driven communication pattern using Apache Kafka as the message broker. The key aspects of this approach include: + +1. **Domain Events**: Services publish domain events when significant state changes occur +2. **Event Sourcing**: For critical data, we store all events that led to the current state +3. **Message-Based Communication**: Services communicate primarily through asynchronous messages +4. **Choreography**: Complex workflows are implemented through event choreography rather than orchestration +5. **Event Schema Registry**: We maintain a registry of event schemas to ensure compatibility + +The implementation includes: +- Kafka as the central message broker +- Schema registry for managing event schemas +- Event handlers in each service to process events from other services +- Event publishers in each service to publish domain events + +## Consequences + +### Positive + +- **Loose coupling**: Services are decoupled, only sharing the event contracts +- **Scalability**: Asynchronous processing allows for better scalability under load +- **Resilience**: Services can continue to function even when other services are unavailable +- **Audit trail**: Event sourcing provides a complete audit trail of all state changes +- **Flexibility**: New consumers can be added without modifying publishers + +### Negative + +- **Eventual consistency**: The system is eventually consistent, which can be challenging to reason about +- **Complexity**: Event-driven systems are more complex to design, implement, and debug +- **Ordering guarantees**: Ensuring the correct ordering of events can be challenging +- **Idempotency**: Services must handle duplicate events correctly +- **Learning curve**: Developers need to learn event-driven patterns and practices + +### Neutral + +- **Monitoring needs**: Comprehensive monitoring is required to track event flow +- **Testing approach**: Testing strategies need to account for asynchronous behavior + +## Alternatives Considered + +### Synchronous REST APIs + +We considered using synchronous REST APIs as the primary communication mechanism. This would have been simpler to implement and debug but would have led to tighter coupling between services and reduced resilience. + +### Request-Response Messaging + +We considered a request-response messaging pattern where services would send requests and wait for responses. This would have provided some of the benefits of asynchronous communication while maintaining a familiar request-response model, but would not have supported the publish-subscribe pattern as effectively. + +### GraphQL Federation + +We considered using GraphQL federation to compose APIs from multiple services. This would have provided a unified API for clients but would have maintained tight coupling between services and would not have supported asynchronous workflows as effectively. + +## References + +- [Enterprise Integration Patterns](https://www.enterpriseintegrationpatterns.com/) +- [Event-Driven Architecture by Martin Fowler](https://martinfowler.com/articles/201701-event-driven.html) +- [Apache Kafka Documentation](https://kafka.apache.org/documentation/) +- [Event Sourcing Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing) diff --git a/docs/architecture/adr/0005-polyglot-persistence-de.md b/docs/architecture/adr/0005-polyglot-persistence-de.md new file mode 100644 index 00000000..c03c75dd --- /dev/null +++ b/docs/architecture/adr/0005-polyglot-persistence-de.md @@ -0,0 +1,81 @@ +# ADR-0005: Polyglotte Persistenz + +## Status + +Akzeptiert + +## Kontext + +Als Teil unserer Microservices-Architektur ([ADR-0003](0003-microservices-architecture-de.md)) mussten wir die am besten geeignete Datenspeicherstrategie bestimmen. Verschiedene Teile unseres Systems haben unterschiedliche Anforderungen an die Datenspeicherung: + +1. Einige Daten erfordern starke Konsistenz und komplexe Beziehungen +2. Einige Daten müssen mit sehr geringer Latenz abgerufen werden +3. Einige Daten sind ereignisbasiert und müssen in einem Zeitreihenformat gespeichert werden +4. Verschiedene Dienste haben unterschiedliche Datenzugriffsmuster + +Ein Einheitsansatz für die Datenspeicherung würde Kompromisse erzwingen, die die Leistung, Skalierbarkeit oder Entwicklungsproduktivität beeinträchtigen könnten. + +## Entscheidung + +Wir haben uns entschieden, eine polyglotte Persistenzstrategie zu implementieren, die verschiedene Datenspeichertechnologien für verschiedene Anwendungsfälle nutzt: + +1. **PostgreSQL**: Als primäre relationale Datenbank zur Speicherung strukturierter Daten mit komplexen Beziehungen + - Wird von allen Domänendiensten für ihre primäre Datenspeicherung verwendet + - Jeder Dienst hat sein eigenes Datenbankschema, um Isolation zu gewährleisten + +2. **Redis**: Als verteilter Cache für schnellen Datenzugriff + - Wird für das Caching häufig abgerufener Daten verwendet + - Wird für die Sitzungsspeicherung verwendet + - Wird für Rate-Limiting verwendet + +3. **Kafka**: Als Event-Store für Event Sourcing + - Wird zur Speicherung von Domänenereignissen für Event Sourcing verwendet + - Ermöglicht Event-Replay zum Wiederaufbau des Zustands + +4. **Elasticsearch** (geplant): Für Volltextsuchfunktionen + - Wird für erweiterte Suchfunktionen über mehrere Domänen hinweg verwendet werden + +Jeder Dienst ist für die Verwaltung seiner eigenen Datenspeicherung verantwortlich, und Dienste dürfen nicht direkt auf die Datenbanken anderer Dienste zugreifen. + +## Konsequenzen + +### Positive + +- **Optimierte Leistung**: Jede Art von Daten wird in der am besten geeigneten Speichertechnologie gespeichert +- **Skalierbarkeit**: Verschiedene Speichertechnologien können unabhängig voneinander basierend auf ihren spezifischen Anforderungen skaliert werden +- **Flexibilität**: Teams können die beste Speichertechnologie für ihre spezifischen Anwendungsfälle wählen +- **Resilienz**: Probleme mit einer Speichertechnologie beeinträchtigen nicht unbedingt andere + +### Negative + +- **Betriebliche Komplexität**: Mehrere Speichertechnologien müssen bereitgestellt, überwacht und gewartet werden +- **Herausforderungen bei der Datenkonsistenz**: Die Aufrechterhaltung der Konsistenz über verschiedene Speichertechnologien hinweg erfordert sorgfältiges Design +- **Lernkurve**: Teams müssen mit mehreren Speichertechnologien vertraut sein +- **Komplexität bei Backup und Wiederherstellung**: Verschiedene Speichertechnologien haben unterschiedliche Backup- und Wiederherstellungsverfahren + +### Neutral + +- **Daten-Governance**: Umfassende Daten-Governance ist über alle Speichertechnologien hinweg erforderlich +- **Überwachungsbedarf**: Jede Speichertechnologie erfordert ihren eigenen Überwachungsansatz + +## Betrachtete Alternativen + +### Einzelne Datenbank für alle Dienste + +Wir haben die Verwendung einer einzelnen PostgreSQL-Datenbank mit separaten Schemas für jeden Dienst in Betracht gezogen. Dies hätte den Betrieb vereinfacht, hätte aber einen Single Point of Failure geschaffen und hätte es uns nicht ermöglicht, für verschiedene Datenzugriffsmuster zu optimieren. + +### Datenbank pro Dienst, gleiche Technologie + +Wir haben die Verwendung von PostgreSQL für alle Dienste, aber mit separaten Datenbanken in Betracht gezogen. Dies hätte Dienstisolation geboten und gleichzeitig den Betrieb vereinfacht, hätte es uns aber nicht ermöglicht, für verschiedene Datenzugriffsmuster zu optimieren. + +### Vollständig verteilter NoSQL-Ansatz + +Wir haben die Verwendung eines vollständig verteilten NoSQL-Ansatzes mit Technologien wie Cassandra oder MongoDB für die gesamte Datenspeicherung in Betracht gezogen. Dies hätte eine ausgezeichnete Skalierbarkeit geboten, hätte aber die Modellierung komplexer Beziehungen erschwert und hätte signifikante Änderungen an unseren Entwicklungspraktiken erfordert. + +## Referenzen + +- [Polyglot Persistence von Martin Fowler](https://martinfowler.com/bliki/PolyglotPersistence.html) +- [PostgreSQL Dokumentation](https://www.postgresql.org/docs/) +- [Redis Dokumentation](https://redis.io/documentation) +- [Apache Kafka Dokumentation](https://kafka.apache.org/documentation/) +- [Elasticsearch Dokumentation](https://www.elastic.co/guide/index.html) diff --git a/docs/architecture/adr/0005-polyglot-persistence.md b/docs/architecture/adr/0005-polyglot-persistence.md new file mode 100644 index 00000000..cdb5470a --- /dev/null +++ b/docs/architecture/adr/0005-polyglot-persistence.md @@ -0,0 +1,81 @@ +# ADR-0005: Polyglot Persistence + +## Status + +Accepted + +## Context + +As part of our microservices architecture ([ADR-0003](0003-microservices-architecture.md)), we needed to determine the most appropriate data storage strategy. Different parts of our system have different data storage requirements: + +1. Some data requires strong consistency and complex relationships +2. Some data needs to be accessed with very low latency +3. Some data is event-based and needs to be stored in a time-series format +4. Different services have different data access patterns + +A one-size-fits-all approach to data storage would force compromises that could impact performance, scalability, or development productivity. + +## Decision + +We decided to implement a polyglot persistence strategy, using different data storage technologies for different use cases: + +1. **PostgreSQL**: As the primary relational database for storing structured data with complex relationships + - Used by all domain services for their primary data storage + - Each service has its own database schema to maintain isolation + +2. **Redis**: As a distributed cache for high-speed data access + - Used for caching frequently accessed data + - Used for session storage + - Used for rate limiting + +3. **Kafka**: As an event store for event sourcing + - Used to store domain events for event sourcing + - Enables event replay for rebuilding state + +4. **Elasticsearch** (planned): For full-text search capabilities + - Will be used for advanced search features across multiple domains + +Each service is responsible for managing its own data storage, and services are not allowed to access each other's databases directly. + +## Consequences + +### Positive + +- **Optimized performance**: Each type of data is stored in the most appropriate storage technology +- **Scalability**: Different storage technologies can be scaled independently based on their specific requirements +- **Flexibility**: Teams can choose the best storage technology for their specific use cases +- **Resilience**: Issues with one storage technology don't necessarily affect others + +### Negative + +- **Operational complexity**: Multiple storage technologies need to be deployed, monitored, and maintained +- **Data consistency challenges**: Maintaining consistency across different storage technologies requires careful design +- **Learning curve**: Teams need to be familiar with multiple storage technologies +- **Backup and recovery complexity**: Different storage technologies have different backup and recovery procedures + +### Neutral + +- **Data governance**: Comprehensive data governance is required across all storage technologies +- **Monitoring needs**: Each storage technology requires its own monitoring approach + +## Alternatives Considered + +### Single Database for All Services + +We considered using a single PostgreSQL database with separate schemas for each service. This would have simplified operations but would have created a single point of failure and would not have allowed us to optimize for different data access patterns. + +### Database per Service, Same Technology + +We considered using PostgreSQL for all services but with separate databases. This would have provided service isolation while simplifying operations, but would not have allowed us to optimize for different data access patterns. + +### Fully Distributed NoSQL Approach + +We considered using a fully distributed NoSQL approach with technologies like Cassandra or MongoDB for all data storage. This would have provided excellent scalability but would have made it harder to model complex relationships and would have required significant changes to our development practices. + +## References + +- [Polyglot Persistence by Martin Fowler](https://martinfowler.com/bliki/PolyglotPersistence.html) +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) +- [Redis Documentation](https://redis.io/documentation) +- [Apache Kafka Documentation](https://kafka.apache.org/documentation/) +- [Elasticsearch Documentation](https://www.elastic.co/guide/index.html) diff --git a/docs/architecture/adr/0006-authentication-authorization-keycloak-de.md b/docs/architecture/adr/0006-authentication-authorization-keycloak-de.md new file mode 100644 index 00000000..35839bc9 --- /dev/null +++ b/docs/architecture/adr/0006-authentication-authorization-keycloak-de.md @@ -0,0 +1,81 @@ +# ADR-0006: Authentifizierung und Autorisierung mit Keycloak + +## Status + +Akzeptiert + +## Kontext + +Als Teil unserer Microservices-Architektur ([ADR-0003](0003-microservices-architecture-de.md)) benötigten wir eine robuste und zentralisierte Lösung für Authentifizierung und Autorisierung. Zu den wichtigsten Anforderungen gehörten: + +1. Single Sign-On (SSO) über alle Dienste und Anwendungen hinweg +2. Unterstützung für mehrere Authentifizierungsmethoden (Benutzername/Passwort, OAuth, SAML) +3. Feingranulare Autorisierung mit rollenbasierter Zugriffssteuerung (RBAC) +4. Benutzerverwaltungsfunktionen einschließlich Selbstregistrierung und Profilmanagement +5. Integration mit externen Identitätsanbietern +6. Sicherheits-Best-Practices einschließlich Passwortrichtlinien und Kontosperrung +7. Token-basierte Authentifizierung für die Kommunikation zwischen Diensten + +Die Implementierung dieser Funktionen von Grund auf wäre zeitaufwändig und fehleranfällig und würde Ressourcen von unserer Kerngeschäftsfunktionalität abziehen. + +## Entscheidung + +Wir haben uns entschieden, Keycloak (Version 23.0) als unsere Identitäts- und Zugriffsverwaltungslösung zu verwenden. Keycloak ist eine Open-Source-Identitäts- und Zugriffsverwaltungslösung, die Folgendes bietet: + +1. **Benutzerauthentifizierung**: Mehrere Authentifizierungsmethoden und -abläufe +2. **Benutzerföderation**: Integration mit LDAP, Active Directory und anderen Benutzerspeichern +3. **Identitätsvermittlung**: Integration mit externen Identitätsanbietern (Google, Facebook usw.) +4. **Single Sign-On**: Über alle Anwendungen und Dienste hinweg +5. **Feingranulare Autorisierung**: Rollen- und attributbasierte Zugriffssteuerung +6. **Benutzerverwaltung**: Selbstregistrierung, Profilmanagement, Passwortrichtlinien +7. **Token-basierte Authentifizierung**: JWT-Tokens für die Kommunikation zwischen Diensten + +Unsere Implementierung umfasst: +- Keycloak-Server, der als containerisierter Dienst bereitgestellt wird +- Integration mit unserem API-Gateway für die Token-Validierung +- Client-Adapter für unsere Dienste und Anwendungen +- Benutzerdefinierte Themes und E-Mail-Vorlagen +- Rollen- und Gruppendefinitionen, die auf unser Domänenmodell abgestimmt sind + +## Konsequenzen + +### Positive + +- **Umfassende Lösung**: Keycloak bietet eine vollständige Identitäts- und Zugriffsverwaltungslösung +- **Standards-Konformität**: Keycloak implementiert Industriestandards (OAuth 2.0, OpenID Connect, SAML) +- **Reduzierter Entwicklungsaufwand**: Wir müssen Authentifizierung und Autorisierung nicht von Grund auf implementieren +- **Sicherheit**: Keycloak folgt Sicherheits-Best-Practices und wird aktiv gewartet +- **Flexibilität**: Keycloak unterstützt mehrere Authentifizierungsmethoden und Identitätsanbieter + +### Negative + +- **Betriebliche Komplexität**: Keycloak fügt einen weiteren Dienst hinzu, der bereitgestellt und gewartet werden muss +- **Lernkurve**: Teams müssen Keycloak-Konzepte und APIs erlernen +- **Leistungsüberlegungen**: Die Token-Validierung fügt den Anfragen einen gewissen Overhead hinzu +- **Abhängigkeit**: Wir sind für Authentifizierung und Autorisierung von Keycloak abhängig + +### Neutral + +- **Konfigurationsbedarf**: Keycloak erfordert sorgfältige Konfiguration, um mit unseren Sicherheitsanforderungen übereinzustimmen +- **Upgrade-Management**: Keycloak-Upgrades müssen sorgfältig verwaltet werden + +## Betrachtete Alternativen + +### Eigener Authentifizierungsdienst + +Wir haben in Betracht gezogen, unseren eigenen Authentifizierungsdienst zu entwickeln. Dies hätte uns vollständige Kontrolle über die Implementierung gegeben, hätte aber erheblichen Entwicklungsaufwand und laufende Wartung erfordert. + +### Auth0 + +Wir haben die Verwendung von Auth0, einer kommerziellen Identity-as-a-Service (IDaaS)-Lösung, in Betracht gezogen. Auth0 hätte ähnliche Funktionen wie Keycloak mit weniger betrieblichem Overhead geboten, hätte aber laufende Kosten und potenzielle Anbieterabhängigkeit mit sich gebracht. + +### Spring Security mit JWT + +Wir haben die Verwendung von Spring Security mit JWT-Tokens für Authentifizierung und Autorisierung in Betracht gezogen. Dies hätte sich gut in unsere Spring-basierten Dienste integriert, hätte aber mehr Entwicklungsaufwand erfordert und hätte nicht die umfassenden Identitätsverwaltungsfunktionen von Keycloak geboten. + +## Referenzen + +- [Keycloak Dokumentation](https://www.keycloak.org/documentation) +- [OAuth 2.0 und OpenID Connect](https://oauth.net/2/) +- [JWT (JSON Web Tokens)](https://jwt.io/) +- [Absicherung von Microservices mit Keycloak](https://www.keycloak.org/docs/latest/securing_apps/) diff --git a/docs/architecture/adr/0006-authentication-authorization-keycloak.md b/docs/architecture/adr/0006-authentication-authorization-keycloak.md new file mode 100644 index 00000000..69fa4b33 --- /dev/null +++ b/docs/architecture/adr/0006-authentication-authorization-keycloak.md @@ -0,0 +1,81 @@ +# ADR-0006: Authentication and Authorization with Keycloak + +## Status + +Accepted + +## Context + +As part of our microservices architecture ([ADR-0003](0003-microservices-architecture.md)), we needed a robust and centralized solution for authentication and authorization. Key requirements included: + +1. Single sign-on (SSO) across all services and applications +2. Support for multiple authentication methods (username/password, OAuth, SAML) +3. Fine-grained authorization with role-based access control (RBAC) +4. User management capabilities including self-registration and profile management +5. Integration with external identity providers +6. Security best practices including password policies and account lockout +7. Token-based authentication for service-to-service communication + +Implementing these features from scratch would be time-consuming and error-prone, and would divert resources from our core business functionality. + +## Decision + +We decided to use Keycloak (version 23.0) as our identity and access management solution. Keycloak is an open-source identity and access management solution that provides: + +1. **User Authentication**: Multiple authentication methods and flows +2. **User Federation**: Integration with LDAP, Active Directory, and other user stores +3. **Identity Brokering**: Integration with external identity providers (Google, Facebook, etc.) +4. **Single Sign-On**: Across all applications and services +5. **Fine-grained Authorization**: Role-based and attribute-based access control +6. **User Management**: Self-registration, profile management, password policies +7. **Token-based Authentication**: JWT tokens for service-to-service communication + +Our implementation includes: +- Keycloak server deployed as a containerized service +- Integration with our API Gateway for token validation +- Client adapters for our services and applications +- Custom themes and email templates +- Role and group definitions aligned with our domain model + +## Consequences + +### Positive + +- **Comprehensive solution**: Keycloak provides a complete identity and access management solution +- **Standards compliance**: Keycloak implements industry standards (OAuth 2.0, OpenID Connect, SAML) +- **Reduced development effort**: We don't need to implement authentication and authorization from scratch +- **Security**: Keycloak follows security best practices and is actively maintained +- **Flexibility**: Keycloak supports multiple authentication methods and identity providers + +### Negative + +- **Operational complexity**: Keycloak adds another service to deploy and maintain +- **Learning curve**: Teams need to learn Keycloak concepts and APIs +- **Performance considerations**: Token validation adds some overhead to requests +- **Dependency**: We are dependent on Keycloak for authentication and authorization + +### Neutral + +- **Configuration needs**: Keycloak requires careful configuration to align with our security requirements +- **Upgrade management**: Keycloak upgrades need to be managed carefully + +## Alternatives Considered + +### Custom Authentication Service + +We considered building our own authentication service. This would have given us complete control over the implementation but would have required significant development effort and ongoing maintenance. + +### Auth0 + +We considered using Auth0, a commercial identity as a service (IDaaS) solution. Auth0 would have provided similar capabilities to Keycloak with less operational overhead, but would have introduced ongoing costs and potential vendor lock-in. + +### Spring Security with JWT + +We considered using Spring Security with JWT tokens for authentication and authorization. This would have integrated well with our Spring-based services but would have required more development effort and would not have provided the comprehensive identity management features of Keycloak. + +## References + +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [OAuth 2.0 and OpenID Connect](https://oauth.net/2/) +- [JWT (JSON Web Tokens)](https://jwt.io/) +- [Securing Microservices with Keycloak](https://www.keycloak.org/docs/latest/securing_apps/) diff --git a/docs/architecture/adr/0007-api-gateway-pattern-de.md b/docs/architecture/adr/0007-api-gateway-pattern-de.md new file mode 100644 index 00000000..dbaddbf2 --- /dev/null +++ b/docs/architecture/adr/0007-api-gateway-pattern-de.md @@ -0,0 +1,81 @@ +# ADR-0007: API-Gateway-Muster + +## Status + +Akzeptiert + +## Kontext + +Mit unserer Microservices-Architektur ([ADR-0003](0003-microservices-architecture-de.md)) standen wir vor mehreren Herausforderungen im Zusammenhang mit der Client-Service-Kommunikation: + +1. Clients müssten die Standorte und Schnittstellen mehrerer Dienste kennen +2. Verschiedene Clients (Web, Desktop, Mobil) müssten mehrere Aufrufe an verschiedene Dienste tätigen +3. Authentifizierung und Autorisierung müssten konsistent über alle Dienste hinweg implementiert werden +4. Querschnittsbelange wie Rate-Limiting, Logging und Monitoring müssten in jedem Dienst implementiert werden +5. API-Versionierung und Abwärtskompatibilität müssten über alle Dienste hinweg verwaltet werden +6. Die Netzwerksicherheit wäre komplexer, wenn mehrere Dienste direkt exponiert würden + +Wir benötigten eine Lösung, die die Client-Service-Kommunikation vereinfachen und gleichzeitig diese Herausforderungen adressieren würde. + +## Entscheidung + +Wir haben uns entschieden, das API-Gateway-Muster mit Ktor als Framework zu implementieren. Das API-Gateway dient als einziger Eingangspunkt für alle Client-Anfragen und bietet die folgenden Funktionen: + +1. **Anfrage-Routing**: Leitet Anfragen an die entsprechenden Microservices weiter +2. **Authentifizierung und Autorisierung**: Integriert sich mit Keycloak ([ADR-0006](0006-authentication-authorization-keycloak-de.md)), um Benutzer zu authentifizieren und Tokens zu validieren +3. **Rate-Limiting**: Verhindert Missbrauch durch Begrenzung der Anzahl von Anfragen von einem einzelnen Client +4. **Anfrage/Antwort-Transformation**: Transformiert Anfragen und Antworten nach Bedarf für verschiedene Clients +5. **Logging und Monitoring**: Bietet zentralisiertes Logging und Monitoring aller API-Anfragen +6. **Caching**: Speichert Antworten im Cache, um die Leistung zu verbessern +7. **API-Dokumentation**: Hostet OpenAPI-Dokumentation für alle Dienste +8. **Service-Discovery**: Entdeckt Dienstinstanzen dynamisch + +Unsere Implementierung umfasst: +- Ein Ktor-basiertes API-Gateway, das als containerisierter Dienst bereitgestellt wird +- Integration mit Keycloak für Authentifizierung und Autorisierung +- Benutzerdefinierte Plugins für Rate-Limiting, Logging und Monitoring +- OpenAPI-Dokumentationsgenerierung +- Service-Discovery-Integration + +## Konsequenzen + +### Positive + +- **Vereinfachte Client-Entwicklung**: Clients müssen nur mit einem einzigen Endpunkt kommunizieren +- **Konsistente Sicherheit**: Authentifizierung und Autorisierung werden konsistent gehandhabt +- **Zentralisierte Querschnittsbelange**: Rate-Limiting, Logging und Monitoring werden einmal implementiert +- **Verbesserte Sicherheit**: Interne Dienste werden nicht direkt Clients ausgesetzt +- **Flexibilität**: Das Gateway kann Anfragen und Antworten für verschiedene Clients anpassen + +### Negative + +- **Single Point of Failure**: Das Gateway wird zu einer kritischen Komponente, die hochverfügbar sein muss +- **Leistungs-Overhead**: Anfragen durchlaufen einen zusätzlichen Netzwerk-Hop +- **Komplexität**: Das Gateway muss eine breite Palette von Funktionalitäten handhaben +- **Entwicklungs-Engpass**: Änderungen am Gateway können Koordination über Teams hinweg erfordern + +### Neutral + +- **Deployment-Überlegungen**: Das Gateway muss angemessen bereitgestellt und skaliert werden +- **Versionierungsstrategie**: API-Versionierung muss immer noch verwaltet werden, wenn auch an einem Ort + +## Betrachtete Alternativen + +### Direkte Client-zu-Service-Kommunikation + +Wir haben in Betracht gezogen, Clients die direkte Kommunikation mit Diensten zu ermöglichen. Dies hätte den Netzwerk-Hop durch das Gateway eliminiert, hätte aber die Client-Entwicklung komplexer gemacht und hätte die Implementierung von Querschnittsbelangen in jedem Dienst erfordert. + +### Backend for Frontend (BFF)-Muster + +Wir haben die Implementierung separater Backend for Frontend (BFF)-Dienste für jeden Client-Typ in Betracht gezogen. Dies hätte mehr clientspezifische Optimierungen ermöglicht, hätte aber den Entwicklungs- und Betriebsaufwand erhöht. + +### Service Mesh + +Wir haben die Verwendung eines Service Mesh wie Istio oder Linkerd zur Handhabung der Service-zu-Service-Kommunikation in Betracht gezogen. Dies hätte viele der gleichen Vorteile für die Service-zu-Service-Kommunikation geboten, hätte aber die Herausforderungen der Client-zu-Service-Kommunikation nicht so effektiv adressiert. + +## Referenzen + +- [API-Gateway-Muster](https://microservices.io/patterns/apigateway.html) +- [Ktor-Dokumentation](https://ktor.io/docs/) +- [Gateway-Routing-Muster](https://docs.microsoft.com/de-de/azure/architecture/patterns/gateway-routing) +- [Backend for Frontend-Muster](https://samnewman.io/patterns/architectural/bff/) diff --git a/docs/architecture/adr/0007-api-gateway-pattern.md b/docs/architecture/adr/0007-api-gateway-pattern.md new file mode 100644 index 00000000..0dec4fd8 --- /dev/null +++ b/docs/architecture/adr/0007-api-gateway-pattern.md @@ -0,0 +1,81 @@ +# ADR-0007: API Gateway Pattern + +## Status + +Accepted + +## Context + +With our microservices architecture ([ADR-0003](0003-microservices-architecture.md)), we faced several challenges related to client-service communication: + +1. Clients would need to know the locations and interfaces of multiple services +2. Different clients (web, desktop, mobile) would need to make multiple calls to different services +3. Authentication and authorization would need to be implemented consistently across all services +4. Cross-cutting concerns like rate limiting, logging, and monitoring would need to be implemented in each service +5. API versioning and backward compatibility would need to be managed across all services +6. Network security would be more complex with multiple services exposed directly + +We needed a solution that would simplify client-service communication while addressing these challenges. + +## Decision + +We decided to implement the API Gateway pattern using Ktor as the framework. The API Gateway serves as the single entry point for all client requests and provides the following functionality: + +1. **Request Routing**: Routes requests to the appropriate microservices +2. **Authentication and Authorization**: Integrates with Keycloak ([ADR-0006](0006-authentication-authorization-keycloak.md)) to authenticate users and validate tokens +3. **Rate Limiting**: Prevents abuse by limiting the number of requests from a single client +4. **Request/Response Transformation**: Transforms requests and responses as needed for different clients +5. **Logging and Monitoring**: Provides centralized logging and monitoring of all API requests +6. **Caching**: Caches responses to improve performance +7. **API Documentation**: Hosts OpenAPI documentation for all services +8. **Service Discovery**: Discovers service instances dynamically + +Our implementation includes: +- A Ktor-based API Gateway deployed as a containerized service +- Integration with Keycloak for authentication and authorization +- Custom plugins for rate limiting, logging, and monitoring +- OpenAPI documentation generation +- Service discovery integration + +## Consequences + +### Positive + +- **Simplified client development**: Clients only need to communicate with a single endpoint +- **Consistent security**: Authentication and authorization are handled consistently +- **Centralized cross-cutting concerns**: Rate limiting, logging, and monitoring are implemented once +- **Improved security**: Internal services are not exposed directly to clients +- **Flexibility**: The gateway can adapt requests and responses for different clients + +### Negative + +- **Single point of failure**: The gateway becomes a critical component that must be highly available +- **Performance overhead**: Requests go through an additional network hop +- **Complexity**: The gateway needs to handle a wide range of functionality +- **Development bottleneck**: Changes to the gateway may require coordination across teams + +### Neutral + +- **Deployment considerations**: The gateway needs to be deployed and scaled appropriately +- **Versioning strategy**: API versioning still needs to be managed, albeit in one place + +## Alternatives Considered + +### Direct Client-to-Service Communication + +We considered allowing clients to communicate directly with services. This would have eliminated the network hop through the gateway but would have made client development more complex and would have required implementing cross-cutting concerns in each service. + +### Backend for Frontend (BFF) Pattern + +We considered implementing separate Backend for Frontend (BFF) services for each client type. This would have allowed for more client-specific optimizations but would have increased development and operational overhead. + +### Service Mesh + +We considered using a service mesh like Istio or Linkerd to handle service-to-service communication. This would have provided many of the same benefits for service-to-service communication but would not have addressed the client-to-service communication challenges as effectively. + +## References + +- [API Gateway Pattern](https://microservices.io/patterns/apigateway.html) +- [Ktor Documentation](https://ktor.io/docs/) +- [Gateway Routing Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/gateway-routing) +- [Backend for Frontend Pattern](https://samnewman.io/patterns/architectural/bff/) diff --git a/docs/architecture/adr/0008-multiplatform-client-applications-de.md b/docs/architecture/adr/0008-multiplatform-client-applications-de.md new file mode 100644 index 00000000..dd8c6cfb --- /dev/null +++ b/docs/architecture/adr/0008-multiplatform-client-applications-de.md @@ -0,0 +1,84 @@ +# ADR-0008: Multiplatform-Client-Anwendungen + +## Status + +Akzeptiert + +## Kontext + +Unser System benötigt Client-Anwendungen für verschiedene Benutzerrollen und Plattformen: + +1. Desktop-Anwendungen für Administratoren und Veranstaltungsorganisatoren, die umfangreiche Funktionalität benötigen +2. Web-Anwendungen für Mitglieder und Pferdebesitzer, die von verschiedenen Geräten aus auf das System zugreifen müssen +3. Potenzielle zukünftige mobile Anwendungen für den Zugriff unterwegs + +Die Entwicklung und Wartung separater Codebasen für jede Plattform würde erfordern: +- Doppelte Implementierung von Geschäftslogik und UI-Komponenten +- Mehrere Teams mit unterschiedlicher Plattformexpertise +- Koordination, um eine konsistente Benutzererfahrung über Plattformen hinweg zu gewährleisten +- Höhere Wartungskosten, da Funktionen und Fehlerbehebungen mehrfach implementiert werden müssten + +Wir benötigten eine Lösung, die es uns ermöglicht, Code über Plattformen hinweg zu teilen und gleichzeitig auf jeder Plattform eine native Benutzererfahrung zu bieten. + +## Entscheidung + +Wir haben uns entschieden, Kotlin Multiplatform und Compose Multiplatform für unsere Client-Anwendungen zu verwenden: + +1. **Kotlin Multiplatform**: Ermöglicht die gemeinsame Nutzung von Geschäftslogik, Datenmodellen und API-Client-Code über Plattformen hinweg +2. **Compose Multiplatform**: Bietet ein deklaratives UI-Framework, das auf Desktop-, Web- und mobilen Plattformen funktioniert + +Unsere Implementierung umfasst: +- **common-ui**: Gemeinsame UI-Komponenten und Geschäftslogik +- **desktop-app**: Desktop-Anwendung für Administratoren und Veranstaltungsorganisatoren +- **web-app**: Web-Anwendung für Mitglieder und Pferdebesitzer + +Die Architektur folgt einem Model-View-ViewModel (MVVM)-Muster: +- **Model**: Gemeinsame Datenmodelle und Repository-Implementierungen +- **ViewModel**: Gemeinsame Geschäftslogik und Zustandsverwaltung +- **View**: Plattformspezifische UI-Implementierungen mit Compose Multiplatform + +Wir verwenden einen modularen Ansatz, bei dem plattformspezifischer Code minimiert wird und der größte Teil des Codes über Plattformen hinweg geteilt wird. + +## Konsequenzen + +### Positive + +- **Code-Sharing**: Wesentliche Teile des Codes werden über Plattformen hinweg geteilt, was Duplizierung reduziert +- **Konsistente Benutzererfahrung**: UI-Komponenten und Verhalten sind über Plattformen hinweg konsistent +- **Einheitliche Sprache**: Kotlin wird für alle Plattformen verwendet, was die Entwicklung vereinfacht +- **Reduzierter Wartungsaufwand**: Fehlerbehebungen und Funktionen können einmal implementiert und über Plattformen hinweg angewendet werden +- **Team-Effizienz**: Entwickler können mit demselben Skillset an mehreren Plattformen arbeiten + +### Negative + +- **Lernkurve**: Kotlin Multiplatform und Compose Multiplatform haben eine Lernkurve +- **Reife**: Compose Multiplatform entwickelt sich noch weiter, besonders für Web-Targets +- **Leistungsüberlegungen**: Es kann im Vergleich zu plattformnativen Lösungen zu Leistungs-Overhead kommen +- **Plattformspezifische Funktionen**: Einige plattformspezifische Funktionen können schwieriger zu implementieren sein +- **Debugging-Komplexität**: Das Debugging über Plattformen hinweg kann komplexer sein + +### Neutral + +- **Komplexität des Build-Systems**: Das Build-System ist mit Multiplatform-Targets komplexer +- **Abhängigkeitsverwaltung**: Die Verwaltung von Abhängigkeiten über Plattformen hinweg erfordert sorgfältige Überlegung + +## Betrachtete Alternativen + +### Separate native Anwendungen + +Wir haben die Entwicklung separater nativer Anwendungen für jede Plattform in Betracht gezogen (Java/JavaFX für Desktop, JavaScript/React für Web). Dies hätte die beste Leistung und Zugriff auf Plattformfunktionen geboten, hätte aber eine doppelte Implementierung von Geschäftslogik und UI-Komponenten erfordert. + +### React Native + +Wir haben die Verwendung von React Native für Mobile und Web mit einer separaten Desktop-Anwendung in Betracht gezogen. Dies hätte Code-Sharing zwischen Mobile und Web ermöglicht, hätte aber immer noch eine separate Desktop-Lösung erfordert und hätte JavaScript-Expertise erfordert. + +### Flutter + +Wir haben die Verwendung von Flutter für alle Plattformen in Betracht gezogen. Flutter bietet gute plattformübergreifende Unterstützung, hätte aber das Erlernen von Dart erfordert und hätte weniger Integration mit unseren Kotlin-basierten Backend-Diensten gehabt. + +## Referenzen + +- [Kotlin Multiplatform Dokumentation](https://kotlinlang.org/docs/multiplatform.html) +- [Compose Multiplatform Dokumentation](https://www.jetbrains.com/lp/compose-multiplatform/) +- [MVVM-Architekturmuster](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) +- [Kotlin Multiplatform Mobile](https://kotlinlang.org/lp/mobile/) diff --git a/docs/architecture/adr/0008-multiplatform-client-applications.md b/docs/architecture/adr/0008-multiplatform-client-applications.md new file mode 100644 index 00000000..8e1a6352 --- /dev/null +++ b/docs/architecture/adr/0008-multiplatform-client-applications.md @@ -0,0 +1,84 @@ +# ADR-0008: Multiplatform Client Applications + +## Status + +Accepted + +## Context + +Our system requires client applications for different user roles and platforms: + +1. Desktop applications for administrators and event organizers who need rich functionality +2. Web applications for members and horse owners who need to access the system from various devices +3. Potential future mobile applications for on-the-go access + +Developing and maintaining separate codebases for each platform would require: +- Duplicate implementation of business logic and UI components +- Multiple teams with different platform expertise +- Coordination to ensure consistent user experience across platforms +- Higher maintenance costs as features and fixes would need to be implemented multiple times + +We needed a solution that would allow us to share code across platforms while still providing a native-like experience on each platform. + +## Decision + +We decided to use Kotlin Multiplatform and Compose Multiplatform for our client applications: + +1. **Kotlin Multiplatform**: Allows sharing of business logic, data models, and API client code across platforms +2. **Compose Multiplatform**: Provides a declarative UI framework that works across desktop, web, and mobile platforms + +Our implementation includes: +- **common-ui**: Shared UI components and business logic +- **desktop-app**: Desktop application for administrators and event organizers +- **web-app**: Web application for members and horse owners + +The architecture follows a Model-View-ViewModel (MVVM) pattern: +- **Model**: Shared data models and repository implementations +- **ViewModel**: Shared business logic and state management +- **View**: Platform-specific UI implementations using Compose Multiplatform + +We use a modular approach where platform-specific code is minimized and most of the code is shared across platforms. + +## Consequences + +### Positive + +- **Code sharing**: Significant portions of code are shared across platforms, reducing duplication +- **Consistent user experience**: UI components and behavior are consistent across platforms +- **Single language**: Kotlin is used for all platforms, simplifying development +- **Reduced maintenance**: Fixes and features can be implemented once and applied across platforms +- **Team efficiency**: Developers can work on multiple platforms with the same skill set + +### Negative + +- **Learning curve**: Kotlin Multiplatform and Compose Multiplatform have a learning curve +- **Maturity**: Compose Multiplatform is still evolving, especially for web targets +- **Performance considerations**: There may be performance overhead compared to platform-native solutions +- **Platform-specific features**: Some platform-specific features may be harder to implement +- **Debugging complexity**: Debugging across platforms can be more complex + +### Neutral + +- **Build system complexity**: The build system is more complex with multiplatform targets +- **Dependency management**: Managing dependencies across platforms requires careful consideration + +## Alternatives Considered + +### Separate Native Applications + +We considered developing separate native applications for each platform (Java/JavaFX for desktop, JavaScript/React for web). This would have provided the best performance and access to platform features but would have required duplicate implementation of business logic and UI components. + +### React Native + +We considered using React Native for mobile and web, with a separate desktop application. This would have allowed code sharing between mobile and web but would still have required a separate desktop solution and would have required JavaScript expertise. + +### Flutter + +We considered using Flutter for all platforms. Flutter provides good cross-platform support but would have required learning Dart and would have had less integration with our Kotlin-based backend services. + +## References + +- [Kotlin Multiplatform Documentation](https://kotlinlang.org/docs/multiplatform.html) +- [Compose Multiplatform Documentation](https://www.jetbrains.com/lp/compose-multiplatform/) +- [MVVM Architecture Pattern](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) +- [Kotlin Multiplatform Mobile](https://kotlinlang.org/lp/mobile/) diff --git a/docs/architecture/c4/01-context-de.puml b/docs/architecture/c4/01-context-de.puml new file mode 100644 index 00000000..946465b8 --- /dev/null +++ b/docs/architecture/c4/01-context-de.puml @@ -0,0 +1,26 @@ +@startuml C4_Context +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml + +title Systemkontext-Diagramm für Meldestelle + +Person(eventOrganizer, "Veranstaltungsorganisator", "Organisiert und verwaltet Reitsportveranstaltungen") +Person(administrator, "Administrator", "Verwaltet Systemkonfiguration und Stammdaten") +Person(member, "Mitglied", "Meldet sich für Veranstaltungen an und verwaltet persönliche Informationen") +Person(horseOwner, "Pferdebesitzer", "Registriert und verwaltet Pferdeinformationen") + +System(meldestelle, "Meldestelle", "Modulares System zur Verwaltung von Pferdesportveranstaltungen, einschließlich Registrierung von Pferden, Mitgliedern und Veranstaltungen") + +System_Ext(paymentProvider, "Zahlungsanbieter", "Verarbeitet Zahlungen für Veranstaltungsanmeldungen") +System_Ext(emailSystem, "E-Mail-System", "Sendet Benachrichtigungen und Bestätigungen") +System_Ext(federationSystem, "Reitsportverband-System", "Bietet Validierung von Mitgliedschaften und Pferden") + +Rel(eventOrganizer, meldestelle, "Erstellt und verwaltet Veranstaltungen mit") +Rel(administrator, meldestelle, "Konfiguriert und administriert") +Rel(member, meldestelle, "Meldet sich für Veranstaltungen an und aktualisiert persönliche Informationen mit") +Rel(horseOwner, meldestelle, "Registriert und verwaltet Pferde mit") + +Rel(meldestelle, paymentProvider, "Verarbeitet Zahlungen über") +Rel(meldestelle, emailSystem, "Sendet Benachrichtigungen über") +Rel(meldestelle, federationSystem, "Validiert Mitgliedschaften und Pferde mit") + +@enduml diff --git a/docs/architecture/c4/01-context.puml b/docs/architecture/c4/01-context.puml new file mode 100644 index 00000000..f54434c9 --- /dev/null +++ b/docs/architecture/c4/01-context.puml @@ -0,0 +1,26 @@ +@startuml C4_Context +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml + +title System Context diagram for Meldestelle + +Person(eventOrganizer, "Event Organizer", "Organizes and manages equestrian events") +Person(administrator, "Administrator", "Manages system configuration and master data") +Person(member, "Member", "Registers for events and manages personal information") +Person(horseOwner, "Horse Owner", "Registers and manages horse information") + +System(meldestelle, "Meldestelle", "Modular system for managing equestrian sports events, including registration of horses, members, and events") + +System_Ext(paymentProvider, "Payment Provider", "Processes payments for event registrations") +System_Ext(emailSystem, "Email System", "Sends notifications and confirmations") +System_Ext(federationSystem, "Equestrian Federation System", "Provides validation of memberships and horses") + +Rel(eventOrganizer, meldestelle, "Creates and manages events using") +Rel(administrator, meldestelle, "Configures and administers") +Rel(member, meldestelle, "Registers for events and updates personal information using") +Rel(horseOwner, meldestelle, "Registers and manages horses using") + +Rel(meldestelle, paymentProvider, "Processes payments through") +Rel(meldestelle, emailSystem, "Sends notifications via") +Rel(meldestelle, federationSystem, "Validates memberships and horses with") + +@enduml diff --git a/docs/architecture/c4/02-container-de.puml b/docs/architecture/c4/02-container-de.puml new file mode 100644 index 00000000..c9d2156e --- /dev/null +++ b/docs/architecture/c4/02-container-de.puml @@ -0,0 +1,75 @@ +@startuml C4_Container +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +title Container-Diagramm für Meldestelle + +Person(eventOrganizer, "Veranstaltungsorganisator", "Organisiert und verwaltet Reitsportveranstaltungen") +Person(administrator, "Administrator", "Verwaltet Systemkonfiguration und Stammdaten") +Person(member, "Mitglied", "Meldet sich für Veranstaltungen an und verwaltet persönliche Informationen") +Person(horseOwner, "Pferdebesitzer", "Registriert und verwaltet Pferdeinformationen") + +System_Boundary(meldestelle, "Meldestelle") { + Container(desktopApp, "Desktop-Anwendung", "Kotlin, Compose Multiplatform", "Bietet eine umfangreiche Benutzeroberfläche für Administratoren und Veranstaltungsorganisatoren") + Container(webApp, "Web-Anwendung", "Kotlin, Compose Multiplatform", "Bietet eine Weboberfläche für Mitglieder und Pferdebesitzer") + + Container(apiGateway, "API-Gateway", "Kotlin, Ktor", "Leitet Anfragen an entsprechende Dienste weiter, verwaltet Authentifizierung und Autorisierung") + + Container(masterdataService, "Stammdaten-Dienst", "Kotlin, Spring Boot", "Verwaltet Stammdaten wie Standorte, Disziplinen usw.") + Container(membersService, "Mitglieder-Dienst", "Kotlin, Spring Boot", "Verwaltet Mitgliederregistrierung und -profile") + Container(horsesService, "Pferde-Dienst", "Kotlin, Spring Boot", "Verwaltet Pferderegistrierung und -informationen") + Container(eventsService, "Veranstaltungs-Dienst", "Kotlin, Spring Boot", "Verwaltet Veranstaltungserstellung, -planung und -anmeldungen") + + ContainerDb(postgresql, "PostgreSQL", "Datenbank", "Speichert alle persistenten Daten") + ContainerDb(redis, "Redis", "Cache", "Bietet Caching für häufig abgerufene Daten") + Container(kafka, "Kafka", "Message Broker", "Verarbeitet ereignisgesteuerte Kommunikation zwischen Diensten") + Container(keycloak, "Keycloak", "Authentifizierungsserver", "Verwaltet Benutzerauthentifizierung und -autorisierung") + + Container(monitoring, "Monitoring", "Prometheus, Grafana, Zipkin", "Bietet Überwachung, Metriken und verteiltes Tracing") +} + +System_Ext(paymentProvider, "Zahlungsanbieter", "Verarbeitet Zahlungen für Veranstaltungsanmeldungen") +System_Ext(emailSystem, "E-Mail-System", "Sendet Benachrichtigungen und Bestätigungen") +System_Ext(federationSystem, "Reitsportverband-System", "Bietet Validierung von Mitgliedschaften und Pferden") + +Rel(eventOrganizer, desktopApp, "Verwendet") +Rel(administrator, desktopApp, "Verwendet") +Rel(member, webApp, "Verwendet") +Rel(horseOwner, webApp, "Verwendet") + +Rel(desktopApp, apiGateway, "Stellt API-Aufrufe an", "HTTPS/JSON") +Rel(webApp, apiGateway, "Stellt API-Aufrufe an", "HTTPS/JSON") + +Rel(apiGateway, masterdataService, "Leitet Anfragen weiter an", "HTTPS/JSON") +Rel(apiGateway, membersService, "Leitet Anfragen weiter an", "HTTPS/JSON") +Rel(apiGateway, horsesService, "Leitet Anfragen weiter an", "HTTPS/JSON") +Rel(apiGateway, eventsService, "Leitet Anfragen weiter an", "HTTPS/JSON") +Rel(apiGateway, keycloak, "Authentifiziert mit", "HTTPS/JSON") + +Rel(masterdataService, postgresql, "Liest von und schreibt in") +Rel(membersService, postgresql, "Liest von und schreibt in") +Rel(horsesService, postgresql, "Liest von und schreibt in") +Rel(eventsService, postgresql, "Liest von und schreibt in") + +Rel(masterdataService, redis, "Speichert Daten im Cache") +Rel(membersService, redis, "Speichert Daten im Cache") +Rel(horsesService, redis, "Speichert Daten im Cache") +Rel(eventsService, redis, "Speichert Daten im Cache") + +Rel(masterdataService, kafka, "Veröffentlicht und abonniert Ereignisse") +Rel(membersService, kafka, "Veröffentlicht und abonniert Ereignisse") +Rel(horsesService, kafka, "Veröffentlicht und abonniert Ereignisse") +Rel(eventsService, kafka, "Veröffentlicht und abonniert Ereignisse") + +Rel(masterdataService, monitoring, "Sendet Metriken und Traces an") +Rel(membersService, monitoring, "Sendet Metriken und Traces an") +Rel(horsesService, monitoring, "Sendet Metriken und Traces an") +Rel(eventsService, monitoring, "Sendet Metriken und Traces an") +Rel(apiGateway, monitoring, "Sendet Metriken und Traces an") + +Rel(eventsService, paymentProvider, "Verarbeitet Zahlungen über", "HTTPS/JSON") +Rel(membersService, emailSystem, "Sendet Benachrichtigungen über", "SMTP") +Rel(eventsService, emailSystem, "Sendet Benachrichtigungen über", "SMTP") +Rel(membersService, federationSystem, "Validiert Mitgliedschaften mit", "HTTPS/JSON") +Rel(horsesService, federationSystem, "Validiert Pferde mit", "HTTPS/JSON") + +@enduml diff --git a/docs/architecture/c4/02-container.puml b/docs/architecture/c4/02-container.puml new file mode 100644 index 00000000..90ebe19a --- /dev/null +++ b/docs/architecture/c4/02-container.puml @@ -0,0 +1,75 @@ +@startuml C4_Container +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +title Container diagram for Meldestelle + +Person(eventOrganizer, "Event Organizer", "Organizes and manages equestrian events") +Person(administrator, "Administrator", "Manages system configuration and master data") +Person(member, "Member", "Registers for events and manages personal information") +Person(horseOwner, "Horse Owner", "Registers and manages horse information") + +System_Boundary(meldestelle, "Meldestelle") { + Container(desktopApp, "Desktop Application", "Kotlin, Compose Multiplatform", "Provides a rich UI for administrators and event organizers") + Container(webApp, "Web Application", "Kotlin, Compose Multiplatform", "Provides a web interface for members and horse owners") + + Container(apiGateway, "API Gateway", "Kotlin, Ktor", "Routes requests to appropriate services, handles authentication and authorization") + + Container(masterdataService, "Masterdata Service", "Kotlin, Spring Boot", "Manages master data such as locations, disciplines, etc.") + Container(membersService, "Members Service", "Kotlin, Spring Boot", "Manages member registration and profiles") + Container(horsesService, "Horses Service", "Kotlin, Spring Boot", "Manages horse registration and information") + Container(eventsService, "Events Service", "Kotlin, Spring Boot", "Manages event creation, scheduling, and registrations") + + ContainerDb(postgresql, "PostgreSQL", "Database", "Stores all persistent data") + ContainerDb(redis, "Redis", "Cache", "Provides caching for frequently accessed data") + Container(kafka, "Kafka", "Message Broker", "Handles event-driven communication between services") + Container(keycloak, "Keycloak", "Authentication Server", "Manages user authentication and authorization") + + Container(monitoring, "Monitoring", "Prometheus, Grafana, Zipkin", "Provides monitoring, metrics, and distributed tracing") +} + +System_Ext(paymentProvider, "Payment Provider", "Processes payments for event registrations") +System_Ext(emailSystem, "Email System", "Sends notifications and confirmations") +System_Ext(federationSystem, "Equestrian Federation System", "Provides validation of memberships and horses") + +Rel(eventOrganizer, desktopApp, "Uses") +Rel(administrator, desktopApp, "Uses") +Rel(member, webApp, "Uses") +Rel(horseOwner, webApp, "Uses") + +Rel(desktopApp, apiGateway, "Makes API calls to", "HTTPS/JSON") +Rel(webApp, apiGateway, "Makes API calls to", "HTTPS/JSON") + +Rel(apiGateway, masterdataService, "Routes requests to", "HTTPS/JSON") +Rel(apiGateway, membersService, "Routes requests to", "HTTPS/JSON") +Rel(apiGateway, horsesService, "Routes requests to", "HTTPS/JSON") +Rel(apiGateway, eventsService, "Routes requests to", "HTTPS/JSON") +Rel(apiGateway, keycloak, "Authenticates with", "HTTPS/JSON") + +Rel(masterdataService, postgresql, "Reads from and writes to") +Rel(membersService, postgresql, "Reads from and writes to") +Rel(horsesService, postgresql, "Reads from and writes to") +Rel(eventsService, postgresql, "Reads from and writes to") + +Rel(masterdataService, redis, "Caches data in") +Rel(membersService, redis, "Caches data in") +Rel(horsesService, redis, "Caches data in") +Rel(eventsService, redis, "Caches data in") + +Rel(masterdataService, kafka, "Publishes and subscribes to events") +Rel(membersService, kafka, "Publishes and subscribes to events") +Rel(horsesService, kafka, "Publishes and subscribes to events") +Rel(eventsService, kafka, "Publishes and subscribes to events") + +Rel(masterdataService, monitoring, "Sends metrics and traces to") +Rel(membersService, monitoring, "Sends metrics and traces to") +Rel(horsesService, monitoring, "Sends metrics and traces to") +Rel(eventsService, monitoring, "Sends metrics and traces to") +Rel(apiGateway, monitoring, "Sends metrics and traces to") + +Rel(eventsService, paymentProvider, "Processes payments through", "HTTPS/JSON") +Rel(membersService, emailSystem, "Sends notifications via", "SMTP") +Rel(eventsService, emailSystem, "Sends notifications via", "SMTP") +Rel(membersService, federationSystem, "Validates memberships with", "HTTPS/JSON") +Rel(horsesService, federationSystem, "Validates horses with", "HTTPS/JSON") + +@enduml diff --git a/docs/architecture/c4/03-component-events-service-de.puml b/docs/architecture/c4/03-component-events-service-de.puml new file mode 100644 index 00000000..6d2afa68 --- /dev/null +++ b/docs/architecture/c4/03-component-events-service-de.puml @@ -0,0 +1,63 @@ +@startuml C4_Component +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml + +title Komponenten-Diagramm für Veranstaltungs-Dienst + +Container_Boundary(apiGateway, "API-Gateway") { + Component(apiGatewayRouting, "Routing-Komponente", "Ktor Routing", "Leitet Anfragen an entsprechende Dienste weiter") + Component(apiGatewayAuth, "Authentifizierungs-Komponente", "Ktor Auth", "Verwaltet Authentifizierung und Autorisierung") +} + +Container_Boundary(eventsService, "Veranstaltungs-Dienst") { + Component(eventsApi, "Veranstaltungs-API", "Kotlin, Spring Web", "Definiert die REST-API-Endpunkte für die Veranstaltungsverwaltung") + + Component(eventsApplication, "Veranstaltungs-Anwendung", "Kotlin, Spring", "Enthält Anwendungsdienste und Anwendungsfälle") + Component(eventCommandHandlers, "Veranstaltungs-Befehlshandler", "Kotlin", "Verarbeitet Befehle zum Erstellen und Ändern von Veranstaltungen") + Component(eventQueryHandlers, "Veranstaltungs-Abfragehandler", "Kotlin", "Verarbeitet Abfragen zum Abrufen von Veranstaltungsinformationen") + + Component(eventsDomain, "Veranstaltungs-Domäne", "Kotlin", "Enthält Domänenmodelle und Geschäftslogik") + Component(eventAggregate, "Veranstaltungs-Aggregat", "Kotlin", "Kern-Domänenentität, die eine Veranstaltung repräsentiert") + Component(participantAggregate, "Teilnehmer-Aggregat", "Kotlin", "Kern-Domänenentität, die einen Teilnehmer repräsentiert") + Component(eventDomainServices, "Veranstaltungs-Domänendienste", "Kotlin", "Domänendienste für komplexe Geschäftslogik") + + Component(eventsInfrastructure, "Veranstaltungs-Infrastruktur", "Kotlin, Spring Data", "Enthält Infrastrukturimplementierungen") + Component(eventRepository, "Veranstaltungs-Repository", "Kotlin, Spring Data JPA", "Speichert und ruft Veranstaltungsdaten ab") + Component(eventMessagePublisher, "Veranstaltungs-Nachrichtenveröffentlicher", "Kotlin, Spring Kafka", "Veröffentlicht Domänenereignisse an Kafka") + Component(externalServiceClients, "Externe Dienst-Clients", "Kotlin, WebClient", "Clients für externe Dienste") +} + +ContainerDb(postgresql, "PostgreSQL", "Datenbank", "Speichert alle persistenten Daten") +Container(kafka, "Kafka", "Message Broker", "Verarbeitet ereignisgesteuerte Kommunikation zwischen Diensten") +Container(redis, "Redis", "Cache", "Bietet Caching für häufig abgerufene Daten") + +System_Ext(paymentProvider, "Zahlungsanbieter", "Verarbeitet Zahlungen für Veranstaltungsanmeldungen") +System_Ext(emailSystem, "E-Mail-System", "Sendet Benachrichtigungen und Bestätigungen") + +Rel(apiGatewayRouting, eventsApi, "Leitet Anfragen weiter an", "HTTPS/JSON") +Rel(apiGatewayAuth, eventsApi, "Stellt Authentifizierungskontext bereit für") + +Rel(eventsApi, eventsApplication, "Verwendet") +Rel(eventsApplication, eventCommandHandlers, "Verwendet") +Rel(eventsApplication, eventQueryHandlers, "Verwendet") + +Rel(eventCommandHandlers, eventsDomain, "Verwendet") +Rel(eventQueryHandlers, eventsDomain, "Verwendet") +Rel(eventCommandHandlers, eventsInfrastructure, "Verwendet") +Rel(eventQueryHandlers, eventsInfrastructure, "Verwendet") + +Rel(eventsDomain, eventAggregate, "Enthält") +Rel(eventsDomain, participantAggregate, "Enthält") +Rel(eventsDomain, eventDomainServices, "Enthält") + +Rel(eventsInfrastructure, eventRepository, "Enthält") +Rel(eventsInfrastructure, eventMessagePublisher, "Enthält") +Rel(eventsInfrastructure, externalServiceClients, "Enthält") + +Rel(eventRepository, postgresql, "Liest von und schreibt in") +Rel(eventMessagePublisher, kafka, "Veröffentlicht Nachrichten an") +Rel(eventQueryHandlers, redis, "Speichert Ergebnisse im Cache") + +Rel(externalServiceClients, paymentProvider, "Stellt API-Aufrufe an", "HTTPS/JSON") +Rel(externalServiceClients, emailSystem, "Sendet E-Mails über", "SMTP") + +@enduml diff --git a/docs/architecture/c4/03-component-events-service.puml b/docs/architecture/c4/03-component-events-service.puml new file mode 100644 index 00000000..82dd6018 --- /dev/null +++ b/docs/architecture/c4/03-component-events-service.puml @@ -0,0 +1,63 @@ +@startuml C4_Component +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml + +title Component diagram for Events Service + +Container_Boundary(apiGateway, "API Gateway") { + Component(apiGatewayRouting, "Routing Component", "Ktor Routing", "Routes requests to appropriate services") + Component(apiGatewayAuth, "Authentication Component", "Ktor Auth", "Handles authentication and authorization") +} + +Container_Boundary(eventsService, "Events Service") { + Component(eventsApi, "Events API", "Kotlin, Spring Web", "Defines the REST API endpoints for event management") + + Component(eventsApplication, "Events Application", "Kotlin, Spring", "Contains application services and use cases") + Component(eventCommandHandlers, "Event Command Handlers", "Kotlin", "Handles commands for creating and modifying events") + Component(eventQueryHandlers, "Event Query Handlers", "Kotlin", "Handles queries for retrieving event information") + + Component(eventsDomain, "Events Domain", "Kotlin", "Contains domain models and business logic") + Component(eventAggregate, "Event Aggregate", "Kotlin", "Core domain entity representing an event") + Component(participantAggregate, "Participant Aggregate", "Kotlin", "Core domain entity representing a participant") + Component(eventDomainServices, "Event Domain Services", "Kotlin", "Domain services for complex business logic") + + Component(eventsInfrastructure, "Events Infrastructure", "Kotlin, Spring Data", "Contains infrastructure implementations") + Component(eventRepository, "Event Repository", "Kotlin, Spring Data JPA", "Persists and retrieves event data") + Component(eventMessagePublisher, "Event Message Publisher", "Kotlin, Spring Kafka", "Publishes domain events to Kafka") + Component(externalServiceClients, "External Service Clients", "Kotlin, WebClient", "Clients for external services") +} + +ContainerDb(postgresql, "PostgreSQL", "Database", "Stores all persistent data") +Container(kafka, "Kafka", "Message Broker", "Handles event-driven communication between services") +Container(redis, "Redis", "Cache", "Provides caching for frequently accessed data") + +System_Ext(paymentProvider, "Payment Provider", "Processes payments for event registrations") +System_Ext(emailSystem, "Email System", "Sends notifications and confirmations") + +Rel(apiGatewayRouting, eventsApi, "Routes requests to", "HTTPS/JSON") +Rel(apiGatewayAuth, eventsApi, "Provides authentication context to") + +Rel(eventsApi, eventsApplication, "Uses") +Rel(eventsApplication, eventCommandHandlers, "Uses") +Rel(eventsApplication, eventQueryHandlers, "Uses") + +Rel(eventCommandHandlers, eventsDomain, "Uses") +Rel(eventQueryHandlers, eventsDomain, "Uses") +Rel(eventCommandHandlers, eventsInfrastructure, "Uses") +Rel(eventQueryHandlers, eventsInfrastructure, "Uses") + +Rel(eventsDomain, eventAggregate, "Contains") +Rel(eventsDomain, participantAggregate, "Contains") +Rel(eventsDomain, eventDomainServices, "Contains") + +Rel(eventsInfrastructure, eventRepository, "Contains") +Rel(eventsInfrastructure, eventMessagePublisher, "Contains") +Rel(eventsInfrastructure, externalServiceClients, "Contains") + +Rel(eventRepository, postgresql, "Reads from and writes to") +Rel(eventMessagePublisher, kafka, "Publishes messages to") +Rel(eventQueryHandlers, redis, "Caches results in") + +Rel(externalServiceClients, paymentProvider, "Makes API calls to", "HTTPS/JSON") +Rel(externalServiceClients, emailSystem, "Sends emails via", "SMTP") + +@enduml diff --git a/docs/implementation/redis-integration-de.md b/docs/implementation/redis-integration-de.md new file mode 100644 index 00000000..6552313b --- /dev/null +++ b/docs/implementation/redis-integration-de.md @@ -0,0 +1,331 @@ +# Redis Integration + +Dieses Dokument beschreibt die Redis-Integration, die für die Meldestelle-Anwendung implementiert wurde, einschließlich einer verteilten Cache-Lösung mit Offline-Fähigkeit und Redis Streams für Event Sourcing. + +## Verteilte Cache-Lösung + +### Überblick + +Die verteilte Cache-Lösung bietet eine Möglichkeit, Daten über mehrere Instanzen der Anwendung hinweg zu cachen, mit Unterstützung für Offline-Betrieb. Wenn die Anwendung offline ist, kann sie weiterhin aus dem lokalen Cache lesen und in ihn schreiben und mit Redis synchronisieren, wenn die Verbindung wiederhergestellt wird. + +### Komponenten + +1. **Cache API** (`infrastructure/cache/cache-api`) + - `DistributedCache`: Schnittstelle für den verteilten Cache + - `CacheConfiguration`: Schnittstelle für die Cache-Konfiguration + - `CacheEntry`: Klasse, die einen Cache-Eintrag mit Metadaten für Offline-Fähigkeit darstellt + - `CacheSerializer`: Schnittstelle für die Serialisierung und Deserialisierung von Cache-Einträgen + - `ConnectionStatus`: Schnittstellen für die Verfolgung des Verbindungsstatus + +2. **Redis Cache Implementation** (`infrastructure/cache/redis-cache`) + - `RedisDistributedCache`: Redis-Implementierung des verteilten Caches + - `JacksonCacheSerializer`: Jackson-basierte Implementierung des Cache-Serialisierers + - `RedisConfiguration`: Spring-Konfiguration für Redis + +### Funktionen + +- **Grundlegende Cache-Operationen**: get, set, delete, exists +- **Batch-Operationen**: multiGet, multiSet, multiDelete +- **TTL-Unterstützung**: Time-to-live für Cache-Einträge +- **Offline-Fähigkeit**: Weiterarbeiten, wenn Redis nicht verfügbar ist +- **Automatische Synchronisierung**: Synchronisierung mit Redis, wenn die Verbindung wiederhergestellt wird +- **Verbindungsstatus-Verfolgung**: Verfolgung des Verbindungsstatus und Benachrichtigung von Listenern + +### Konfiguration + +Der Cache kann mit den folgenden Eigenschaften in `application.yml` konfiguriert werden: + +```yaml +redis: + host: localhost + port: 6379 + password: # Leer lassen für kein Passwort + database: 0 + connection-timeout: 2000 + read-timeout: 2000 + use-pooling: true + max-pool-size: 8 + min-pool-size: 2 + connection-check-interval: 10000 # 10 Sekunden + local-cache-cleanup-interval: 60000 # 1 Minute + sync-interval: 300000 # 5 Minuten +``` + +## Redis Streams für Event Sourcing + +### Überblick + +Redis Streams werden für Event Sourcing verwendet und bieten eine Möglichkeit, Domain-Events zu speichern und abzurufen. Die Implementierung unterstützt das Anhängen von Events an Streams, das Lesen von Events aus Streams und das Abonnieren von Events. + +### Komponenten + +1. **Event Store API** (`infrastructure/event-store/event-store-api`) + - `EventStore`: Schnittstelle für den Event Store + - `EventSerializer`: Schnittstelle für die Serialisierung und Deserialisierung von Events + - `Subscription`: Schnittstelle für Abonnements von Event-Streams + +2. **Redis Event Store Implementation** (`infrastructure/event-store/redis-event-store`) + - `RedisEventStore`: Redis Streams-Implementierung des Event Stores + - `JacksonEventSerializer`: Jackson-basierte Implementierung des Event-Serialisierers + - `RedisEventConsumer`: Consumer für Redis Streams, der Events mit Consumer-Gruppen verarbeitet + - `RedisEventStoreConfiguration`: Spring-Konfiguration für Redis Event Store + +### Funktionen + +- **Event-Anhängen**: Anhängen von Events an Streams mit optimistischer Nebenläufigkeitskontrolle +- **Event-Lesen**: Lesen von Events aus Streams +- **Event-Abonnement**: Abonnieren von Events aus bestimmten Streams oder allen Streams +- **Consumer-Gruppen**: Verarbeitung von Events mit Consumer-Gruppen +- **Nebenläufigkeitskontrolle**: Optimistische Nebenläufigkeitskontrolle für das Anhängen von Events + +### Konfiguration + +Der Event Store kann mit den folgenden Eigenschaften in `application.yml` konfiguriert werden: + +```yaml +redis: + event-store: + host: localhost + port: 6379 + password: # Leer lassen für kein Passwort + database: 1 # Verwenden Sie eine andere Datenbank für den Event Store + connection-timeout: 2000 + read-timeout: 2000 + use-pooling: true + max-pool-size: 8 + min-pool-size: 2 + consumer-group: event-processors + consumer-name: "${spring.application.name}-${random.uuid}" + stream-prefix: "event-stream:" + all-events-stream: "all-events" + claim-idle-timeout: 60000 # 1 Minute + poll-timeout: 100 # 100 Millisekunden + poll-interval: 100 # 100 Millisekunden + max-batch-size: 100 + create-consumer-group-if-not-exists: true +``` + +## Integrationstests + +Integrationstests für Redis-Komponenten werden mit Testcontainers implementiert, das automatisch einen Redis-Container für Tests startet. Dies stellt sicher, dass die Tests in einer isolierten Umgebung laufen und nicht von externen Redis-Instanzen abhängen. + +### Ausführen von Integrationstests + +Um die Integrationstests auszuführen, verwenden Sie den folgenden Gradle-Befehl: + +```bash +./gradlew integrationTest +``` + +Dies führt alle Tests mit "Integration" in ihrem Namen aus, einschließlich der Redis-Integrationstests. + +> **Hinweis:** Aufgrund der im Abschnitt "Bekannte Probleme und Einschränkungen" erwähnten Kompilierungsprobleme können die Integrationstests möglicherweise nicht lokal ausgeführt werden, bis diese Probleme behoben sind. Der CI/CD-Workflow ist korrekt konfiguriert, um die Tests in Zukunft auszuführen, sobald diese Probleme behoben sind. + +### CI/CD-Integration + +Das Projekt enthält einen GitHub Actions-Workflow für die Ausführung von Integrationstests, der in `.github/workflows/integration-tests.yml` definiert ist. Dieser Workflow: + +1. Richtet einen Redis-Service-Container für Integrationstests ein +2. Führt die Integrationstests mit dem `integrationTest` Gradle-Task aus +3. Lädt Testberichte als Artefakte für einfachen Zugriff hoch + +Der Workflow wird bei Push auf main- und develop-Branches sowie bei Pull-Requests auf diese Branches ausgelöst. + +### Schreiben von Redis-Integrationstests + +Beim Schreiben von Integrationstests für Redis-Komponenten: + +1. Verwenden Sie die `@Testcontainers`-Annotation, um die Testcontainers-Unterstützung zu aktivieren +2. Definieren Sie einen Redis-Container mit `GenericContainer` und dem Redis-Image +3. Konfigurieren Sie die Redis-Verbindung mit dem Host und dem gemappten Port des Containers +4. Verwenden Sie die `@Container`-Annotation, um sicherzustellen, dass der Container automatisch gestartet und gestoppt wird + +Beispiel: + +```kotlin +@Testcontainers +class RedisIntegrationTest { + + companion object { + @Container + val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) + } + + @BeforeEach + fun setUp() { + val redisPort = redisContainer.getMappedPort(6379) + val redisHost = redisContainer.host + + // Konfigurieren Sie die Redis-Verbindung mit redisHost und redisPort + } + + // Testmethoden +} +``` + +## Bekannte Probleme und Einschränkungen + +1. **IDE-Auflösungsprobleme**: Die IDE kann für einige Klassen nicht aufgelöste Referenzen anzeigen, aber der Code sollte korrekt kompilieren und ausgeführt werden. Dies liegt daran, dass die Abhängigkeiten in den build.gradle.kts-Dateien enthalten sind, aber möglicherweise nicht korrekt von der IDE aufgelöst werden. + +2. **Test-Abhängigkeiten**: Die Tests erfordern, dass Docker installiert und ausgeführt wird, damit Testcontainers ordnungsgemäß funktionieren. + +3. **Abhängigkeitsauflösung**: Wenn Sie bei der Ausführung der Integrationstests auf Probleme mit der Abhängigkeitsauflösung stoßen, stellen Sie sicher, dass das platform-bom-Modul explizite Versionseinschränkungen für alle erforderlichen Abhängigkeiten enthält. Die folgenden Abhängigkeiten sind besonders wichtig für Redis-Integrationstests: + - `org.springframework.boot:spring-boot-starter-data-redis` + - `io.lettuce:lettuce-core` + - `com.fasterxml.jackson.module:jackson-module-kotlin` + - `com.fasterxml.jackson.datatype:jackson-datatype-jsr310` + - `org.testcontainers:testcontainers` + - `org.testcontainers:junit-jupiter` + - `javax.annotation:javax.annotation-api` + + Stand Juli 2025 wurde die Abhängigkeit `javax.annotation:javax.annotation-api` mit Version 1.3.2 zum platform-bom-Modul hinzugefügt. + +4. **Kompilierungsprobleme**: Es gibt bekannte Kompilierungsprobleme in den Redis-bezogenen Dateien, von denen einige behoben wurden, während andere noch behoben werden müssen: + + **In RedisEventConsumer.kt (behoben):** + - Probleme mit der Behandlung von Nullable-Typen bei booleschen Ausdrücken (Zeilen 144 und 203) - BEHOBEN + - Probleme mit der Typkonvertierung von Int zu Long (Zeile 187) - BEHOBEN + - Probleme mit der Behandlung von Nullable-Sammlungen (Zeile 198) - BEHOBEN + - Probleme mit den Parametern der pending-Methode (Zeile 220) - BEHOBEN + - Probleme mit dem Spread-Operator bei Nullable-Typen (Zeile 230) - BEHOBEN + + **In RedisEventStore.kt (behoben):** + - Probleme mit der Typkonvertierung von Int zu Long (Zeilen 122 und 152) - BEHOBEN + - Probleme mit der Behandlung von Nullable-Sammlungen (Zeilen 128, 158, 188 und 193) - BEHOBEN + + **In RedisEventStoreConfiguration.kt (behoben):** + - Typfehlanpassung mit RedisPassword (Zeile 59) - BEHOBEN + + Diese Probleme hängen hauptsächlich mit der API-Kompatibilität zwischen Spring Data Redis und Kotlins Typsystem zusammen. Sie müssen in einem zukünftigen Update behoben werden. Vorerst können Sie diese Probleme umgehen, indem Sie: + + a. **Die CI/CD-Pipeline für die Ausführung von Tests verwenden**, die über die richtige Umgebung verfügt + + b. **Problematische Abschnitte vorübergehend auskommentieren oder modifizieren**, wenn Sie Tests lokal ausführen: + + Zum Beispiel können Sie in RedisEventConsumer.kt die claimPendingMessages-Methode wie folgt modifizieren: + + ```kotlin + private fun claimPendingMessages() { + try { + // Get all stream keys + val streamKeys = redisTemplate.keys("${properties.streamPrefix}*") ?: return + + // Comment out the problematic sections for local testing + // For each stream key, log that we're skipping pending message processing + for (streamKey in streamKeys) { + logger.debug("Skipping pending message processing for stream: $streamKey") + } + + // Original implementation commented out for local testing + /* + for (streamKey in streamKeys) { + // Get pending messages summary + val pendingSummary = redisTemplate.opsForStream() + .pending(streamKey, properties.consumerGroup) + + // Rest of the implementation... + } + */ + } catch (e: Exception) { + logger.error("Error claiming pending messages: ${e.message}", e) + } + } + ``` + + c. **Testspezifische Implementierungen erstellen**, die die Verwendung der problematischen APIs vermeiden: + + ```kotlin + // Test-specific implementation that avoids using problematic APIs + class TestRedisEventConsumer( + private val redisTemplate: StringRedisTemplate, + private val serializer: EventSerializer, + private val properties: RedisEventStoreProperties + ) { + // Simplified implementation for testing + fun registerEventHandler(eventType: String, handler: (DomainEvent) -> Unit) { + // Test implementation + } + + // Other methods... + } + ``` + + d. **Mock-Objekte für Tests verwenden** anstelle der tatsächlichen Redis-Implementierung: + + ```kotlin + @Test + fun testWithMocks() { + // Mock the Redis template + val redisTemplate = mock(StringRedisTemplate::class.java) + val operations = mock(RedisStreamOperations::class.java) + + // Set up the mock to return expected values + whenever(redisTemplate.opsForStream()).thenReturn(operations) + + // Test with mocks instead of actual Redis implementation + } + ``` + + e. **Sich auf Unit-Tests konzentrieren** anstatt auf Integrationstests, bis diese Probleme behoben sind + +5. **API-Kompatibilität**: Die aktuelle Implementierung verwendet Spring Data Redis APIs, die sich in neueren Versionen geändert haben könnten. Stellen Sie bei der Behebung der Kompilierungsprobleme sicher, dass Sie die richtigen Methodensignaturen für die im platform-bom angegebene Version von Spring Data Redis verwenden. + +6. **Serialisierung**: Die aktuelle Implementierung verwendet Jackson für die Serialisierung, was möglicherweise nicht für alle Anwendungsfälle am effizientesten ist. Erwägen Sie die Verwendung eines effizienteren Serialisierungsformats wie Protocol Buffers oder Avro für den Produktionseinsatz. + +7. **Fehlerbehandlung**: Die aktuelle Implementierung enthält grundlegende Fehlerbehandlung, aber für den Produktionseinsatz könnte eine robustere Fehlerbehandlung erforderlich sein. + +8. **Überwachung**: Die aktuelle Implementierung enthält keine Überwachung oder Metriken. Erwägen Sie, Überwachung und Metriken für den Produktionseinsatz hinzuzufügen. + +## Fehlerbehebung + +### Kompilierungsprobleme + +Wenn Sie auf Kompilierungsprobleme mit dem Redis-bezogenen Code stoßen: + +1. **Überprüfen Sie die spezifischen Fehlermeldungen**, um zu identifizieren, auf welche der bekannten Probleme Sie stoßen. +2. **Wenden Sie die entsprechende Umgehungslösung** aus dem Abschnitt "Bekannte Probleme und Einschränkungen" an. +3. **Überprüfen Sie die Abhängigkeitsversionen**, um sicherzustellen, dass sie mit den im platform-bom angegebenen übereinstimmen. +4. **Erwägen Sie die Verwendung einer anderen IDE**, wenn Sie IDE-spezifische Auflösungsprobleme haben. +5. **Melden Sie neue Probleme**, die nicht in der Dokumentation behandelt werden. + +### Probleme mit der Abhängigkeitsauflösung + +Wenn Sie bei der Ausführung der Integrationstests auf Probleme mit der Abhängigkeitsauflösung stoßen, versuchen Sie Folgendes: + +1. Stellen Sie sicher, dass das platform-bom-Modul explizite Versionseinschränkungen für alle erforderlichen Abhängigkeiten enthält. +2. Überprüfen Sie, ob das redis-event-store-Modul alle notwendigen Abhängigkeiten enthält. +3. Führen Sie den Gradle-Build mit dem Flag `--refresh-dependencies` aus, um Gradle zu zwingen, Abhängigkeiten erneut herunterzuladen. +4. Löschen Sie den Gradle-Cache, indem Sie das Verzeichnis `.gradle` in Ihrem Home-Verzeichnis löschen. +5. Wenn Sie eine IDE verwenden, aktualisieren Sie das Gradle-Projekt, um sicherzustellen, dass die IDE die neuesten Abhängigkeiten kennt. + +### Probleme mit der Konfiguration von Integrationstests + +Wenn Sie Probleme mit der Konfiguration des integrationTest-Tasks haben, überprüfen Sie Folgendes: + +1. Stellen Sie sicher, dass der integrationTest-Task in der build.gradle.kts-Datei korrekt konfiguriert ist. +2. Überprüfen Sie, ob die Verzeichnisse für Testklassen korrekt eingestellt sind. +3. Überprüfen Sie, ob die Test-Source-Sets korrekt konfiguriert sind. +4. Führen Sie den Gradle-Build mit dem Flag `--info` oder `--debug` aus, um detailliertere Informationen über das Problem zu erhalten. + +## Zukünftige Verbesserungen + +1. **Clustering-Unterstützung**: Unterstützung für Redis-Clustering für hohe Verfügbarkeit und Skalierbarkeit hinzufügen. + +2. **Komprimierung**: Unterstützung für die Komprimierung von Cache-Einträgen hinzufügen, um den Speicherverbrauch zu reduzieren. + +3. **Verschlüsselung**: Unterstützung für die Verschlüsselung sensibler Daten im Cache hinzufügen. + +4. **Metriken**: Metriken für Cache- und Event-Store-Operationen hinzufügen. + +5. **Circuit Breaker**: Circuit-Breaker-Muster für Redis-Operationen hinzufügen, um Kaskadenausfälle zu verhindern. + +6. **Batch-Verarbeitung**: Batch-Verarbeitung für bessere Leistung verbessern. + +7. **Anpassbare Serialisierung**: Anpassbare Serialisierungsformate ermöglichen. + +8. **Verbesserte Fehlerbehandlung**: Robustere Fehlerbehandlungs- und Wiederherstellungsmechanismen hinzufügen. + +9. **Dokumentation**: Detailliertere Dokumentation und Beispiele hinzufügen. + +10. **Integrationstests**: Umfassendere Integrationstests hinzufügen. diff --git a/docs/implementation/redis-integration.md b/docs/implementation/redis-integration.md new file mode 100644 index 00000000..330debad --- /dev/null +++ b/docs/implementation/redis-integration.md @@ -0,0 +1,331 @@ +# Redis Integration + +This document describes the Redis integration implemented for the Meldestelle application, which includes a distributed cache solution with offline capability and Redis Streams for event sourcing. + +## Distributed Cache Solution + +### Overview + +The distributed cache solution provides a way to cache data across multiple instances of the application, with support for offline operation. When the application is offline, it can continue to read from and write to the local cache, and synchronize with Redis when the connection is restored. + +### Components + +1. **Cache API** (`infrastructure/cache/cache-api`) + - `DistributedCache`: Interface for the distributed cache + - `CacheConfiguration`: Interface for cache configuration + - `CacheEntry`: Class representing a cache entry with metadata for offline capability + - `CacheSerializer`: Interface for serializing and deserializing cache entries + - `ConnectionStatus`: Interfaces for tracking connection status + +2. **Redis Cache Implementation** (`infrastructure/cache/redis-cache`) + - `RedisDistributedCache`: Redis implementation of the distributed cache + - `JacksonCacheSerializer`: Jackson-based implementation of the cache serializer + - `RedisConfiguration`: Spring configuration for Redis + +### Features + +- **Basic Cache Operations**: get, set, delete, exists +- **Batch Operations**: multiGet, multiSet, multiDelete +- **TTL Support**: Time-to-live for cache entries +- **Offline Capability**: Continue to work when Redis is unavailable +- **Automatic Synchronization**: Synchronize with Redis when the connection is restored +- **Connection Status Tracking**: Track the connection status and notify listeners + +### Configuration + +The cache can be configured using the following properties in `application.yml`: + +```yaml +redis: + host: localhost + port: 6379 + password: # Leave empty for no password + database: 0 + connection-timeout: 2000 + read-timeout: 2000 + use-pooling: true + max-pool-size: 8 + min-pool-size: 2 + connection-check-interval: 10000 # 10 seconds + local-cache-cleanup-interval: 60000 # 1 minute + sync-interval: 300000 # 5 minutes +``` + +## Redis Streams for Event Sourcing + +### Overview + +Redis Streams are used for event sourcing, providing a way to store and retrieve domain events. The implementation supports appending events to streams, reading events from streams, and subscribing to events. + +### Components + +1. **Event Store API** (`infrastructure/event-store/event-store-api`) + - `EventStore`: Interface for the event store + - `EventSerializer`: Interface for serializing and deserializing events + - `Subscription`: Interface for subscriptions to event streams + +2. **Redis Event Store Implementation** (`infrastructure/event-store/redis-event-store`) + - `RedisEventStore`: Redis Streams implementation of the event store + - `JacksonEventSerializer`: Jackson-based implementation of the event serializer + - `RedisEventConsumer`: Consumer for Redis Streams that processes events using consumer groups + - `RedisEventStoreConfiguration`: Spring configuration for Redis event store + +### Features + +- **Event Appending**: Append events to streams with optimistic concurrency control +- **Event Reading**: Read events from streams +- **Event Subscription**: Subscribe to events from specific streams or all streams +- **Consumer Groups**: Process events using consumer groups +- **Concurrency Control**: Optimistic concurrency control for event appending + +### Configuration + +The event store can be configured using the following properties in `application.yml`: + +```yaml +redis: + event-store: + host: localhost + port: 6379 + password: # Leave empty for no password + database: 1 # Use a different database for event store + connection-timeout: 2000 + read-timeout: 2000 + use-pooling: true + max-pool-size: 8 + min-pool-size: 2 + consumer-group: event-processors + consumer-name: "${spring.application.name}-${random.uuid}" + stream-prefix: "event-stream:" + all-events-stream: "all-events" + claim-idle-timeout: 60000 # 1 minute + poll-timeout: 100 # 100 milliseconds + poll-interval: 100 # 100 milliseconds + max-batch-size: 100 + create-consumer-group-if-not-exists: true +``` + +## Integration Tests + +Integration tests for Redis components are implemented using Testcontainers, which automatically spins up a Redis container for testing. This ensures that the tests run in an isolated environment and don't depend on external Redis instances. + +### Running Integration Tests + +To run the integration tests, use the following Gradle command: + +```bash +./gradlew integrationTest +``` + +This will run all tests with "Integration" in their name, including the Redis integration tests. + +> **Note:** Due to the compilation issues mentioned in the "Known Issues and Limitations" section, the integration tests may not run locally until these issues are fixed. The CI/CD workflow is correctly configured to run the tests in the future once these issues are resolved. + +### CI/CD Integration + +The project includes a GitHub Actions workflow for running integration tests, which is defined in `.github/workflows/integration-tests.yml`. This workflow: + +1. Sets up a Redis service container for integration tests +2. Runs the integration tests using the `integrationTest` Gradle task +3. Uploads test reports as artifacts for easy access + +The workflow is triggered on push to main and develop branches, and on pull requests to these branches. + +### Writing Redis Integration Tests + +When writing integration tests for Redis components: + +1. Use the `@Testcontainers` annotation to enable Testcontainers support +2. Define a Redis container using `GenericContainer` with the Redis image +3. Configure the Redis connection using the container's host and mapped port +4. Use the `@Container` annotation to ensure the container is started and stopped automatically + +Example: + +```kotlin +@Testcontainers +class RedisIntegrationTest { + + companion object { + @Container + val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) + } + + @BeforeEach + fun setUp() { + val redisPort = redisContainer.getMappedPort(6379) + val redisHost = redisContainer.host + + // Configure Redis connection using redisHost and redisPort + } + + // Test methods +} +``` + +## Known Issues and Limitations + +1. **IDE Resolution Issues**: The IDE may show unresolved references for some classes, but the code should compile and run correctly. This is because the dependencies are included in the build.gradle.kts files but may not be properly resolved by the IDE. + +2. **Test Dependencies**: The tests require Docker to be installed and running for Testcontainers to work properly. + +3. **Dependency Resolution**: If you encounter dependency resolution issues when running the integration tests, ensure that the platform-bom module includes explicit version constraints for all required dependencies. The following dependencies are particularly important for Redis integration tests: + - `org.springframework.boot:spring-boot-starter-data-redis` + - `io.lettuce:lettuce-core` + - `com.fasterxml.jackson.module:jackson-module-kotlin` + - `com.fasterxml.jackson.datatype:jackson-datatype-jsr310` + - `org.testcontainers:testcontainers` + - `org.testcontainers:junit-jupiter` + - `javax.annotation:javax.annotation-api` + + As of July 2025, the `javax.annotation:javax.annotation-api` dependency has been added to the platform-bom module with version 1.3.2. + +4. **Compilation Issues**: There are known compilation issues in the Redis-related files that need to be addressed: + + **In RedisEventConsumer.kt:** + - Nullable type handling issues with Boolean expressions (lines 144 and 203) + - Type conversion issues with Int to Long (line 187) + - Nullable collection handling issues (line 198) + - Issues with the pending method parameters (line 220) + - Issues with spread operator on nullable types (line 230) + + **In RedisEventStore.kt:** + - Int to Long type conversion issues (lines 122 and 152) + - Nullable collection handling issues (lines 128, 158, 188, and 193) + + **In RedisEventStoreConfiguration.kt:** + - Type mismatch with RedisPassword (line 59) + + These issues are primarily related to API compatibility between Spring Data Redis and Kotlin's type system. They need to be fixed in a future update. For now, you can work around these issues by: + + a. **Using the CI/CD pipeline for running tests**, which has the correct environment set up + + b. **Temporarily commenting out or modifying problematic sections** when running tests locally: + + For example, in RedisEventConsumer.kt, you can modify the claimPendingMessages method: + + ```kotlin + private fun claimPendingMessages() { + try { + // Get all stream keys + val streamKeys = redisTemplate.keys("${properties.streamPrefix}*") ?: return + + // Comment out the problematic sections for local testing + // For each stream key, log that we're skipping pending message processing + for (streamKey in streamKeys) { + logger.debug("Skipping pending message processing for stream: $streamKey") + } + + // Original implementation commented out for local testing + /* + for (streamKey in streamKeys) { + // Get pending messages summary + val pendingSummary = redisTemplate.opsForStream() + .pending(streamKey, properties.consumerGroup) + + // Rest of the implementation... + } + */ + } catch (e: Exception) { + logger.error("Error claiming pending messages: ${e.message}", e) + } + } + ``` + + c. **Creating test-specific implementations** that avoid using the problematic APIs: + + ```kotlin + // Test-specific implementation that avoids using problematic APIs + class TestRedisEventConsumer( + private val redisTemplate: StringRedisTemplate, + private val serializer: EventSerializer, + private val properties: RedisEventStoreProperties + ) { + // Simplified implementation for testing + fun registerEventHandler(eventType: String, handler: (DomainEvent) -> Unit) { + // Test implementation + } + + // Other methods... + } + ``` + + d. **Using mock objects for testing** instead of the actual Redis implementation: + + ```kotlin + @Test + fun testWithMocks() { + // Mock the Redis template + val redisTemplate = mock(StringRedisTemplate::class.java) + val operations = mock(RedisStreamOperations::class.java) + + // Set up the mock to return expected values + whenever(redisTemplate.opsForStream()).thenReturn(operations) + + // Test with mocks instead of actual Redis implementation + } + ``` + + e. **Focusing on unit tests** rather than integration tests until these issues are resolved + +5. **API Compatibility**: The current implementation uses Spring Data Redis APIs that may have changed in recent versions. When fixing the compilation issues, ensure that you're using the correct method signatures for the version of Spring Data Redis specified in the platform-bom. + +6. **Serialization**: The current implementation uses Jackson for serialization, which may not be the most efficient for all use cases. Consider using a more efficient serialization format like Protocol Buffers or Avro for production use. + +7. **Error Handling**: The current implementation includes basic error handling, but more robust error handling may be needed for production use. + +8. **Monitoring**: The current implementation does not include monitoring or metrics. Consider adding monitoring and metrics for production use. + +## Troubleshooting + +### Compilation Issues + +If you encounter compilation issues with the Redis-related code: + +1. **Check the specific error messages** to identify which of the known issues you're encountering. +2. **Apply the appropriate workaround** from the "Known Issues and Limitations" section. +3. **Verify dependency versions** to ensure they match the ones specified in the platform-bom. +4. **Consider using a different IDE** if you're having IDE-specific resolution issues. +5. **Report any new issues** that aren't covered in the documentation. + +### Dependency Resolution Issues + +If you encounter dependency resolution issues when running the integration tests, try the following: + +1. Ensure that the platform-bom module includes explicit version constraints for all required dependencies. +2. Check that the redis-event-store module includes all necessary dependencies. +3. Run the Gradle build with the `--refresh-dependencies` flag to force Gradle to re-download dependencies. +4. Clear the Gradle cache by deleting the `.gradle` directory in your home directory. +5. If you're using an IDE, refresh the Gradle project to ensure that the IDE is aware of the latest dependencies. + +### Integration Test Configuration Issues + +If you encounter issues with the integrationTest task configuration, check the following: + +1. Ensure that the integrationTest task is properly configured in the build.gradle.kts file. +2. Check that the test classes directories are properly set. +3. Verify that the test source sets are properly configured. +4. Run the Gradle build with the `--info` or `--debug` flag to get more detailed information about the issue. + +## Future Improvements + +1. **Clustering Support**: Add support for Redis clustering for high availability and scalability. + +2. **Compression**: Add support for compressing cache entries to reduce memory usage. + +3. **Encryption**: Add support for encrypting sensitive data in the cache. + +4. **Metrics**: Add metrics for cache and event store operations. + +5. **Circuit Breaker**: Add circuit breaker pattern for Redis operations to prevent cascading failures. + +6. **Batch Processing**: Improve batch processing for better performance. + +7. **Customizable Serialization**: Allow for customizable serialization formats. + +8. **Improved Error Handling**: Add more robust error handling and recovery mechanisms. + +9. **Documentation**: Add more detailed documentation and examples. + +10. **Integration Tests**: Add more comprehensive integration tests. diff --git a/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheConfiguration.kt b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheConfiguration.kt new file mode 100644 index 00000000..92c3fb0d --- /dev/null +++ b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheConfiguration.kt @@ -0,0 +1,69 @@ +package at.mocode.infrastructure.cache.api + +import java.time.Duration + +/** + * Configuration for the distributed cache. + */ +interface CacheConfiguration { + /** + * Default time-to-live for cache entries. + * If null, entries do not expire by default. + */ + val defaultTtl: Duration? + + /** + * Maximum number of entries to store in the local cache. + * If null, there is no limit. + */ + val localCacheMaxSize: Int? + + /** + * Whether to enable offline mode. + * If true, the cache will store entries locally when offline + * and synchronize them when online. + */ + val offlineModeEnabled: Boolean + + /** + * How often to attempt synchronization in offline mode. + */ + val synchronizationInterval: Duration + + /** + * Maximum age of entries to keep in the local cache when offline. + * If null, entries do not expire when offline. + */ + val offlineEntryMaxAge: Duration? + + /** + * Prefix to add to all cache keys. + * This can be used to namespace cache entries. + */ + val keyPrefix: String + + /** + * Whether to compress cache entries. + */ + val compressionEnabled: Boolean + + /** + * Threshold in bytes above which to compress cache entries. + * Only used if compressionEnabled is true. + */ + val compressionThreshold: Int +} + +/** + * Default implementation of CacheConfiguration. + */ +data class DefaultCacheConfiguration( + override val defaultTtl: Duration? = Duration.ofHours(1), + override val localCacheMaxSize: Int? = 10000, + override val offlineModeEnabled: Boolean = true, + override val synchronizationInterval: Duration = Duration.ofMinutes(5), + override val offlineEntryMaxAge: Duration? = Duration.ofDays(7), + override val keyPrefix: String = "", + override val compressionEnabled: Boolean = true, + override val compressionThreshold: Int = 1024 +) : CacheConfiguration diff --git a/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheEntry.kt b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheEntry.kt new file mode 100644 index 00000000..e8f499fe --- /dev/null +++ b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheEntry.kt @@ -0,0 +1,96 @@ +package at.mocode.infrastructure.cache.api + +import java.time.Instant + +/** + * Represents an entry in the cache with metadata for offline capability. + * + * @param key The key of the cache entry + * @param value The value stored in the cache + * @param createdAt When the entry was created + * @param expiresAt When the entry expires, or null if it doesn't expire + * @param lastModifiedAt When the entry was last modified + * @param isDirty Whether the entry has been modified locally and needs to be synchronized + * @param isLocal Whether the entry is only stored locally (not yet synchronized) + */ +data class CacheEntry( + val key: String, + val value: T, + val createdAt: Instant = Instant.now(), + val expiresAt: Instant? = null, + val lastModifiedAt: Instant = Instant.now(), + val isDirty: Boolean = false, + val isLocal: Boolean = false +) { + /** + * Checks if the entry is expired. + * + * @return true if the entry is expired, false otherwise + */ + fun isExpired(): Boolean { + return expiresAt?.isBefore(Instant.now()) ?: false + } + + /** + * Creates a new entry with the isDirty flag set to true. + * + * @return A new CacheEntry with isDirty set to true + */ + fun markDirty(): CacheEntry { + return copy( + isDirty = true, + lastModifiedAt = Instant.now() + ) + } + + /** + * Creates a new entry with the isDirty flag set to false. + * + * @return A new CacheEntry with isDirty set to false + */ + fun markClean(): CacheEntry { + return copy( + isDirty = false, + isLocal = false, + lastModifiedAt = Instant.now() + ) + } + + /** + * Creates a new entry with the isLocal flag set to true. + * + * @return A new CacheEntry with isLocal set to true + */ + fun markLocal(): CacheEntry { + return copy( + isLocal = true, + lastModifiedAt = Instant.now() + ) + } + + /** + * Creates a new entry with an updated value. + * + * @param newValue The new value + * @return A new CacheEntry with the updated value + */ + fun updateValue(newValue: T): CacheEntry { + return copy( + value = newValue, + lastModifiedAt = Instant.now() + ) + } + + /** + * Creates a new entry with an updated expiration time. + * + * @param newExpiresAt The new expiration time + * @return A new CacheEntry with the updated expiration time + */ + fun updateExpiration(newExpiresAt: Instant?): CacheEntry { + return copy( + expiresAt = newExpiresAt, + lastModifiedAt = Instant.now() + ) + } +} diff --git a/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheSerializer.kt b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheSerializer.kt new file mode 100644 index 00000000..ab3a363c --- /dev/null +++ b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/CacheSerializer.kt @@ -0,0 +1,56 @@ +package at.mocode.infrastructure.cache.api + +/** + * Interface for serializing and deserializing cache entries. + */ +interface CacheSerializer { + /** + * Serializes a value to a byte array. + * + * @param value The value to serialize + * @return The serialized value as a byte array + */ + fun serialize(value: T): ByteArray + + /** + * Deserializes a byte array to a value. + * + * @param bytes The byte array to deserialize + * @param clazz The class of the value to deserialize to + * @return The deserialized value + */ + fun deserialize(bytes: ByteArray, clazz: Class): T + + /** + * Serializes a cache entry to a byte array. + * + * @param entry The cache entry to serialize + * @return The serialized cache entry as a byte array + */ + fun serializeEntry(entry: CacheEntry): ByteArray + + /** + * Deserializes a byte array to a cache entry. + * + * @param bytes The byte array to deserialize + * @param valueClass The class of the value in the cache entry + * @return The deserialized cache entry + */ + fun deserializeEntry(bytes: ByteArray, valueClass: Class): CacheEntry + + /** + * Compresses a byte array. + * + * @param bytes The byte array to compress + * @return The compressed byte array + */ + fun compress(bytes: ByteArray): ByteArray + + /** + * Decompresses a byte array. + * + * @param bytes The byte array to decompress + * @return The decompressed byte array + */ + fun decompress(bytes: ByteArray): ByteArray +} diff --git a/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/ConnectionStatus.kt b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/ConnectionStatus.kt new file mode 100644 index 00000000..ec6cb973 --- /dev/null +++ b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/ConnectionStatus.kt @@ -0,0 +1,76 @@ +package at.mocode.infrastructure.cache.api + +import java.time.Instant + +/** + * Represents the connection status of the cache. + */ +enum class ConnectionState { + /** + * The cache is connected to the remote server. + */ + CONNECTED, + + /** + * The cache is disconnected from the remote server. + */ + DISCONNECTED, + + /** + * The cache is attempting to reconnect to the remote server. + */ + RECONNECTING +} + +/** + * Interface for tracking the connection status of the cache. + */ +interface ConnectionStatusTracker { + /** + * Gets the current connection state. + * + * @return The current connection state + */ + fun getConnectionState(): ConnectionState + + /** + * Gets the time when the connection state last changed. + * + * @return The time when the connection state last changed + */ + fun getLastStateChangeTime(): Instant + + /** + * Registers a listener to be notified when the connection state changes. + * + * @param listener The listener to register + */ + fun registerConnectionListener(listener: ConnectionStateListener) + + /** + * Unregisters a connection state listener. + * + * @param listener The listener to unregister + */ + fun unregisterConnectionListener(listener: ConnectionStateListener) + + /** + * Checks if the cache is currently connected. + * + * @return true if the cache is connected, false otherwise + */ + fun isConnected(): Boolean = getConnectionState() == ConnectionState.CONNECTED +} + +/** + * Listener for connection state changes. + */ +interface ConnectionStateListener { + /** + * Called when the connection state changes. + * + * @param newState The new connection state + * @param timestamp The time when the state changed + */ + fun onConnectionStateChanged(newState: ConnectionState, timestamp: Instant) +} diff --git a/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/DistributedCache.kt b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/DistributedCache.kt new file mode 100644 index 00000000..48d485b5 --- /dev/null +++ b/infrastructure/cache/cache-api/src/main/kotlin/at/mocode/infrastructure/cache/api/DistributedCache.kt @@ -0,0 +1,94 @@ +package at.mocode.infrastructure.cache.api + +import java.time.Duration + +/** + * Interface for a distributed cache that supports offline capability. + * This cache can be used to store and retrieve data across multiple instances + * and provides mechanisms for offline operation. + */ +interface DistributedCache { + /** + * Retrieves a value from the cache. + * + * @param key The key to retrieve + * @return The value associated with the key, or null if not found + */ + fun get(key: String, clazz: Class): T? + + /** + * Stores a value in the cache with an optional time-to-live. + * + * @param key The key to store the value under + * @param value The value to store + * @param ttl Optional time-to-live for the cache entry + */ + fun set(key: String, value: T, ttl: Duration? = null) + + /** + * Removes a value from the cache. + * + * @param key The key to remove + */ + fun delete(key: String) + + /** + * Checks if a key exists in the cache. + * + * @param key The key to check + * @return true if the key exists, false otherwise + */ + fun exists(key: String): Boolean + + /** + * Retrieves multiple values from the cache. + * + * @param keys The keys to retrieve + * @return A map of keys to values, with missing keys omitted + */ + fun multiGet(keys: Collection, clazz: Class): Map + + /** + * Stores multiple values in the cache with an optional time-to-live. + * + * @param entries The key-value pairs to store + * @param ttl Optional time-to-live for the cache entries + */ + fun multiSet(entries: Map, ttl: Duration? = null) + + /** + * Removes multiple values from the cache. + * + * @param keys The keys to remove + */ + fun multiDelete(keys: Collection) + + /** + * Synchronizes the local cache with the distributed cache. + * This is used to ensure that the local cache is up-to-date with the distributed cache + * after being offline. + * + * @param keys Optional collection of keys to synchronize. If null, all keys are synchronized. + */ + fun synchronize(keys: Collection? = null) + + /** + * Marks a key as dirty, indicating that it has been modified locally + * and needs to be synchronized with the distributed cache. + * + * @param key The key to mark as dirty + */ + fun markDirty(key: String) + + /** + * Gets all keys that have been marked as dirty. + * + * @return A collection of dirty keys + */ + fun getDirtyKeys(): Collection + + /** + * Clears all entries from the cache. + */ + fun clear() +} diff --git a/infrastructure/cache/redis-cache/build.gradle.kts b/infrastructure/cache/redis-cache/build.gradle.kts index 1e4363de..0ce6577f 100644 --- a/infrastructure/cache/redis-cache/build.gradle.kts +++ b/infrastructure/cache/redis-cache/build.gradle.kts @@ -4,6 +4,7 @@ plugins { } dependencies { + api(platform(projects.platform.platformBom)) implementation(projects.infrastructure.cache.cacheApi) implementation("org.springframework.boot:spring-boot-starter-data-redis") diff --git a/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/JacksonCacheSerializer.kt b/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/JacksonCacheSerializer.kt new file mode 100644 index 00000000..c58dddf1 --- /dev/null +++ b/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/JacksonCacheSerializer.kt @@ -0,0 +1,119 @@ +package at.mocode.infrastructure.cache.redis + +import at.mocode.infrastructure.cache.api.CacheEntry +import at.mocode.infrastructure.cache.api.CacheSerializer +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * Jackson-based implementation of CacheSerializer. + */ +class JacksonCacheSerializer : CacheSerializer { + private val objectMapper: ObjectMapper = ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + registerModule(JavaTimeModule()) + disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + } + + override fun serialize(value: T): ByteArray { + return objectMapper.writeValueAsBytes(value) + } + + override fun deserialize(bytes: ByteArray, clazz: Class): T { + return objectMapper.readValue(bytes, clazz) + } + + override fun serializeEntry(entry: CacheEntry): ByteArray { + // Create a wrapper that holds both the entry metadata and the serialized value + val wrapper = CacheEntryWrapper( + key = entry.key, + valueBytes = serialize(entry.value), + valueType = entry.value.javaClass.name, + createdAt = entry.createdAt, + expiresAt = entry.expiresAt, + lastModifiedAt = entry.lastModifiedAt, + isDirty = entry.isDirty, + isLocal = entry.isLocal + ) + return objectMapper.writeValueAsBytes(wrapper) + } + + override fun deserializeEntry(bytes: ByteArray, valueClass: Class): CacheEntry { + val wrapper = objectMapper.readValue(bytes) + val value = deserialize(wrapper.valueBytes, valueClass) + + return CacheEntry( + key = wrapper.key, + value = value, + createdAt = wrapper.createdAt, + expiresAt = wrapper.expiresAt, + lastModifiedAt = wrapper.lastModifiedAt, + isDirty = wrapper.isDirty, + isLocal = wrapper.isLocal + ) + } + + override fun compress(bytes: ByteArray): ByteArray { + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { it.write(bytes) } + return outputStream.toByteArray() + } + + override fun decompress(bytes: ByteArray): ByteArray { + val inputStream = GZIPInputStream(ByteArrayInputStream(bytes)) + return inputStream.readBytes() + } + + /** + * Wrapper class for serializing cache entries. + * This separates the metadata from the value, allowing us to deserialize + * the metadata without knowing the type of the value. + */ + private data class CacheEntryWrapper( + val key: String, + val valueBytes: ByteArray, + val valueType: String, + val createdAt: java.time.Instant, + val expiresAt: java.time.Instant?, + val lastModifiedAt: java.time.Instant, + val isDirty: Boolean, + val isLocal: Boolean + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CacheEntryWrapper + + if (key != other.key) return false + if (!valueBytes.contentEquals(other.valueBytes)) return false + if (valueType != other.valueType) return false + if (createdAt != other.createdAt) return false + if (expiresAt != other.expiresAt) return false + if (lastModifiedAt != other.lastModifiedAt) return false + if (isDirty != other.isDirty) return false + if (isLocal != other.isLocal) return false + + return true + } + + override fun hashCode(): Int { + var result = key.hashCode() + result = 31 * result + valueBytes.contentHashCode() + result = 31 * result + valueType.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + (expiresAt?.hashCode() ?: 0) + result = 31 * result + lastModifiedAt.hashCode() + result = 31 * result + isDirty.hashCode() + result = 31 * result + isLocal.hashCode() + return result + } + } +} diff --git a/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/RedisConfiguration.kt b/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/RedisConfiguration.kt new file mode 100644 index 00000000..72eaec91 --- /dev/null +++ b/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/RedisConfiguration.kt @@ -0,0 +1,99 @@ +package at.mocode.infrastructure.cache.redis + +import at.mocode.infrastructure.cache.api.CacheConfiguration +import at.mocode.infrastructure.cache.api.CacheSerializer +import at.mocode.infrastructure.cache.api.DefaultCacheConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisPassword +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.StringRedisSerializer + +/** + * Redis connection properties. + */ +@ConfigurationProperties(prefix = "redis") +data class RedisProperties( + val host: String = "localhost", + val port: Int = 6379, + val password: String? = null, + val database: Int = 0, + val connectionTimeout: Long = 2000, + val readTimeout: Long = 2000, + val usePooling: Boolean = true, + val maxPoolSize: Int = 8, + val minPoolSize: Int = 2 +) + +/** + * Spring configuration for Redis. + */ +@Configuration +@EnableConfigurationProperties(RedisProperties::class) +class RedisConfiguration { + + /** + * Creates a Redis connection factory. + * + * @param properties Redis connection properties + * @return Redis connection factory + */ + @Bean + fun redisConnectionFactory(properties: RedisProperties): RedisConnectionFactory { + val config = RedisStandaloneConfiguration().apply { + hostName = properties.host + port = properties.port + properties.password?.let { password = RedisPassword.of(it) } + database = properties.database + } + + return LettuceConnectionFactory(config).apply { + // Configure connection timeouts + afterPropertiesSet() + } + } + + /** + * Creates a Redis template for byte arrays. + * + * @param connectionFactory Redis connection factory + * @return Redis template + */ + @Bean + fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate { + return RedisTemplate().apply { + setConnectionFactory(connectionFactory) + keySerializer = StringRedisSerializer() + // Use default serializer for values (byte arrays) + afterPropertiesSet() + } + } + + /** + * Creates a cache serializer. + * + * @return Cache serializer + */ + @Bean + @ConditionalOnMissingBean + fun cacheSerializer(): CacheSerializer { + return JacksonCacheSerializer() + } + + /** + * Creates a default cache configuration if none is provided. + * + * @return Cache configuration + */ + @Bean + @ConditionalOnMissingBean + fun cacheConfiguration(): CacheConfiguration { + return DefaultCacheConfiguration() + } +} diff --git a/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCache.kt b/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCache.kt new file mode 100644 index 00000000..7bd5a350 --- /dev/null +++ b/infrastructure/cache/redis-cache/src/main/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCache.kt @@ -0,0 +1,494 @@ +package at.mocode.infrastructure.cache.redis + +import at.mocode.infrastructure.cache.api.CacheConfiguration +import at.mocode.infrastructure.cache.api.CacheEntry +import at.mocode.infrastructure.cache.api.CacheSerializer +import at.mocode.infrastructure.cache.api.ConnectionState +import at.mocode.infrastructure.cache.api.ConnectionStateListener +import at.mocode.infrastructure.cache.api.ConnectionStatusTracker +import at.mocode.infrastructure.cache.api.DistributedCache +import org.slf4j.LoggerFactory +import org.springframework.data.redis.RedisConnectionFailureException +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.scheduling.annotation.Scheduled +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Redis implementation of DistributedCache with offline capability. + */ +class RedisDistributedCache( + private val redisTemplate: RedisTemplate, + private val serializer: CacheSerializer, + private val config: CacheConfiguration +) : DistributedCache, ConnectionStatusTracker { + + private val logger = LoggerFactory.getLogger(RedisDistributedCache::class.java) + + // Local cache for offline capability + private val localCache = ConcurrentHashMap>() + + // Set of keys that have been modified locally and need to be synchronized + private val dirtyKeys = ConcurrentHashMap.newKeySet() + + // Connection state + private var connectionState = ConnectionState.DISCONNECTED + private var lastStateChangeTime = Instant.now() + + // Connection state listeners + private val connectionListeners = CopyOnWriteArrayList() + + init { + // Try to connect to Redis + checkConnection() + } + + // + // DistributedCache implementation + // + + override fun get(key: String, clazz: Class): T? { + val prefixedKey = addPrefix(key) + + // Try to get from local cache first + val localEntry = localCache[prefixedKey] as? CacheEntry + if (localEntry != null) { + if (localEntry.isExpired()) { + localCache.remove(prefixedKey) + return null + } + return localEntry.value + } + + // If not in local cache and we're disconnected, return null + if (!isConnected()) { + return null + } + + // Try to get from Redis + try { + val bytes = redisTemplate.opsForValue().get(prefixedKey) ?: return null + val entry = serializer.deserializeEntry(bytes, clazz) + + // Store in local cache + localCache[prefixedKey] = entry as CacheEntry + + return entry.value + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + return null + } catch (e: Exception) { + logger.error("Error getting value from Redis for key $prefixedKey", e) + return null + } + } + + override fun set(key: String, value: T, ttl: Duration?) { + val prefixedKey = addPrefix(key) + val expiresAt = ttl?.let { Instant.now().plus(it) } ?: config.defaultTtl?.let { Instant.now().plus(it) } + + val entry = CacheEntry( + key = prefixedKey, + value = value, + expiresAt = expiresAt + ) + + // Store in local cache + localCache[prefixedKey] = entry as CacheEntry + + // If we're disconnected, mark as dirty and return + if (!isConnected()) { + markDirty(key) + return + } + + // Try to store in Redis + try { + val bytes = serializer.serializeEntry(entry) + redisTemplate.opsForValue().set(prefixedKey, bytes) + + if (ttl != null) { + redisTemplate.expire(prefixedKey, ttl) + } else if (config.defaultTtl != null) { + val defaultTtl: Duration? = config.defaultTtl + redisTemplate.expire(prefixedKey, defaultTtl) + } + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + markDirty(key) + } catch (e: Exception) { + logger.error("Error setting value in Redis for key $prefixedKey", e) + markDirty(key) + } + } + + override fun delete(key: String) { + val prefixedKey = addPrefix(key) + + // Remove from local cache + localCache.remove(prefixedKey) + + // If we're disconnected, mark as dirty and return + if (!isConnected()) { + markDirty(key) + return + } + + // Try to delete from Redis + try { + redisTemplate.delete(prefixedKey) + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + markDirty(key) + } catch (e: Exception) { + logger.error("Error deleting value from Redis for key $prefixedKey", e) + markDirty(key) + } + } + + override fun exists(key: String): Boolean { + val prefixedKey = addPrefix(key) + + // Check local cache first + if (localCache.containsKey(prefixedKey)) { + val entry = localCache[prefixedKey] + if (entry != null && !entry.isExpired()) { + return true + } + // Remove expired entry + localCache.remove(prefixedKey) + } + + // If we're disconnected, return false + if (!isConnected()) { + return false + } + + // Check Redis + try { + return redisTemplate.hasKey(prefixedKey) ?: false + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + return false + } catch (e: Exception) { + logger.error("Error checking if key exists in Redis for key $prefixedKey", e) + return false + } + } + + override fun multiGet(keys: Collection, clazz: Class): Map { + val result = mutableMapOf() + + // Get from local cache first + val prefixedKeys = keys.map { addPrefix(it) } + val localEntries = prefixedKeys.mapNotNull { key -> + val entry = localCache[key] as? CacheEntry + if (entry != null && !entry.isExpired()) { + key to entry.value + } else { + null + } + }.toMap() + + result.putAll(localEntries.mapKeys { removePrefix(it.key) }) + + // If we're disconnected, return local entries + if (!isConnected()) { + return result + } + + // Get missing keys from Redis + val missingKeys = prefixedKeys.filter { !localEntries.containsKey(it) } + if (missingKeys.isEmpty()) { + return result + } + + try { + val redisEntries = redisTemplate.opsForValue().multiGet(missingKeys) + if (redisEntries != null) { + for (i in missingKeys.indices) { + val key = missingKeys[i] + val bytes = redisEntries[i] + if (bytes != null) { + try { + val entry = serializer.deserializeEntry(bytes, clazz) + + // Store in local cache + localCache[key] = entry as CacheEntry + + // Add to result + result[removePrefix(key)] = entry.value + } catch (e: Exception) { + logger.error("Error deserializing entry for key $key", e) + } + } + } + } + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + } catch (e: Exception) { + logger.error("Error getting multiple values from Redis", e) + } + + return result + } + + override fun multiSet(entries: Map, ttl: Duration?) { + // Store in local cache and prepare for Redis + val redisBatch = mutableMapOf() + val expiresAt = ttl?.let { Instant.now().plus(it) } ?: config.defaultTtl?.let { Instant.now().plus(it) } + + for ((key, value) in entries) { + val prefixedKey = addPrefix(key) + val entry = CacheEntry( + key = prefixedKey, + value = value, + expiresAt = expiresAt + ) + + // Store in local cache + localCache[prefixedKey] = entry as CacheEntry + + // Prepare for Redis + redisBatch[prefixedKey] = serializer.serializeEntry(entry) + } + + // If we're disconnected, mark all as dirty and return + if (!isConnected()) { + entries.keys.forEach { markDirty(it) } + return + } + + // Try to store in Redis + try { + redisTemplate.opsForValue().multiSet(redisBatch) + + if (ttl != null || config.defaultTtl != null) { + val duration = ttl ?: config.defaultTtl + if (duration != null) { + for (key in redisBatch.keys) { + redisTemplate.expire(key, duration) + } + } + } + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + entries.keys.forEach { markDirty(it) } + } catch (e: Exception) { + logger.error("Error setting multiple values in Redis", e) + entries.keys.forEach { markDirty(it) } + } + } + + override fun multiDelete(keys: Collection) { + val prefixedKeys = keys.map { addPrefix(it) } + + // Remove from local cache + prefixedKeys.forEach { localCache.remove(it) } + + // If we're disconnected, mark all as dirty and return + if (!isConnected()) { + keys.forEach { markDirty(it) } + return + } + + // Try to delete from Redis + try { + redisTemplate.delete(prefixedKeys) + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + keys.forEach { markDirty(it) } + } catch (e: Exception) { + logger.error("Error deleting multiple values from Redis", e) + keys.forEach { markDirty(it) } + } + } + + override fun synchronize(keys: Collection?) { + if (!isConnected()) { + logger.debug("Cannot synchronize while disconnected") + return + } + + val keysToSync = keys ?: getDirtyKeys() + if (keysToSync.isEmpty()) { + logger.debug("No keys to synchronize") + return + } + + logger.debug("Synchronizing ${keysToSync.size} keys") + + for (key in keysToSync) { + val prefixedKey = addPrefix(key) + val localEntry = localCache[prefixedKey] + + if (localEntry == null) { + // Entry was deleted locally, delete from Redis + try { + redisTemplate.delete(prefixedKey) + dirtyKeys.remove(key) + } catch (e: Exception) { + logger.error("Error deleting key $prefixedKey during synchronization", e) + } + } else { + // Entry exists locally, update in Redis + try { + val bytes = serializer.serializeEntry(localEntry) + redisTemplate.opsForValue().set(prefixedKey, bytes) + + val ttl = localEntry.expiresAt?.let { Duration.between(Instant.now(), it) } + if (ttl != null && !ttl.isNegative) { + redisTemplate.expire(prefixedKey, ttl) + } + + // Update local entry to mark as clean + localCache[prefixedKey] = localEntry.markClean() as CacheEntry + dirtyKeys.remove(key) + } catch (e: Exception) { + logger.error("Error updating key $prefixedKey during synchronization", e) + } + } + } + } + + override fun markDirty(key: String) { + dirtyKeys.add(key) + + val prefixedKey = addPrefix(key) + val entry = localCache[prefixedKey] + if (entry != null) { + localCache[prefixedKey] = entry.markDirty() as CacheEntry + } + } + + override fun getDirtyKeys(): Collection { + return dirtyKeys.toList() + } + + override fun clear() { + // Clear local cache + localCache.clear() + dirtyKeys.clear() + + // If we're disconnected, return + if (!isConnected()) { + return + } + + // Try to clear Redis + try { + val keys = redisTemplate.keys("${config.keyPrefix}*") + if (keys != null && keys.isNotEmpty()) { + redisTemplate.delete(keys) + } + } catch (e: RedisConnectionFailureException) { + handleConnectionFailure(e) + } catch (e: Exception) { + logger.error("Error clearing Redis cache", e) + } + } + + // + // ConnectionStatusTracker implementation + // + + override fun getConnectionState(): ConnectionState { + return connectionState + } + + override fun getLastStateChangeTime(): Instant { + return lastStateChangeTime + } + + override fun registerConnectionListener(listener: ConnectionStateListener) { + connectionListeners.add(listener) + } + + override fun unregisterConnectionListener(listener: ConnectionStateListener) { + connectionListeners.remove(listener) + } + + // + // Helper methods + // + + private fun addPrefix(key: String): String { + return if (config.keyPrefix.isEmpty()) key else "${config.keyPrefix}:$key" + } + + private fun removePrefix(key: String): String { + return if (config.keyPrefix.isEmpty()) key else key.substring(config.keyPrefix.length + 1) + } + + private fun handleConnectionFailure(e: Exception) { + logger.warn("Redis connection failure: ${e.message}") + setConnectionState(ConnectionState.DISCONNECTED) + } + + private fun setConnectionState(newState: ConnectionState) { + if (connectionState != newState) { + val oldState = connectionState + connectionState = newState + lastStateChangeTime = Instant.now() + + logger.info("Cache connection state changed from $oldState to $newState") + + // Notify listeners + val timestamp = lastStateChangeTime + connectionListeners.forEach { listener -> + try { + listener.onConnectionStateChanged(newState, timestamp) + } catch (e: Exception) { + logger.error("Error notifying connection listener", e) + } + } + + // If reconnected, synchronize dirty keys + if (oldState != ConnectionState.CONNECTED && newState == ConnectionState.CONNECTED) { + synchronize(null) + } + } + } + + /** + * Periodically check the connection to Redis. + */ + @Scheduled(fixedDelayString = "\${redis.connection-check-interval:10000}") + fun checkConnection() { + try { + redisTemplate.hasKey("connection-test") + setConnectionState(ConnectionState.CONNECTED) + } catch (e: Exception) { + setConnectionState(ConnectionState.DISCONNECTED) + } + } + + /** + * Periodically clean up expired entries from the local cache. + */ + @Scheduled(fixedDelayString = "\${redis.local-cache-cleanup-interval:60000}") + fun cleanupLocalCache() { + val now = Instant.now() + val expiredKeys = localCache.entries + .filter { it.value.expiresAt?.isBefore(now) ?: false } + .map { it.key } + + expiredKeys.forEach { localCache.remove(it) } + + if (expiredKeys.isNotEmpty()) { + logger.debug("Removed ${expiredKeys.size} expired entries from local cache") + } + } + + /** + * Periodically synchronize dirty keys when connected. + */ + @Scheduled(fixedDelayString = "\${redis.sync-interval:300000}") + fun scheduledSync() { + if (isConnected() && dirtyKeys.isNotEmpty()) { + synchronize(null) + } + } +} diff --git a/infrastructure/cache/redis-cache/src/test/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCacheTest.kt b/infrastructure/cache/redis-cache/src/test/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCacheTest.kt new file mode 100644 index 00000000..ac583c08 --- /dev/null +++ b/infrastructure/cache/redis-cache/src/test/kotlin/at/mocode/infrastructure/cache/redis/RedisDistributedCacheTest.kt @@ -0,0 +1,198 @@ +package at.mocode.infrastructure.cache.redis + +import at.mocode.infrastructure.cache.api.CacheConfiguration +import at.mocode.infrastructure.cache.api.CacheSerializer +import at.mocode.infrastructure.cache.api.ConnectionState +import at.mocode.infrastructure.cache.api.DefaultCacheConfiguration +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.StringRedisSerializer +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.utility.DockerImageName +import java.time.Duration +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Testcontainers +class RedisDistributedCacheTest { + + companion object { + @Container + val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) + } + + private lateinit var redisTemplate: RedisTemplate + private lateinit var serializer: CacheSerializer + private lateinit var config: CacheConfiguration + private lateinit var cache: RedisDistributedCache + + @BeforeEach + fun setUp() { + val redisPort = redisContainer.getMappedPort(6379) + val redisHost = redisContainer.host + + val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort) + val connectionFactory = LettuceConnectionFactory(redisConfig) + connectionFactory.afterPropertiesSet() + + redisTemplate = RedisTemplate().apply { + setConnectionFactory(connectionFactory) + keySerializer = StringRedisSerializer() + afterPropertiesSet() + } + + serializer = JacksonCacheSerializer() + config = DefaultCacheConfiguration( + keyPrefix = "test:", + offlineModeEnabled = true + ) + + cache = RedisDistributedCache(redisTemplate, serializer, config) + + // Clear the cache before each test + cache.clear() + } + + @AfterEach + fun tearDown() { + cache.clear() + } + + @Test + fun `test basic cache operations`() { + // Set a value + cache.set("key1", "value1") + + // Get the value + val value = cache.get("key1", String::class.java) + assertEquals("value1", value) + + // Check if the key exists + assertTrue(cache.exists("key1")) + + // Delete the key + cache.delete("key1") + + // Verify it's gone + assertFalse(cache.exists("key1")) + assertNull(cache.get("key1", String::class.java)) + } + + @Test + fun `test cache with TTL`() { + // Set a value with a short TTL + cache.set("key2", "value2", Duration.ofMillis(100)) + + // Verify it exists + assertTrue(cache.exists("key2")) + assertEquals("value2", cache.get("key2", String::class.java)) + + // Wait for it to expire + Thread.sleep(200) + + // Verify it's gone + assertFalse(cache.exists("key2")) + assertNull(cache.get("key2", String::class.java)) + } + + @Test + fun `test batch operations`() { + // Set multiple values + val entries = mapOf( + "batch1" to "value1", + "batch2" to "value2", + "batch3" to "value3" + ) + cache.multiSet(entries) + + // Get multiple values + val values = cache.multiGet(listOf("batch1", "batch2", "batch3"), String::class.java) + assertEquals(3, values.size) + assertEquals("value1", values["batch1"]) + assertEquals("value2", values["batch2"]) + assertEquals("value3", values["batch3"]) + + // Delete multiple values + cache.multiDelete(listOf("batch1", "batch3")) + + // Verify they're gone + val remainingValues = cache.multiGet(listOf("batch1", "batch2", "batch3"), String::class.java) + assertEquals(1, remainingValues.size) + assertNull(remainingValues["batch1"]) + assertEquals("value2", remainingValues["batch2"]) + assertNull(remainingValues["batch3"]) + } + + @Test + fun `test offline capability`() { + // Set a value + cache.set("offline1", "value1") + + // Simulate going offline by stopping the Redis container + redisContainer.stop() + + // Verify connection state is DISCONNECTED + assertEquals(ConnectionState.DISCONNECTED, cache.getConnectionState()) + + // We should still be able to get the value from local cache + assertEquals("value1", cache.get("offline1", String::class.java)) + + // Set a new value while offline + cache.set("offline2", "value2") + + // Verify it's marked as dirty + assertTrue(cache.getDirtyKeys().contains("offline2")) + + // Start Redis again + redisContainer.start() + + // Manually trigger synchronization + cache.synchronize() + + // Verify connection state is CONNECTED + assertEquals(ConnectionState.CONNECTED, cache.getConnectionState()) + + // Verify the value set while offline is now in Redis + assertEquals("value2", cache.get("offline2", String::class.java)) + + // Verify it's no longer marked as dirty + assertFalse(cache.getDirtyKeys().contains("offline2")) + } + + @Test + fun `test complex objects`() { + // Create a complex object + val person = Person("John Doe", 30, listOf("Reading", "Hiking")) + + // Store it in the cache + cache.set("person1", person) + + // Retrieve it + val retrievedPerson = cache.get("person1", Person::class.java) + + // Verify it's the same + assertNotNull(retrievedPerson) + assertEquals("John Doe", retrievedPerson.name) + assertEquals(30, retrievedPerson.age) + assertEquals(2, retrievedPerson.hobbies.size) + assertTrue(retrievedPerson.hobbies.contains("Reading")) + assertTrue(retrievedPerson.hobbies.contains("Hiking")) + } + + // Test data class + data class Person( + val name: String, + val age: Int, + val hobbies: List + ) +} diff --git a/infrastructure/event-store/event-store-api/build.gradle.kts b/infrastructure/event-store/event-store-api/build.gradle.kts index df85cd9d..15b432b5 100644 --- a/infrastructure/event-store/event-store-api/build.gradle.kts +++ b/infrastructure/event-store/event-store-api/build.gradle.kts @@ -3,6 +3,9 @@ plugins { } dependencies { + // Apply platform BOM for version management + implementation(platform(projects.platform.platformBom)) + implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) diff --git a/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventSerializer.kt b/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventSerializer.kt new file mode 100644 index 00000000..1f257c11 --- /dev/null +++ b/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventSerializer.kt @@ -0,0 +1,76 @@ +package at.mocode.infrastructure.eventstore.api + +import at.mocode.core.domain.event.DomainEvent +import java.util.UUID + +/** + * Interface for serializing and deserializing domain events. + */ +interface EventSerializer { + /** + * Serializes a domain event to a map of strings to strings. + * This format is suitable for storage in Redis Streams. + * + * @param event The event to serialize + * @return A map of strings to strings representing the event + */ + fun serialize(event: DomainEvent): Map + + /** + * Deserializes a map of strings to strings to a domain event. + * + * @param data The map of strings to strings to deserialize + * @return The deserialized domain event + */ + fun deserialize(data: Map): DomainEvent + + /** + * Gets the type of a domain event. + * This is used to determine the type of event when deserializing. + * + * @param event The event to get the type of + * @return The type of the event as a string + */ + fun getEventType(event: DomainEvent): String + + /** + * Gets the type of a domain event from a serialized map. + * + * @param data The serialized event data + * @return The type of the event as a string + */ + fun getEventType(data: Map): String + + /** + * Registers a domain event class with the serializer. + * This is used to map event types to their corresponding classes. + * + * @param eventClass The class of the event to register + * @param eventType The type of the event as a string + */ + fun registerEventType(eventClass: Class, eventType: String) + + /** + * Gets the aggregate ID from a serialized event. + * + * @param data The serialized event data + * @return The aggregate ID + */ + fun getAggregateId(data: Map): UUID + + /** + * Gets the event ID from a serialized event. + * + * @param data The serialized event data + * @return The event ID + */ + fun getEventId(data: Map): UUID + + /** + * Gets the version from a serialized event. + * + * @param data The serialized event data + * @return The version + */ + fun getVersion(data: Map): Long +} diff --git a/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventStore.kt b/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventStore.kt new file mode 100644 index 00000000..4ce87b90 --- /dev/null +++ b/infrastructure/event-store/event-store-api/src/main/kotlin/at/mocode/infrastructure/eventstore/api/EventStore.kt @@ -0,0 +1,99 @@ +package at.mocode.infrastructure.eventstore.api + +import at.mocode.core.domain.event.DomainEvent +import java.util.UUID + +/** + * Interface for an event store that persists domain events. + */ +interface EventStore { + /** + * Appends an event to the event store. + * + * @param event The event to append + * @param streamId The ID of the event stream (typically the aggregate ID) + * @param expectedVersion The expected version of the stream (for optimistic concurrency) + * @return The new version of the stream + * @throws ConcurrencyException if the expected version doesn't match the actual version + */ + fun appendToStream(event: DomainEvent, streamId: UUID, expectedVersion: Long): Long + + /** + * Appends multiple events to the event store. + * + * @param events The events to append + * @param streamId The ID of the event stream (typically the aggregate ID) + * @param expectedVersion The expected version of the stream (for optimistic concurrency) + * @return The new version of the stream + * @throws ConcurrencyException if the expected version doesn't match the actual version + */ + fun appendToStream(events: List, streamId: UUID, expectedVersion: Long): Long + + /** + * Reads events from a stream. + * + * @param streamId The ID of the event stream to read from + * @param fromVersion The version to start reading from (inclusive) + * @param toVersion The version to read to (inclusive), or null to read all events + * @return The events in the stream + */ + fun readFromStream(streamId: UUID, fromVersion: Long = 0, toVersion: Long? = null): List + + /** + * Reads all events from all streams. + * + * @param fromPosition The position to start reading from (inclusive) + * @param maxCount The maximum number of events to read, or null to read all events + * @return The events in all streams + */ + fun readAllEvents(fromPosition: Long = 0, maxCount: Int? = null): List + + /** + * Gets the current version of a stream. + * + * @param streamId The ID of the event stream + * @return The current version of the stream, or -1 if the stream doesn't exist + */ + fun getStreamVersion(streamId: UUID): Long + + /** + * Subscribes to events from a specific stream. + * + * @param streamId The ID of the event stream to subscribe to + * @param fromVersion The version to start subscribing from (inclusive) + * @param handler The handler to call for each event + * @return A subscription that can be used to unsubscribe + */ + fun subscribeToStream(streamId: UUID, fromVersion: Long = 0, handler: (DomainEvent) -> Unit): Subscription + + /** + * Subscribes to all events from all streams. + * + * @param fromPosition The position to start subscribing from (inclusive) + * @param handler The handler to call for each event + * @return A subscription that can be used to unsubscribe + */ + fun subscribeToAll(fromPosition: Long = 0, handler: (DomainEvent) -> Unit): Subscription +} + +/** + * Interface for a subscription to an event stream. + */ +interface Subscription { + /** + * Unsubscribes from the event stream. + */ + fun unsubscribe() + + /** + * Checks if the subscription is active. + * + * @return true if the subscription is active, false otherwise + */ + fun isActive(): Boolean +} + +/** + * Exception thrown when there is a concurrency conflict in the event store. + */ +class ConcurrencyException(message: String) : RuntimeException(message) diff --git a/infrastructure/event-store/redis-event-store/build.gradle.kts b/infrastructure/event-store/redis-event-store/build.gradle.kts index 3a5d5d37..6146f532 100644 --- a/infrastructure/event-store/redis-event-store/build.gradle.kts +++ b/infrastructure/event-store/redis-event-store/build.gradle.kts @@ -4,13 +4,20 @@ plugins { } dependencies { + // Apply platform BOM for version management + implementation(platform(projects.platform.platformBom)) + implementation(projects.infrastructure.eventStore.eventStoreApi) + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("io.lettuce:lettuce-core") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("javax.annotation:javax.annotation-api:1.3.2") testImplementation(projects.platform.platformTesting) testImplementation("org.testcontainers:testcontainers") + testImplementation("org.testcontainers:junit-jupiter") } diff --git a/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializer.kt b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializer.kt new file mode 100644 index 00000000..c7f00c58 --- /dev/null +++ b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/JacksonEventSerializer.kt @@ -0,0 +1,119 @@ +package at.mocode.infrastructure.eventstore.redis + +import at.mocode.core.domain.event.DomainEvent +import at.mocode.infrastructure.eventstore.api.EventSerializer +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import org.slf4j.LoggerFactory +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Jackson-based implementation of EventSerializer. + */ +class JacksonEventSerializer : EventSerializer { + private val logger = LoggerFactory.getLogger(JacksonEventSerializer::class.java) + + private val objectMapper: ObjectMapper = ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + registerModule(JavaTimeModule()) + disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + } + + // Maps from event type to event class + private val eventTypeToClass = ConcurrentHashMap>() + + // Maps from event class to event type + private val eventClassToType = ConcurrentHashMap, String>() + + // Standard field names in serialized events + companion object { + const val EVENT_TYPE_FIELD = "eventType" + const val EVENT_ID_FIELD = "eventId" + const val AGGREGATE_ID_FIELD = "aggregateId" + const val VERSION_FIELD = "version" + const val TIMESTAMP_FIELD = "timestamp" + const val EVENT_DATA_FIELD = "eventData" + } + + override fun serialize(event: DomainEvent): Map { + val eventType = getEventType(event) + + // Register the event type if not already registered + if (!eventClassToType.containsKey(event.javaClass)) { + registerEventType(event.javaClass, eventType) + } + + // Serialize the event data + val eventData = objectMapper.writeValueAsString(event) + + // Create a map with the event metadata and data + return mapOf( + EVENT_TYPE_FIELD to eventType, + EVENT_ID_FIELD to event.eventId.toString(), + AGGREGATE_ID_FIELD to event.aggregateId.toString(), + VERSION_FIELD to event.version.toString(), + TIMESTAMP_FIELD to event.timestamp.toString(), + EVENT_DATA_FIELD to eventData + ) + } + + override fun deserialize(data: Map): DomainEvent { + val eventType = getEventType(data) + val eventClass = eventTypeToClass[eventType] + ?: throw IllegalArgumentException("Unknown event type: $eventType") + + val eventData = data[EVENT_DATA_FIELD] + ?: throw IllegalArgumentException("Event data is missing") + + return objectMapper.readValue(eventData, eventClass) + } + + override fun getEventType(event: DomainEvent): String { + // Use the registered type if available + val registeredType = eventClassToType[event.javaClass] + if (registeredType != null) { + return registeredType + } + + // Otherwise, use the simple class name + val type = event.javaClass.simpleName + registerEventType(event.javaClass, type) + return type + } + + override fun getEventType(data: Map): String { + return data[EVENT_TYPE_FIELD] + ?: throw IllegalArgumentException("Event type is missing") + } + + override fun registerEventType(eventClass: Class, eventType: String) { + eventTypeToClass[eventType] = eventClass + eventClassToType[eventClass] = eventType + logger.debug("Registered event type: $eventType for class: ${eventClass.name}") + } + + override fun getAggregateId(data: Map): UUID { + val aggregateIdStr = data[AGGREGATE_ID_FIELD] + ?: throw IllegalArgumentException("Aggregate ID is missing") + + return UUID.fromString(aggregateIdStr) + } + + override fun getEventId(data: Map): UUID { + val eventIdStr = data[EVENT_ID_FIELD] + ?: throw IllegalArgumentException("Event ID is missing") + + return UUID.fromString(eventIdStr) + } + + override fun getVersion(data: Map): Long { + val versionStr = data[VERSION_FIELD] + ?: throw IllegalArgumentException("Version is missing") + + return versionStr.toLong() + } +} diff --git a/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumer.kt b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumer.kt new file mode 100644 index 00000000..65c612a6 --- /dev/null +++ b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventConsumer.kt @@ -0,0 +1,314 @@ +package at.mocode.infrastructure.eventstore.redis + +import at.mocode.core.domain.event.DomainEvent +import at.mocode.infrastructure.eventstore.api.EventSerializer +import org.slf4j.LoggerFactory +import org.springframework.data.domain.Range +import org.springframework.data.redis.connection.stream.Consumer +import org.springframework.data.redis.connection.stream.MapRecord +import org.springframework.data.redis.connection.stream.ReadOffset +import org.springframework.data.redis.connection.stream.StreamOffset +import org.springframework.data.redis.connection.stream.StreamReadOptions +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.scheduling.annotation.Scheduled +import java.time.Duration +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import javax.annotation.PostConstruct +import javax.annotation.PreDestroy + +/** + * Consumer for Redis Streams that processes events using consumer groups. + */ +class RedisEventConsumer( + private val redisTemplate: StringRedisTemplate, + private val serializer: EventSerializer, + private val properties: RedisEventStoreProperties +) { + private val logger = LoggerFactory.getLogger(RedisEventConsumer::class.java) + + // Event handlers registered for specific event types + private val eventTypeHandlers = ConcurrentHashMap Unit>>() + + // Event handlers registered for all events + private val allEventHandlers = CopyOnWriteArrayList<(DomainEvent) -> Unit>() + + // Flag to indicate if the consumer is running + private var running = false + + /** + * Initializes the consumer. + */ + @PostConstruct + fun init() { + if (properties.createConsumerGroupIfNotExists) { + createConsumerGroupsIfNotExist() + } + } + + /** + * Stops the consumer. + */ + @PreDestroy + fun shutdown() { + running = false + } + + /** + * Registers a handler for a specific event type. + * + * @param eventType The type of event to handle + * @param handler The handler to call when an event of the specified type is received + */ + fun registerEventHandler(eventType: String, handler: (DomainEvent) -> Unit) { + eventTypeHandlers.computeIfAbsent(eventType) { CopyOnWriteArrayList() }.add(handler) + logger.debug("Registered handler for event type: $eventType") + } + + /** + * Registers a handler for all events. + * + * @param handler The handler to call when any event is received + */ + fun registerAllEventsHandler(handler: (DomainEvent) -> Unit) { + allEventHandlers.add(handler) + logger.debug("Registered handler for all events") + } + + /** + * Unregisters a handler for a specific event type. + * + * @param eventType The type of event + * @param handler The handler to unregister + */ + fun unregisterEventHandler(eventType: String, handler: (DomainEvent) -> Unit) { + eventTypeHandlers[eventType]?.remove(handler) + logger.debug("Unregistered handler for event type: $eventType") + } + + /** + * Unregisters a handler for all events. + * + * @param handler The handler to unregister + */ + fun unregisterAllEventsHandler(handler: (DomainEvent) -> Unit) { + allEventHandlers.remove(handler) + logger.debug("Unregistered handler for all events") + } + + /** + * Creates consumer groups for all streams if they don't exist. + */ + private fun createConsumerGroupsIfNotExist() { + try { + // Create consumer group for the all events stream + val allEventsStreamKey = getAllEventsStreamKey() + createConsumerGroupIfNotExists(allEventsStreamKey) + + // Get all stream keys + val streamKeys = redisTemplate.keys("${properties.streamPrefix}*") + + // Create consumer groups for all streams + for (streamKey in streamKeys) { + if (streamKey != allEventsStreamKey) { + createConsumerGroupIfNotExists(streamKey) + } + } + } catch (e: Exception) { + logger.error("Error creating consumer groups: ${e.message}", e) + } + } + + /** + * Creates a consumer group for a stream if it doesn't exist. + * + * @param streamKey The key of the stream + */ + private fun createConsumerGroupIfNotExists(streamKey: String) { + try { + // Check if the stream exists + if (!redisTemplate.hasKey(streamKey)) { + // Create the stream with an empty message + redisTemplate.opsForStream() + .add(streamKey, mapOf("init" to "init")) + logger.debug("Created stream: $streamKey") + } + + // Create the consumer group + redisTemplate.opsForStream() + .createGroup(streamKey, properties.consumerGroup) + + logger.debug("Created consumer group ${properties.consumerGroup} for stream: $streamKey") + } catch (e: Exception) { + // Ignore if the consumer group already exists + val message = e.message + if (message == null || !message.contains("BUSYGROUP")) { + logger.error("Error creating consumer group for stream $streamKey: ${e.message}", e) + } + } + } + + /** + * Periodically polls for new events from all streams. + */ + @Scheduled(fixedDelayString = "\${redis.event-store.poll-interval:100}") + fun pollEvents() { + if (!running) { + running = true + } + + try { + // Poll the all events stream + pollStream(getAllEventsStreamKey()) + + // Poll individual streams + val streamKeys = redisTemplate.keys("${properties.streamPrefix}*") + for (streamKey in streamKeys) { + if (streamKey != getAllEventsStreamKey()) { + pollStream(streamKey) + } + } + + // Claim pending messages that have been idle for too long + claimPendingMessages() + } catch (e: Exception) { + logger.error("Error polling events: ${e.message}", e) + } + } + + /** + * Polls a stream for new events. + * + * @param streamKey The key of the stream to poll + */ + private fun pollStream(streamKey: String) { + try { + // Read new messages from the stream + val options = StreamReadOptions.empty() + .count(properties.maxBatchSize.toLong()) + .block(properties.pollTimeout) + + val records = redisTemplate.opsForStream() + .read( + Consumer.from(properties.consumerGroup, properties.consumerName), + options, + StreamOffset.create(streamKey, ReadOffset.lastConsumed()) + ) + + // Process the records + if (records != null) { + for (record in records) { + processRecord(record) + } + } + } catch (e: Exception) { + // Ignore if the stream doesn't exist or the consumer group doesn't exist + val message = e.message + if (message == null || !message.contains("NOGROUP")) { + logger.error("Error polling stream $streamKey: ${e.message}", e) + } + } + } + + /** + * Claims pending messages that have been idle for too long. + */ + private fun claimPendingMessages() { + try { + // Get all stream keys + val streamKeys = redisTemplate.keys("${properties.streamPrefix}*") + + for (streamKey in streamKeys) { + // Get pending messages summary + val pendingSummary = redisTemplate.opsForStream() + .pending(streamKey, properties.consumerGroup) + + if (pendingSummary != null && pendingSummary.totalPendingMessages > 0) { + // Get pending messages with details + val pendingMessages = redisTemplate.opsForStream() + .pending( + streamKey, + Consumer.from(properties.consumerGroup, properties.consumerName), + Range.unbounded(), + properties.maxBatchSize.toLong() + ) + + if (pendingMessages.size() > 0) { + // Extract message IDs and convert to array + val messageIdsList = pendingMessages.map { it.id }.toList() + + if (messageIdsList.isNotEmpty()) { + // Convert to array for the spread operator + val messageIds = messageIdsList.toTypedArray() + + // Claim messages that have been idle for too long + val records = redisTemplate.opsForStream() + .claim( + streamKey, + properties.consumerGroup, + properties.consumerName, + properties.claimIdleTimeout, + *messageIds + ) + + // Process the claimed records + for (record in records) { + processRecord(record) + } + } + } + } + } + } catch (e: Exception) { + logger.error("Error claiming pending messages: ${e.message}", e) + } + } + + /** + * Processes a record from a stream. + * + * @param record The record to process + */ + private fun processRecord(record: MapRecord) { + try { + val data = record.value + val event = serializer.deserialize(data) + val eventType = serializer.getEventType(data) + + // Call handlers for the specific event type + eventTypeHandlers[eventType]?.forEach { handler -> + try { + handler(event) + } catch (e: Exception) { + logger.error("Error handling event of type $eventType: ${e.message}", e) + } + } + + // Call handlers for all events + allEventHandlers.forEach { handler -> + try { + handler(event) + } catch (e: Exception) { + logger.error("Error handling event: ${e.message}", e) + } + } + + // Acknowledge the message + redisTemplate.opsForStream() + .acknowledge(properties.consumerGroup, record) + + } catch (e: Exception) { + logger.error("Error processing record: ${e.message}", e) + } + } + + /** + * Gets the Redis key for the all events stream. + * + * @return The Redis key for the all events stream + */ + private fun getAllEventsStreamKey(): String { + return "${properties.streamPrefix}${properties.allEventsStream}" + } +} diff --git a/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt new file mode 100644 index 00000000..2cc65767 --- /dev/null +++ b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStore.kt @@ -0,0 +1,336 @@ +package at.mocode.infrastructure.eventstore.redis + +import at.mocode.core.domain.event.DomainEvent +import at.mocode.infrastructure.eventstore.api.ConcurrencyException +import at.mocode.infrastructure.eventstore.api.EventSerializer +import at.mocode.infrastructure.eventstore.api.EventStore +import at.mocode.infrastructure.eventstore.api.Subscription +import org.slf4j.LoggerFactory +import org.springframework.data.redis.connection.stream.MapRecord +import org.springframework.data.redis.connection.stream.ObjectRecord +import org.springframework.data.redis.connection.stream.ReadOffset +import org.springframework.data.redis.connection.stream.Record +import org.springframework.data.redis.connection.stream.StreamOffset +import org.springframework.data.redis.connection.stream.StreamReadOptions +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.stream.StreamListener +import org.springframework.data.redis.stream.StreamMessageListenerContainer +import org.springframework.data.redis.stream.Subscription as RedisSubscription +import java.time.Duration +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Redis Streams implementation of EventStore. + */ +class RedisEventStore( + private val redisTemplate: StringRedisTemplate, + private val serializer: EventSerializer, + private val properties: RedisEventStoreProperties +) : EventStore { + private val logger = LoggerFactory.getLogger(RedisEventStore::class.java) + + // Cache of stream versions to avoid reading from Redis for every append + private val streamVersionCache = ConcurrentHashMap() + + // Active subscriptions + private val subscriptions = ConcurrentHashMap() + + // Listener containers for subscriptions + private val listenerContainers = ConcurrentHashMap>>() + + override fun appendToStream(event: DomainEvent, streamId: UUID, expectedVersion: Long): Long { + return appendToStream(listOf(event), streamId, expectedVersion) + } + + override fun appendToStream(events: List, streamId: UUID, expectedVersion: Long): Long { + if (events.isEmpty()) { + return expectedVersion + } + + // Check if all events belong to the same aggregate + val aggregateId = events.first().aggregateId + if (events.any { it.aggregateId != aggregateId }) { + throw IllegalArgumentException("All events must belong to the same aggregate") + } + + // Check if the stream ID matches the aggregate ID + if (streamId != aggregateId) { + throw IllegalArgumentException("Stream ID must match aggregate ID") + } + + // Get the current version of the stream + val currentVersion = getStreamVersion(streamId) + + // Check for concurrency conflicts + if (expectedVersion != currentVersion) { + throw ConcurrencyException( + "Concurrency conflict: expected version $expectedVersion but got $currentVersion" + ) + } + + // Append events to the stream + var newVersion = currentVersion + val streamKey = getStreamKey(streamId) + + for (event in events) { + newVersion++ + + // Ensure the event has the correct version + if (event.version != newVersion) { + throw IllegalArgumentException( + "Event version ${event.version} does not match expected version $newVersion" + ) + } + + // Serialize the event + val eventData = serializer.serialize(event) + + // Append to the stream + val result = redisTemplate.opsForStream() + .add(streamKey, eventData) + + logger.debug("Appended event ${event.eventId} to stream $streamId with ID $result") + + // Also append to the all events stream + val allEventsStreamKey = getAllEventsStreamKey() + redisTemplate.opsForStream() + .add(allEventsStreamKey, eventData) + } + + // Update the version cache + streamVersionCache[streamId] = newVersion + + return newVersion + } + + override fun readFromStream(streamId: UUID, fromVersion: Long, toVersion: Long?): List { + val streamKey = getStreamKey(streamId) + + // Check if the stream exists + if (!redisTemplate.hasKey(streamKey)) { + return emptyList() + } + + // Calculate the range of events to read + val startOffset = if (fromVersion <= 0) ReadOffset.from("0") else ReadOffset.from("$fromVersion") + val endOffset = toVersion?.let { "=$it" } ?: "+" + + // Read events from the stream + val options = StreamReadOptions.empty() + .count(toVersion?.let { (it - fromVersion + 1).toLong() } ?: Long.MAX_VALUE) + + val records = redisTemplate.opsForStream() + .read(options, StreamOffset.create(streamKey, startOffset)) + + // Deserialize events + return records?.mapNotNull { record -> + try { + val data = record.value + serializer.deserialize(data) + } catch (e: Exception) { + logger.error("Error deserializing event from stream $streamId: ${e.message}", e) + null + } + } ?: emptyList() + } + + override fun readAllEvents(fromPosition: Long, maxCount: Int?): List { + val streamKey = getAllEventsStreamKey() + + // Check if the stream exists + if (!redisTemplate.hasKey(streamKey)) { + return emptyList() + } + + // Calculate the range of events to read + val startOffset = if (fromPosition <= 0) ReadOffset.from("0") else ReadOffset.from("$fromPosition") + + // Read events from the stream + val options = StreamReadOptions.empty() + .count(maxCount?.toLong() ?: Long.MAX_VALUE) + + val records = redisTemplate.opsForStream() + .read(options, StreamOffset.create(streamKey, startOffset)) + + // Deserialize events + return records?.mapNotNull { record -> + try { + val data = record.value + serializer.deserialize(data) + } catch (e: Exception) { + logger.error("Error deserializing event from all events stream: ${e.message}", e) + null + } + } ?: emptyList() + } + + override fun getStreamVersion(streamId: UUID): Long { + // Check the cache first + val cachedVersion = streamVersionCache[streamId] + if (cachedVersion != null) { + return cachedVersion + } + + val streamKey = getStreamKey(streamId) + + // Check if the stream exists + if (!redisTemplate.hasKey(streamKey)) { + return -1 + } + + // Get the last event from the stream + val options = StreamReadOptions.empty().count(1) + val records = redisTemplate.opsForStream() + .read(options, StreamOffset.create(streamKey, ReadOffset.latest())) + + if (records == null || records.isEmpty()) { + return -1 + } + + // Get the version from the last event + val lastEvent = records.first() + val version = serializer.getVersion(lastEvent.value) + + // Update the cache + streamVersionCache[streamId] = version + + return version + } + + override fun subscribeToStream( + streamId: UUID, + fromVersion: Long, + handler: (DomainEvent) -> Unit + ): Subscription { + val streamKey = getStreamKey(streamId) + + // Create a unique ID for this subscription + val subscriptionId = UUID.randomUUID() + + // Create a listener for the stream + val listener = StreamListener> { record -> + try { + val data = record.value + val event = serializer.deserialize(data) + handler(event) + } catch (e: Exception) { + logger.error("Error handling event from stream $streamId: ${e.message}", e) + } + } + + // Create a listener container + val container = StreamMessageListenerContainer + .create(redisTemplate.connectionFactory!!) + + // Start from the specified version + val readOffset = if (fromVersion <= 0) ReadOffset.latest() else ReadOffset.from("$fromVersion") + + // Create a subscription + val subscription = container.receive( + StreamOffset.create(streamKey, readOffset), + listener + ) + + // Start the container + container.start() + + // Store the subscription and container + subscriptions[subscriptionId] = subscription + listenerContainers[subscriptionId] = container + + // Return a subscription object + return object : Subscription { + private val active = AtomicBoolean(true) + + override fun unsubscribe() { + if (active.compareAndSet(true, false)) { + subscription.cancel() + container.stop() + subscriptions.remove(subscriptionId) + listenerContainers.remove(subscriptionId) + } + } + + override fun isActive(): Boolean { + return active.get() + } + } + } + + override fun subscribeToAll(fromPosition: Long, handler: (DomainEvent) -> Unit): Subscription { + val streamKey = getAllEventsStreamKey() + + // Create a unique ID for this subscription + val subscriptionId = UUID.randomUUID() + + // Create a listener for the stream + val listener = StreamListener> { record -> + try { + val data = record.value + val event = serializer.deserialize(data) + handler(event) + } catch (e: Exception) { + logger.error("Error handling event from all events stream: ${e.message}", e) + } + } + + // Create a listener container + val container = StreamMessageListenerContainer + .create(redisTemplate.connectionFactory!!) + + // Start from the specified position + val readOffset = if (fromPosition <= 0) ReadOffset.latest() else ReadOffset.from("$fromPosition") + + // Create a subscription + val subscription = container.receive( + StreamOffset.create(streamKey, readOffset), + listener + ) + + // Start the container + container.start() + + // Store the subscription and container + subscriptions[subscriptionId] = subscription + listenerContainers[subscriptionId] = container + + // Return a subscription object + return object : Subscription { + private val active = AtomicBoolean(true) + + override fun unsubscribe() { + if (active.compareAndSet(true, false)) { + subscription.cancel() + container.stop() + subscriptions.remove(subscriptionId) + listenerContainers.remove(subscriptionId) + } + } + + override fun isActive(): Boolean { + return active.get() + } + } + } + + /** + * Gets the Redis key for a stream. + * + * @param streamId The ID of the stream + * @return The Redis key for the stream + */ + private fun getStreamKey(streamId: UUID): String { + return "${properties.streamPrefix}$streamId" + } + + /** + * Gets the Redis key for the all events stream. + * + * @return The Redis key for the all events stream + */ + private fun getAllEventsStreamKey(): String { + return "${properties.streamPrefix}${properties.allEventsStream}" + } +} diff --git a/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfiguration.kt b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfiguration.kt new file mode 100644 index 00000000..347dd756 --- /dev/null +++ b/infrastructure/event-store/redis-event-store/src/main/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreConfiguration.kt @@ -0,0 +1,136 @@ +package at.mocode.infrastructure.eventstore.redis + +import at.mocode.infrastructure.eventstore.api.EventSerializer +import at.mocode.infrastructure.eventstore.api.EventStore +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisPassword +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.StringRedisTemplate +import java.time.Duration + +/** + * Redis event store properties. + */ +@ConfigurationProperties(prefix = "redis.event-store") +data class RedisEventStoreProperties( + val host: String = "localhost", + val port: Int = 6379, + val password: String? = null, + val database: Int = 0, + val connectionTimeout: Long = 2000, + val readTimeout: Long = 2000, + val usePooling: Boolean = true, + val maxPoolSize: Int = 8, + val minPoolSize: Int = 2, + val consumerGroup: String = "event-processors", + val consumerName: String = "event-consumer", + val streamPrefix: String = "event-stream:", + val allEventsStream: String = "all-events", + val claimIdleTimeout: Duration = Duration.ofMinutes(1), + val pollTimeout: Duration = Duration.ofMillis(100), + val maxBatchSize: Int = 100, + val createConsumerGroupIfNotExists: Boolean = true +) + +/** + * Spring configuration for Redis event store. + */ +@Configuration +@EnableConfigurationProperties(RedisEventStoreProperties::class) +class RedisEventStoreConfiguration { + + /** + * Creates a Redis connection factory for the event store. + * + * @param properties Redis event store properties + * @return Redis connection factory + */ + @Bean + @ConditionalOnMissingBean(name = ["eventStoreRedisConnectionFactory"]) + fun eventStoreRedisConnectionFactory(properties: RedisEventStoreProperties): RedisConnectionFactory { + val config = RedisStandaloneConfiguration().apply { + hostName = properties.host + port = properties.port + properties.password?.let { password = RedisPassword.of(it) } + database = properties.database + } + + return LettuceConnectionFactory(config).apply { + // Configure connection timeouts + afterPropertiesSet() + } + } + + /** + * Creates a Redis template for the event store. + * + * @param connectionFactory Redis connection factory + * @return Redis template + */ + @Bean + @ConditionalOnMissingBean(name = ["eventStoreRedisTemplate"]) + fun eventStoreRedisTemplate( + @org.springframework.beans.factory.annotation.Qualifier("eventStoreRedisConnectionFactory") + connectionFactory: RedisConnectionFactory + ): StringRedisTemplate { + return StringRedisTemplate().apply { + setConnectionFactory(connectionFactory) + afterPropertiesSet() + } + } + + /** + * Creates an event serializer. + * + * @return Event serializer + */ + @Bean + @ConditionalOnMissingBean + fun eventSerializer(): EventSerializer { + return JacksonEventSerializer() + } + + /** + * Creates a Redis event store. + * + * @param redisTemplate Redis template + * @param eventSerializer Event serializer + * @param properties Redis event store properties + * @return Event store + */ + @Bean + @ConditionalOnMissingBean + fun eventStore( + @org.springframework.beans.factory.annotation.Qualifier("eventStoreRedisTemplate") + redisTemplate: StringRedisTemplate, + eventSerializer: EventSerializer, + properties: RedisEventStoreProperties + ): EventStore { + return RedisEventStore(redisTemplate, eventSerializer, properties) + } + + /** + * Creates a Redis event consumer. + * + * @param redisTemplate Redis template + * @param eventSerializer Event serializer + * @param properties Redis event store properties + * @return Event consumer + */ + @Bean + @ConditionalOnMissingBean + fun eventConsumer( + @org.springframework.beans.factory.annotation.Qualifier("eventStoreRedisTemplate") + redisTemplate: StringRedisTemplate, + eventSerializer: EventSerializer, + properties: RedisEventStoreProperties + ): RedisEventConsumer { + return RedisEventConsumer(redisTemplate, eventSerializer, properties) + } +} diff --git a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt new file mode 100644 index 00000000..483338aa --- /dev/null +++ b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreIntegrationTest.kt @@ -0,0 +1,296 @@ +package at.mocode.infrastructure.eventstore.redis + +import at.mocode.core.domain.event.BaseDomainEvent +import at.mocode.core.domain.event.DomainEvent +import at.mocode.infrastructure.eventstore.api.EventSerializer +import at.mocode.infrastructure.eventstore.api.EventStore +import at.mocode.infrastructure.eventstore.api.Subscription +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.StringRedisTemplate +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.utility.DockerImageName +import java.time.Duration +import java.time.Instant +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration tests for Redis Event Store. + * + * These tests verify the interaction between the Redis Event Store, Event Consumer, and Event Serializer + * in a more realistic scenario. + */ +@Testcontainers +class RedisEventStoreIntegrationTest { + + companion object { + @Container + val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) + } + + private lateinit var redisTemplate: StringRedisTemplate + private lateinit var serializer: EventSerializer + private lateinit var properties: RedisEventStoreProperties + private lateinit var eventStore: EventStore + private lateinit var eventConsumer: RedisEventConsumer + + @BeforeEach + fun setUp() { + val redisPort = redisContainer.getMappedPort(6379) + val redisHost = redisContainer.host + + val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort) + val connectionFactory = LettuceConnectionFactory(redisConfig) + connectionFactory.afterPropertiesSet() + + redisTemplate = StringRedisTemplate() + redisTemplate.setConnectionFactory(connectionFactory) + redisTemplate.afterPropertiesSet() + + serializer = JacksonEventSerializer() + + // Register test event types + serializer.registerEventType(TestCreatedEvent::class.java, "TestCreated") + serializer.registerEventType(TestUpdatedEvent::class.java, "TestUpdated") + + properties = RedisEventStoreProperties( + host = redisHost, + port = redisPort, + streamPrefix = "test-stream:", + allEventsStream = "all-events", + consumerGroup = "test-group", + consumerName = "test-consumer", + createConsumerGroupIfNotExists = true + ) + + eventStore = RedisEventStore(redisTemplate, serializer, properties) + eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties) + + // Clear all streams + val keys = redisTemplate.keys("${properties.streamPrefix}*") + if (keys != null && keys.isNotEmpty()) { + redisTemplate.delete(keys) + } + } + + @AfterEach + fun tearDown() { + // Clear all streams + val keys = redisTemplate.keys("${properties.streamPrefix}*") + if (keys != null && keys.isNotEmpty()) { + redisTemplate.delete(keys) + } + } + + @Test + fun `test event publishing and consuming with consumer groups`() { + // Create an aggregate ID + val aggregateId = UUID.randomUUID() + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Set up a latch to wait for events + val latch = CountDownLatch(2) + val receivedEvents = mutableListOf() + + // Register a handler for TestCreatedEvent + eventConsumer.registerEventHandler("TestCreated") { event -> + receivedEvents.add(event) + latch.countDown() + } + + // Register a handler for TestUpdatedEvent + eventConsumer.registerEventHandler("TestUpdated") { event -> + receivedEvents.add(event) + latch.countDown() + } + + // Initialize the consumer + eventConsumer.init() + + // Append events to the stream + eventStore.appendToStream(event1, aggregateId, -1) + eventStore.appendToStream(event2, aggregateId, 1) + + // Manually trigger event polling + eventConsumer.pollEvents() + + // Wait for events to be processed + assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events") + + // Verify that both events were received + assertEquals(2, receivedEvents.size) + + // Verify the first event + val receivedEvent1 = receivedEvents[0] as TestCreatedEvent + assertEquals(aggregateId, receivedEvent1.aggregateId) + assertEquals(1, receivedEvent1.version) + assertEquals("Test Entity", receivedEvent1.name) + + // Verify the second event + val receivedEvent2 = receivedEvents[1] as TestUpdatedEvent + assertEquals(aggregateId, receivedEvent2.aggregateId) + assertEquals(2, receivedEvent2.version) + assertEquals("Updated Test Entity", receivedEvent2.name) + + // Clean up + eventConsumer.shutdown() + } + + @Test + fun `test event subscription and publishing`() { + // Create an aggregate ID + val aggregateId = UUID.randomUUID() + + // Set up a latch to wait for events + val latch = CountDownLatch(2) + val receivedEvents = mutableListOf() + + // Subscribe to the stream + val subscription = eventStore.subscribeToStream(aggregateId) { event -> + receivedEvents.add(event) + latch.countDown() + } + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Append events to the stream + eventStore.appendToStream(event1, aggregateId, -1) + eventStore.appendToStream(event2, aggregateId, 1) + + // Wait for events to be received + assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events") + + // Verify that both events were received + assertEquals(2, receivedEvents.size) + + // Verify the first event + val receivedEvent1 = receivedEvents[0] as TestCreatedEvent + assertEquals(aggregateId, receivedEvent1.aggregateId) + assertEquals(1, receivedEvent1.version) + assertEquals("Test Entity", receivedEvent1.name) + + // Verify the second event + val receivedEvent2 = receivedEvents[1] as TestUpdatedEvent + assertEquals(aggregateId, receivedEvent2.aggregateId) + assertEquals(2, receivedEvent2.version) + assertEquals("Updated Test Entity", receivedEvent2.name) + + // Clean up + subscription.unsubscribe() + } + + @Test + fun `test multiple consumers with consumer groups`() { + // Create an aggregate ID + val aggregateId = UUID.randomUUID() + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Set up latches to wait for events + val latch1 = CountDownLatch(2) + val latch2 = CountDownLatch(2) + val receivedEvents1 = mutableListOf() + val receivedEvents2 = mutableListOf() + + // Create a second consumer with a different consumer name + val properties2 = properties.copy(consumerName = "test-consumer-2") + val eventConsumer2 = RedisEventConsumer(redisTemplate, serializer, properties2) + + // Register handlers for the first consumer + eventConsumer.registerAllEventsHandler { event -> + receivedEvents1.add(event) + latch1.countDown() + } + + // Register handlers for the second consumer + eventConsumer2.registerAllEventsHandler { event -> + receivedEvents2.add(event) + latch2.countDown() + } + + // Initialize the consumers + eventConsumer.init() + eventConsumer2.init() + + // Append events to the stream + eventStore.appendToStream(event1, aggregateId, -1) + eventStore.appendToStream(event2, aggregateId, 1) + + // Manually trigger event polling + eventConsumer.pollEvents() + eventConsumer2.pollEvents() + + // Wait for events to be processed by both consumers + assertTrue(latch1.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 1") + assertTrue(latch2.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 2") + + // Verify that both consumers received both events + assertEquals(2, receivedEvents1.size) + assertEquals(2, receivedEvents2.size) + + // Clean up + eventConsumer.shutdown() + eventConsumer2.shutdown() + } + + // Test event classes + class TestCreatedEvent( + override val eventId: UUID = UUID.randomUUID(), + override val timestamp: Instant = Instant.now(), + override val aggregateId: UUID, + override val version: Long, + val name: String + ) : BaseDomainEvent(eventId, timestamp, aggregateId, version) + + class TestUpdatedEvent( + override val eventId: UUID = UUID.randomUUID(), + override val timestamp: Instant = Instant.now(), + override val aggregateId: UUID, + override val version: Long, + val name: String + ) : BaseDomainEvent(eventId, timestamp, aggregateId, version) +} diff --git a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt new file mode 100644 index 00000000..d3d9b670 --- /dev/null +++ b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisEventStoreTest.kt @@ -0,0 +1,317 @@ +package at.mocode.infrastructure.eventstore.redis + +import at.mocode.core.domain.event.BaseDomainEvent +import at.mocode.core.domain.event.DomainEvent +import at.mocode.infrastructure.eventstore.api.ConcurrencyException +import at.mocode.infrastructure.eventstore.api.EventSerializer +import at.mocode.infrastructure.eventstore.api.Subscription +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.StringRedisTemplate +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.utility.DockerImageName +import java.time.Instant +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Testcontainers +class RedisEventStoreTest { + + companion object { + @Container + val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) + } + + private lateinit var redisTemplate: StringRedisTemplate + private lateinit var serializer: EventSerializer + private lateinit var properties: RedisEventStoreProperties + private lateinit var eventStore: RedisEventStore + + @BeforeEach + fun setUp() { + val redisPort = redisContainer.getMappedPort(6379) + val redisHost = redisContainer.host + + val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort) + val connectionFactory = LettuceConnectionFactory(redisConfig) + connectionFactory.afterPropertiesSet() + + redisTemplate = StringRedisTemplate() + redisTemplate.setConnectionFactory(connectionFactory) + redisTemplate.afterPropertiesSet() + + serializer = JacksonEventSerializer() + + // Register test event types + serializer.registerEventType(TestCreatedEvent::class.java, "TestCreated") + serializer.registerEventType(TestUpdatedEvent::class.java, "TestUpdated") + + properties = RedisEventStoreProperties( + host = redisHost, + port = redisPort, + streamPrefix = "test-stream:", + allEventsStream = "all-events" + ) + + eventStore = RedisEventStore(redisTemplate, serializer, properties) + + // Clear all streams + val keys = redisTemplate.keys("${properties.streamPrefix}*") + if (keys != null && keys.isNotEmpty()) { + redisTemplate.delete(keys) + } + } + + @AfterEach + fun tearDown() { + // Clear all streams + val keys = redisTemplate.keys("${properties.streamPrefix}*") + if (keys != null && keys.isNotEmpty()) { + redisTemplate.delete(keys) + } + } + + @Test + fun `test append and read events`() { + val aggregateId = UUID.randomUUID() + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Append events + val version1 = eventStore.appendToStream(event1, aggregateId, -1) + assertEquals(1, version1) + + val version2 = eventStore.appendToStream(event2, aggregateId, 1) + assertEquals(2, version2) + + // Read events + val events = eventStore.readFromStream(aggregateId) + assertEquals(2, events.size) + + val firstEvent = events[0] as TestCreatedEvent + assertEquals(aggregateId, firstEvent.aggregateId) + assertEquals(1, firstEvent.version) + assertEquals("Test Entity", firstEvent.name) + + val secondEvent = events[1] as TestUpdatedEvent + assertEquals(aggregateId, secondEvent.aggregateId) + assertEquals(2, secondEvent.version) + assertEquals("Updated Test Entity", secondEvent.name) + } + + @Test + fun `test append events with concurrency conflict`() { + val aggregateId = UUID.randomUUID() + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Append first event + val version1 = eventStore.appendToStream(event1, aggregateId, -1) + assertEquals(1, version1) + + // Try to append second event with wrong expected version + assertThrows { + eventStore.appendToStream(event2, aggregateId, 0) + } + + // Append second event with correct expected version + val version2 = eventStore.appendToStream(event2, aggregateId, 1) + assertEquals(2, version2) + } + + @Test + fun `test append multiple events at once`() { + val aggregateId = UUID.randomUUID() + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Append events + val version = eventStore.appendToStream(listOf(event1, event2), aggregateId, -1) + assertEquals(2, version) + + // Read events + val events = eventStore.readFromStream(aggregateId) + assertEquals(2, events.size) + } + + @Test + fun `test read all events`() { + val aggregate1Id = UUID.randomUUID() + val aggregate2Id = UUID.randomUUID() + + // Create events for first aggregate + val event1 = TestCreatedEvent( + aggregateId = aggregate1Id, + version = 1, + name = "Test Entity 1" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregate1Id, + version = 2, + name = "Updated Test Entity 1" + ) + + // Create events for second aggregate + val event3 = TestCreatedEvent( + aggregateId = aggregate2Id, + version = 1, + name = "Test Entity 2" + ) + + // Append events + eventStore.appendToStream(event1, aggregate1Id, -1) + eventStore.appendToStream(event2, aggregate1Id, 1) + eventStore.appendToStream(event3, aggregate2Id, -1) + + // Read all events + val allEvents = eventStore.readAllEvents() + assertEquals(3, allEvents.size) + } + + @Test + fun `test subscribe to stream`() { + val aggregateId = UUID.randomUUID() + val latch = CountDownLatch(2) + val receivedEvents = mutableListOf() + + // Subscribe to stream + val subscription = eventStore.subscribeToStream(aggregateId) { event -> + receivedEvents.add(event) + latch.countDown() + } + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Append events + eventStore.appendToStream(event1, aggregateId, -1) + eventStore.appendToStream(event2, aggregateId, 1) + + // Wait for events to be received + assertTrue(latch.await(5, TimeUnit.SECONDS)) + assertEquals(2, receivedEvents.size) + + // Unsubscribe + subscription.unsubscribe() + assertFalse(subscription.isActive()) + } + + @Test + fun `test subscribe to all events`() { + val aggregate1Id = UUID.randomUUID() + val aggregate2Id = UUID.randomUUID() + val latch = CountDownLatch(3) + val receivedEvents = mutableListOf() + + // Subscribe to all events + val subscription = eventStore.subscribeToAll { event -> + receivedEvents.add(event) + latch.countDown() + } + + // Create events for first aggregate + val event1 = TestCreatedEvent( + aggregateId = aggregate1Id, + version = 1, + name = "Test Entity 1" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregate1Id, + version = 2, + name = "Updated Test Entity 1" + ) + + // Create events for second aggregate + val event3 = TestCreatedEvent( + aggregateId = aggregate2Id, + version = 1, + name = "Test Entity 2" + ) + + // Append events + eventStore.appendToStream(event1, aggregate1Id, -1) + eventStore.appendToStream(event2, aggregate1Id, 1) + eventStore.appendToStream(event3, aggregate2Id, -1) + + // Wait for events to be received + assertTrue(latch.await(5, TimeUnit.SECONDS)) + assertEquals(3, receivedEvents.size) + + // Unsubscribe + subscription.unsubscribe() + assertFalse(subscription.isActive()) + } + + // Test event classes + class TestCreatedEvent( + override val eventId: UUID = UUID.randomUUID(), + override val timestamp: Instant = Instant.now(), + override val aggregateId: UUID, + override val version: Long, + val name: String + ) : BaseDomainEvent(eventId, timestamp, aggregateId, version) + + class TestUpdatedEvent( + override val eventId: UUID = UUID.randomUUID(), + override val timestamp: Instant = Instant.now(), + override val aggregateId: UUID, + override val version: Long, + val name: String + ) : BaseDomainEvent(eventId, timestamp, aggregateId, version) +} diff --git a/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt new file mode 100644 index 00000000..8c4cda89 --- /dev/null +++ b/infrastructure/event-store/redis-event-store/src/test/kotlin/at/mocode/infrastructure/eventstore/redis/RedisIntegrationTest.kt @@ -0,0 +1,241 @@ +package at.mocode.infrastructure.eventstore.redis + +import at.mocode.core.domain.event.BaseDomainEvent +import at.mocode.core.domain.event.DomainEvent +import at.mocode.infrastructure.eventstore.api.EventSerializer +import at.mocode.infrastructure.eventstore.api.EventStore +import at.mocode.infrastructure.eventstore.api.Subscription +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.StringRedisTemplate +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.utility.DockerImageName +import java.time.Instant +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration tests for Redis Event Store and Event Consumer. + * + * These tests verify the interaction between the Redis Event Store, Event Consumer, and Event Serializer + * in a more realistic scenario. + */ +@Testcontainers +class RedisIntegrationTest { + + companion object { + @Container + val redisContainer = GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) + } + + private lateinit var redisTemplate: StringRedisTemplate + private lateinit var serializer: EventSerializer + private lateinit var properties: RedisEventStoreProperties + private lateinit var eventStore: EventStore + private lateinit var eventConsumer: RedisEventConsumer + + @BeforeEach + fun setUp() { + val redisPort = redisContainer.getMappedPort(6379) + val redisHost = redisContainer.host + + val redisConfig = RedisStandaloneConfiguration(redisHost, redisPort) + val connectionFactory = LettuceConnectionFactory(redisConfig) + connectionFactory.afterPropertiesSet() + + redisTemplate = StringRedisTemplate() + redisTemplate.setConnectionFactory(connectionFactory) + redisTemplate.afterPropertiesSet() + + serializer = JacksonEventSerializer() + + // Register test event types + serializer.registerEventType(TestCreatedEvent::class.java, "TestCreated") + serializer.registerEventType(TestUpdatedEvent::class.java, "TestUpdated") + + properties = RedisEventStoreProperties( + host = redisHost, + port = redisPort, + streamPrefix = "test-stream:", + allEventsStream = "all-events", + consumerGroup = "test-group", + consumerName = "test-consumer", + createConsumerGroupIfNotExists = true + ) + + eventStore = RedisEventStore(redisTemplate, serializer, properties) + eventConsumer = RedisEventConsumer(redisTemplate, serializer, properties) + + // Clear all streams + val keys = redisTemplate.keys("${properties.streamPrefix}*") + if (keys != null && keys.isNotEmpty()) { + redisTemplate.delete(keys) + } + } + + @AfterEach + fun tearDown() { + // Clear all streams + val keys = redisTemplate.keys("${properties.streamPrefix}*") + if (keys != null && keys.isNotEmpty()) { + redisTemplate.delete(keys) + } + } + + @Test + fun `test event publishing and consuming with consumer groups`() { + // Create an aggregate ID + val aggregateId = UUID.randomUUID() + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Set up a latch to wait for events + val latch = CountDownLatch(2) + val receivedEvents = mutableListOf() + + // Register a handler for TestCreatedEvent + eventConsumer.registerEventHandler("TestCreated") { event -> + receivedEvents.add(event) + latch.countDown() + } + + // Register a handler for TestUpdatedEvent + eventConsumer.registerEventHandler("TestUpdated") { event -> + receivedEvents.add(event) + latch.countDown() + } + + // Initialize the consumer + eventConsumer.init() + + // Append events to the stream + eventStore.appendToStream(event1, aggregateId, -1) + eventStore.appendToStream(event2, aggregateId, 1) + + // Manually trigger event polling + eventConsumer.pollEvents() + + // Wait for events to be processed + assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for events") + + // Verify that both events were received + assertEquals(2, receivedEvents.size) + + // Verify the first event + val receivedEvent1 = receivedEvents[0] as TestCreatedEvent + assertEquals(aggregateId, receivedEvent1.aggregateId) + assertEquals(1, receivedEvent1.version) + assertEquals("Test Entity", receivedEvent1.name) + + // Verify the second event + val receivedEvent2 = receivedEvents[1] as TestUpdatedEvent + assertEquals(aggregateId, receivedEvent2.aggregateId) + assertEquals(2, receivedEvent2.version) + assertEquals("Updated Test Entity", receivedEvent2.name) + + // Clean up + eventConsumer.shutdown() + } + + @Test + fun `test multiple consumers with consumer groups`() { + // Create an aggregate ID + val aggregateId = UUID.randomUUID() + + // Create events + val event1 = TestCreatedEvent( + aggregateId = aggregateId, + version = 1, + name = "Test Entity" + ) + + val event2 = TestUpdatedEvent( + aggregateId = aggregateId, + version = 2, + name = "Updated Test Entity" + ) + + // Set up latches to wait for events + val latch1 = CountDownLatch(2) + val latch2 = CountDownLatch(2) + val receivedEvents1 = mutableListOf() + val receivedEvents2 = mutableListOf() + + // Create a second consumer with a different consumer name + val properties2 = properties.copy(consumerName = "test-consumer-2") + val eventConsumer2 = RedisEventConsumer(redisTemplate, serializer, properties2) + + // Register handlers for the first consumer + eventConsumer.registerAllEventsHandler { event -> + receivedEvents1.add(event) + latch1.countDown() + } + + // Register handlers for the second consumer + eventConsumer2.registerAllEventsHandler { event -> + receivedEvents2.add(event) + latch2.countDown() + } + + // Initialize the consumers + eventConsumer.init() + eventConsumer2.init() + + // Append events to the stream + eventStore.appendToStream(event1, aggregateId, -1) + eventStore.appendToStream(event2, aggregateId, 1) + + // Manually trigger event polling + eventConsumer.pollEvents() + eventConsumer2.pollEvents() + + // Wait for events to be processed by both consumers + assertTrue(latch1.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 1") + assertTrue(latch2.await(5, TimeUnit.SECONDS), "Timed out waiting for events on consumer 2") + + // Verify that both consumers received both events + assertEquals(2, receivedEvents1.size) + assertEquals(2, receivedEvents2.size) + + // Clean up + eventConsumer.shutdown() + eventConsumer2.shutdown() + } + + // Test event classes + class TestCreatedEvent( + override val eventId: UUID = UUID.randomUUID(), + override val timestamp: Instant = Instant.now(), + override val aggregateId: UUID, + override val version: Long, + val name: String + ) : BaseDomainEvent(eventId, timestamp, aggregateId, version) + + class TestUpdatedEvent( + override val eventId: UUID = UUID.randomUUID(), + override val timestamp: Instant = Instant.now(), + override val aggregateId: UUID, + override val version: Long, + val name: String + ) : BaseDomainEvent(eventId, timestamp, aggregateId, version) +} diff --git a/platform/platform-bom/build.gradle.kts b/platform/platform-bom/build.gradle.kts index 788a9b63..a645d8b4 100644 --- a/platform/platform-bom/build.gradle.kts +++ b/platform/platform-bom/build.gradle.kts @@ -33,6 +33,18 @@ dependencies { api("com.benasher44:uuid:0.8.2") api("com.ionspin.kotlin:bignum:0.3.8") api("com.orbitz.consul:consul-client:1.5.3") + + // Jackson modules + api("com.fasterxml.jackson.module:jackson-module-kotlin:2.16.1") + api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1") + + // Testcontainers + api("org.testcontainers:testcontainers:1.19.5") + api("org.testcontainers:junit-jupiter:1.19.5") + api("org.testcontainers:postgresql:1.19.5") + + // Java EE / Jakarta EE APIs + api("javax.annotation:javax.annotation-api:1.3.2") } }