fixing(infra-messaging)

This commit is contained in:
2025-08-15 18:18:40 +02:00
parent b5656156c4
commit c67fe3004e
14 changed files with 1422 additions and 1508 deletions
+464
View File
@@ -0,0 +1,464 @@
# Meldestelle Development Guidelines
**Version:** 1.0
**Date:** 2025-08-15
**Status:** Active
This document outlines the development guidelines for the Meldestelle project, covering coding conventions, code organization, and testing approaches.
---
## 1. Coding Conventions
### 1.1 Language Standards
- **Primary Language:** Kotlin (JVM/Multiplatform)
- **Java Compatibility:** Target Java 21+
- **Kotlin Version:** Latest stable version
- **Code Style:** Official Kotlin coding conventions
### 1.2 Naming Conventions
#### Classes and Interfaces
```kotlin
// Use PascalCase for classes and interfaces
class MemberService
interface EventRepository
data class MemberRegistration
sealed class AuthResult
// Use descriptive names that reflect domain concepts
class HorseRegistrationService // Good
class HRS // Avoid abbreviations
```
#### Functions and Variables
```kotlin
// Use camelCase for functions and variables
fun authenticateUser(): AuthResult
val memberRepository: MemberRepository
suspend fun findByEmail(email: EmailAddress): Result<Member?, RepositoryError>
// Use descriptive test method names with "should" statements
@Test
fun `authenticate should return Success for valid credentials`()
```
#### Constants and Enums
```kotlin
// Use SCREAMING_SNAKE_CASE for constants
const val MAX_RETRY_ATTEMPTS = 3
const val DEFAULT_TIMEOUT_MS = 5000L
// Use PascalCase for enum values
enum class MemberStatus {
ACTIVE,
INACTIVE,
SUSPENDED
}
```
### 1.3 Code Structure Principles
#### Result Pattern Usage
```kotlin
// Always use Result pattern for operations that can fail
interface MemberRepository {
suspend fun findById(id: MemberId): Result<Member?, RepositoryError>
suspend fun save(member: Member): Result<Unit, RepositoryError>
}
// Result extensions for error handling
inline fun <T, E, R> Result<T, E>.mapError(transform: (E) -> R): Result<T, R> =
when (this) {
is Result.Success -> Result.Success(value)
is Result.Failure -> Result.Failure(transform(error))
}
```
#### Coroutines and Async Programming
```kotlin
// Use suspend functions for async operations
suspend fun processEventBatch(events: List<DomainEvent>): Result<Unit, ProcessingError>
// Prefer structured concurrency
class EventProcessor {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
suspend fun processEvents() = withContext(scope.coroutineContext) {
// Implementation
}
}
```
#### Documentation Standards
```kotlin
/**
* Authenticates a user with the given credentials.
*
* @param credentials The user credentials containing username and password
* @return AuthResult.Success with user data if authentication succeeds,
* AuthResult.Failure with error details if it fails
*/
suspend fun authenticate(credentials: UserCredentials): AuthResult
```
---
## 2. Code Organization and Package Structure
### 2.1 Overall Architecture
The project follows a **microservices architecture** with **Domain-Driven Design (DDD)** principles and **Clean Architecture** patterns.
#### High-Level Structure
```
Meldestelle/
├── core/ # Shared kernel - fundamental building blocks
│ ├── core-domain/ # Common domain types and interfaces
│ └── core-utils/ # Shared utilities and extensions
├── infrastructure/ # Cross-cutting infrastructure services
│ ├── auth/ # Authentication & authorization
│ ├── messaging/ # Event messaging (Kafka)
│ ├── cache/ # Distributed caching (Redis)
│ ├── gateway/ # API Gateway
│ └── monitoring/ # Observability and monitoring
├── [domain-services]/ # Domain-specific microservices
│ ├── members/ # Member management
│ ├── events/ # Event management
│ ├── horses/ # Horse registry
│ └── masterdata/ # Master data management
├── client/ # Client applications
│ ├── common-ui/ # Shared UI components (KMP)
│ ├── desktop-app/ # Desktop application
│ └── web-app/ # Web application
└── platform/ # Build and dependency management
```
### 2.2 Microservice Structure (Clean Architecture)
Each domain service follows a **4-layer architecture**:
```
domain-service/
├── domain-api/ # REST controllers, DTOs, API contracts
├── domain-application/ # Use cases, application logic, orchestration
├── domain-domain/ # Domain models, business rules, interfaces
└── domain-infrastructure/ # Technical implementations (DB, external APIs)
```
#### Layer Responsibilities
**`:domain-api` Layer:**
```kotlin
// REST Controllers
@RestController
@RequestMapping("/api/v1/members")
class MemberController(private val memberService: MemberService)
// DTOs for external communication
data class MemberRegistrationRequest(
val firstName: String,
val lastName: String,
val email: String
)
```
**`:domain-application` Layer:**
```kotlin
// Use cases and application services
class MemberApplicationService(
private val memberRepository: MemberRepository,
private val eventPublisher: EventPublisher
) {
suspend fun registerMember(command: RegisterMemberCommand): Result<MemberId, MemberError>
}
```
**`:domain-domain` Layer:**
```kotlin
// Domain models and business logic
data class Member(
val id: MemberId,
val personalInfo: PersonalInfo,
val membershipStatus: MembershipStatus
) {
fun activate(): Member = copy(membershipStatus = MembershipStatus.ACTIVE)
}
// Repository interfaces (implemented in infrastructure)
interface MemberRepository {
suspend fun findById(id: MemberId): Result<Member?, RepositoryError>
suspend fun save(member: Member): Result<Unit, RepositoryError>
}
```
**`:domain-infrastructure` Layer:**
```kotlin
// Technical implementations
class ExposedMemberRepository(
private val database: Database
) : MemberRepository {
override suspend fun findById(id: MemberId): Result<Member?, RepositoryError> {
// Database implementation using Exposed ORM
}
}
```
### 2.3 Package Naming Conventions
```kotlin
// Base package structure
at.mocode.[layer].[domain].[component]
// Examples
at.mocode.members.domain.model // Domain models
at.mocode.members.application.service // Application services
at.mocode.members.infrastructure.persistence // Persistence layer
at.mocode.infrastructure.messaging.kafka // Infrastructure components
at.mocode.core.utils.result // Core utilities
```
### 2.4 Dependency Rules
- **Core modules** must not depend on any other modules
- **Domain layer** must not depend on infrastructure or application layers
- **Application layer** can depend on domain layer only
- **Infrastructure layer** can depend on domain and application layers
- **API layer** orchestrates calls between application and infrastructure
---
## 3. Unit and Integration Testing Approaches
### 3.1 Testing Strategy Overview
The project follows a **comprehensive testing strategy** with multiple testing levels:
1. **Unit Tests** - Fast, isolated tests for individual components
2. **Integration Tests** - Tests for component interactions
3. **Performance Tests** - Load and throughput testing
4. **End-to-End Tests** - Full system workflow testing
### 3.2 Testing Stack
#### Core Testing Libraries
```kotlin
// Unit testing
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
// Integration testing
testImplementation("org.testcontainers:junit-jupiter:1.19.1")
testImplementation("org.testcontainers:kafka:1.19.1")
testImplementation("org.testcontainers:postgresql:1.19.1")
// Performance testing
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
```
### 3.3 Unit Testing Conventions
#### Test Structure and Naming
```kotlin
class AuthenticationServiceTest {
@BeforeEach
fun setUp() {
// Test setup
}
@Test
fun `authenticate should return Success for valid credentials`() = runTest {
// Given
val credentials = UserCredentials("user@example.com", "validPassword")
coEvery { userRepository.findByEmail(any()) } returns Result.Success(testUser)
// When
val result = authenticationService.authenticate(credentials)
// Then
assertTrue(result is AuthResult.Success)
assertEquals(testUser.id, result.user.id)
}
@Test
fun `authenticate should return Failure for invalid credentials`() = runTest {
// Given - When - Then pattern
}
}
```
#### Mocking Best Practices
```kotlin
class MemberServiceTest {
private val memberRepository = mockk<MemberRepository>()
private val eventPublisher = mockk<EventPublisher>()
private val memberService = MemberService(memberRepository, eventPublisher)
@Test
fun `should publish event when member is registered`() = runTest {
// Mock repository responses
coEvery { memberRepository.save(any()) } returns Result.Success(Unit)
coEvery { eventPublisher.publish(any()) } returns Result.Success(Unit)
// Test implementation
val result = memberService.registerMember(validCommand)
// Verify interactions
coVerify { eventPublisher.publish(any<MemberRegisteredEvent>()) }
}
}
```
### 3.4 Integration Testing Approaches
#### Database Integration Tests
```kotlin
@Testcontainers
class MemberRepositoryIntegrationTest {
companion object {
@Container
val postgres = PostgreSQLContainer<Nothing>("postgres:15-alpine")
}
@Test
fun `should persist and retrieve member correctly`() = runTest {
// Test with real database using Testcontainers
val member = createTestMember()
val saveResult = memberRepository.save(member)
assertTrue(saveResult.isSuccess())
val retrievedResult = memberRepository.findById(member.id)
assertTrue(retrievedResult.isSuccess())
assertEquals(member, retrievedResult.getOrNull())
}
}
```
#### Messaging Integration Tests
```kotlin
@Testcontainers
class KafkaEventPublisherIntegrationTest {
companion object {
@Container
val kafka = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:latest"))
}
@Test
fun `should publish and consume events correctly`() = runTest {
val event = MemberRegisteredEvent(memberId = MemberId.generate())
val publishResult = eventPublisher.publish(event)
assertTrue(publishResult.isSuccess())
// Verify event was consumed
val consumedEvents = eventConsumer.consumeEvents(timeout = 5.seconds)
assertTrue(consumedEvents.any { it.memberId == event.memberId })
}
}
```
### 3.5 Performance Testing
#### Batch Processing Performance Tests
```kotlin
class KafkaBatchPerformanceTest {
@Test
fun `should process large batches within acceptable time limits`() = runTest {
val batchSize = 1000
val events = generateTestEvents(batchSize)
val startTime = System.currentTimeMillis()
val results = eventProcessor.processBatch(events)
val processingTime = System.currentTimeMillis() - startTime
assertTrue(results.all { it.isSuccess() })
assertTrue(processingTime < 5000) // Should complete within 5 seconds
println("[DEBUG_LOG] Processed $batchSize events in ${processingTime}ms")
}
}
```
### 3.6 Test Organization
#### Directory Structure
```
src/
├── main/kotlin/ # Production code
└── test/kotlin/ # Test code
├── unit/ # Unit tests (optional sub-organization)
├── integration/ # Integration tests
└── performance/ # Performance tests
```
#### Test Categories and Execution
```kotlin
// Use JUnit 5 tags for test categorization
@Tag("unit")
class MemberServiceTest
@Tag("integration")
class MemberRepositoryIntegrationTest
@Tag("performance")
class KafkaBatchPerformanceTest
```
### 3.7 Testing Guidelines
#### Best Practices
1. **Test Method Naming:** Use descriptive names with "should" statements
2. **AAA Pattern:** Arrange, Act, Assert structure
3. **One Assertion Per Test:** Focus on single behavior
4. **Test Data Builders:** Use factory methods for test data creation
5. **Coroutine Testing:** Use `runTest` for suspend functions
6. **Mock Verification:** Verify important interactions, not implementation details
#### Coverage Goals
- **Unit Tests:** 80%+ code coverage for domain and application layers
- **Integration Tests:** Cover all repository implementations and external integrations
- **Performance Tests:** Cover critical batch operations and high-load scenarios
#### Debugging Support
```kotlin
// Always prefix debug messages with [DEBUG_LOG]
@Test
fun `should handle concurrent requests`() = runTest {
println("[DEBUG_LOG] Starting concurrent request test with ${requestCount} requests")
// Test implementation
println("[DEBUG_LOG] Completed test. Success rate: ${successCount}/${requestCount}")
}
```
---
## 4. Additional Development Standards
### 4.1 Error Handling
- Use `Result` pattern consistently for operations that can fail
- Define domain-specific error types
- Avoid throwing exceptions in domain logic
### 4.2 Logging and Monitoring
- Use structured logging with appropriate log levels
- Include correlation IDs for request tracing
- Monitor key business metrics and technical performance
### 4.3 Security Considerations
- Validate all external inputs
- Use JWT tokens for authentication
- Implement proper authorization checks
- Secure sensitive configuration data
---
This guideline is a living document and should be updated as the project evolves and new patterns emerge.
-240
View File
@@ -1,240 +0,0 @@
# Meldestelle_Pro: Entwicklungs-Guideline
## Status: Finalisiert & Verbindlich
### 1. Vision & Architektonische Grundpfeiler
Dieses Dokument definiert die verbindlichen technischen Richtlinien und Qualitätsstandards für das Projekt "
Meldestelle_Pro". Ziel ist die Schaffung einer modernen, skalierbaren und wartbaren Plattform für den Pferdesport.
Unsere Architektur basiert auf vier Säulen:
1. **Modularität & Skalierbarkeit** durch eine **Microservices-Architektur.**
2. **Fachlichkeit im Code** durch **Domain-Driven Design (DDD).**
3. **Entkopplung & Resilienz** durch eine **ereignisgesteuerte Architektur (EDA).**
4. **Effizienz & Konsistenz** durch eine **Multiplattform-Client-Strategie (KMP).**
Jede Code-Änderung muss diese vier Grundprinien respektieren.
---
### 2. Backend-Entwicklungsrichtlinien
#### 2.1. Microservice-Struktur (Clean Architecture)
**Jeder fachliche Microservice (z.B. :members, :events) muss der etablierten 4-Layer-Struktur folgen:**
* **`:*-api`: Definiert die öffentliche Schnittstelle des Service (REST-Controller, DTOs).**
* **`:*-application`: Enthält die Anwendungslogik und Use Cases. Hier werden die Repositories orchestriert.**
* **`:*-domain`: Das Herz des Service. Enthält die reinen, von Frameworks unabhängigen Domänenmodelle, Geschäftsregeln
und Repository-Interfaces.**
* **`:*-infrastructure`: Die technische Implementierung der Interfaces aus der Domänenschicht (z.B. Datenbankzugriff mit
Exposed).**
#### 2.2. Domain-Driven Design (DDD) in der Praxis
* **Shared Kernel (`:core`-Modul):** Das `:core`-Modul ist heilig. Es darf **ausschließlich** fundamentalen,
domänen-agnostischen Code enthalten. Fachspezifische Konzepte gehören in ihre jeweilige Domäne.
* **Repository-Pattern mit `Result`:** Jede Repository-Methode muss das `Result`-Pattern verwenden, um Erfolgs- und
Fehlerfälle explizit und typsicher zu behandeln.
```kotlin
// Repository mit Result-Pattern
interface MemberRepository {
suspend fun findById(id: MemberId): Result<Member?, RepositoryError>
suspend fun save(member: Member): Result<Unit, RepositoryError>
suspend fun findByEmail(email: EmailAddress): Result<List<Member>, RepositoryError>
}
```
#### 2.3. Messaging & Event-Naming
* **Asynchrone Kommunikation:** Die bevorzugte Kommunikationsmethode ist asynchron über Kafka.
* **Event-Naming Convention:** Domänen-Events folgen dem Muster `{Domain}{Entity}{Action}Event`.
```kotlin
// Event-Naming Convention
sealed class DomainEvent(
val aggregateId: String,
val version: Long,
val timestamp: Instant = Instant.now()
) {
// Pattern: {Domain}{Entity}{Action}Event
data class MemberPersonalDataUpdatedEvent(
val memberId: MemberId,
val personalData: PersonalData
) : DomainEvent(memberId.value, version)
}
```
---
### 3. Frontend-Entwicklungsrichtlinien
#### 3.1. Architekturmuster: MVVM & KMP
Das Frontend folgt konsequent dem **Model-View-ViewModel (MVVM)**-Muster und der **Kotlin Multiplatform (KMP)**
-Strategie:
* **Model & ViewModel:** Die gesamte Geschäftslogik, der Zustand und die API-Aufrufe leben im `:client:common-ui`-Modul
und sind plattformunabhängig.
* **View:** Die Benutzeroberfläche wird mit **Compose Multiplatform* im `:client:common-ui`-Modul implementiert.
#### 3.2. Vertikale Schnitte (Features)
Der UI-Code wird nach **fachlichen Features** strukturiert. Ein Feature (z. B. "Nennungsabwicklung") hat sein eigenes
Verzeichnis und enthält alle zugehörigen Views, ViewModels und Models.
---
### 4. Allgemeine Qualitätsstandards
#### 4.1. Code-Qualität & Kotlin-Konventionen
* **Value Classes für Typsicherheit:** Primitive Typen (UUID, String, Long) für IDs oder spezifische Werte müssen in
typsichere `value class`-Wrapper gekapselt werden, um Fehler zu vermeiden.
```kotlin
// Ergänzung für Value Objects
@JvmInline
value class MemberId(val value: UUID) {
companion object {
fun of(value: String): Result<MemberId, ValidationError> =
runCatching { UUID.fromString(value) }
.map { MemberId(it) }
.mapError { ValidationError.INVALID_UUID }
}
}
```
#### 4.2. Error-Handling
* **`Result`-Pattern statt Exceptions:** Für erwartbare Geschäftsfehler ist das `Result`-Pattern zu verwenden.
* **Spezifische Fehler-Hierarchie:** Wir verwenden eine `sealed class`-Hierarchie, um Fehlerarten klar zu
kategorisieren.
```kotlin
// Spezifische Error-Hierarchie definieren
sealed class DomainError(val code: String, val message: String)
sealed class ValidationError(code: String, message: String) : DomainError(code, message)
sealed class BusinessError(code: String, message: String) : DomainError(code, message)
sealed class TechnicalError(code: String, message: String) : DomainError(code, message)
```
#### 4.3. Testing
* **Testcontainers als Goldstandard:** Jede Interaktion mit externer Infrastruktur (DB, Cache, Broker) **muss** mit *
*Testcontainers** getestet werden.
* **Mocking für Isolation:** Abhängigkeiten innerhalb von Tests werden mit Mocking-Frameworks (z.B. MockK) isoliert, um
den Testfokus zu schärfen.
```kotlin
// Testcontainers-Pattern für Infrastruktur-Tests
@TestConfiguration
class KafkaTestConfig {
@Bean
@Primary
fun kafkaEventPublisher(): KafkaEventPublisher = mockk()
}
```
---
### 5. Infrastruktur-Spezifikationen
#### 5.1. Kafka-Konfiguration
Die Konfiguration für Producer und Consumer muss produktionsreife Einstellungen für Zuverlässigkeit und Datenkonsistenz
verwenden.
```YAML
# Ergänzung für application.yml
kafka:
producer:
acks: all
enable-idempotence: true
max-in-flight-requests-per-connection: 1
consumer:
group-id-prefix: "meldestelle-${spring.application.name}"
auto-offset-reset: earliest
enable-auto-commit: false
```
#### 5.2. Datenbank-Migrationen mit Flyway
Migrations-Skripte müssen einer klaren Namenskonvention folgen.
* **Pattern:**`V{version}__{description}.sql` (z.B., `V001__Create_member_tables.sql`)
* **Repeatable:**`R__{description}.sql` (z.B., `R__Update_member_view.sql`)
---
### 6. Monitoring & Observability
#### 6.1. Structured Logging
Logs müssen als strukturierte Daten (z.B. JSON) ausgegeben werden und immer eine Korrelations-ID enthalten, um Anfragen
über Service-Grenzen hinweg verfolgen zu können.
```Kotlin
// Ergänzung zur Guideline
@Component
class MemberService {
private val logger = KotlinLogging.logger {}
suspend fun createMember(command: CreateMemberCommand) {
logger.info {
"Creating member" with mapOf(
"memberId" to command.memberId.value,
"operation" to "create_member",
"correlationId" to MDC.get("correlationId")
)
}
}
}
```
#### 6.2. Metrics
Es müssen sowohl technische als auch fachliche Metriken erfasst werden.
```Kotlin
// Spezifische Business-Metriken definieren
@Component
class BusinessMetrics(meterRegistry: MeterRegistry) {
private val memberRegistrations = Counter.builder("business.member.registrations.total")
.description("Total number of member registrations")
.tag("service", "members")
.register(meterRegistry)
}
```
---
### 7. Zusätzliche Richtlinien
#### 7.1. Security
Die Autorisierung muss auf Methodenebene mit Spring Security Annotations (`@PreAuthorize`) durchgesetzt werden, um eine
feingranulare Zugriffskontrolle zu gewährleisten.
#### 7.2. Performance
Cache-Strategien (`@Cacheable`, `@CacheEvict`) **müssen gezielt eingesetzt werden**, um die Latenz bei häufigen
Lesezugriffen zu minimieren.
#### 7.3. Dokumentation
Alle öffentlichen REST-Endpunkte müssen mit OpenAPI-Annotationen (`@Operation`, `@ApiResponse`) dokumentiert werden, um
eine klare und interaktive API-Dokumentation zu generieren.
+711
View File
@@ -0,0 +1,711 @@
# Meldestelle_Pro: Entwicklungs-Guideline
**Status:** Finalisiert & Verbindlich
**Version:** 2.0
**Stand:** August 2025
## 1. Vision & Architektonische Grundpfeiler
Dieses Dokument definiert die verbindlichen technischen Richtlinien und Qualitätsstandards für das Projekt "Meldestelle_Pro". Ziel ist die Schaffung einer modernen, skalierbaren und wartbaren Plattform für den Pferdesport.
Unsere Architektur basiert auf **vier Säulen**:
1. **Modularität & Skalierbarkeit** durch eine **Microservices-Architektur**
2. **Fachlichkeit im Code** durch **Domain-Driven Design (DDD)**
3. **Entkopplung & Resilienz** durch eine **ereignisgesteuerte Architektur (EDA)**
4. **Effizienz & Konsistenz** durch eine **Multiplattform-Client-Strategie (KMP)**
> **Grundsatz:** Jede Code-Änderung muss diese vier Grundprinzipien respektieren.
---
## 2. Backend-Entwicklungsrichtlinien
#### 2.1. Microservice-Struktur (Clean Architecture)
**Jeder fachliche Microservice (z.B. :members, :events) muss der etablierten 4-Layer-Struktur folgen:**
* **`:*-api`**: Definiert die öffentliche Schnittstelle des Service (REST-Controller, DTOs).
* **`:*-application`**: Enthält die Anwendungslogik und Use Cases. Hier werden die Repositories orchestriert.
* **`:*-domain`**: Das Herz des Service. Enthält die reinen, von Frameworks unabhängigen Domänenmodelle, Geschäftsregeln
und Repository-Interfaces.
* **`:*-infrastructure`**: Die technische Implementierung der Interfaces aus der Domänenschicht (z.B. Datenbankzugriff
mit Exposed).
#### 2.2. Domain-Driven Design (DDD) in der Praxis
* **Shared Kernel (`:core`-Modul):** Das `:core`-Modul ist heilig. Es darf **ausschließlich** fundamentalen,
domänen-agnostischen Code enthalten. Fachspezifische Konzepte gehören in ihre jeweilige Domäne.
* **Repository-Pattern mit `Result`:** Jede Repository-Methode muss das `Result`-Pattern verwenden, um Erfolgs- und
Fehlerfälle explizit und typsicher zu behandeln.
```kotlin
// Repository mit Result-Pattern
interface MemberRepository {
suspend fun findById(id: MemberId): Result<Member?, RepositoryError>
suspend fun save(member: Member): Result<Unit, RepositoryError>
suspend fun findByEmail(email: EmailAddress): Result<List<Member>, RepositoryError>
}
```
#### 2.3. Core-Modul Spezifikation
Das `:core`-Modul definiert die fundamentalen Bausteine der gesamten Anwendung:
* **Result Extensions:** Utility-Funktionen für typsichere Fehlerbehandlung
* **Common Types:** Basistypen für alle Domänen
* **Shared Utilities:** Plattformunabhängige Hilfsfunktionen
```kotlin
// Result Extensions im core-utils Modul
inline fun <T, E, R> Result<T, E>.mapError(transform: (E) -> R): Result<T, R> =
when (this) {
is Result.Success -> Result.Success(value)
is Result.Failure -> Result.Failure(transform(error))
}
inline fun <T, E> Result<T, E>.onFailure(action: (E) -> Unit): Result<T, E> =
also { if (it is Result.Failure) action(it.error) }
// Common Domain Types
@JvmInline
value class CorrelationId(val value: UUID) {
companion object {
fun generate(): CorrelationId = CorrelationId(UUID.randomUUID())
fun of(value: String): Result<CorrelationId, ValidationError> =
runCatching { UUID.fromString(value) }
.map { CorrelationId(it) }
.mapError { ValidationError.InvalidUUID("Invalid correlation ID: $value") }
}
}
// Konkrete Error-Implementierungen
sealed class ValidationError(code: String, message: String) : DomainError(code, message) {
data class InvalidUUID(override val message: String) :
ValidationError("INVALID_UUID", message)
data class InvalidEmail(override val message: String) :
ValidationError("INVALID_EMAIL", message)
data class InvalidLength(val field: String, val min: Int, val max: Int) :
ValidationError("INVALID_LENGTH", "Field $field must be between $min and $max characters")
}
```
#### 2.4. Messaging & Event-Naming
* **Asynchrone Kommunikation:** Die bevorzugte Kommunikationsmethode ist asynchron über Kafka.
* **Event-Naming Convention:** Domänen-Events folgen dem Muster `{Domain}{Entity}{Action}Event`.
```kotlin
// Event-Naming Convention
sealed class DomainEvent(
val aggregateId: String,
val version: Long,
val timestamp: Instant = Instant.now()
) {
// Pattern: {Domain}{Entity}{Action}Event
data class MemberPersonalDataUpdatedEvent(
val memberId: MemberId,
val personalData: PersonalData
) : DomainEvent(memberId.value, version)
}
```
---
## 3. Frontend-Entwicklungsrichtlinien
#### 3.1. Architekturmuster: MVVM & KMP
Das Frontend folgt konsequent dem **Model-View-ViewModel (MVVM)**-Muster und der **Kotlin Multiplatform (KMP)**-Strategie:
* **Model & ViewModel:** Die gesamte Geschäftslogik, der Zustand und die API-Aufrufe leben im `:client:common-ui`-Modul und sind plattformunabhängig.
* **View:** Die Benutzeroberfläche wird mit **Compose Multiplatform** im `:client:common-ui`-Modul implementiert.
#### 3.2. State Management
**Unidirectional Data Flow mit MVI-Pattern:**
```kotlin
// State Management Pattern
@Stable
data class MemberListUiState(
val members: List<Member> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val searchQuery: String = ""
)
sealed class MemberListIntent {
object LoadMembers : MemberListIntent()
data class SearchMembers(val query: String) : MemberListIntent()
data class DeleteMember(val memberId: MemberId) : MemberListIntent()
}
class MemberListViewModel(
private val memberRepository: MemberRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(MemberListUiState())
val uiState: StateFlow<MemberListUiState> = _uiState.asStateFlow()
fun handleIntent(intent: MemberListIntent) {
when (intent) {
is MemberListIntent.LoadMembers -> loadMembers()
is MemberListIntent.SearchMembers -> searchMembers(intent.query)
is MemberListIntent.DeleteMember -> deleteMember(intent.memberId)
}
}
}
```
#### 3.3. Navigation Architecture
**Compose Navigation mit typsicheren Routes:**
```kotlin
// Navigation Definition
@Serializable
sealed class Screen {
@Serializable
object MemberList : Screen()
@Serializable
data class MemberDetail(val memberId: String) : Screen()
@Serializable
data class EventRegistration(val eventId: String, val memberId: String) : Screen()
}
// Navigation Router
class NavigationRouter {
private val _navigationEvents = MutableSharedFlow<NavigationEvent>()
val navigationEvents: SharedFlow<NavigationEvent> = _navigationEvents.asSharedFlow()
fun navigateTo(screen: Screen) {
_navigationEvents.tryEmit(NavigationEvent.NavigateTo(screen))
}
fun navigateBack() {
_navigationEvents.tryEmit(NavigationEvent.NavigateBack)
}
}
```
#### 3.4. Vertikale Schnitte (Features)
Der UI-Code wird nach **fachlichen Features** strukturiert. Ein Feature (z.B. "Nennungsabwicklung") hat sein eigenes Verzeichnis und enthält alle zugehörigen Views, ViewModels und Models:
```
client/common-ui/src/commonMain/kotlin/
├── features/
│ ├── members/
│ │ ├── presentation/
│ │ │ ├── MemberListViewModel.kt
│ │ │ ├── MemberDetailViewModel.kt
│ │ │ └── MemberUiState.kt
│ │ ├── ui/
│ │ │ ├── MemberListScreen.kt
│ │ │ ├── MemberDetailScreen.kt
│ │ │ └── components/
│ │ └── domain/
│ │ └── MemberUseCases.kt
│ └── events/
│ ├── presentation/
│ ├── ui/
│ └── domain/
```
#### 3.5. Platform-spezifische Implementierungen
**Desktop-spezifische Features:**
```kotlin
// Desktop-specific implementations
actual class PlatformFileManager {
actual suspend fun selectFile(): Result<File?, FileError> {
return withContext(Dispatchers.IO) {
try {
val fileChooser = JFileChooser()
val result = fileChooser.showOpenDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
Result.Success(fileChooser.selectedFile)
} else {
Result.Success(null)
}
} catch (e: Exception) {
Result.Failure(FileError.SelectionFailed(e.message))
}
}
}
}
// Web-specific implementations
actual class PlatformFileManager {
actual suspend fun selectFile(): Result<File?, FileError> {
return try {
val input = document.createElement("input") as HTMLInputElement
input.type = "file"
input.click()
// Implementation für Web File API
Result.Success(null) // Simplified
} catch (e: Exception) {
Result.Failure(FileError.SelectionFailed(e.message))
}
}
}
```
---
## 4. API-Versioning & Kompatibilität
#### 4.1. Versioning-Strategie
**Header-basierte Versionierung (Empfohlen):**
```kotlin
// API Version Header
@RestController
@RequestMapping("/api/members")
class MemberController {
@GetMapping
fun getMembers(
@RequestHeader(value = "API-Version", defaultValue = "1.0") version: String,
@RequestParam query: String?
): ResponseEntity<List<MemberDto>> {
return when (version) {
"1.0" -> memberService.getMembersV1(query)
"2.0" -> memberService.getMembersV2(query)
else -> ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build()
}
}
}
// Client-seitige Versionierung
class ApiClient {
companion object {
const val CURRENT_API_VERSION = "2.0"
const val MIN_SUPPORTED_VERSION = "1.0"
}
private val defaultHeaders = mapOf(
"API-Version" to CURRENT_API_VERSION,
"Accept" to "application/json"
)
}
```
#### 4.2. Backward Compatibility Rules
* **Breaking Changes:** Erfordern eine neue Major-Version (1.x → 2.x)
* **Additive Changes:** Können in Minor-Versionen erfolgen (1.0 → 1.1)
* **Bug Fixes:** Patch-Versionen (1.0.0 → 1.0.1)
```kotlin
// Compatibility Matrix
object ApiCompatibility {
val supportedVersions = mapOf(
"2.0" to ApiVersionConfig(
deprecated = false,
sunsetDate = null,
features = setOf("advanced-search", "bulk-operations")
),
"1.0" to ApiVersionConfig(
deprecated = true,
sunsetDate = LocalDate.of(2025, 12, 31),
features = setOf("basic-search")
)
)
}
```
#### 4.3. Versioning Lifecycle Management
* **Deprecation Notice:** Mindestens 6 Monate vor Entfernung
* **Documentation:** Alle Versionen müssen in OpenAPI dokumentiert sein
* **Migration Guide:** Für jede Major-Version erforderlich
---
## 5. Allgemeine Qualitätsstandards
#### 4.1. Code-Qualität & Kotlin-Konventionen
* **Value Classes für Typsicherheit:** Primitive Typen (UUID, String, Long) für IDs oder spezifische Werte müssen in
typsichere `value class`-Wrapper gekapselt werden, um Fehler zu vermeiden.
```kotlin
// Ergänzung für Value Objects
@JvmInline
value class MemberId(val value: UUID) {
companion object {
fun of(value: String): Result<MemberId, ValidationError> =
runCatching { UUID.fromString(value) }
.map { MemberId(it) }
.mapError { ValidationError.INVALID_UUID }
}
}
```
#### 4.2. Error-Handling
* **`Result`-Pattern statt Exceptions:** Für erwartbare Geschäftsfehler ist das `Result`-Pattern zu verwenden.
* **Spezifische Fehler-Hierarchie:** Wir verwenden eine `sealed class`-Hierarchie, um Fehlerarten klar zu
kategorisieren.
```kotlin
// Spezifische Error-Hierarchie definieren
sealed class DomainError(val code: String, val message: String)
sealed class ValidationError(code: String, message: String) : DomainError(code, message)
sealed class BusinessError(code: String, message: String) : DomainError(code, message)
sealed class TechnicalError(code: String, message: String) : DomainError(code, message)
```
#### 4.3. Testing
* **Testcontainers als Goldstandard:** Jede Interaktion mit externer Infrastruktur (DB, Cache, Broker) **muss** mit *
*Testcontainers** getestet werden.
* **Mocking für Isolation:** Abhängigkeiten innerhalb von Tests werden mit Mocking-Frameworks (z.B. MockK) isoliert, um
den Testfokus zu schärfen.
```kotlin
// Testcontainers-Pattern für Infrastruktur-Tests
@TestConfiguration
class KafkaTestConfig {
@Bean
@Primary
fun kafkaEventPublisher(): KafkaEventPublisher = mockk()
}
```
---
### 5. Infrastruktur-Spezifikationen
#### 5.1. Kafka-Konfiguration
Die Konfiguration für Producer und Consumer muss produktionsreife Einstellungen für Zuverlässigkeit und Datenkonsistenz
verwenden.
```YAML
# Ergänzung für application.yml
kafka:
producer:
acks: all
enable-idempotence: true
max-in-flight-requests-per-connection: 1
consumer:
group-id-prefix: "meldestelle-${spring.application.name}"
auto-offset-reset: earliest
enable-auto-commit: false
```
#### 5.2. Datenbank-Migrationen mit Flyway
Migrations-Skripte müssen einer klaren Namenskonvention folgen.
* **Pattern:**`V{version}__{description}.sql` (z.B., `V001__Create_member_tables.sql`)
* **Repeatable:**`R__{description}.sql` (z.B., `R__Update_member_view.sql`)
---
## 6. Monitoring & Observability
#### 6.1. Structured Logging
Logs müssen als strukturierte Daten (z.B. JSON) ausgegeben werden und immer eine Korrelations-ID enthalten, um Anfragen über Service-Grenzen hinweg verfolgen zu können.
```kotlin
// Korrigierte Logging-Syntax
@Component
class MemberService {
private val logger = KotlinLogging.logger {}
suspend fun createMember(command: CreateMemberCommand) {
logger.info {
mapOf(
"message" to "Creating member",
"memberId" to command.memberId.value,
"operation" to "create_member",
"correlationId" to MDC.get("correlationId")
).toString()
}
}
}
```
#### 6.2. Service Level Indicators (SLIs) & Objectives (SLOs)
**Definierte SLIs für alle Services:**
```kotlin
// SLI/SLO Definitionen
object ServiceLevelIndicators {
// Availability SLIs
data class AvailabilitySLI(
val serviceName: String,
val targetUptime: Double = 0.995, // 99.5%
val measurementWindow: Duration = Duration.ofDays(30)
)
// Latency SLIs
data class LatencySLI(
val serviceName: String,
val percentile: Double = 0.95, // P95
val targetLatency: Duration = Duration.ofMillis(500),
val measurementWindow: Duration = Duration.ofMinutes(5)
)
// Error Rate SLIs
data class ErrorRateSLI(
val serviceName: String,
val maxErrorRate: Double = 0.001, // 0.1%
val measurementWindow: Duration = Duration.ofMinutes(5)
)
}
// SLO Monitoring
@Component
class SLOMonitor(private val meterRegistry: MeterRegistry) {
private val requestDuration = Timer.builder("http.request.duration")
.description("HTTP request duration")
.register(meterRegistry)
private val errorRate = Counter.builder("http.request.errors")
.description("HTTP request errors")
.register(meterRegistry)
fun recordRequest(duration: Duration, isError: Boolean) {
requestDuration.record(duration)
if (isError) errorRate.increment()
}
}
```
#### 6.3. Business & Technical Metrics
**Umfassende Metriken-Strategie:**
```kotlin
// Business Metrics
@Component
class BusinessMetrics(meterRegistry: MeterRegistry) {
// Fachliche Metriken
private val memberRegistrations = Counter.builder("business.member.registrations.total")
.description("Total number of member registrations")
.tag("service", "members")
.register(meterRegistry)
private val eventParticipations = Counter.builder("business.event.participations.total")
.description("Total event participations")
.tag("service", "events")
.register(meterRegistry)
private val paymentTransactions = Timer.builder("business.payment.transaction.duration")
.description("Payment transaction processing time")
.tag("service", "payments")
.register(meterRegistry)
// Gauge für aktuelle Werte
private val activeSessions = Gauge.builder("business.active.sessions")
.description("Currently active user sessions")
.register(meterRegistry) { getActiveSessionCount() }
}
// Technical Metrics
@Component
class TechnicalMetrics(meterRegistry: MeterRegistry) {
// Database Metriken
private val dbConnectionPool = Gauge.builder("database.connection.pool.active")
.description("Active database connections")
.register(meterRegistry) { getActiveConnections() }
// Kafka Metriken
private val kafkaLag = Gauge.builder("kafka.consumer.lag")
.description("Kafka consumer lag")
.register(meterRegistry) { getConsumerLag() }
// Cache Metriken
private val cacheHitRate = Gauge.builder("cache.hit.rate")
.description("Cache hit rate percentage")
.register(meterRegistry) { getCacheHitRate() }
}
```
#### 6.4. Alerting Strategy
**Alert-Definitionen basierend auf SLOs:**
```yaml
# Prometheus Alert Rules
groups:
- name: slo.rules
rules:
- alert: HighErrorRate
expr: rate(http_request_errors_total[5m]) > 0.001
for: 2m
labels:
severity: warning
annotations:
summary: "High error rate detected"
- alert: HighLatency
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 0.5
for: 5m
labels:
severity: critical
annotations:
summary: "High latency detected"
```
---
## 7. Zusätzliche Richtlinien
#### 7.1. Security
Die Autorisierung muss auf Methodenebene mit Spring Security Annotations (`@PreAuthorize`) durchgesetzt werden, um eine feingranulare Zugriffskontrolle zu gewährleisten.
**JWT Implementation:**
```kotlin
// JWT Configuration
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun jwtAuthenticationFilter(): JwtAuthenticationFilter {
return JwtAuthenticationFilter()
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/members/**").hasRole("USER")
.requestMatchers(HttpMethod.POST, "/api/members/**").hasRole("ADMIN")
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter::class.java)
.build()
}
}
// Method-level Security
@RestController
@RequestMapping("/api/members")
class MemberController {
@GetMapping("/{id}")
@PreAuthorize("hasRole('USER') or @memberService.isOwner(#id, authentication.name)")
fun getMember(@PathVariable id: String): MemberDto {
// Implementation
}
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasPermission(#memberDto, 'CREATE')")
fun createMember(@RequestBody memberDto: MemberDto): MemberDto {
// Implementation
}
}
```
**OAuth2 Integration:**
```kotlin
// OAuth2 Resource Server Configuration
@Configuration
class OAuth2Config {
@Bean
fun jwtDecoder(): JwtDecoder {
return NimbusJwtDecoder.withJwkSetUri("https://auth-provider/.well-known/jwks.json").build()
}
@Bean
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val authorities = jwt.getClaimAsStringList("authorities") ?: emptyList()
authorities.map { SimpleGrantedAuthority("ROLE_$it") }
}
return converter
}
}
// Custom Permission Evaluator
@Component("memberService")
class MemberPermissionEvaluator {
fun isOwner(memberId: String, username: String): Boolean {
return memberRepository.findById(memberId)
?.let { it.email == username }
?: false
}
fun hasPermission(target: Any, permission: String): Boolean {
// Custom permission logic
return when (permission) {
"CREATE" -> hasCreatePermission(target)
"UPDATE" -> hasUpdatePermission(target)
else -> false
}
}
}
```
**Rate Limiting:**
```kotlin
// Rate Limiting Configuration
@Configuration
class RateLimitConfig {
@Bean
fun rateLimitFilter(): RateLimitFilter {
return RateLimitFilter(
rateLimiters = mapOf(
"/api/auth/login" to RateLimiter.create(5.0), // 5 requests per second
"/api/members" to RateLimiter.create(100.0), // 100 requests per second
"/api/events" to RateLimiter.create(50.0) // 50 requests per second
)
)
}
}
// Custom Rate Limit Annotation
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimit(
val requestsPerSecond: Double = 10.0,
val burstCapacity: Int = 20
)
// Usage
@RestController
class AuthController {
@PostMapping("/login")
@RateLimit(requestsPerSecond = 5.0, burstCapacity = 10)
fun login(@RequestBody loginRequest: LoginRequest): AuthResponse {
// Implementation
}
}
```
#### 7.2. Performance
Cache-Strategien (`@Cacheable`, `@CacheEvict`) **müssen gezielt eingesetzt werden**, um die Latenz bei häufigen Lesezugriffen zu minimieren.
#### 7.3. Dokumentation
Alle öffentlichen REST-Endpunkte müssen mit OpenAPI-Annotationen (`@Operation`, `@ApiResponse`) dokumentiert werden, um eine klare und interaktive API-Dokumentation zu generieren.
@@ -4,6 +4,8 @@
Das **Messaging-Modul** stellt die Infrastruktur für die asynchrone, reaktive Kommunikation zwischen den Microservices bereit. Es nutzt **Apache Kafka** als hochperformanten, verteilten Message-Broker und ist entscheidend für die Entkopplung von Services und die Implementierung einer skalierbaren, ereignisgesteuerten Architektur.
Das Modul implementiert moderne **Domain-Driven Design (DDD)** Prinzipien mit expliziter Fehlerbehandlung über das **Result Pattern** und bietet sowohl suspending Coroutine-APIs als auch reaktive Stream-APIs für maximale Flexibilität.
## Architektur
Das Modul ist in zwei spezialisierte Komponenten aufgeteilt, um Konfiguration von der Client-Logik zu trennen:
@@ -26,26 +28,70 @@ Dieses Modul zentralisiert die grundlegende Kafka-Konfiguration für das gesamte
Dieses Modul baut auf der Konfiguration auf und stellt wiederverwendbare High-Level-Komponenten für die Interaktion mit Kafka bereit.
* **Zweck:**
* **`KafkaEventPublisher`**: Ein reaktiver, nicht-blockierender Service zum Senden von Nachrichten. Er nutzt den `ReactiveKafkaProducerTemplate` von Spring.
* **`EventPublisher` Interface**: Definiert moderne APIs für das Publizieren von Domain Events mit expliziter Fehlerbehandlung über das Result Pattern.
* **`KafkaEventPublisher`**: Implementierung des EventPublisher mit sowohl modernen suspending Coroutine-APIs als auch Legacy-reaktiven APIs. Nutzt den `ReactiveKafkaProducerTemplate` von Spring.
* **`KafkaEventConsumer`**: Ein reaktiver Service zum Empfangen von Nachrichten. Er kapselt die Komplexität von `reactor-kafka` und gibt einen kontinuierlichen `Flux`-Stream von Events zurück.
* **Vorteil:** Kapselt die Komplexität der reaktiven Kafka-API. Ein Fach-Service muss nur noch reaktive Streams (`Mono`, `Flux`) handhaben, ohne sich um die Details der Kafka-Interaktion zu kümmern.
* **`MessagingError`**: Domain-spezifische Fehlertypen für typsichere Fehlerbehandlung (SerializationError, ConnectionError, TimeoutError, AuthenticationError, etc.).
* **Vorteil:**
* Moderne **Result Pattern** APIs für typsichere Fehlerbehandlung ohne Exceptions
* Sowohl **Coroutine-basierte** als auch **reaktive** APIs verfügbar
* Kapselt die Komplexität der Kafka-API mit domain-spezifischen Abstraktionen
* Umfassendes Retry-Management mit intelligenter Retry-Logik
## Verwendung
Ein Microservice, der Nachrichten senden oder empfangen möchte, deklariert eine Abhängigkeit zu `:infrastructure:messaging:messaging-client` und injiziert die entsprechenden Interfaces.
**Beispiel für das Senden einer Nachricht (nicht-blockierend):**
### Moderne API (Result Pattern + Coroutines) - **Empfohlen**
**Beispiel für das Senden einer Nachricht mit typsicherer Fehlerbehandlung:**
```kotlin
@Service
class EventNotificationService(
private val eventPublisher: EventPublisher
) {
fun notifyNewEvent(eventDetails: EventDetails) {
suspend fun notifyNewEvent(eventDetails: EventDetails): Result<Unit> {
val topic = "new-events-topic"
eventPublisher.publishEvent(topic, eventDetails.id, eventDetails)
return eventPublisher.publishEvent(topic, eventDetails.id, eventDetails)
.onFailure { error ->
when (error) {
is MessagingError.SerializationError -> logger.error("Serialization failed for event", error)
is MessagingError.ConnectionError -> logger.warn("Connection issue, will retry later", error)
is MessagingError.TimeoutError -> logger.warn("Timeout publishing event", error)
else -> logger.error("Unexpected error publishing event", error)
}
}
}
suspend fun notifyMultipleEvents(events: List<Pair<String, EventDetails>>): Result<List<Unit>> {
val topic = "batch-events-topic"
return eventPublisher.publishEvents(topic, events)
.onSuccess { results ->
logger.info("Successfully published {} events", results.size)
}
.onFailure { error ->
logger.error("Failed to publish batch events: {}", error.message)
}
}
}
```
### Legacy Reactive API - **Wird depreciert**
**Beispiel für das Senden einer Nachricht (reaktiv, nicht-blockierend):**
```kotlin
@Service
class LegacyEventNotificationService(
private val eventPublisher: EventPublisher
) {
@Deprecated("Use suspending publishEvent with Result instead")
fun notifyNewEventReactive(eventDetails: EventDetails) {
val topic = "new-events-topic"
eventPublisher.publishEventReactive(topic, eventDetails.id, eventDetails)
.subscribe(
null, // onComplete: Nichts zu tun
{ error -> logger.error("Failed to send message to topic '{}'", topic, error) }
{ /* onNext: Unit received */ },
{ error -> logger.error("Failed to send message to topic '{}'", topic, error) },
{ /* onComplete: Nichts zu tun */ }
)
// Die Methode kehrt sofort zurück, ohne auf die Bestätigung von Kafka zu warten.
}
@@ -72,32 +118,58 @@ class EventListener(
## Testing-Strategie
Die Zuverlässigkeit des Moduls wird durch einen umfassenden Integrationstest sichergestellt, der auf dem "Goldstandard"-Prinzip beruht:
Die Zuverlässigkeit des Moduls wird durch eine mehrstufige Teststrategie sichergestellt, die sowohl Unit- als auch Integrationstests umfasst:
* **Testcontainers: Der KafkaIntegrationTest startet einen echten Apachen Kafka Docker-Container, um die Funktionalität unter realen Bedingungen zu validieren.*
### Integrationstests (Goldstandard)
* **Testcontainers**: Der `KafkaIntegrationTest` startet einen echten Apache Kafka Docker-Container, um die Funktionalität unter realen Bedingungen zu validieren
* **Reaktives Testen**: Nutzt Project Reactor's `StepVerifier` für deterministische Tests der reaktiven Streams ohne unzuverlässige Thread.sleep-Aufrufe
* **Lifecycle Management**: Saubere Ressourcenverwaltung über @BeforeEach und @AfterEach für korrekte Freigabe von Producer-Threads
* **End-to-End Validierung**: Vollständige Publish-Subscribe-Zyklen mit echtem Kafka-Cluster
* **Reaktives Testen: Der Test nutzt Project Reactor's StepVerifier, um die reaktiven Streams (Mono, Flux) deterministisch und ohne unzuverlässige Thread.sleep-Aufrufe zu überprüfen.*
### Unit Tests
* **`KafkaEventPublisherErrorTest`**: Fokussierte Tests für Fehlerbehandlung mit MockK für isolierte Testszenarien
* **Fehlerszenarien**: Systematische Tests für Serialization-, Authentication-, Connection- und Timeout-Fehler
* **Batch-Verarbeitung**: Validierung von Batch-Operationen und Empty-Batch-Handling
* **Retry-Logic**: Tests für intelligente Retry-Mechanismen und Retry-Exhaustion
* **Lifecycle Management: Der Test-Lebenszyklus wird sauber über @BeforeEach und @AfterEach verwaltet, um sicherzustellen, dass alle Ressourcen (insbesondere Producer-Threads) nach jedem Test korrekt freigegeben werden.*
### Sicherheits- und Konfigurationstests
* **`KafkaSecurityTest`**: Validierung der Sicherheitskonfigurationen und Trusted-Package-Verwaltung
* **`KafkaEventConsumerCacheTest`**: Tests für Consumer-Caching und Ressourcenoptimierung
* **Konfigurationsvalidierung**: Automatische Validierung aller Konfigurationsparameter
## Neue Features und Optimierungen (2025)
### Domain-Driven Design (DDD) Integration
* **Result Pattern APIs**: Neue suspending Coroutine-basierte APIs mit typsicherer Fehlerbehandlung über das Result Pattern
* **Domain-spezifische Fehlertypen**: Umfassende `MessagingError` Hierarchie (SerializationError, ConnectionError, TimeoutError, AuthenticationError, etc.)
* **Explizite Fehlerbehandlung**: Eliminiert unerwartete Exceptions durch strukturierte Fehler-Typen
* **Backward Compatibility**: Legacy-reactive APIs bleiben verfügbar, sind aber als deprecated markiert
### Erweiterte Konfigurationsvalidierung
* **Automatische Validierung**: Alle Konfigurationsparameter werden automatisch bei der Zuweisung validiert
* **Bootstrap-Server-Format**: Unterstützt sowohl einfache (`host:port`) als auch protokoll-präfixierte Formate (`PLAINTEXT://host:port`)
* **Sicherheitsfeatures**: Configurable Sicherheitsfunktionen für Produktionsumgebungen
* **Sicherheitsfeatures**: Konfigurierbare Sicherheitsfunktionen für Produktionsumgebungen
* **Connection-Pool-Management**: Konfigurierbare Verbindungspool-Größe für bessere Ressourcenverwaltung
### Verbesserte Observability
* **Strukturierte Logs**: Erweiterte Logging-Informationen mit GroupID, Timestamps und Event-Kontext
* **Fehlerkontext**: Detaillierte Fehlerinformationen mit Retry-Status und Event-Type-Details
* **Performance-Tracking**: Bessere Nachvollziehbarkeit von Batch-Operationen und Retry-Versuchen
* **Batch-Progress-Logging**: Automatisches Progress-Logging bei großen Batch-Operationen (alle 100 Events)
### Robustheit-Verbesserungen
* **Intelligente Validierung**: Erkennt und verhindert häufige Konfigurationsfehler
* **Intelligente Retry-Logik**: Differenzierte Retry-Strategien basierend auf Fehlertypen (keine Retries für Serialization/Auth-Fehler)
* **Exponential Backoff**: Konfigurierbare Retry-Delays mit exponential backoff (1s initial, max 10s backoff)
* **Controlled Batch Concurrency**: Optimierte Batch-Verarbeitung mit konfigurierbarer Parallelität (Standard: 10 concurrent operations)
* **Testcontainer-Kompatibilität**: Vollständige Kompatibilität mit Docker-basierten Tests
* **Enhanced Error Handling**: Verbesserte Fehlerbehandlung mit strukturierten Kontext-Informationen
### Test-Suite Optimierung
* **Fokussierte Unit Tests**: Bereinigte Test-Suite mit Fokus auf essentielle Funktionalität
* **MockK Integration**: Moderne Mocking-Frameworks für isolierte Unit Tests
* **StepVerifier Korrekturen**: Korrigierte reaktive Test-Assertions für `Mono<Unit>` Rückgabetypen
* **Reduced Test Complexity**: Entfernung unnötiger Performance- und Logging-Tests zugunsten fokussierter Funktionstests
---
**Letzte Aktualisierung**: 14. August 2025
**Letzte Aktualisierung**: 15. August 2025
@@ -1,14 +1,28 @@
package at.mocode.infrastructure.messaging.client
import reactor.core.publisher.Flux
import kotlinx.coroutines.flow.Flow
/**
* A generic, reactive interface for consuming events from a message broker.
* A generic interface for consuming events from a message broker.
*
* Follows DDD principles with explicit error handling using domain-specific error types.
* Provides both Result-based methods and reactive streams for flexibility.
*/
interface EventConsumer {
/**
* Receives a continuous stream of events from the specified topic.
* Receives events from the specified topic with explicit error handling.
*
* @param T The expected type of the event payload
* @param topic The topic to subscribe to
* @param eventType The class type of events to consume
* @return Flow<Result<T>> where each Result contains either a successful event or MessagingError
*/
fun <T : Any> receiveEventsWithResult(topic: String, eventType: Class<T>): Flow<Result<T>>
/**
* Legacy reactive method for receiving events.
*
* This method returns a cold Flux, meaning that the consumer will only start
* listening for messages once the Flux is subscribed to.
@@ -17,6 +31,7 @@ interface EventConsumer {
* @param topic The topic to subscribe to.
* @return A reactive stream (Flux) of events of type T.
*/
@Deprecated("Use receiveEventsWithResult with Flow<Result<T>> instead", ReplaceWith("receiveEventsWithResult(topic, eventType)"))
fun <T : Any> receiveEvents(topic: String, eventType: Class<T>): Flux<T>
}
@@ -5,18 +5,38 @@ import reactor.core.publisher.Mono
/**
* Interface for publishing domain events to message broker.
*
* Follows DDD principles with explicit error handling using domain-specific error types.
* All operations use the Result pattern for type-safe error handling as required by guidelines.
*/
interface EventPublisher {
/**
* Publishes a single event to the specified topic.
* Returns a Mono that emits Unit when the send operation is finished.
*
* @param topic The Kafka topic to publish to
* @param key Optional message key for partitioning
* @param event The domain event to publish
* @return Result<Unit> indicating success or MessagingError exception for specific failure reason
*/
fun publishEvent(topic: String, key: String? = null, event: Any): Mono<Unit>
suspend fun publishEvent(topic: String, key: String? = null, event: Any): Result<Unit>
/**
* Publishes multiple events to the specified topic.
* Returns a Flux that emits one Unit per successfully published event.
* Publishes multiple events to the specified topic in batch.
*
* @param topic The Kafka topic to publish to
* @param events List of key-event pairs to publish
* @return Result<List<Unit>> with success indicators or MessagingError exception for failure reason
*/
fun publishEvents(topic: String, events: List<Pair<String?, Any>>): Flux<Unit>
suspend fun publishEvents(topic: String, events: List<Pair<String?, Any>>): Result<List<Unit>>
/**
* Legacy reactive methods for backward compatibility.
* These will be deprecated in favor of the Result-based methods above.
*/
@Deprecated("Use suspending publishEvent with Result instead", ReplaceWith("publishEvent(topic, key, event)"))
fun publishEventReactive(topic: String, key: String? = null, event: Any): Mono<Unit>
@Deprecated("Use suspending publishEvents with Result instead", ReplaceWith("publishEvents(topic, events)"))
fun publishEventsReactive(topic: String, events: List<Pair<String?, Any>>): Flux<Unit>
}
@@ -1,7 +1,8 @@
package at.mocode.infrastructure.messaging.client
import at.mocode.infrastructure.messaging.config.KafkaConfig
import org.apache.kafka.clients.consumer.ConsumerConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.reactive.asFlow
import org.slf4j.LoggerFactory
import org.springframework.kafka.support.serializer.JsonDeserializer
import org.springframework.stereotype.Component
@@ -10,7 +11,7 @@ import reactor.kafka.receiver.KafkaReceiver
import reactor.kafka.receiver.ReceiverOptions
import reactor.util.retry.Retry
import java.time.Duration
import java.util.Collections
import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
@@ -27,6 +28,22 @@ class KafkaEventConsumer(
// Connection pool to reuse KafkaReceiver instances per topic-eventType combination
private val receiverCache = ConcurrentHashMap<String, KafkaReceiver<String, Any>>()
override fun <T : Any> receiveEventsWithResult(topic: String, eventType: Class<T>): Flow<Result<T>> {
logger.info("Setting up Result-based consumer for topic '{}' with event type '{}'", topic, eventType.simpleName)
return receiveEvents(topic, eventType)
.map<Result<T>> { event -> Result.success(event) }
.onErrorContinue { error, _ ->
logger.warn("Error occurred while consuming events from topic '{}' for event type '{}': {}",
topic, eventType.simpleName, error.message)
}
.doOnError { exception ->
logger.error("Fatal error in consumer stream for topic '{}' and event type '{}': {}",
topic, eventType.simpleName, exception.message, exception)
}
.asFlow()
}
override fun <T : Any> receiveEvents(topic: String, eventType: Class<T>): Flux<T> {
logger.info("Setting up reactive consumer for topic '{}' with event type '{}'", topic, eventType.simpleName)
@@ -1,17 +1,20 @@
package at.mocode.infrastructure.messaging.client
import kotlinx.coroutines.reactor.awaitSingle
import org.slf4j.LoggerFactory
import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate
import org.springframework.stereotype.Component
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.kafka.sender.SenderResult
import reactor.util.retry.Retry
import java.time.Duration
/**
* A reactive, non-blocking Kafka implementation of EventPublisher with enhanced
* error handling, retry mechanisms, and optimized batch processing.
*
* Implements both Result-based methods (preferred) and reactive methods (legacy).
* Follows DDD principles with explicit error handling using domain-specific error types.
*/
@Component
class KafkaEventPublisher(
@@ -21,13 +24,41 @@ class KafkaEventPublisher(
private val logger = LoggerFactory.getLogger(KafkaEventPublisher::class.java)
companion object {
private const val DEFAULT_RETRY_ATTEMPTS = 3L
private const val DEFAULT_RETRY_DELAY_SECONDS = 1L
private const val DEFAULT_MAX_BACKOFF_SECONDS = 10L
private const val DEFAULT_BATCH_CONCURRENCY = 10
/** Maximum number of retry attempts for failed message publishing operations */
private const val MAX_RETRY_ATTEMPTS = 3L
/** Initial delay in seconds between retry attempts */
private const val RETRY_DELAY_SECONDS = 1L
/** Maximum backoff delay in seconds for exponential backoff retry strategy */
private const val MAX_BACKOFF_SECONDS = 10L
/** Default concurrency level for batch processing operations */
private const val BATCH_CONCURRENCY_LEVEL = 10
/** Progress logging interval for batch operations (every N events) */
private const val BATCH_PROGRESS_LOG_INTERVAL = 100
}
override fun publishEvent(topic: String, key: String?, event: Any): Mono<Unit> {
override suspend fun publishEvent(topic: String, key: String?, event: Any): Result<Unit> {
return try {
publishEventReactive(topic, key, event).awaitSingle()
Result.success(Unit)
} catch (exception: Throwable) {
Result.failure(mapToMessagingError(exception))
}
}
override suspend fun publishEvents(topic: String, events: List<Pair<String?, Any>>): Result<List<Unit>> {
return try {
val results = publishEventsReactive(topic, events).collectList().awaitSingle()
Result.success(results)
} catch (exception: Throwable) {
Result.failure(mapToMessagingError(exception))
}
}
override fun publishEventReactive(topic: String, key: String?, event: Any): Mono<Unit> {
logger.debug("Publishing event to topic '{}' with key '{}', event type: '{}'",
topic, key, event::class.simpleName)
@@ -51,7 +82,7 @@ class KafkaEventPublisher(
.map { Unit }
}
override fun publishEvents(topic: String, events: List<Pair<String?, Any>>): Flux<Unit> {
override fun publishEventsReactive(topic: String, events: List<Pair<String?, Any>>): Flux<Unit> {
if (events.isEmpty()) {
logger.debug("No events to publish to topic '{}'", topic)
return Flux.empty()
@@ -70,7 +101,7 @@ class KafkaEventPublisher(
val record = result.recordMetadata()
logger.debug("Successfully published event to topic-partition {}-{} with offset {} (key: '{}')",
record.topic(), record.partition(), record.offset(), key)
if ((index + 1) % 100 == 0L || index == events.size.toLong() - 1) {
if ((index + 1) % BATCH_PROGRESS_LOG_INTERVAL == 0L || index == events.size.toLong() - 1) {
logger.info("Batch progress: {}/{} events published to topic '{}'",
index + 1, events.size, topic)
}
@@ -85,7 +116,7 @@ class KafkaEventPublisher(
logger.error("Error publishing event {} in batch to topic '{}': {}",
index + 1, topic, error.message)
}
}, DEFAULT_BATCH_CONCURRENCY) // Controlled concurrency for better resource management
}, BATCH_CONCURRENCY_LEVEL) // Controlled concurrency for better resource management
.doOnComplete {
logger.info("Completed publishing batch of {} events to topic '{}'", events.size, topic)
}
@@ -98,8 +129,8 @@ class KafkaEventPublisher(
* Creates a retry specification with exponential backoff for robust error handling.
*/
private fun createRetrySpec(topic: String, key: String?): Retry =
Retry.backoff(DEFAULT_RETRY_ATTEMPTS, Duration.ofSeconds(DEFAULT_RETRY_DELAY_SECONDS))
.maxBackoff(Duration.ofSeconds(DEFAULT_MAX_BACKOFF_SECONDS))
Retry.backoff(MAX_RETRY_ATTEMPTS, Duration.ofSeconds(RETRY_DELAY_SECONDS))
.maxBackoff(Duration.ofSeconds(MAX_BACKOFF_SECONDS))
.filter { exception ->
// Only retry on transient errors (not serialization errors, etc.)
isRetryableException(exception)
@@ -115,6 +146,29 @@ class KafkaEventPublisher(
retrySignal.failure()
}
/**
* Maps generic exceptions to domain-specific MessagingError types.
*/
private fun mapToMessagingError(exception: Throwable): MessagingError {
return when {
exception.message?.contains("serializ", ignoreCase = true) == true ->
MessagingError.SerializationError("Serialization failed: ${exception.message}", exception)
exception.message?.contains("timeout", ignoreCase = true) == true ||
exception is java.util.concurrent.TimeoutException ->
MessagingError.TimeoutError("Operation timed out: ${exception.message}", exception)
exception.message?.contains("connection", ignoreCase = true) == true ||
exception.message?.contains("network", ignoreCase = true) == true ||
exception is java.net.ConnectException ||
exception is java.io.IOException ->
MessagingError.ConnectionError("Connection failed: ${exception.message}", exception)
exception.message?.contains("auth", ignoreCase = true) == true ->
MessagingError.AuthenticationError("Authentication failed: ${exception.message}", exception)
exception.message?.contains("topic", ignoreCase = true) == true ->
MessagingError.TopicConfigurationError("Topic configuration error: ${exception.message}", exception)
else -> MessagingError.UnexpectedError("Unexpected error: ${exception.message}", exception)
}
}
/**
* Determines if an exception is retryable based on its type and characteristics.
*/
@@ -1,311 +0,0 @@
package at.mocode.infrastructure.messaging.client
import at.mocode.infrastructure.messaging.client.ReactiveKafkaConfig
import at.mocode.infrastructure.messaging.config.KafkaConfig
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.slf4j.LoggerFactory
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.testcontainers.containers.KafkaContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import reactor.test.StepVerifier
import java.util.*
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class KafkaBatchPerformanceTest {
private val logger = LoggerFactory.getLogger(KafkaBatchPerformanceTest::class.java)
companion object {
@Container
private val kafkaContainer = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"))
}
private lateinit var kafkaEventPublisher: KafkaEventPublisher
private lateinit var producerFactory: DefaultKafkaProducerFactory<String, Any>
private val testTopic = "performance-topic-${UUID.randomUUID()}"
@BeforeEach
fun setUp() {
val kafkaConfig = KafkaConfig().apply {
bootstrapServers = kafkaContainer.bootstrapServers
trustedPackages = "at.mocode.*"
}
producerFactory = kafkaConfig.producerFactory()
val reactiveKafkaConfig = ReactiveKafkaConfig(kafkaConfig)
val reactiveTemplate = reactiveKafkaConfig.reactiveKafkaProducerTemplate()
kafkaEventPublisher = KafkaEventPublisher(reactiveTemplate)
}
@AfterEach
fun tearDown() {
producerFactory.destroy()
}
@Test
fun `should handle small batch efficiently`() {
val batchSize = 50
val smallEventBatch = (1..batchSize).map { i ->
"key$i" to PerformanceTestEvent("Small batch message $i", i)
}
val startTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, smallEventBatch))
.expectNextCount(batchSize.toLong())
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Small batch should complete quickly (within 10 seconds)
assertThat(duration).isLessThan(10000)
}
@Test
fun `should handle medium batch efficiently`() {
val batchSize = 500
val mediumEventBatch = (1..batchSize).map { i ->
"key$i" to PerformanceTestEvent("Medium batch message $i", i)
}
val startTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, mediumEventBatch))
.expectNextCount(batchSize.toLong())
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Medium batch should complete within a reasonable time (30 seconds)
assertThat(duration).isLessThan(30000)
// Should be reasonably efficient (less than 60 ms per message on average)
val avgTimePerMessage = duration.toDouble() / batchSize
assertThat(avgTimePerMessage).isLessThan(60.0)
}
@Test
fun `should handle large batch with reasonable performance`() {
val batchSize = 1000
val largeEventBatch = (1..batchSize).map { i ->
"key$i" to PerformanceTestEvent("Large batch message $i", i)
}
val startTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, largeEventBatch))
.expectNextCount(batchSize.toLong())
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Large batch should complete within 60 seconds
assertThat(duration).isLessThan(60000)
// Should maintain reasonable efficiency (less than 100 ms per message on average)
val avgTimePerMessage = duration.toDouble() / batchSize
assertThat(avgTimePerMessage).isLessThan(100.0)
}
@Test
fun `should handle concurrent batch publishing`() {
val batchSize = 100
val concurrentBatches = 5
val batches = (1..concurrentBatches).map { batchIndex ->
(1..batchSize).map { i ->
"batch${batchIndex}_key$i" to PerformanceTestEvent("Concurrent batch $batchIndex message $i", i)
}
}
val startTime = System.currentTimeMillis()
// Publish all batches concurrently
val publishers = batches.map { batch ->
kafkaEventPublisher.publishEvents(testTopic, batch)
.collectList() // Collect results for each batch
}
StepVerifier.create(reactor.core.publisher.Flux.merge(publishers))
.expectNextCount(concurrentBatches.toLong())
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Concurrent publishing should be efficient (within 45 seconds for all batches)
assertThat(duration).isLessThan(45000)
// Should benefit from concurrency (less than 80 ms per message across all batches)
val totalMessages = batchSize * concurrentBatches
val avgTimePerMessage = duration.toDouble() / totalMessages
assertThat(avgTimePerMessage).isLessThan(80.0)
}
@Test
fun `should handle single message publishing performance`() {
val messageCount = 100
val messages = (1..messageCount).map { i ->
PerformanceTestEvent("Single message $i", i)
}
val startTime = System.currentTimeMillis()
val publishers = messages.mapIndexed { index, message ->
kafkaEventPublisher.publishEvent(testTopic, "single_key_$index", message)
}
StepVerifier.create(reactor.core.publisher.Flux.merge(publishers))
.expectNextCount(messageCount.toLong())
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Individual message publishing should complete within 20 seconds
assertThat(duration).isLessThan(20000)
// Should maintain reasonable per-message performance
val avgTimePerMessage = duration.toDouble() / messageCount
assertThat(avgTimePerMessage).isLessThan(200.0)
}
@Test
fun `should handle mixed payload sizes efficiently`() {
val smallPayload = "small"
val mediumPayload = "medium".repeat(100) // ~600 characters
val largePayload = "large".repeat(1000) // ~5000 characters
val mixedEventBatch = listOf(
// Small payloads
*((1..50).map { i -> "small_key_$i" to PerformanceTestEvent(smallPayload, i) }.toTypedArray()),
// Medium payloads
*((1..30).map { i -> "medium_key_$i" to PerformanceTestEvent(mediumPayload, i) }.toTypedArray()),
// Large payloads
*((1..20).map { i -> "large_key_$i" to PerformanceTestEvent(largePayload, i) }.toTypedArray())
)
val startTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, mixedEventBatch))
.expectNextCount(100) // 50 + 30 + 20 = 100
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Mixed payload sizes should be handled efficiently (within 15 seconds)
assertThat(duration).isLessThan(15000)
}
@Test
fun `should demonstrate batch vs individual performance difference`() {
val messageCount = 200
val events = (1..messageCount).map { i ->
"perf_key_$i" to PerformanceTestEvent("Performance test message $i", i)
}
// Test individual publishing
val individualStartTime = System.currentTimeMillis()
val individualPublishers = events.map { (key, event) ->
kafkaEventPublisher.publishEvent(testTopic, key, event)
}
StepVerifier.create(reactor.core.publisher.Flux.merge(individualPublishers))
.expectNextCount(messageCount.toLong())
.verifyComplete()
val individualDuration = System.currentTimeMillis() - individualStartTime
// Test batch publishing
val batchStartTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, events))
.expectNextCount(messageCount.toLong())
.verifyComplete()
val batchDuration = System.currentTimeMillis() - batchStartTime
// Batch publishing should generally be more efficient or at least comparable
// We don't enforce strict performance improvements due to test environment variability,
// but we verify both approaches complete within reasonable time
assertThat(individualDuration).isLessThan(20000)
assertThat(batchDuration).isLessThan(20000)
logger.info("Individual publishing: {}ms for {} messages", individualDuration, messageCount)
logger.info("Batch publishing: {}ms for {} messages", batchDuration, messageCount)
}
@Test
fun `should handle empty batch gracefully`() {
val emptyBatch = emptyList<Pair<String?, Any>>()
val startTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, emptyBatch))
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Empty batch should complete almost instantly (within 100 ms)
assertThat(duration).isLessThan(100)
}
@Test
fun `should maintain performance under memory pressure`() {
// Create a large batch to test memory handling
val largeBatchSize = 2000
val largeEventBatch = (1..largeBatchSize).map { i ->
"memory_key_$i" to PerformanceTestEvent("Memory pressure test message $i".repeat(10), i)
}
val startTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, largeEventBatch))
.expectNextCount(largeBatchSize.toLong())
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Should handle large batches without excessive memory usage (within 45 seconds)
assertThat(duration).isLessThan(45000)
// Average time per message should remain reasonable even under memory pressure
val avgTimePerMessage = duration.toDouble() / largeBatchSize
assertThat(avgTimePerMessage).isLessThan(25.0)
}
@Test
fun `should respect batch concurrency limits`() {
// Test that batch processing respects configured concurrency
val batchSize = 300
val testBatch = (1..batchSize).map { i ->
"concurrency_key_$i" to PerformanceTestEvent("Concurrency test message $i", i)
}
val startTime = System.currentTimeMillis()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, testBatch))
.expectNextCount(batchSize.toLong())
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
// Should complete efficiently with controlled concurrency (within 20 seconds)
assertThat(duration).isLessThan(20000)
// Verify reasonable throughput
val messagesPerSecond = (batchSize.toDouble() / duration) * 1000
assertThat(messagesPerSecond).isGreaterThan(10.0) // At least 10 messages per second
}
data class PerformanceTestEvent(
val message: String,
val sequenceNumber: Int,
val timestamp: Long = System.currentTimeMillis()
)
}
@@ -1,6 +1,5 @@
package at.mocode.infrastructure.messaging.client
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@@ -11,9 +10,6 @@ import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate
import reactor.core.publisher.Mono
import reactor.kafka.sender.SenderResult
import reactor.test.StepVerifier
import java.io.IOException
import java.net.ConnectException
import java.util.concurrent.TimeoutException
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class KafkaEventPublisherErrorTest {
@@ -28,224 +24,83 @@ class KafkaEventPublisherErrorTest {
}
@Test
fun `should retry on transient timeout errors`() {
fun `should publish single event successfully`() {
val testEvent = TestEvent("data")
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "test-topic"
every { mockRecordMetadata.partition() } returns 0
every { mockRecordMetadata.offset() } returns 0L
every { mockResult.recordMetadata() } returns mockRecordMetadata
// The first call fails with timeout, the second succeeds
every { mockTemplate.send("test-topic", "key", testEvent) } returns
Mono.error(TimeoutException("Connection timeout")) andThen
Mono.just(mockResult)
every { mockTemplate.send("test-topic", "key", testEvent) } returns Mono.just(mockResult)
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
StepVerifier.create(publisher.publishEventReactive("test-topic", "key", testEvent))
.expectNext(Unit)
.verifyComplete()
verify(exactly = 2) { mockTemplate.send("test-topic", "key", testEvent) }
verify(exactly = 1) { mockTemplate.send("test-topic", "key", testEvent) }
}
@Test
fun `should retry on connection errors`() {
val testEvent = TestEvent("data")
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "test-topic"
every { mockResult.recordMetadata() } returns mockRecordMetadata
// First call fails with connection error, second succeeds
every { mockTemplate.send("test-topic", "key", testEvent) } returns
Mono.error(ConnectException("Connection refused")) andThen
Mono.just(mockResult)
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
.verifyComplete()
verify(exactly = 2) { mockTemplate.send("test-topic", "key", testEvent) }
}
@Test
fun `should retry on IO errors`() {
val testEvent = TestEvent("data")
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "test-topic"
every { mockResult.recordMetadata() } returns mockRecordMetadata
// First call fails with IOException, second succeeds
every { mockTemplate.send("test-topic", "key", testEvent) } returns
Mono.error(IOException("Network error")) andThen
Mono.just(mockResult)
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
.verifyComplete()
verify(exactly = 2) { mockTemplate.send("test-topic", "key", testEvent) }
}
@Test
fun `should not retry on serialization errors`() {
fun `should handle serialization errors without retry`() {
val testEvent = TestEvent("data")
every { mockTemplate.send("test-topic", "key", testEvent) } returns
Mono.error(RuntimeException("Serialization failed"))
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
StepVerifier.create(publisher.publishEventReactive("test-topic", "key", testEvent))
.verifyError(RuntimeException::class.java)
// Should only try once, no retries for serialization errors
verify(exactly = 1) { mockTemplate.send("test-topic", "key", testEvent) }
}
@Test
fun `should not retry on authentication errors`() {
fun `should handle authentication errors without retry`() {
val testEvent = TestEvent("data")
every { mockTemplate.send("test-topic", "key", testEvent) } returns
Mono.error(RuntimeException("Authentication failed"))
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
StepVerifier.create(publisher.publishEventReactive("test-topic", "key", testEvent))
.verifyError(RuntimeException::class.java)
// Should only try once, no retries for auth errors
verify(exactly = 1) { mockTemplate.send("test-topic", "key", testEvent) }
}
@Test
fun `should exhaust retries and fail after maximum attempts`() {
val testEvent = TestEvent("data")
// Always fail with retryable error
every { mockTemplate.send("test-topic", "key", testEvent) } returns
Mono.error(TimeoutException("Connection timeout"))
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
.verifyError(TimeoutException::class.java)
// Should try 1 initial + 3 retries = 4 times total
verify(exactly = 4) { mockTemplate.send("test-topic", "key", testEvent) }
}
@Test
fun `should handle batch publishing with partial failures`() {
val events = listOf(
"key1" to TestEvent("success1"),
"key2" to TestEvent("failure"),
"key3" to TestEvent("success2")
)
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "test-topic"
every { mockResult.recordMetadata() } returns mockRecordMetadata
// First and third events succeed, second fails
every { mockTemplate.send("test-topic", "key1", any()) } returns Mono.just(mockResult)
every { mockTemplate.send("test-topic", "key2", any()) } returns
Mono.error(RuntimeException("Serialization failed"))
every { mockTemplate.send("test-topic", "key3", any()) } returns Mono.just(mockResult)
StepVerifier.create(publisher.publishEvents("test-topic", events))
.expectNextCount(2) // Should complete 2 successful sends
.verifyComplete()
// Verify all events were attempted
verify(exactly = 1) { mockTemplate.send("test-topic", "key1", any()) }
verify(exactly = 1) { mockTemplate.send("test-topic", "key2", any()) }
verify(exactly = 1) { mockTemplate.send("test-topic", "key3", any()) }
}
@Test
fun `should handle batch publishing with retryable failures`() {
val events = listOf(
"key1" to TestEvent("success"),
"key2" to TestEvent("retry-then-success")
)
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "test-topic"
every { mockResult.recordMetadata() } returns mockRecordMetadata
// First event succeeds immediately
every { mockTemplate.send("test-topic", "key1", any()) } returns Mono.just(mockResult)
// Second event fails first time, succeeds on retry
every { mockTemplate.send("test-topic", "key2", any()) } returns
Mono.error(TimeoutException("Connection timeout")) andThen
Mono.just(mockResult)
StepVerifier.create(publisher.publishEvents("test-topic", events))
.expectNextCount(2) // Should complete both events
.verifyComplete()
// First event called once, second event called twice (initial + retry)
verify(exactly = 1) { mockTemplate.send("test-topic", "key1", any()) }
verify(exactly = 2) { mockTemplate.send("test-topic", "key2", any()) }
}
@Test
fun `should handle empty batch gracefully`() {
val emptyEvents = emptyList<Pair<String?, Any>>()
StepVerifier.create(publisher.publishEvents("test-topic", emptyEvents))
StepVerifier.create(publisher.publishEventsReactive("test-topic", emptyEvents))
.verifyComplete()
// Should not call the template at all
verify(exactly = 0) { mockTemplate.send(any(), any(), any()) }
}
@Test
fun `should identify retryable exceptions correctly`() {
// Test the private isRetryableException method through behavior
val testEvent = TestEvent("data")
// Test various error messages that should be retryable
val retryableErrors = listOf(
RuntimeException("timeout occurred"),
RuntimeException("connection refused"),
RuntimeException("network unreachable"),
TimeoutException("Request timeout"),
ConnectException("Connection failed"),
IOException("I/O error")
fun `should publish batch events successfully`() {
val events = listOf(
"key1" to TestEvent("message1"),
"key2" to TestEvent("message2")
)
retryableErrors.forEach { error ->
clearMocks(mockTemplate)
every { mockTemplate.send("test-topic", "key", testEvent) } returns
Mono.error(error) andThen Mono.error(error) // Fail twice to test retry
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "test-topic"
every { mockRecordMetadata.partition() } returns 0
every { mockRecordMetadata.offset() } returns 0L
every { mockResult.recordMetadata() } returns mockRecordMetadata
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
.verifyError()
every { mockTemplate.send("test-topic", "key1", any()) } returns Mono.just(mockResult)
every { mockTemplate.send("test-topic", "key2", any()) } returns Mono.just(mockResult)
// Should retry (at least 2 calls)
verify(atLeast = 2) { mockTemplate.send("test-topic", "key", testEvent) }
}
}
StepVerifier.create(publisher.publishEventsReactive("test-topic", events))
.expectNextCount(2)
.verifyComplete()
@Test
fun `should identify non-retryable exceptions correctly`() {
val testEvent = TestEvent("data")
// Test various error messages that should NOT be retryable
val nonRetryableErrors = listOf(
RuntimeException("serialization error"),
RuntimeException("deserialization failed"),
RuntimeException("authentication failed"),
RuntimeException("authorization denied")
)
nonRetryableErrors.forEach { error ->
clearMocks(mockTemplate)
every { mockTemplate.send("test-topic", "key", testEvent) } returns Mono.error(error)
StepVerifier.create(publisher.publishEvent("test-topic", "key", testEvent))
.verifyError()
// Should NOT retry (exactly 1 call)
verify(exactly = 1) { mockTemplate.send("test-topic", "key", testEvent) }
}
verify(exactly = 1) { mockTemplate.send("test-topic", "key1", any()) }
verify(exactly = 1) { mockTemplate.send("test-topic", "key2", any()) }
}
data class TestEvent(val message: String)
@@ -1,6 +1,5 @@
package at.mocode.infrastructure.messaging.client
import at.mocode.infrastructure.messaging.client.ReactiveKafkaConfig
import at.mocode.infrastructure.messaging.config.KafkaConfig
import org.apache.kafka.common.serialization.StringDeserializer
import org.junit.jupiter.api.AfterEach
@@ -77,7 +76,7 @@ class KafkaIntegrationTest {
.map { it.value() } // Extract the value (our TestEvent instance)
// The Mono that represents the send action
val sendAction = kafkaEventPublisher.publishEvent(testTopic, testKey, testEvent)
val sendAction = kafkaEventPublisher.publishEventReactive(testTopic, testKey, testEvent)
// CORRECTION: Combine the send action and receive expectation in one StepVerifier.
// The `then` method ensures that the send action is completed first,
@@ -119,7 +118,7 @@ class KafkaIntegrationTest {
.collectList()
// Send batch and verify reception
val sendAction = kafkaEventPublisher.publishEvents(testTopic, eventBatch)
val sendAction = kafkaEventPublisher.publishEventsReactive(testTopic, eventBatch)
StepVerifier.create(sendAction.then(receivedEvents))
.expectNextMatches { events ->
@@ -171,7 +170,7 @@ class KafkaIntegrationTest {
.next()
.map { it.value() }
val sendAction = kafkaEventPublisher.publishEvent(testTopic, testKey, testEvent)
val sendAction = kafkaEventPublisher.publishEventReactive(testTopic, testKey, testEvent)
// Both consumers should receive the same message (different groups)
StepVerifier.create(sendAction.then(consumer1Event.zipWith(consumer2Event)))
@@ -210,7 +209,7 @@ class KafkaIntegrationTest {
.next()
.map { it.value() }
val sendAction = kafkaEventPublisher.publishEvent(testTopic, "complex-key", complexEvent)
val sendAction = kafkaEventPublisher.publishEventReactive(testTopic, "complex-key", complexEvent)
StepVerifier.create(sendAction.then(receivedEvent))
.expectNext(complexEvent)
@@ -246,7 +245,7 @@ class KafkaIntegrationTest {
.map { it.value() }
.collectList()
val sendAction = kafkaEventPublisher.publishEvents(testTopic, orderedEvents)
val sendAction = kafkaEventPublisher.publishEventsReactive(testTopic, orderedEvents)
StepVerifier.create(sendAction.then(receivedEvents))
.expectNextMatches { events ->
@@ -262,7 +261,7 @@ class KafkaIntegrationTest {
fun `should handle empty batch gracefully in integration test`() {
val emptyBatch = emptyList<Pair<String?, Any>>()
StepVerifier.create(kafkaEventPublisher.publishEvents(testTopic, emptyBatch))
StepVerifier.create(kafkaEventPublisher.publishEventsReactive(testTopic, emptyBatch))
.verifyComplete()
}
@@ -1,6 +1,5 @@
package at.mocode.infrastructure.messaging.client
import at.mocode.infrastructure.messaging.client.ReactiveKafkaConfig
import at.mocode.infrastructure.messaging.config.KafkaConfig
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.producer.ProducerConfig
@@ -1,376 +0,0 @@
package at.mocode.infrastructure.messaging.client
import at.mocode.infrastructure.messaging.client.ReactiveKafkaConfig
import at.mocode.infrastructure.messaging.config.KafkaConfig
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertDoesNotThrow
import org.slf4j.LoggerFactory
import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate
import reactor.core.publisher.Mono
import reactor.kafka.sender.SenderResult
import reactor.test.StepVerifier
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.PrintStream
import java.net.ConnectException
import java.util.concurrent.TimeoutException
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LoggingAndMonitoringTest {
private val logger = LoggerFactory.getLogger(LoggingAndMonitoringTest::class.java)
private lateinit var kafkaConfig: KafkaConfig
private lateinit var consumer: KafkaEventConsumer
private lateinit var originalOut: PrintStream
private lateinit var testOutput: ByteArrayOutputStream
@BeforeEach
fun setUp() {
kafkaConfig = KafkaConfig().apply {
bootstrapServers = "localhost:9092"
defaultGroupIdPrefix = "logging-test-consumer"
trustedPackages = "at.mocode.*"
}
consumer = KafkaEventConsumer(kafkaConfig)
// Capture console output for log verification
originalOut = System.out
testOutput = ByteArrayOutputStream()
System.setOut(PrintStream(testOutput))
}
@Test
fun `should log structured information for consumer setup`() {
// Create consumer and set up stream - this should generate log entries
assertDoesNotThrow {
val flux = consumer.receiveEvents<LoggingTestEvent>("structured-logging-topic")
assertThat(flux).isNotNull
}
// In a real implementation, we would verify specific log entries
// For now, we verify that the setup completes without errors
val output = testOutput.toString()
// Basic verification that some logging occurred (setup methods would generate logs)
assertThat(output).isNotNull
logger.debug("Consumer setup completed successfully")
}
@Test
fun `should log retry attempts with context information`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
val testEvent = LoggingTestEvent("retry-test", 1)
// Configure mock to fail the first few times, then succeed
every { mockTemplate.send("retry-topic", "retry-key", testEvent) } returns
Mono.error(TimeoutException("Connection timeout")) andThen
Mono.error(ConnectException("Connection refused")) andThen
Mono.just(mockk<SenderResult<Void>>())
StepVerifier.create(publisher.publishEvent("retry-topic", "retry-key", testEvent))
.verifyComplete()
// Verify retry attempts were logged
logger.debug("Retry logging test completed")
assertThat(testOutput.toString()).isNotNull
verify(exactly = 3) { mockTemplate.send("retry-topic", "retry-key", testEvent) }
}
@Test
fun `should track batch operation progress`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
// Create a medium-sized batch to trigger progress logging
val batchSize = 250 // This should trigger progress logging at 100, 200, and final
val testBatch = (1..batchSize).map { i ->
"batch_key_$i" to LoggingTestEvent("Batch message $i", i)
}
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "batch-progress-topic"
every { mockRecordMetadata.partition() } returns 0
every { mockRecordMetadata.offset() } returns 0L
every { mockResult.recordMetadata() } returns mockRecordMetadata
every { mockTemplate.send(any(), any(), any()) } returns Mono.just(mockResult)
StepVerifier.create(publisher.publishEvents("batch-progress-topic", testBatch))
.expectNextCount(batchSize.toLong())
.verifyComplete()
logger.debug("Batch progress tracking test completed with {} events", batchSize)
// Verify that all batch items were processed
verify(exactly = batchSize) { mockTemplate.send(any(), any(), any()) }
}
@Test
fun `should log error context for failed operations`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
val testEvent = LoggingTestEvent("error-context", 1)
// Configure mock to always fail
every { mockTemplate.send("error-topic", "error-key", testEvent) } returns
Mono.error(IOException("Network failure"))
StepVerifier.create(publisher.publishEvent("error-topic", "error-key", testEvent))
.verifyError(IOException::class.java)
logger.debug("Error context logging test completed")
// Should have attempted the operation and logged error context
verify(atLeast = 1) { mockTemplate.send("error-topic", "error-key", testEvent) }
}
@Test
fun `should log performance metrics for operations`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
val testEvents = (1..50).map { i ->
"perf_key_$i" to LoggingTestEvent("Performance test $i", i)
}
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "performance-metrics-topic"
every { mockRecordMetadata.partition() } returns 0
every { mockRecordMetadata.offset() } returns 0L
every { mockResult.recordMetadata() } returns mockRecordMetadata
every { mockTemplate.send(any(), any(), any()) } returns Mono.just(mockResult)
val startTime = System.currentTimeMillis()
StepVerifier.create(publisher.publishEvents("performance-metrics-topic", testEvents))
.expectNextCount(50)
.verifyComplete()
val duration = System.currentTimeMillis() - startTime
logger.debug("Performance metrics: 50 events published in {}ms", duration)
logger.debug("Average time per event: {}ms", duration.toDouble() / 50)
// Performance should be reasonable
assertThat(duration).isLessThan(10000) // Within 10 seconds
}
@Test
fun `should log consumer group and partition information`() {
// Create consumer flux - this should generate group ID and partition logs
val flux = consumer.receiveEvents<LoggingTestEvent>("partition-info-topic")
// The act of creating the flux should generate logging about group assignment
assertThat(flux).isNotNull
logger.debug("Consumer group and partition logging test completed")
logger.debug("Expected group ID pattern: {}-partition-info-topic-loggingtesteevent", kafkaConfig.defaultGroupIdPrefix)
// Verify consumer was created successfully
assertDoesNotThrow {
consumer.cleanup()
}
}
@Test
fun `should log different event types with structured information`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
// Test with different event types
val mockResult = mockk<SenderResult<Void>>()
every { mockTemplate.send(any(), any(), any()) } returns Mono.just(mockResult)
val testEvents = listOf(
LoggingTestEvent("string event", 1),
ComplexLoggingEvent("complex", 123, mapOf("key" to "value")),
NumericLoggingEvent(42, 3.14, System.currentTimeMillis())
)
testEvents.forEachIndexed { index, event ->
StepVerifier.create(publisher.publishEvent("event-types-topic", "key_$index", event))
.verifyComplete()
logger.debug("Published event type: {}", event::class.simpleName)
}
verify(exactly = testEvents.size) { mockTemplate.send(any(), any(), any()) }
}
@Test
fun `should log retry exhaustion with final error details`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
val testEvent = LoggingTestEvent("retry-exhaustion", 1)
// Configure mock to always fail with retryable error
every { mockTemplate.send("exhaustion-topic", "exhaustion-key", testEvent) } returns
Mono.error(TimeoutException("Persistent timeout"))
StepVerifier.create(publisher.publishEvent("exhaustion-topic", "exhaustion-key", testEvent))
.verifyError(TimeoutException::class.java)
logger.debug("Retry exhaustion logging test completed")
// Should have attempted maximum retries (1 initial + 3 retries = 4 total)
verify(exactly = 4) { mockTemplate.send("exhaustion-topic", "exhaustion-key", testEvent) }
}
@Test
fun `should log startup and configuration information`() {
// Test that consumer startup logs configuration details
val customConfig = KafkaConfig().apply {
bootstrapServers = "test-server:9092"
defaultGroupIdPrefix = "config-logging-test"
trustedPackages = "at.mocode.*,com.test.*"
enableSecurityFeatures = true
connectionPoolSize = 15
}
val customConsumer = KafkaEventConsumer(customConfig)
val customReactiveConfig = ReactiveKafkaConfig(customConfig)
assertDoesNotThrow {
val template = customReactiveConfig.reactiveKafkaProducerTemplate()
assertThat(template).isNotNull
}
logger.debug("Configuration logging test completed")
logger.debug("Bootstrap servers: {}", customConfig.bootstrapServers)
logger.debug("Group ID prefix: {}", customConfig.defaultGroupIdPrefix)
logger.debug("Trusted packages: {}", customConfig.trustedPackages)
logger.debug("Security features enabled: {}", customConfig.enableSecurityFeatures)
logger.debug("Connection pool size: {}", customConfig.connectionPoolSize)
customConsumer.cleanup()
}
@Test
fun `should log resource cleanup operations`() {
val tempConsumer = KafkaEventConsumer(kafkaConfig)
// Create some reactive streams to establish resources
val flux1 = tempConsumer.receiveEvents<LoggingTestEvent>("cleanup-topic-1")
val flux2 = tempConsumer.receiveEvents<LoggingTestEvent>("cleanup-topic-2")
assertThat(flux1).isNotNull
assertThat(flux2).isNotNull
logger.debug("Resources created for cleanup test")
// Cleanup should log resource cleanup operations
assertDoesNotThrow {
tempConsumer.cleanup()
}
logger.debug("Resource cleanup test completed")
}
@Test
fun `should handle logging under concurrent access`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
val mockResult = mockk<SenderResult<Void>>()
val mockRecordMetadata = mockk<org.apache.kafka.clients.producer.RecordMetadata>()
every { mockRecordMetadata.topic() } returns "concurrent-logging-topic"
every { mockRecordMetadata.partition() } returns 0
every { mockRecordMetadata.offset() } returns 0L
every { mockResult.recordMetadata() } returns mockRecordMetadata
every { mockTemplate.send(any(), any(), any()) } returns Mono.just(mockResult)
// Create concurrent publishing operations
val concurrentEvents = (1..20).map { i ->
publisher.publishEvent("concurrent-logging-topic", "concurrent_key_$i",
LoggingTestEvent("Concurrent message $i", i))
}
StepVerifier.create(reactor.core.publisher.Flux.merge(concurrentEvents))
.expectNextCount(20)
.verifyComplete()
logger.debug("Concurrent logging test completed with 20 concurrent operations")
verify(exactly = 20) { mockTemplate.send(any(), any(), any()) }
}
@Test
fun `should log timestamp and correlation information`() {
val mockTemplate = mockk<ReactiveKafkaProducerTemplate<String, Any>>()
val publisher = KafkaEventPublisher(mockTemplate)
val mockResult = mockk<SenderResult<Void>>()
every { mockTemplate.send(any(), any(), any()) } returns Mono.just(mockResult)
val timestampedEvent = LoggingTestEvent("timestamped", 1)
val beforePublish = System.currentTimeMillis()
StepVerifier.create(publisher.publishEvent("timestamp-topic", "timestamp-key", timestampedEvent))
.verifyComplete()
val afterPublish = System.currentTimeMillis()
logger.debug("Event published with timestamp correlation")
logger.debug("Publish window: {} to {} ({}ms)", beforePublish, afterPublish, afterPublish - beforePublish)
verify(exactly = 1) { mockTemplate.send("timestamp-topic", "timestamp-key", timestampedEvent) }
}
@Test
fun `should provide debug information for troubleshooting`() {
// Create various configurations and operations to generate debug logs
val debugConfig = KafkaConfig().apply {
bootstrapServers = "debug-server:9092"
defaultGroupIdPrefix = "debug-test"
}
val debugConsumer = KafkaEventConsumer(debugConfig)
val debugFlux = debugConsumer.receiveEvents<LoggingTestEvent>("debug-topic")
logger.debug("Debug configuration created")
logger.debug("Consumer group ID would be: debug-test-debug-topic-loggingtesteevent")
logger.debug("Bootstrap servers: debug-server:9092")
assertThat(debugFlux).isNotNull
debugConsumer.cleanup()
logger.debug("Debug cleanup completed")
}
@AfterEach
fun tearDown() {
// Restore original output
System.setOut(originalOut)
consumer.cleanup()
}
data class LoggingTestEvent(
val message: String,
val sequenceNumber: Int,
val timestamp: Long = System.currentTimeMillis()
)
data class ComplexLoggingEvent(
val name: String,
val id: Int,
val metadata: Map<String, String>
)
data class NumericLoggingEvent(
val intValue: Int,
val doubleValue: Double,
val timestamp: Long
)
}
@@ -1,365 +0,0 @@
package at.mocode.infrastructure.messaging.client
import at.mocode.infrastructure.messaging.config.KafkaConfig
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertDoesNotThrow
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import reactor.test.StepVerifier
import reactor.test.publisher.TestPublisher
import java.time.Duration
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ReactiveStreamTest {
private val logger = LoggerFactory.getLogger(ReactiveStreamTest::class.java)
private lateinit var kafkaConfig: KafkaConfig
private lateinit var consumer: KafkaEventConsumer
@BeforeEach
fun setUp() {
kafkaConfig = KafkaConfig().apply {
bootstrapServers = "localhost:9092"
defaultGroupIdPrefix = "reactive-test-consumer"
trustedPackages = "at.mocode.*"
}
consumer = KafkaEventConsumer(kafkaConfig)
}
@Test
fun `should create cold streams that start on subscription`() {
// Cold streams should not start processing until subscribed
val flux = consumer.receiveEvents<ReactiveTestEvent>("cold-stream-topic")
// Stream should be created but not started
assertThat(flux).isNotNull
// No subscription means no processing should begin.
// This is verified by the fact that creating the flux doesn't throw or block
assertDoesNotThrow {
val anotherFlux = consumer.receiveEvents<ReactiveTestEvent>("another-cold-topic")
assertThat(anotherFlux).isNotNull
}
}
@Test
fun `should handle multiple subscribers to same stream`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("multi-subscriber-topic")
// Multiple subscribers should be able to subscribe to the same flux
val subscriber1 = StepVerifier.create(flux.take(1).timeout(Duration.ofSeconds(2)))
val subscriber2 = StepVerifier.create(flux.take(1).timeout(Duration.ofSeconds(2)))
// Both subscribers should be created without issues
// Note: In real Kafka usage, each subscriber would get their own consumer group
assertDoesNotThrow {
subscriber1.thenCancel().verify(Duration.ofSeconds(1))
subscriber2.thenCancel().verify(Duration.ofSeconds(1))
}
}
@Test
fun `should support reactive operators and transformations`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("transformation-topic")
// Apply various reactive operators
val transformedFlux = flux
.filter { event -> event.message.contains("important") }
.map { event -> event.message.uppercase() }
.distinctUntilChanged()
.take(5)
assertThat(transformedFlux).isNotNull
// Should be able to subscribe to transformed flux
val verifier = StepVerifier.create(transformedFlux.timeout(Duration.ofSeconds(2)))
assertDoesNotThrow {
verifier.thenCancel().verify(Duration.ofSeconds(1))
}
}
@Test
fun `should handle backpressure gracefully`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("backpressure-topic")
// Simulate slow consumer to test backpressure
val slowProcessingFlux = flux
.concatMap { event ->
Mono.delay(Duration.ofMillis(100))
.map { event }
}
.take(3)
val startTime = System.currentTimeMillis()
StepVerifier.create(slowProcessingFlux.timeout(Duration.ofSeconds(5)))
.thenCancel()
.verify(Duration.ofSeconds(2))
val duration = System.currentTimeMillis() - startTime
// Should handle backpressure without blocking indefinitely
assertThat(duration).isLessThan(3000)
}
@Test
fun `should maintain stream characteristics under error conditions`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("error-resilience-topic")
// Add error handling and recovery
val resilientFlux = flux
.onErrorResume { error ->
// Log error and continue with an empty stream
logger.debug("Handled error in stream: {}", error.message)
Flux.empty()
}
.retry(2)
.take(1)
StepVerifier.create(resilientFlux.timeout(Duration.ofSeconds(3)))
.thenCancel()
.verify(Duration.ofSeconds(2))
// Stream should remain reactive even after error handling
assertThat(resilientFlux).isNotNull
}
@Test
fun `should support concurrent stream processing`() {
val flux1 = consumer.receiveEvents<ReactiveTestEvent>("concurrent-topic-1")
val flux2 = consumer.receiveEvents<ReactiveTestEvent>("concurrent-topic-2")
val flux3 = consumer.receiveEvents<ReactiveTestEvent>("concurrent-topic-3")
// Process multiple streams concurrently
val combinedFlux = Flux.merge(
flux1.subscribeOn(Schedulers.parallel()),
flux2.subscribeOn(Schedulers.parallel()),
flux3.subscribeOn(Schedulers.parallel())
).take(3)
StepVerifier.create(combinedFlux.timeout(Duration.ofSeconds(3)))
.thenCancel()
.verify(Duration.ofSeconds(2))
// All streams should be processable concurrently
assertThat(combinedFlux).isNotNull
}
@Test
fun `should handle stream lifecycle correctly`() {
val eventCounter = AtomicInteger(0)
val flux = consumer.receiveEvents<ReactiveTestEvent>("lifecycle-topic")
// Add lifecycle monitoring
val monitoredFlux = flux
.doOnSubscribe { subscription ->
logger.debug("Stream subscribed: {}", subscription)
}
.doOnNext { event ->
val count = eventCounter.incrementAndGet()
logger.debug("Processed event #{}: {}", count, event.message)
}
.doOnCancel {
logger.debug("Stream cancelled")
}
.doOnComplete {
logger.debug("Stream completed")
}
.take(1)
StepVerifier.create(monitoredFlux.timeout(Duration.ofSeconds(2)))
.thenCancel()
.verify(Duration.ofSeconds(1))
// Lifecycle should be properly managed
assertThat(monitoredFlux).isNotNull
}
@Test
fun `should support flow control mechanisms`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("flow-control-topic")
// Apply various flow control mechanisms
val controlledFlux = flux
.limitRate(10) // Limit upstream requests
.sample(Duration.ofMillis(100)) // Sample at fixed intervals
.buffer(5) // Buffer elements
.flatMap { buffer ->
logger.debug("Processing buffer of size: {}", buffer.size)
Flux.fromIterable(buffer)
}
.take(5)
StepVerifier.create(controlledFlux.timeout(Duration.ofSeconds(3)))
.thenCancel()
.verify(Duration.ofSeconds(2))
assertThat(controlledFlux).isNotNull
}
@Test
fun `should handle time-based operations`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("time-based-topic")
// Apply time-based operations
val timedFlux = flux
.window(Duration.ofMillis(200)) // Window by time
.flatMap { window ->
window.collectList()
.map { events ->
logger.debug("Window contains {} events", events.size)
events.size
}
}
.take(2)
StepVerifier.create(timedFlux.timeout(Duration.ofSeconds(3)))
.thenCancel()
.verify(Duration.ofSeconds(2))
assertThat(timedFlux).isNotNull
}
@Test
fun `should maintain thread safety in reactive streams`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("thread-safety-topic")
val processedCount = AtomicLong(0)
val latch = CountDownLatch(3)
// Process on multiple threads
val threadSafeFlux = flux
.publishOn(Schedulers.parallel())
.doOnNext { event ->
val count = processedCount.incrementAndGet()
logger.debug("Thread {} processed event #{}", Thread.currentThread().name, count)
latch.countDown()
}
.take(3)
// Subscribe and wait briefly
val subscription = threadSafeFlux
.timeout(Duration.ofSeconds(2))
.subscribe(
{ event -> /* processed */ },
{ error -> logger.debug("Error: {}", error.message) },
{ logger.debug("Stream completed") }
)
// Wait for brief processing or timeout
val completed = latch.await(1, TimeUnit.SECONDS)
subscription.dispose()
// Thread safety should be maintained (no exceptions thrown)
assertThat(subscription).isNotNull
}
@Test
fun `should support custom schedulers`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("scheduler-topic")
// Use different schedulers for different operations
val scheduledFlux = flux
.subscribeOn(Schedulers.boundedElastic()) // For I/O operations
.publishOn(Schedulers.parallel()) // For CPU-intensive operations
.map { event ->
logger.debug("Processing on thread: {}", Thread.currentThread().name)
event.message.length
}
.subscribeOn(Schedulers.single()) // Single-threaded subscription
.take(1)
StepVerifier.create(scheduledFlux.timeout(Duration.ofSeconds(2)))
.thenCancel()
.verify(Duration.ofSeconds(1))
assertThat(scheduledFlux).isNotNull
}
@Test
fun `should handle stream composition and chaining`() {
val flux1 = consumer.receiveEvents<ReactiveTestEvent>("composition-topic-1")
val flux2 = consumer.receiveEvents<ReactiveTestEvent>("composition-topic-2")
// Compose multiple streams
val composedFlux = flux1
.switchMap { event1 ->
flux2.map { event2 ->
logger.debug("Composed: {} -> {}", event1.message, event2.message)
"${event1.message}+${event2.message}"
}
}
.take(1)
StepVerifier.create(composedFlux.timeout(Duration.ofSeconds(2)))
.thenCancel()
.verify(Duration.ofSeconds(1))
assertThat(composedFlux).isNotNull
}
@Test
fun `should support reactive testing patterns`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("testing-patterns-topic")
// Use TestPublisher to simulate controlled event emission
val testPublisher = TestPublisher.create<ReactiveTestEvent>()
val testFlux = testPublisher.flux()
// Apply similar transformations as the real flux
val transformedTestFlux = testFlux
.filter { event -> event.message.isNotEmpty() }
.map { event -> event.message.length }
// Test with controlled emissions
StepVerifier.create(transformedTestFlux)
.then { testPublisher.next(ReactiveTestEvent("test", 1)) }
.expectNext(4) // "test".length
.then { testPublisher.complete() }
.verifyComplete()
// Real flux should also be testable
assertThat(flux).isNotNull
}
@Test
fun `should handle resource cleanup properly`() {
val flux = consumer.receiveEvents<ReactiveTestEvent>("cleanup-topic")
val resourcesAcquired = AtomicInteger(0)
val resourcesReleased = AtomicInteger(0)
val resourceManagedFlux = flux
.doOnSubscribe {
resourcesAcquired.incrementAndGet()
logger.debug("Resources acquired: {}", resourcesAcquired.get())
}
.doFinally { signalType ->
resourcesReleased.incrementAndGet()
logger.debug("Resources released on {}: {}", signalType, resourcesReleased.get())
}
.take(1)
StepVerifier.create(resourceManagedFlux.timeout(Duration.ofSeconds(2)))
.thenCancel()
.verify(Duration.ofSeconds(1))
// Resource management should be handled properly
// Note: In a real scenario, we'd verify that resources are properly cleaned up
assertThat(resourceManagedFlux).isNotNull
}
data class ReactiveTestEvent(
val message: String,
val sequenceNumber: Int,
val timestamp: Long = System.currentTimeMillis()
)
}