├── .env ├── .github ├── scripts │ ├── deploy.sh │ ├── init_vm.sh │ ├── redeploy_docker_containers.sh │ └── redeploy_docker_containers_with_log.sh └── workflows │ └── workflow.yaml ├── .gitignore ├── LICENSE ├── book-service ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── com │ │ │ └── romankudryashov │ │ │ └── eventdrivenarchitecture │ │ │ └── bookservice │ │ │ ├── BookServiceApplication.kt │ │ │ ├── api │ │ │ ├── AuthorsApiDelegateImpl.kt │ │ │ ├── BooksApiDelegateImpl.kt │ │ │ └── BooksApiDelegateLimitedImpl.kt │ │ │ ├── config │ │ │ ├── Config.kt │ │ │ └── TestConfig.kt │ │ │ ├── exception │ │ │ ├── Exceptions.kt │ │ │ └── GlobalExceptionHandler.kt │ │ │ ├── persistence │ │ │ ├── Repositories.kt │ │ │ └── entity │ │ │ │ ├── AbstractEntity.kt │ │ │ │ └── Entities.kt │ │ │ ├── service │ │ │ ├── AuthorService.kt │ │ │ ├── BookService.kt │ │ │ ├── InboxMessageService.kt │ │ │ ├── IncomingEventService.kt │ │ │ ├── OutboxMessageService.kt │ │ │ ├── UserReplicaService.kt │ │ │ ├── converter │ │ │ │ ├── AuthorConverters.kt │ │ │ │ ├── BookConverters.kt │ │ │ │ ├── BookLoanConverters.kt │ │ │ │ └── Converter.kt │ │ │ └── impl │ │ │ │ ├── AuthorServiceImpl.kt │ │ │ │ ├── BookServiceImpl.kt │ │ │ │ ├── InboxMessageServiceImpl.kt │ │ │ │ ├── IncomingEventServiceImpl.kt │ │ │ │ ├── OutboxMessageServiceImpl.kt │ │ │ │ └── UserReplicaServiceImpl.kt │ │ │ └── task │ │ │ └── InboxProcessingTask.kt │ └── resources │ │ ├── application-local.yaml │ │ ├── application.yaml │ │ ├── db │ │ └── migration │ │ │ ├── V1_0_0__structure.sql │ │ │ └── V1_0_1__data.sql │ │ └── openapi │ │ └── api.yaml │ └── test │ └── kotlin │ └── com │ └── romankudryashov │ └── eventdrivenarchitecture │ └── bookservice │ └── BookServiceApplicationTests.kt ├── common-model ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── romankudryashov │ └── eventdrivenarchitecture │ └── commonmodel │ ├── DataModel.kt │ ├── EventModel.kt │ ├── OutboxMessage.kt │ └── spring │ └── CommonRuntimeHints.kt ├── compose.override.yaml ├── compose.yaml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── kafka-connect ├── connectors │ ├── book.sink.json │ ├── book.sink.streaming.json │ ├── book.source.json │ ├── notification.sink.json │ ├── user.sink.dlq-ce-json.json │ ├── user.sink.dlq-unprocessed.json │ ├── user.sink.json │ ├── user.source.json │ └── user.source.streaming.json ├── filtering │ └── groovy │ │ ├── groovy-4.0.24.jar │ │ └── groovy-jsr223-4.0.24.jar ├── load-connectors.sh └── postgres.properties ├── misc ├── caddy │ ├── Caddyfile │ └── Dockerfile ├── pgadmin │ ├── pgpass │ ├── preferences.json │ └── servers.json ├── postgres │ └── batch_insert.sql └── postman │ └── testing.postman_collection.json ├── notification-service ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── com │ │ │ └── romankudryashov │ │ │ └── eventdrivenarchitecture │ │ │ └── notificationservice │ │ │ ├── NotificationServiceApplication.kt │ │ │ ├── config │ │ │ ├── Config.kt │ │ │ ├── TestConfig.kt │ │ │ └── WebSocketConfig.kt │ │ │ ├── exception │ │ │ └── Exceptions.kt │ │ │ ├── persistence │ │ │ ├── Repositories.kt │ │ │ └── entity │ │ │ │ ├── AbstractEntity.kt │ │ │ │ └── Entities.kt │ │ │ ├── service │ │ │ ├── EmailService.kt │ │ │ ├── InboxMessageService.kt │ │ │ ├── IncomingEventService.kt │ │ │ └── impl │ │ │ │ ├── EmailServiceTestImpl.kt │ │ │ │ ├── EmailServiceWebSocketStub.kt │ │ │ │ ├── InboxMessageServiceImpl.kt │ │ │ │ └── IncomingEventServiceImpl.kt │ │ │ └── task │ │ │ └── InboxProcessingTask.kt │ └── resources │ │ ├── application-local.yaml │ │ ├── application-test.yaml │ │ ├── application.yaml │ │ ├── db │ │ └── migration │ │ │ └── V1_0_0__structure.sql │ │ └── static │ │ ├── app.js │ │ ├── index.html │ │ └── main.css │ └── test │ └── kotlin │ └── com │ └── romankudryashov │ └── eventdrivenarchitecture │ └── notificationservice │ └── NotificationServiceApplicationTests.kt ├── readme.adoc ├── restart.bat ├── settings.gradle.kts └── user-service ├── build.gradle.kts └── src ├── main ├── kotlin │ └── com │ │ └── romankudryashov │ │ └── eventdrivenarchitecture │ │ └── userservice │ │ ├── UserServiceApplication.kt │ │ ├── config │ │ └── Config.kt │ │ ├── exception │ │ └── Exceptions.kt │ │ ├── model │ │ └── NotificationMessageParams.kt │ │ ├── persistence │ │ ├── Repositories.kt │ │ └── entity │ │ │ ├── AbstractEntity.kt │ │ │ └── Entities.kt │ │ ├── service │ │ ├── DeltaService.kt │ │ ├── InboxMessageService.kt │ │ ├── IncomingEventService.kt │ │ ├── NotificationService.kt │ │ ├── OutboxMessageService.kt │ │ ├── UserService.kt │ │ └── impl │ │ │ ├── DeltaServiceImpl.kt │ │ │ ├── InboxMessageServiceImpl.kt │ │ │ ├── IncomingEventServiceImpl.kt │ │ │ ├── NotificationServiceImpl.kt │ │ │ ├── OutboxMessageServiceImpl.kt │ │ │ └── UserServiceImpl.kt │ │ └── task │ │ └── InboxProcessingTask.kt └── resources │ ├── application-local.yaml │ ├── application.yaml │ └── db │ └── migration │ ├── V1_0_0__structure.sql │ └── V1_0_1__data.sql └── test └── kotlin └── com └── romankudryashov └── eventdrivenarchitecture └── userservice └── UserServiceApplicationTests.kt /.env: -------------------------------------------------------------------------------- 1 | BOOK_DB_PASSWORD=password 2 | USER_DB_PASSWORD=password 3 | NOTIFICATION_DB_PASSWORD=password 4 | -------------------------------------------------------------------------------- /.github/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | chmod +x ./init_vm.sh ./redeploy_docker_containers.sh ./redeploy_docker_containers_with_log.sh 4 | 5 | if ! [ -x "$(command -v docker)" ]; then 6 | ./init_vm.sh 7 | fi 8 | 9 | echo "Remove existing cron jobs to avoid interfering with the next Docker Compose redeployment" 10 | crontab -r 11 | 12 | ./redeploy_docker_containers.sh 13 | 14 | # the cron job below is a workaround to prevent a VM from becoming unresponsive a few hours after redeployment 15 | # maybe the problem is that after some time docker resources (volumes) eat up all the disk space 16 | echo "Add cron job to redeploy project each six hours" 17 | touch tmp_file 18 | echo "0 0,6,12,18 * * * ~/event-driven-architecture/.github/scripts/redeploy_docker_containers_with_log.sh" > tmp_file 19 | crontab tmp_file 20 | rm tmp_file 21 | -------------------------------------------------------------------------------- /.github/scripts/init_vm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Start initialization" 4 | 5 | echo "Install Docker" 6 | # https://docs.docker.com/engine/install/ubuntu 7 | sudo apt-get update 8 | sudo apt-get install -y ca-certificates curl 9 | sudo install -m 0755 -d /etc/apt/keyrings 10 | sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc 11 | sudo chmod a+r /etc/apt/keyrings/docker.asc 12 | echo \ 13 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ 14 | $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ 15 | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null 16 | sudo apt-get update 17 | sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 18 | 19 | echo "Configure VM to use Docker as a non-root user" 20 | # https://docs.docker.com/engine/install/linux-postinstall 21 | sudo groupadd docker 22 | sudo usermod -aG docker $USER 23 | newgrp docker 24 | 25 | echo "Create daemon.json to configure log rotation" 26 | sudo apt-get install -y jq 27 | echo '{"log-driver": "json-file", "log-opts": {"max-size": "1m", "max-file": "5"}}' | jq . | sudo tee /etc/docker/daemon.json 28 | sudo systemctl daemon-reload 29 | sudo systemctl restart docker 30 | 31 | echo "Create a directory on the host that will later be mapped to the kafka container volume and change its owner" 32 | mkdir ~/event-driven-architecture/misc/kafka_data 33 | sudo chown -R 1001:1001 ~/event-driven-architecture/misc/kafka_data 34 | 35 | echo "End initialization" 36 | -------------------------------------------------------------------------------- /.github/scripts/redeploy_docker_containers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Start redeployment" 4 | 5 | cd ~/event-driven-architecture 6 | sudo docker compose pull 7 | sudo docker compose down --volumes 8 | rm -rf ./misc/kafka_data/* 9 | sudo docker compose up -d 10 | sudo docker system prune -f 11 | 12 | echo "End redeployment" 13 | -------------------------------------------------------------------------------- /.github/scripts/redeploy_docker_containers_with_log.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "------------------------------" >> /tmp/redeployment.log 2>&1 4 | echo "Redeployment with logs started at $(date)" >> /tmp/redeployment.log 2>&1 5 | ~/event-driven-architecture/.github/scripts/redeploy_docker_containers.sh >> /tmp/redeployment.log 2>&1 6 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Setup 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: '21' 19 | distribution: 'adopt' 20 | - name: Build 21 | # TODO: enable test 22 | run: ./gradlew build -x test 23 | 24 | build-and-push-book-service-image: 25 | needs: test 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Setup 31 | uses: actions/setup-java@v4 32 | with: 33 | java-version: '21' 34 | distribution: 'adopt' 35 | - name: Build Docker image 36 | run: ./gradlew :book-service:bootBuildImage 37 | - name: Login to Docker Hub 38 | if: ${{ github.event_name == 'push' }} 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | - name: Publish Docker image 44 | if: ${{ github.event_name == 'push' }} 45 | run: docker push kudryashovroman/event-driven-architecture:book-service 46 | 47 | build-and-push-user-service-image: 48 | needs: test 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout 52 | uses: actions/checkout@v4 53 | - name: Setup 54 | uses: actions/setup-java@v4 55 | with: 56 | java-version: '21' 57 | distribution: 'adopt' 58 | - name: Build Docker image 59 | run: ./gradlew :user-service:bootBuildImage 60 | - name: Login to Docker Hub 61 | if: ${{ github.event_name == 'push' }} 62 | uses: docker/login-action@v3 63 | with: 64 | username: ${{ secrets.DOCKERHUB_USERNAME }} 65 | password: ${{ secrets.DOCKERHUB_TOKEN }} 66 | - name: Publish Docker image 67 | if: ${{ github.event_name == 'push' }} 68 | run: docker push kudryashovroman/event-driven-architecture:user-service 69 | 70 | build-and-push-notification-service-image: 71 | needs: test 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | - name: Setup 77 | uses: actions/setup-java@v4 78 | with: 79 | java-version: '21' 80 | distribution: 'adopt' 81 | - name: Build Docker image 82 | run: ./gradlew :notification-service:bootBuildImage 83 | - name: Login to Docker Hub 84 | if: ${{ github.event_name == 'push' }} 85 | uses: docker/login-action@v3 86 | with: 87 | username: ${{ secrets.DOCKERHUB_USERNAME }} 88 | password: ${{ secrets.DOCKERHUB_TOKEN }} 89 | - name: Publish Docker image 90 | if: ${{ github.event_name == 'push' }} 91 | run: docker push kudryashovroman/event-driven-architecture:notification-service 92 | 93 | build-and-push-caddy-image: 94 | needs: test 95 | runs-on: ubuntu-latest 96 | steps: 97 | - name: Checkout 98 | uses: actions/checkout@v4 99 | - name: Set up Docker Buildx 100 | uses: docker/setup-buildx-action@v3 101 | - name: Login to Docker Hub 102 | uses: docker/login-action@v3 103 | with: 104 | username: ${{ secrets.DOCKERHUB_USERNAME }} 105 | password: ${{ secrets.DOCKERHUB_TOKEN }} 106 | - name: Build and push 107 | uses: docker/build-push-action@v6 108 | with: 109 | context: ./misc/caddy 110 | push: true 111 | tags: kudryashovroman/event-driven-architecture:caddy 112 | 113 | deploy: 114 | if: ${{ github.event_name == 'push' }} 115 | needs: [ build-and-push-book-service-image, build-and-push-user-service-image, build-and-push-notification-service-image, build-and-push-caddy-image ] 116 | runs-on: ubuntu-latest 117 | steps: 118 | - name: Checkout 119 | uses: actions/checkout@v4 120 | - name: Copy files 121 | uses: appleboy/scp-action@v1 122 | with: 123 | host: ${{ secrets.DEPLOYMENT_SERVER_HOST }} 124 | port: ${{ secrets.DEPLOYMENT_SERVER_PORT }} 125 | username: ${{ secrets.DEPLOYMENT_SERVER_USERNAME }} 126 | key: ${{ secrets.DEPLOYMENT_SERVER_KEY }} 127 | source: ".github/scripts,compose.yaml,.env,kafka-connect,misc/caddy/Caddyfile" 128 | target: "event-driven-architecture" 129 | - name: Deployment 130 | uses: appleboy/ssh-action@v1 131 | with: 132 | host: ${{ secrets.DEPLOYMENT_SERVER_HOST }} 133 | port: ${{ secrets.DEPLOYMENT_SERVER_PORT }} 134 | username: ${{ secrets.DEPLOYMENT_SERVER_USERNAME }} 135 | key: ${{ secrets.DEPLOYMENT_SERVER_KEY }} 136 | script: | 137 | cd ~/event-driven-architecture/.github/scripts 138 | chmod +x ./deploy.sh 139 | ./deploy.sh 140 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea 9 | *.iws 10 | *.iml 11 | *.ipr 12 | out/ 13 | !**/src/main/**/out/ 14 | !**/src/test/**/out/ 15 | -------------------------------------------------------------------------------- /book-service/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | import org.springframework.boot.gradle.tasks.aot.ProcessAot 3 | import org.springframework.boot.gradle.tasks.bundling.BootBuildImage 4 | 5 | plugins { 6 | id("org.springframework.boot") 7 | id("io.spring.dependency-management") 8 | id("org.openapi.generator") 9 | id("org.graalvm.buildtools.native") 10 | kotlin("jvm") 11 | kotlin("plugin.spring") 12 | kotlin("plugin.jpa") 13 | } 14 | 15 | java { 16 | toolchain { 17 | languageVersion = JavaLanguageVersion.of(21) 18 | } 19 | } 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | val dockerRepository: String by project 26 | 27 | dependencies { 28 | implementation(project(":common-model")) 29 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 30 | implementation("org.springframework.boot:spring-boot-starter-validation") 31 | implementation("org.springframework.boot:spring-boot-starter-web") 32 | implementation("org.springframework.boot:spring-boot-starter-actuator") 33 | implementation("org.flywaydb:flyway-database-postgresql") 34 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 35 | implementation("org.jetbrains.kotlin:kotlin-reflect") 36 | // `implementation` is needed only to handle PSQLException in the exception handler. if that is not necessary, the dependency should be `runtimeOnly` 37 | implementation("org.postgresql:postgresql") 38 | testImplementation("org.springframework.boot:spring-boot-starter-test") 39 | } 40 | 41 | kotlin { 42 | compilerOptions { 43 | freeCompilerArgs.addAll("-Xjsr305=strict") 44 | } 45 | } 46 | 47 | springBoot { 48 | mainClass.set("com.romankudryashov.eventdrivenarchitecture.bookservice.BookServiceApplicationKt") 49 | } 50 | 51 | tasks.withType { 52 | dependsOn(tasks.openApiGenerate) 53 | } 54 | 55 | tasks.withType { 56 | args = mutableListOf("--spring.profiles.active=test") 57 | } 58 | 59 | tasks.withType { 60 | buildpacks = setOf("paketobuildpacks/java-native-image", "paketobuildpacks/health-checker") 61 | environment = mapOf("BP_HEALTH_CHECKER_ENABLED" to "true") 62 | imageName = "$dockerRepository:${project.name}" 63 | } 64 | 65 | tasks.withType { 66 | useJUnitPlatform() 67 | } 68 | 69 | val openApiPackage = "com.romankudryashov.eventdrivenarchitecture.bookservice.api" 70 | 71 | // TODO: haven't found settings not to produce main class and build files. so it is needed to explicitly specify main class above 72 | openApiGenerate { 73 | generatorName.set("kotlin-spring") 74 | inputSpec.set("$projectDir/src/main/resources/openapi/api.yaml") 75 | outputDir.set("$projectDir/build/generated/openapi") 76 | invokerPackage.set(openApiPackage) 77 | apiPackage.set("$openApiPackage.controller") 78 | modelPackage.set("$openApiPackage.model") 79 | 80 | configOptions.set( 81 | mapOf( 82 | "delegatePattern" to true.toString(), 83 | "documentationProvider" to "none", 84 | "enumPropertyNaming" to "PascalCase", 85 | "exceptionHandler" to false.toString(), 86 | "gradleBuildFile" to false.toString(), 87 | "useSpringBoot3" to true.toString(), 88 | ) 89 | ) 90 | } 91 | 92 | sourceSets { 93 | main { 94 | kotlin.srcDir(openApiGenerate.outputDir) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/BookServiceApplication.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.spring.CommonRuntimeHints 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.boot.runApplication 6 | import org.springframework.context.annotation.ImportRuntimeHints 7 | import org.springframework.scheduling.annotation.EnableScheduling 8 | 9 | @SpringBootApplication 10 | @ImportRuntimeHints(CommonRuntimeHints::class) 11 | @EnableScheduling 12 | class BookServiceApplication 13 | 14 | fun main(args: Array) { 15 | runApplication(*args) 16 | } 17 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/api/AuthorsApiDelegateImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.api 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.controller.AuthorsApiDelegate 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Author 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.AuthorToSave 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.AuthorService 7 | import org.springframework.http.ResponseEntity 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class AuthorsApiDelegateImpl( 12 | private val authorService: AuthorService 13 | ) : AuthorsApiDelegate { 14 | 15 | override fun getAuthors(): ResponseEntity> = ResponseEntity.ok(authorService.getAll()) 16 | 17 | override fun updateAuthor(id: Long, authorToSave: AuthorToSave): ResponseEntity { 18 | val updatedAuthor = authorService.update(id, authorToSave) 19 | return ResponseEntity.ok(updatedAuthor) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/api/BooksApiDelegateImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.api 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.controller.BooksApiDelegate 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Book 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoan 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoanToSave 7 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookToSave 8 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.BookService 9 | import org.springframework.http.ResponseEntity 10 | import org.springframework.stereotype.Component 11 | 12 | @Component 13 | class BooksApiDelegateImpl( 14 | private val bookService: BookService 15 | ) : BooksApiDelegate { 16 | 17 | override fun getBooks(): ResponseEntity> = ResponseEntity.ok(bookService.getAll()) 18 | 19 | override fun createBook(bookToSave: BookToSave): ResponseEntity { 20 | val createdBook = bookService.create(bookToSave) 21 | return ResponseEntity.ok(createdBook) 22 | } 23 | 24 | override fun updateBook(id: Long, bookToSave: BookToSave): ResponseEntity { 25 | val updatedBook = bookService.update(id, bookToSave) 26 | return ResponseEntity.ok(updatedBook) 27 | } 28 | 29 | override fun deleteBook(id: Long): ResponseEntity { 30 | bookService.delete(id) 31 | return ResponseEntity.noContent().build() 32 | } 33 | 34 | override fun createBookLoan(bookId: Long, bookLoanToSave: BookLoanToSave): ResponseEntity { 35 | val createdBookLoan = bookService.lendBook(bookId, bookLoanToSave) 36 | return ResponseEntity.ok(createdBookLoan) 37 | } 38 | 39 | override fun deleteBookLoan(bookId: Long, id: Long): ResponseEntity { 40 | bookService.returnBook(bookId, id) 41 | return ResponseEntity.noContent().build() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/api/BooksApiDelegateLimitedImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.api 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.controller.BooksApiDelegate 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Book 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookToSave 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.BookService 7 | import org.springframework.http.ResponseEntity 8 | 9 | class BooksApiDelegateLimitedImpl( 10 | private val bookService: BookService 11 | ) : BooksApiDelegate { 12 | 13 | override fun getBooks(): ResponseEntity> = ResponseEntity.ok(bookService.getAll()) 14 | 15 | override fun updateBook(id: Long, bookToSave: BookToSave): ResponseEntity { 16 | val updatedBook = bookService.update(id, bookToSave) 17 | return ResponseEntity.ok(updatedBook) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/config/Config.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.config 2 | 3 | import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.core.task.AsyncTaskExecutor 7 | import org.springframework.core.task.support.TaskExecutorAdapter 8 | import java.util.concurrent.Executors 9 | 10 | @Configuration 11 | class Config { 12 | 13 | @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) 14 | fun asyncTaskExecutor(): AsyncTaskExecutor = TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()) 15 | } 16 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/config/TestConfig.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.config 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.BooksApiDelegateLimitedImpl 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.controller.BooksApiDelegate 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.AuthorService 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.BookService 7 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.AuthorToSaveToEntityConverter 8 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.AuthorToSaveToEntityLimitedConverter 9 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.BookToSaveToEntityConverter 10 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.BookToSaveToEntityLimitedConverter 11 | import org.springframework.context.annotation.Bean 12 | import org.springframework.context.annotation.Configuration 13 | import org.springframework.context.annotation.Primary 14 | import org.springframework.context.annotation.Profile 15 | 16 | @Configuration 17 | @Profile("test") 18 | class TestConfig { 19 | 20 | @Bean 21 | @Primary 22 | fun booksApiDelegate(bookService: BookService): BooksApiDelegate = BooksApiDelegateLimitedImpl(bookService) 23 | 24 | @Bean 25 | @Primary 26 | fun bookToSaveToEntityConverter(authorService: AuthorService): BookToSaveToEntityConverter = BookToSaveToEntityLimitedConverter(authorService) 27 | 28 | @Bean 29 | @Primary 30 | fun authorToSaveToEntityConverter(): AuthorToSaveToEntityConverter = AuthorToSaveToEntityLimitedConverter() 31 | } 32 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/exception/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.exception 2 | 3 | open class BookServiceException(message: String) : RuntimeException(message) 4 | 5 | class AccessRestrictedException : BookServiceException("Access to entity is restricted") 6 | 7 | class NotFoundException(entityName: String, entityId: Long) : BookServiceException("Entity '$entityName' not found by id=$entityId") 8 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/exception/GlobalExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | // TODO: use the same format of responses for all exceptions: https://github.com/spring-projects/spring-boot/issues/33885 2 | // TODO: use RFC 9457 error response format (see https://github.com/spring-projects/spring-boot/issues/19525) 3 | // TODO: no need to print an exception stack trace explicitly (`spring.mvc.log-resolved-exception` not working) 4 | package com.romankudryashov.eventdrivenarchitecture.bookservice.exception 5 | 6 | import org.postgresql.util.PSQLException 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.http.HttpStatus 9 | import org.springframework.web.ErrorResponse 10 | import org.springframework.web.bind.annotation.ControllerAdvice 11 | import org.springframework.web.bind.annotation.ExceptionHandler 12 | import org.springframework.web.context.request.WebRequest 13 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler 14 | import jakarta.validation.ConstraintViolationException 15 | 16 | @ControllerAdvice 17 | class GlobalExceptionHandler : ResponseEntityExceptionHandler() { 18 | 19 | private val log = LoggerFactory.getLogger(this.javaClass) 20 | 21 | @ExceptionHandler(BookServiceException::class) 22 | fun handleBookServiceException(exception: BookServiceException, request: WebRequest): ErrorResponse { 23 | log.error("An exception was handled:", exception) 24 | return ErrorResponse.builder(exception, HttpStatus.BAD_REQUEST, exception.message!!).build() 25 | } 26 | 27 | @ExceptionHandler(AccessRestrictedException::class) 28 | fun handleAccessRestrictedException(exception: AccessRestrictedException, request: WebRequest): ErrorResponse { 29 | log.error("An exception was handled:", exception) 30 | return ErrorResponse.builder(exception, HttpStatus.FORBIDDEN, exception.message!!).build() 31 | } 32 | 33 | @ExceptionHandler(NotFoundException::class) 34 | fun handleNotFoundException(exception: NotFoundException, request: WebRequest): ErrorResponse { 35 | log.error("An exception was handled:", exception) 36 | return ErrorResponse.builder(exception, HttpStatus.NOT_FOUND, exception.message!!).build() 37 | } 38 | 39 | @ExceptionHandler(ConstraintViolationException::class) 40 | fun handleConstraintViolationException(exception: ConstraintViolationException, request: WebRequest): ErrorResponse { 41 | log.error("An exception was handled:", exception) 42 | return ErrorResponse.builder(exception, HttpStatus.BAD_REQUEST, "Some data is not valid").apply { 43 | exception.constraintViolations.forEach { 44 | this.detail("'${it.propertyPath}': " + it.message) 45 | } 46 | }.build() 47 | } 48 | 49 | @ExceptionHandler(PSQLException::class) 50 | fun handlePSQLException(exception: PSQLException, request: WebRequest): ErrorResponse { 51 | log.error("An exception was handled:", exception) 52 | return ErrorResponse.builder(exception, HttpStatus.BAD_REQUEST, "Database access error. Please contact a system administrator").build() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/persistence/Repositories.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.persistence 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.AuthorEntity 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.BookEntity 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.InboxMessageEntity 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.OutboxMessageEntity 7 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.UserReplicaEntity 8 | import org.springframework.data.domain.Pageable 9 | import org.springframework.data.jpa.repository.JpaRepository 10 | import org.springframework.data.jpa.repository.Lock 11 | import java.util.UUID 12 | import jakarta.persistence.LockModeType 13 | 14 | interface BookRepository : JpaRepository { 15 | fun findAllByStatusOrderByIdAsc(status: BookEntity.Status): List 16 | } 17 | 18 | interface AuthorRepository : JpaRepository { 19 | fun findAllByOrderByIdAsc(): List 20 | } 21 | 22 | interface UserReplicaRepository : JpaRepository 23 | 24 | interface InboxMessageRepository : JpaRepository { 25 | 26 | @Lock(LockModeType.PESSIMISTIC_WRITE) 27 | fun findAllByStatusOrderByCreatedAtAsc(status: InboxMessageEntity.Status, pageable: Pageable): List 28 | 29 | fun findAllByStatusAndProcessedByOrderByCreatedAtAsc(status: InboxMessageEntity.Status, processedBy: String, pageable: Pageable): List 30 | } 31 | 32 | interface OutboxMessageRepository : JpaRepository 33 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/persistence/entity/AbstractEntity.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity 2 | 3 | import org.hibernate.annotations.SourceType 4 | import org.hibernate.annotations.UpdateTimestamp 5 | import java.time.ZonedDateTime 6 | import jakarta.persistence.Column 7 | import jakarta.persistence.MappedSuperclass 8 | 9 | @MappedSuperclass 10 | abstract class AbstractEntity( 11 | @Column(insertable = false, updatable = false) 12 | val createdAt: ZonedDateTime? = null, 13 | @Column(insertable = false) 14 | @UpdateTimestamp(source = SourceType.DB) 15 | val updatedAt: ZonedDateTime? = null 16 | ) 17 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/persistence/entity/Entities.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.AggregateType 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Country 5 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 6 | import com.fasterxml.jackson.databind.JsonNode 7 | import org.hibernate.annotations.ColumnDefault 8 | import org.hibernate.annotations.Generated 9 | import org.hibernate.annotations.JdbcTypeCode 10 | import org.hibernate.type.SqlTypes 11 | import java.time.LocalDate 12 | import java.util.UUID 13 | import jakarta.persistence.CascadeType 14 | import jakarta.persistence.Entity 15 | import jakarta.persistence.EnumType 16 | import jakarta.persistence.Enumerated 17 | import jakarta.persistence.GeneratedValue 18 | import jakarta.persistence.GenerationType 19 | import jakarta.persistence.Id 20 | import jakarta.persistence.JoinColumn 21 | import jakarta.persistence.JoinTable 22 | import jakarta.persistence.ManyToMany 23 | import jakarta.persistence.ManyToOne 24 | import jakarta.persistence.OneToMany 25 | import jakarta.persistence.Table 26 | import jakarta.persistence.Version 27 | import jakarta.validation.constraints.NotBlank 28 | import jakarta.validation.constraints.NotEmpty 29 | 30 | @Entity 31 | @Table(name = "book") 32 | class BookEntity( 33 | @Id 34 | @GeneratedValue(strategy = GenerationType.IDENTITY) 35 | val id: Long = 0, 36 | @field:NotBlank 37 | var name: String, 38 | @ManyToMany 39 | @JoinTable( 40 | name = "book_author", 41 | joinColumns = [JoinColumn(name = "book_id")], 42 | inverseJoinColumns = [JoinColumn(name = "author_id")] 43 | ) 44 | @field:NotEmpty 45 | val authors: Set, 46 | var publicationYear: Int, 47 | @Enumerated(value = EnumType.STRING) 48 | var status: Status = Status.Active, 49 | @OneToMany(cascade = [CascadeType.ALL]) 50 | @JoinColumn(name = "book_id") 51 | val loans: MutableSet = mutableSetOf() 52 | ) : AbstractEntity() { 53 | 54 | fun currentLoan(): BookLoanEntity? { 55 | if (loans.isNotEmpty()) { 56 | return loans.find { it.status == BookLoanEntity.Status.Active } 57 | } 58 | return null 59 | } 60 | 61 | enum class Status { 62 | Active, 63 | Deleted 64 | } 65 | } 66 | 67 | @Entity 68 | @Table(name = "author") 69 | class AuthorEntity( 70 | @Id 71 | @GeneratedValue(strategy = GenerationType.IDENTITY) 72 | val id: Long = 0, 73 | @field:NotBlank 74 | var firstName: String, 75 | @field:NotBlank 76 | var middleName: String, 77 | @field:NotBlank 78 | var lastName: String, 79 | @Enumerated(value = EnumType.STRING) 80 | var country: Country, 81 | var dateOfBirth: LocalDate, 82 | @ManyToMany(mappedBy = "authors") 83 | val books: Set = setOf() 84 | ) : AbstractEntity() 85 | 86 | @Entity 87 | @Table(name = "book_loan") 88 | class BookLoanEntity( 89 | @Id 90 | @GeneratedValue(strategy = GenerationType.IDENTITY) 91 | val id: Long = 0, 92 | @ManyToOne 93 | val book: BookEntity, 94 | val userId: Long, 95 | @Enumerated(value = EnumType.STRING) 96 | var status: Status = Status.Active 97 | ) : AbstractEntity() { 98 | 99 | enum class Status { 100 | Active, 101 | Returned, 102 | Canceled 103 | } 104 | } 105 | 106 | @Entity 107 | @Table(name = "user_replica") 108 | class UserReplicaEntity( 109 | @Id 110 | val id: Long, 111 | @Enumerated(value = EnumType.STRING) 112 | var status: Status, 113 | ) { 114 | 115 | enum class Status { 116 | Active, 117 | Inactive 118 | } 119 | } 120 | 121 | @Entity 122 | @Table(name = "inbox") 123 | class InboxMessageEntity( 124 | @Id 125 | val id: UUID, 126 | val source: String, 127 | @Enumerated(value = EnumType.STRING) 128 | val type: EventType, 129 | @JdbcTypeCode(SqlTypes.JSON) 130 | val payload: JsonNode, 131 | @Enumerated(value = EnumType.STRING) 132 | var status: Status, 133 | var error: String?, 134 | var processedBy: String?, 135 | @Version 136 | val version: Int 137 | ) : AbstractEntity() { 138 | 139 | enum class Status { 140 | New, 141 | ReadyForProcessing, 142 | Completed, 143 | Error 144 | } 145 | } 146 | 147 | @Entity 148 | @Table(name = "outbox") 149 | class OutboxMessageEntity( 150 | @Id 151 | @Generated 152 | @ColumnDefault("gen_random_uuid()") 153 | val id: UUID? = null, 154 | @Enumerated(value = EnumType.STRING) 155 | val aggregateType: AggregateType, 156 | val aggregateId: Long, 157 | @Enumerated(value = EnumType.STRING) 158 | val type: EventType, 159 | @JdbcTypeCode(SqlTypes.JSON) 160 | val payload: JsonNode 161 | ) : AbstractEntity() 162 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/AuthorService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Author as AuthorDto 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.AuthorToSave 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.AuthorEntity 6 | 7 | interface AuthorService { 8 | 9 | fun getAll(): List 10 | 11 | fun getEntityById(id: Long): AuthorEntity? 12 | 13 | fun update(id: Long, author: AuthorToSave): AuthorDto 14 | } 15 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/BookService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Book as BookDto 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoan as BookLoanDto 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoanToSave 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookToSave 7 | 8 | interface BookService { 9 | 10 | fun getAll(): List 11 | 12 | fun getById(id: Long): BookDto? 13 | 14 | fun create(book: BookToSave): BookDto 15 | 16 | fun update(id: Long, book: BookToSave): BookDto 17 | 18 | fun delete(id: Long) 19 | 20 | fun lendBook(bookId: Long, bookLoan: BookLoanToSave): BookLoanDto 21 | 22 | fun cancelBookLoan(bookId: Long, bookLoanId: Long) 23 | 24 | fun returnBook(bookId: Long, bookLoanId: Long) 25 | } 26 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/InboxMessageService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.InboxMessageEntity 4 | 5 | interface InboxMessageService { 6 | 7 | fun markInboxMessagesAsReadyForProcessingByInstance(batchSize: Int): Int 8 | 9 | fun getBatchForProcessing(batchSize: Int): List 10 | 11 | fun process(inboxMessage: InboxMessageEntity) 12 | } 13 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/IncomingEventService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | import com.fasterxml.jackson.databind.JsonNode 5 | 6 | interface IncomingEventService { 7 | fun process(eventType: EventType, payload: JsonNode) 8 | } 9 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/OutboxMessageService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Author 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 5 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.CurrentAndPreviousState 6 | 7 | interface OutboxMessageService { 8 | 9 | fun saveBookCreatedEventMessage(payload: Book) 10 | 11 | fun saveBookChangedEventMessage(payload: CurrentAndPreviousState) 12 | 13 | fun saveBookDeletedEventMessage(payload: Book) 14 | 15 | fun saveBookLentEventMessage(payload: Book) 16 | 17 | fun saveBookLoanCanceledEventMessage(payload: Book) 18 | 19 | fun saveBookReturnedEventMessage(payload: Book) 20 | 21 | fun saveAuthorChangedEventMessage(payload: CurrentAndPreviousState) 22 | } 23 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/UserReplicaService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.UserReplicaEntity 4 | 5 | interface UserReplicaService { 6 | fun getById(id: Long): UserReplicaEntity? 7 | } 8 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/converter/AuthorConverters.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Author as AuthorDto 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Country as CountryDto 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.AuthorToSave 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.AuthorEntity 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Author 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Country 9 | import org.springframework.stereotype.Component 10 | import java.time.LocalDate 11 | 12 | @Component 13 | class AuthorEntityToDtoConverter : Converter { 14 | 15 | override fun convert(source: AuthorEntity): AuthorDto = AuthorDto( 16 | id = source.id, 17 | firstName = source.firstName, 18 | middleName = source.middleName, 19 | lastName = source.lastName, 20 | country = CountryDto.valueOf(source.country.name), 21 | dateOfBirth = source.dateOfBirth 22 | ) 23 | } 24 | 25 | @Component 26 | class AuthorEntityToModelConverter( 27 | private val bookEntityToModelConverter: BookEntityToModelConverter 28 | ) : Converter { 29 | 30 | override fun convert(source: AuthorEntity): Author = Author( 31 | id = source.id, 32 | firstName = source.firstName, 33 | middleName = source.middleName, 34 | lastName = source.lastName, 35 | country = source.country, 36 | dateOfBirth = source.dateOfBirth, 37 | books = source.books 38 | .toList() 39 | .sortedBy { it.id } 40 | .map { bookEntityToModelConverter.convert(it) } 41 | ) 42 | } 43 | 44 | @Component 45 | class AuthorEntityToModelSimpleConverter : Converter { 46 | 47 | override fun convert(source: AuthorEntity): Author = Author( 48 | id = source.id, 49 | firstName = source.firstName, 50 | middleName = source.middleName, 51 | lastName = source.lastName, 52 | country = source.country, 53 | dateOfBirth = source.dateOfBirth, 54 | books = listOf() 55 | ) 56 | } 57 | 58 | @Component 59 | class AuthorToSaveToEntityConverter : Converter, AuthorEntity> { 60 | 61 | override fun convert(source: Pair): AuthorEntity { 62 | val author = source.first 63 | val existingAuthorEntity = source.second 64 | return existingAuthorEntity?.apply { 65 | this.firstName = author.firstName 66 | this.middleName = author.middleName 67 | this.lastName = author.lastName 68 | this.country = Country.valueOf(author.country.name) 69 | this.dateOfBirth = author.dateOfBirth 70 | } ?: AuthorEntity( 71 | firstName = author.firstName, 72 | middleName = author.middleName, 73 | lastName = author.lastName, 74 | country = Country.valueOf(author.country.name), 75 | dateOfBirth = author.dateOfBirth 76 | ) 77 | } 78 | } 79 | 80 | class AuthorToSaveToEntityLimitedConverter : AuthorToSaveToEntityConverter() { 81 | 82 | override fun convert(source: Pair): AuthorEntity { 83 | val author = source.first 84 | val existingAuthorEntity = source.second 85 | return existingAuthorEntity?.apply { 86 | this.dateOfBirth = author.dateOfBirth.coerceIn(LocalDate.ofYearDay(1800, 1), LocalDate.ofYearDay(1950, 1)) 87 | }!! 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/converter/BookConverters.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Book as BookDto 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookToSave 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.exception.NotFoundException 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.BookEntity 7 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.AuthorService 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 9 | import org.springframework.stereotype.Component 10 | 11 | @Component 12 | class BookEntityToDtoConverter( 13 | private val authorEntityToDtoConverter: AuthorEntityToDtoConverter, 14 | private val bookLoanEntityToDtoConverter: BookLoanEntityToDtoConverter 15 | ) : Converter { 16 | 17 | override fun convert(source: BookEntity): BookDto = BookDto( 18 | id = source.id, 19 | name = source.name, 20 | authors = source.authors.map { authorEntityToDtoConverter.convert(it) }, 21 | publicationYear = source.publicationYear, 22 | currentLoan = source.currentLoan()?.let { bookLoanEntityToDtoConverter.convert(it) } 23 | ) 24 | } 25 | 26 | @Component 27 | class BookEntityToModelConverter( 28 | private val authorEntityToModelSimpleConverter: AuthorEntityToModelSimpleConverter, 29 | private val bookLoanEntityToModelConverter: BookLoanEntityToModelConverter 30 | ) : Converter { 31 | 32 | override fun convert(source: BookEntity): Book = Book( 33 | id = source.id, 34 | name = source.name, 35 | authors = source.authors 36 | .toList() 37 | .sortedBy { it.id } 38 | .map { authorEntityToModelSimpleConverter.convert(it) }, 39 | publicationYear = source.publicationYear, 40 | currentLoan = source.currentLoan()?.let { bookLoanEntityToModelConverter.convert(it) } 41 | ) 42 | } 43 | 44 | @Component 45 | class BookToSaveToEntityConverter( 46 | private val authorService: AuthorService 47 | ) : Converter, BookEntity> { 48 | 49 | override fun convert(source: Pair): BookEntity { 50 | val book = source.first 51 | val existingBookEntity = source.second 52 | return existingBookEntity?.apply { 53 | this.name = book.name 54 | this.publicationYear = book.publicationYear 55 | } ?: BookEntity( 56 | name = book.name, 57 | authors = book.authorIds 58 | .map { authorId -> authorService.getEntityById(authorId) ?: throw NotFoundException("Author", authorId) } 59 | .toSet(), 60 | publicationYear = book.publicationYear 61 | ) 62 | } 63 | } 64 | 65 | class BookToSaveToEntityLimitedConverter( 66 | authorService: AuthorService 67 | ) : BookToSaveToEntityConverter(authorService) { 68 | 69 | override fun convert(source: Pair): BookEntity { 70 | val book = source.first 71 | val existingBookEntity = source.second 72 | return existingBookEntity?.apply { 73 | this.publicationYear = book.publicationYear.coerceIn(1800, 1950) 74 | }!! 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/converter/BookLoanConverters.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoan as BookLoanDto 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoanToSave 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.BookEntity 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.BookLoanEntity 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.BookLoan 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class BookLoanEntityToDtoConverter : Converter { 12 | 13 | override fun convert(source: BookLoanEntity): BookLoanDto = BookLoanDto( 14 | id = source.id, 15 | userId = source.userId 16 | ) 17 | } 18 | 19 | @Component 20 | class BookLoanEntityToModelConverter : Converter { 21 | 22 | override fun convert(source: BookLoanEntity): BookLoan = BookLoan( 23 | id = source.id, 24 | userId = source.userId 25 | ) 26 | } 27 | 28 | @Component 29 | class BookLoanToSaveToEntityConverter : Converter, BookLoanEntity> { 30 | 31 | override fun convert(source: Pair): BookLoanEntity = BookLoanEntity( 32 | book = source.second, 33 | userId = source.first.userId, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/converter/Converter.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter 2 | 3 | interface Converter { 4 | fun convert(source: S): T 5 | } 6 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/impl/AuthorServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Author as AuthorDto 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.AuthorToSave 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.exception.NotFoundException 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.AuthorRepository 7 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.AuthorEntity 8 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.AuthorService 9 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.OutboxMessageService 10 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.AuthorEntityToDtoConverter 11 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.AuthorEntityToModelConverter 12 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.AuthorToSaveToEntityConverter 13 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.CurrentAndPreviousState 14 | import org.slf4j.LoggerFactory 15 | import org.springframework.context.annotation.Primary 16 | import org.springframework.data.repository.findByIdOrNull 17 | import org.springframework.stereotype.Service 18 | import org.springframework.transaction.annotation.Propagation 19 | import org.springframework.transaction.annotation.Transactional 20 | 21 | @Service 22 | @Primary 23 | class AuthorServiceImpl( 24 | private val outboxMessageService: OutboxMessageService, 25 | private val authorRepository: AuthorRepository, 26 | private val authorEntityToDtoConverter: AuthorEntityToDtoConverter, 27 | private val authorEntityToModelConverter: AuthorEntityToModelConverter, 28 | private val authorToSaveToEntityConverter: AuthorToSaveToEntityConverter 29 | ) : AuthorService { 30 | 31 | private val log = LoggerFactory.getLogger(this.javaClass) 32 | 33 | override fun getAll(): List = authorRepository.findAllByOrderByIdAsc() 34 | .map { authorEntityToDtoConverter.convert(it) } 35 | 36 | override fun getEntityById(id: Long): AuthorEntity? = authorRepository.findByIdOrNull(id) 37 | 38 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 39 | override fun update(id: Long, author: AuthorToSave): AuthorDto { 40 | log.debug("Start update an author: id={}, new state={}", id, author) 41 | 42 | val existingAuthor = authorRepository.findByIdOrNull(id) ?: throw NotFoundException("Author", id) 43 | val existingAuthorModel = authorEntityToModelConverter.convert(existingAuthor) 44 | val authorToUpdate = authorToSaveToEntityConverter.convert(Pair(author, existingAuthor)) 45 | val updatedAuthor = authorRepository.save(authorToUpdate) 46 | 47 | val updatedAuthorModel = authorEntityToModelConverter.convert(updatedAuthor) 48 | outboxMessageService.saveAuthorChangedEventMessage(CurrentAndPreviousState(updatedAuthorModel, existingAuthorModel)) 49 | 50 | return authorEntityToDtoConverter.convert(updatedAuthor) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/impl/BookServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.Book as BookDto 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoan as BookLoanDto 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookLoanToSave 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.api.model.BookToSave 7 | import com.romankudryashov.eventdrivenarchitecture.bookservice.exception.BookServiceException 8 | import com.romankudryashov.eventdrivenarchitecture.bookservice.exception.NotFoundException 9 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.BookRepository 10 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.BookEntity 11 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.BookLoanEntity 12 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.BookService 13 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.OutboxMessageService 14 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.UserReplicaService 15 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.BookEntityToDtoConverter 16 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.BookEntityToModelConverter 17 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.BookLoanEntityToDtoConverter 18 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.BookLoanToSaveToEntityConverter 19 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.converter.BookToSaveToEntityConverter 20 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.CurrentAndPreviousState 21 | import org.slf4j.LoggerFactory 22 | import org.springframework.beans.factory.annotation.Value 23 | import org.springframework.context.annotation.Primary 24 | import org.springframework.data.repository.findByIdOrNull 25 | import org.springframework.stereotype.Service 26 | import org.springframework.transaction.annotation.Propagation 27 | import org.springframework.transaction.annotation.Transactional 28 | 29 | @Service 30 | @Primary 31 | class BookServiceImpl( 32 | private val outboxMessageService: OutboxMessageService, 33 | private val bookRepository: BookRepository, 34 | private val bookEntityToDtoConverter: BookEntityToDtoConverter, 35 | private val bookEntityToModelConverter: BookEntityToModelConverter, 36 | private val bookToSaveToEntityConverter: BookToSaveToEntityConverter, 37 | private val bookLoanEntityToDtoConverter: BookLoanEntityToDtoConverter, 38 | private val bookLoanToSaveToEntityConverter: BookLoanToSaveToEntityConverter, 39 | private val userReplicaService: UserReplicaService, 40 | @Value("\${user.check.use-streaming-data}") 41 | private val useStreamingDataToCheckUser: Boolean 42 | ) : BookService { 43 | 44 | private val log = LoggerFactory.getLogger(this.javaClass) 45 | 46 | override fun getAll(): List = bookRepository.findAllByStatusOrderByIdAsc(BookEntity.Status.Active) 47 | .map { bookEntityToDtoConverter.convert(it) } 48 | 49 | override fun getById(id: Long): BookDto? = getBookEntityById(id)?.let { entity -> 50 | bookEntityToDtoConverter.convert(entity) 51 | } 52 | 53 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 54 | override fun create(book: BookToSave): BookDto { 55 | log.debug("Start creating a book: {}", book) 56 | 57 | val bookToCreate = bookToSaveToEntityConverter.convert(Pair(book, null)) 58 | val createdBook = bookRepository.save(bookToCreate) 59 | 60 | val createdBookModel = bookEntityToModelConverter.convert(createdBook) 61 | outboxMessageService.saveBookCreatedEventMessage(createdBookModel) 62 | 63 | return bookEntityToDtoConverter.convert(createdBook) 64 | } 65 | 66 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 67 | override fun update(id: Long, book: BookToSave): BookDto { 68 | log.debug("Start updating a book: id={}, new state={}", id, book) 69 | 70 | val existingBook = getBookEntityById(id) ?: throw NotFoundException("Book", id) 71 | val existingBookModel = bookEntityToModelConverter.convert(existingBook) 72 | val bookToUpdate = bookToSaveToEntityConverter.convert(Pair(book, existingBook)) 73 | val updatedBook = bookRepository.save(bookToUpdate) 74 | 75 | val updatedBookModel = bookEntityToModelConverter.convert(updatedBook) 76 | outboxMessageService.saveBookChangedEventMessage(CurrentAndPreviousState(updatedBookModel, existingBookModel)) 77 | 78 | return bookEntityToDtoConverter.convert(updatedBook) 79 | } 80 | 81 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 82 | override fun delete(id: Long) { 83 | log.debug("Start deleting a book: id={}", id) 84 | 85 | val bookToDelete = getBookEntityById(id) ?: throw NotFoundException("Book", id) 86 | if (bookToDelete.currentLoan() == null) { 87 | bookToDelete.status = BookEntity.Status.Deleted 88 | bookRepository.save(bookToDelete) 89 | 90 | val deletedBookModel = bookEntityToModelConverter.convert(bookToDelete) 91 | outboxMessageService.saveBookDeletedEventMessage(deletedBookModel) 92 | } else { 93 | throw BookServiceException("Can't delete a book with id=$id because it is borrowed by a user") 94 | } 95 | } 96 | 97 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 98 | override fun lendBook(bookId: Long, bookLoan: BookLoanToSave): BookLoanDto { 99 | log.debug("Start lending a book: bookId={}, bookLoan={}", bookId, bookLoan) 100 | 101 | val bookToLend = getBookEntityById(bookId) ?: throw NotFoundException("Book", bookId) 102 | if (bookToLend.currentLoan() != null) throw BookServiceException("The book with id=$bookId is already borrowed by a user") 103 | if (useStreamingDataToCheckUser) { 104 | if (userReplicaService.getById(bookLoan.userId) == null) { 105 | throw NotFoundException("User", bookLoan.userId) 106 | } 107 | } 108 | 109 | val bookLoanToCreate = bookLoanToSaveToEntityConverter.convert(Pair(bookLoan, bookToLend)) 110 | bookToLend.loans.add(bookLoanToCreate) 111 | bookRepository.save(bookToLend) 112 | 113 | val lentBookModel = bookEntityToModelConverter.convert(bookToLend) 114 | outboxMessageService.saveBookLentEventMessage(lentBookModel) 115 | 116 | return bookLoanEntityToDtoConverter.convert(bookToLend.currentLoan()!!) 117 | } 118 | 119 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 120 | override fun cancelBookLoan(bookId: Long, bookLoanId: Long) { 121 | log.debug("Start cancelling book loan for a book: bookId={}, bookLoanId={}", bookId, bookLoanId) 122 | 123 | val bookToCancelLoan = getBookEntityById(bookId) ?: throw NotFoundException("Book", bookId) 124 | val modelOfBookToCancelLoan = bookEntityToModelConverter.convert(bookToCancelLoan) 125 | val bookLoanToCancel = bookToCancelLoan.loans.find { it.id == bookLoanId } ?: throw NotFoundException("BookLoan", bookLoanId) 126 | val currentLoan = bookToCancelLoan.currentLoan() 127 | if (currentLoan == null || bookLoanToCancel.id != currentLoan.id) throw BookServiceException("BookLoan with id=$bookLoanId can't be canceled") 128 | bookLoanToCancel.status = BookLoanEntity.Status.Canceled 129 | bookRepository.save(bookToCancelLoan) 130 | 131 | outboxMessageService.saveBookLoanCanceledEventMessage(modelOfBookToCancelLoan) 132 | } 133 | 134 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 135 | override fun returnBook(bookId: Long, bookLoanId: Long) { 136 | log.debug("Start returning a book: bookId={}, bookLoanId={}", bookId, bookLoanId) 137 | 138 | val bookToReturn = getBookEntityById(bookId) ?: throw NotFoundException("Book", bookId) 139 | val modelOfBookToReturn = bookEntityToModelConverter.convert(bookToReturn) 140 | val bookLoanToClose = bookToReturn.loans.find { it.id == bookLoanId } ?: throw NotFoundException("BookLoan", bookLoanId) 141 | val currentLoan = bookToReturn.currentLoan() 142 | if (currentLoan == null || bookLoanToClose.id != currentLoan.id) throw BookServiceException("BookLoan with id=$bookLoanId can't be canceled") 143 | bookLoanToClose.status = BookLoanEntity.Status.Returned 144 | bookRepository.save(bookToReturn) 145 | 146 | outboxMessageService.saveBookReturnedEventMessage(modelOfBookToReturn) 147 | } 148 | 149 | private fun getBookEntityById(id: Long): BookEntity? { 150 | val book = bookRepository.findByIdOrNull(id) 151 | return if (book != null && book.status != BookEntity.Status.Deleted) { 152 | book 153 | } else null 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/impl/InboxMessageServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.exception.BookServiceException 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.InboxMessageRepository 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.InboxMessageEntity 6 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.InboxMessageService 7 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.IncomingEventService 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.beans.factory.annotation.Value 10 | import org.springframework.data.domain.PageRequest 11 | import org.springframework.stereotype.Service 12 | import org.springframework.transaction.annotation.Propagation 13 | import org.springframework.transaction.annotation.Transactional 14 | 15 | @Service 16 | class InboxMessageServiceImpl( 17 | private val incomingEventService: IncomingEventService, 18 | private val inboxMessageRepository: InboxMessageRepository, 19 | @Value("\${spring.application.name}") 20 | private val applicationName: String 21 | ) : InboxMessageService { 22 | 23 | private val log = LoggerFactory.getLogger(this.javaClass) 24 | 25 | @Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = [RuntimeException::class]) 26 | override fun markInboxMessagesAsReadyForProcessingByInstance(batchSize: Int): Int { 27 | fun saveReadyForProcessing(inboxMessage: InboxMessageEntity) { 28 | log.debug("Start saving a message ready for processing, id={}", inboxMessage.id) 29 | 30 | if (inboxMessage.status != InboxMessageEntity.Status.New) throw BookServiceException("Inbox message with id=${inboxMessage.id} is not in 'New' status") 31 | 32 | inboxMessage.status = InboxMessageEntity.Status.ReadyForProcessing 33 | inboxMessage.processedBy = applicationName 34 | 35 | inboxMessageRepository.save(inboxMessage) 36 | } 37 | 38 | val newInboxMessages = inboxMessageRepository.findAllByStatusOrderByCreatedAtAsc(InboxMessageEntity.Status.New, PageRequest.of(0, batchSize)) 39 | 40 | return if (newInboxMessages.isNotEmpty()) { 41 | newInboxMessages.forEach { inboxMessage -> saveReadyForProcessing(inboxMessage) } 42 | newInboxMessages.size 43 | } else 0 44 | } 45 | 46 | override fun getBatchForProcessing(batchSize: Int): List = 47 | inboxMessageRepository.findAllByStatusAndProcessedByOrderByCreatedAtAsc(InboxMessageEntity.Status.ReadyForProcessing, applicationName, PageRequest.of(0, batchSize)) 48 | 49 | @Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = [RuntimeException::class]) 50 | override fun process(inboxMessage: InboxMessageEntity) { 51 | log.debug("Start processing an inbox message with id={}", inboxMessage.id) 52 | 53 | if (inboxMessage.status != InboxMessageEntity.Status.ReadyForProcessing) 54 | throw BookServiceException("Inbox message with id=${inboxMessage.id} is not in 'ReadyForProcessing' status") 55 | if (inboxMessage.processedBy == null) 56 | throw BookServiceException("'processedBy' field should be set for an inbox message with id=${inboxMessage.id}") 57 | 58 | try { 59 | incomingEventService.process(inboxMessage.type, inboxMessage.payload) 60 | inboxMessage.status = InboxMessageEntity.Status.Completed 61 | } catch (e: Exception) { 62 | log.error("Exception while processing an incoming event (type=${inboxMessage.type}, payload=${inboxMessage.payload})", e) 63 | inboxMessage.status = InboxMessageEntity.Status.Error 64 | inboxMessage.error = e.stackTraceToString() 65 | } 66 | 67 | inboxMessageRepository.save(inboxMessage) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/impl/IncomingEventServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.exception.BookServiceException 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.BookService 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.IncomingEventService 6 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.RollbackBookLentCommand 9 | import com.fasterxml.jackson.databind.JsonNode 10 | import com.fasterxml.jackson.databind.ObjectMapper 11 | import com.fasterxml.jackson.module.kotlin.treeToValue 12 | import org.slf4j.LoggerFactory 13 | import org.springframework.stereotype.Service 14 | import org.springframework.transaction.annotation.Propagation 15 | import org.springframework.transaction.annotation.Transactional 16 | 17 | @Service 18 | class IncomingEventServiceImpl( 19 | private val bookService: BookService, 20 | private val objectMapper: ObjectMapper 21 | ) : IncomingEventService { 22 | 23 | private val log = LoggerFactory.getLogger(this.javaClass) 24 | 25 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 26 | override fun process(eventType: EventType, payload: JsonNode) { 27 | log.debug("Start processing an incoming event: type={}, payload={}", eventType, payload) 28 | 29 | when (eventType) { 30 | RollbackBookLentCommand -> processRollbackBookLentCommand(getData(payload)) 31 | else -> throw BookServiceException("Event type $eventType can't be processed") 32 | } 33 | 34 | log.debug("Event processed") 35 | } 36 | 37 | private inline fun getData(payload: JsonNode): T = objectMapper.treeToValue(payload) 38 | 39 | private fun processRollbackBookLentCommand(book: Book) { 40 | val bookLoan = book.currentLoan ?: throw BookServiceException("The book: $book doesn't contain loan to cancel") 41 | bookService.cancelBookLoan(book.id, bookLoan.id) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/impl/OutboxMessageServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.OutboxMessageRepository 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.OutboxMessageEntity 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.OutboxMessageService 6 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.AggregateType 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Author 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 9 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.CurrentAndPreviousState 10 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 11 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.AuthorChanged 12 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookChanged 13 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookCreated 14 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookDeleted 15 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookLent 16 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookLoanCanceled 17 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookReturned 18 | import com.fasterxml.jackson.databind.JsonNode 19 | import com.fasterxml.jackson.databind.ObjectMapper 20 | import org.slf4j.LoggerFactory 21 | import org.springframework.stereotype.Service 22 | 23 | @Service 24 | class OutboxMessageServiceImpl( 25 | private val outboxMessageRepository: OutboxMessageRepository, 26 | private val objectMapper: ObjectMapper 27 | ) : OutboxMessageService { 28 | 29 | private val log = LoggerFactory.getLogger(this.javaClass) 30 | 31 | override fun saveBookCreatedEventMessage(payload: Book) = save(createOutboxMessage(AggregateType.Book, payload.id, BookCreated, payload)) 32 | 33 | override fun saveBookChangedEventMessage(payload: CurrentAndPreviousState) = 34 | save(createOutboxMessage(AggregateType.Book, payload.current.id, BookChanged, payload)) 35 | 36 | override fun saveBookDeletedEventMessage(payload: Book) = save(createOutboxMessage(AggregateType.Book, payload.id, BookDeleted, payload)) 37 | 38 | override fun saveBookLentEventMessage(payload: Book) = save(createOutboxMessage(AggregateType.Book, payload.id, BookLent, payload)) 39 | 40 | override fun saveBookLoanCanceledEventMessage(payload: Book) = save(createOutboxMessage(AggregateType.Book, payload.id, BookLoanCanceled, payload)) 41 | 42 | override fun saveBookReturnedEventMessage(payload: Book) = save(createOutboxMessage(AggregateType.Book, payload.id, BookReturned, payload)) 43 | 44 | override fun saveAuthorChangedEventMessage(payload: CurrentAndPreviousState) = 45 | save(createOutboxMessage(AggregateType.Author, payload.current.id, AuthorChanged, payload)) 46 | 47 | private fun createOutboxMessage(aggregateType: AggregateType, aggregateId: Long, type: EventType, payload: T) = OutboxMessageEntity( 48 | aggregateType = aggregateType, 49 | aggregateId = aggregateId, 50 | type = type, 51 | payload = objectMapper.convertValue(payload, JsonNode::class.java) 52 | ) 53 | 54 | private fun save(outboxMessage: OutboxMessageEntity) { 55 | log.debug("Start saving an outbox message: {}", outboxMessage) 56 | outboxMessageRepository.save(outboxMessage) 57 | outboxMessageRepository.deleteById(outboxMessage.id!!) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/service/impl/UserReplicaServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.UserReplicaRepository 4 | import com.romankudryashov.eventdrivenarchitecture.bookservice.persistence.entity.UserReplicaEntity 5 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.UserReplicaService 6 | import org.springframework.data.repository.findByIdOrNull 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class UserReplicaServiceImpl( 11 | private val userReplicaRepository: UserReplicaRepository 12 | ) : UserReplicaService { 13 | 14 | override fun getById(id: Long): UserReplicaEntity? = userReplicaRepository.findByIdOrNull(id)?.let { user -> 15 | if (user.status == UserReplicaEntity.Status.Active) user 16 | else null 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /book-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/task/InboxProcessingTask.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice.task 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.bookservice.service.InboxMessageService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.core.task.AsyncTaskExecutor 7 | import org.springframework.scheduling.annotation.Scheduled 8 | import org.springframework.stereotype.Component 9 | import java.util.concurrent.CompletableFuture 10 | import java.util.concurrent.TimeUnit 11 | 12 | @Component 13 | class InboxProcessingTask( 14 | private val inboxMessageService: InboxMessageService, 15 | private val applicationTaskExecutor: AsyncTaskExecutor, 16 | @Value("\${inbox.processing.task.batch.size}") 17 | private val batchSize: Int, 18 | @Value("\${inbox.processing.task.subtask.timeout}") 19 | private val subtaskTimeout: Long 20 | ) { 21 | 22 | private val log = LoggerFactory.getLogger(this.javaClass) 23 | 24 | @Scheduled(cron = "\${inbox.processing.task.cron}") 25 | fun execute() { 26 | log.debug("Start inbox processing task") 27 | 28 | val newInboxMessagesCount = inboxMessageService.markInboxMessagesAsReadyForProcessingByInstance(batchSize) 29 | log.debug("{} new inbox message(s) marked as ready for processing", newInboxMessagesCount) 30 | 31 | val inboxMessagesToProcess = inboxMessageService.getBatchForProcessing(batchSize) 32 | if (inboxMessagesToProcess.isNotEmpty()) { 33 | log.debug("Start processing {} inbox message(s)", inboxMessagesToProcess.size) 34 | val subtasks = inboxMessagesToProcess.map { inboxMessage -> 35 | applicationTaskExecutor 36 | .submitCompletable { inboxMessageService.process(inboxMessage) } 37 | .orTimeout(subtaskTimeout, TimeUnit.SECONDS) 38 | } 39 | CompletableFuture.allOf(*subtasks.toTypedArray()).join() 40 | } 41 | 42 | log.debug("Inbox processing task completed") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /book-service/src/main/resources/application-local.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://localhost:5491/book 4 | 5 | server: 6 | port: 8091 7 | -------------------------------------------------------------------------------- /book-service/src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: book-service 4 | 5 | datasource: 6 | driverClassName: org.postgresql.Driver 7 | url: jdbc:postgresql://book-db:5432/book 8 | username: postgres 9 | password: password 10 | 11 | logging.level.com.romankudryashov.eventdrivenarchitecture: debug 12 | 13 | inbox: 14 | processing: 15 | task: 16 | cron: "*/5 * * * * *" 17 | batch.size: 50 18 | subtask.timeout: 10000 19 | 20 | user.check.use-streaming-data: false 21 | -------------------------------------------------------------------------------- /book-service/src/main/resources/db/migration/V1_0_0__structure.sql: -------------------------------------------------------------------------------- 1 | create table book( 2 | id bigint primary key generated always as identity, 3 | name varchar not null, 4 | publication_year smallint not null, 5 | status varchar not null default 'Active', 6 | created_at timestamptz not null default current_timestamp, 7 | updated_at timestamptz not null default current_timestamp 8 | ); 9 | 10 | create table author( 11 | id bigint primary key generated always as identity, 12 | last_name varchar not null, 13 | first_name varchar not null, 14 | middle_name varchar, 15 | country varchar not null, 16 | date_of_birth date not null, 17 | created_at timestamptz not null default current_timestamp, 18 | updated_at timestamptz not null default current_timestamp 19 | ); 20 | 21 | create table book_author( 22 | id bigint primary key generated always as identity, 23 | book_id bigint references book(id) not null, 24 | author_id bigint references author(id) not null, 25 | created_at timestamptz not null default current_timestamp, 26 | updated_at timestamptz not null default current_timestamp 27 | ); 28 | 29 | create table book_loan( 30 | id bigint primary key generated always as identity, 31 | book_id bigint references book(id) not null, 32 | user_id bigint not null, 33 | status varchar not null default 'Active', 34 | created_at timestamptz not null default current_timestamp, 35 | updated_at timestamptz not null default current_timestamp 36 | ); 37 | 38 | create table user_replica( 39 | id bigint primary key, 40 | status varchar not null 41 | ); 42 | 43 | create table inbox( 44 | id uuid primary key, 45 | source varchar not null, 46 | type varchar not null, 47 | payload jsonb not null, 48 | status varchar not null default 'New', 49 | error varchar, 50 | processed_by varchar, 51 | version smallint not null default 0, 52 | created_at timestamptz not null default current_timestamp, 53 | updated_at timestamptz not null default current_timestamp 54 | ); 55 | 56 | create table outbox( 57 | id uuid primary key default gen_random_uuid(), 58 | aggregate_type varchar not null, 59 | aggregate_id varchar not null, 60 | type varchar not null, 61 | payload jsonb not null, 62 | created_at timestamptz not null default current_timestamp, 63 | updated_at timestamptz not null default current_timestamp 64 | ); 65 | -------------------------------------------------------------------------------- /book-service/src/main/resources/db/migration/V1_0_1__data.sql: -------------------------------------------------------------------------------- 1 | insert into book (name, publication_year) values ('The Gambler', 1866); 2 | insert into book (name, publication_year) values ('The Brothers Karamazov', 1879); 3 | insert into book (name, publication_year) values ('The Cherry Orchard', 1904); 4 | insert into book (name, publication_year) values ('The Master and Margarita', 1966); 5 | insert into book (name, publication_year) values ('And Quiet Flows the Don', 1928); 6 | 7 | insert into author (first_name, middle_name, last_name, country, date_of_birth) values ('Fyodor', 'Mikhailovich', 'Dostoevsky', 'Russia', '1821-11-11'); 8 | insert into author (first_name, middle_name, last_name, country, date_of_birth) values ('Anton', 'Pavlovich', 'Chekhov', 'Russia', '1860-01-29'); 9 | insert into author (first_name, middle_name, last_name, country, date_of_birth) values ('Mikhail', 'Afanasyevich', 'Bulgakov', 'Russia', '1891-05-15'); 10 | insert into author (first_name, middle_name, last_name, country, date_of_birth) values ('Mikhail', 'Aleksandrovich', 'Sholokhov', 'Russia', '1905-05-24'); 11 | 12 | insert into book_author (book_id, author_id) values (1, 1); 13 | insert into book_author (book_id, author_id) values (2, 1); 14 | insert into book_author (book_id, author_id) values (3, 2); 15 | insert into book_author (book_id, author_id) values (4, 3); 16 | insert into book_author (book_id, author_id) values (5, 4); 17 | 18 | insert into book_loan (book_id, user_id) values (1, 1); 19 | insert into book_loan (book_id, user_id) values (2, 2); 20 | insert into book_loan (book_id, user_id) values (3, 2); 21 | -------------------------------------------------------------------------------- /book-service/src/main/resources/openapi/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.3" 2 | info: 3 | title: Book service 4 | version: 0.0.1 5 | paths: 6 | /books: 7 | get: 8 | tags: 9 | - books 10 | operationId: getBooks 11 | responses: 12 | '200': 13 | description: list of books 14 | content: 15 | application/json: 16 | schema: 17 | type: array 18 | items: 19 | $ref: '#/components/schemas/Book' 20 | post: 21 | tags: 22 | - books 23 | operationId: createBook 24 | requestBody: 25 | content: 26 | application/json: 27 | schema: 28 | "$ref": "#/components/schemas/BookToSave" 29 | required: true 30 | responses: 31 | '200': 32 | description: created book 33 | content: 34 | application/json: 35 | schema: 36 | $ref: '#/components/schemas/Book' 37 | /books/{id}: 38 | put: 39 | tags: 40 | - books 41 | operationId: updateBook 42 | parameters: 43 | - in: path 44 | name: id 45 | schema: 46 | type: integer 47 | format: int64 48 | required: true 49 | requestBody: 50 | content: 51 | application/json: 52 | schema: 53 | "$ref": "#/components/schemas/BookToSave" 54 | required: true 55 | responses: 56 | '200': 57 | description: updated book 58 | content: 59 | application/json: 60 | schema: 61 | $ref: '#/components/schemas/Book' 62 | delete: 63 | tags: 64 | - books 65 | operationId: deleteBook 66 | parameters: 67 | - in: path 68 | name: id 69 | schema: 70 | type: integer 71 | format: int64 72 | required: true 73 | responses: 74 | '204': 75 | description: book was deleted 76 | /books/{bookId}/loans: 77 | post: 78 | tags: 79 | - bookLoans 80 | operationId: createBookLoan 81 | parameters: 82 | - in: path 83 | name: bookId 84 | schema: 85 | type: integer 86 | format: int64 87 | required: true 88 | requestBody: 89 | content: 90 | application/json: 91 | schema: 92 | "$ref": "#/components/schemas/BookLoanToSave" 93 | required: true 94 | responses: 95 | '200': 96 | description: created book loan 97 | content: 98 | application/json: 99 | schema: 100 | $ref: '#/components/schemas/BookLoan' 101 | /books/{bookId}/loans/{id}: 102 | delete: 103 | tags: 104 | - bookLoans 105 | operationId: deleteBookLoan 106 | parameters: 107 | - in: path 108 | name: bookId 109 | schema: 110 | type: integer 111 | format: int64 112 | required: true 113 | - in: path 114 | name: id 115 | schema: 116 | type: integer 117 | format: int64 118 | required: true 119 | responses: 120 | '204': 121 | description: book was deleted 122 | /authors: 123 | get: 124 | tags: 125 | - authors 126 | operationId: getAuthors 127 | responses: 128 | '200': 129 | description: list of authors 130 | content: 131 | application/json: 132 | schema: 133 | type: array 134 | items: 135 | $ref: '#/components/schemas/Author' 136 | /authors/{id}: 137 | put: 138 | tags: 139 | - authors 140 | operationId: updateAuthor 141 | parameters: 142 | - in: path 143 | name: id 144 | schema: 145 | type: integer 146 | format: int64 147 | required: true 148 | requestBody: 149 | content: 150 | application/json: 151 | schema: 152 | "$ref": "#/components/schemas/AuthorToSave" 153 | required: true 154 | responses: 155 | '200': 156 | description: updated author 157 | content: 158 | application/json: 159 | schema: 160 | $ref: '#/components/schemas/Author' 161 | components: 162 | schemas: 163 | Book: 164 | required: 165 | - id 166 | - name 167 | - authors 168 | - publicationYear 169 | type: object 170 | properties: 171 | id: 172 | type: integer 173 | format: int64 174 | name: 175 | type: string 176 | authors: 177 | type: array 178 | items: 179 | $ref: '#/components/schemas/Author' 180 | publicationYear: 181 | type: integer 182 | currentLoan: 183 | $ref: '#/components/schemas/BookLoan' 184 | BookToSave: 185 | required: 186 | - name 187 | - authorIds 188 | - publicationYear 189 | type: object 190 | properties: 191 | name: 192 | type: string 193 | authorIds: 194 | type: array 195 | items: 196 | type: integer 197 | format: int64 198 | publicationYear: 199 | type: integer 200 | Author: 201 | required: 202 | - id 203 | - firstName 204 | - middleName 205 | - lastName 206 | - country 207 | - dateOfBirth 208 | type: object 209 | properties: 210 | id: 211 | type: integer 212 | format: int64 213 | firstName: 214 | type: string 215 | middleName: 216 | type: string 217 | lastName: 218 | type: string 219 | country: 220 | $ref: '#/components/schemas/Country' 221 | dateOfBirth: 222 | type: string 223 | format: date 224 | AuthorToSave: 225 | required: 226 | - firstName 227 | - middleName 228 | - lastName 229 | - country 230 | - dateOfBirth 231 | type: object 232 | properties: 233 | firstName: 234 | type: string 235 | middleName: 236 | type: string 237 | lastName: 238 | type: string 239 | country: 240 | $ref: '#/components/schemas/Country' 241 | dateOfBirth: 242 | type: string 243 | format: date 244 | BookLoan: 245 | required: 246 | - id 247 | - userId 248 | type: object 249 | properties: 250 | id: 251 | type: integer 252 | format: int64 253 | userId: 254 | type: integer 255 | format: int64 256 | BookLoanToSave: 257 | required: 258 | - userId 259 | type: object 260 | properties: 261 | userId: 262 | type: integer 263 | format: int64 264 | Country: 265 | type: string 266 | enum: 267 | - Russia 268 | -------------------------------------------------------------------------------- /book-service/src/test/kotlin/com/romankudryashov/eventdrivenarchitecture/bookservice/BookServiceApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.bookservice 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.boot.test.context.SpringBootTest 5 | 6 | @SpringBootTest 7 | class BookServiceApplicationTests { 8 | 9 | @Test 10 | fun contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /common-model/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.springframework.boot.gradle.tasks.bundling.BootJar 2 | 3 | plugins { 4 | id("java-library") 5 | id("org.springframework.boot") 6 | kotlin("jvm") 7 | } 8 | 9 | repositories { 10 | mavenCentral() 11 | } 12 | 13 | val springCoreVersion: String by project 14 | val flywayVersion: String by project 15 | 16 | dependencies { 17 | implementation("org.springframework:spring-core:$springCoreVersion") 18 | // TODO: remove after https://github.com/oracle/graalvm-reachability-metadata/issues/424 19 | implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") 20 | } 21 | 22 | tasks.withType { 23 | enabled = false 24 | } 25 | -------------------------------------------------------------------------------- /common-model/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/commonmodel/DataModel.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.commonmodel 2 | 3 | import java.time.LocalDate 4 | import java.time.LocalDateTime 5 | 6 | class Book( 7 | val id: Long, 8 | val name: String, 9 | val authors: List, 10 | val publicationYear: Int, 11 | val currentLoan: BookLoan? 12 | ) 13 | 14 | class Author( 15 | val id: Long, 16 | val firstName: String, 17 | val middleName: String, 18 | val lastName: String, 19 | val country: Country, 20 | val dateOfBirth: LocalDate, 21 | // the initialization of the field helps to avoid an exception during deserialization 22 | val books: List = listOf() 23 | ) 24 | 25 | class BookLoan( 26 | val id: Long, 27 | val userId: Long 28 | ) 29 | 30 | class CurrentAndPreviousState( 31 | val current: T, 32 | val previous: T 33 | ) 34 | 35 | enum class Country { 36 | Russia 37 | } 38 | 39 | class Notification( 40 | val channel: Channel, 41 | val recipient: String, 42 | val subject: String, 43 | val message: String, 44 | val createdAt: LocalDateTime 45 | ) { 46 | 47 | enum class Channel { 48 | Email 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /common-model/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/commonmodel/EventModel.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.commonmodel 2 | 3 | enum class AggregateType { 4 | Book, 5 | Author, 6 | Notification, 7 | } 8 | 9 | enum class EventType { 10 | // events 11 | BookCreated, 12 | BookChanged, 13 | BookDeleted, 14 | BookLent, 15 | BookLoanCanceled, 16 | BookReturned, 17 | AuthorChanged, 18 | 19 | // commands 20 | RollbackBookLentCommand, 21 | SendNotificationCommand 22 | } 23 | -------------------------------------------------------------------------------- /common-model/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/commonmodel/OutboxMessage.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.commonmodel 2 | 3 | import java.util.UUID 4 | 5 | // this class is used for direct writes of outbox messages to the WAL 6 | data class OutboxMessage( 7 | val id: UUID = UUID.randomUUID(), 8 | val aggregateType: AggregateType, 9 | val aggregateId: Long?, 10 | val type: EventType, 11 | val topic: String, 12 | val payload: T 13 | ) 14 | -------------------------------------------------------------------------------- /common-model/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/commonmodel/spring/CommonRuntimeHints.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.commonmodel.spring 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Author 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 5 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.BookLoan 6 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.CurrentAndPreviousState 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.OutboxMessage 9 | import org.flywaydb.core.internal.publishing.PublishingConfigurationExtension 10 | import org.springframework.aot.hint.MemberCategory 11 | import org.springframework.aot.hint.RuntimeHints 12 | import org.springframework.aot.hint.RuntimeHintsRegistrar 13 | import java.util.UUID 14 | 15 | class CommonRuntimeHints : RuntimeHintsRegistrar { 16 | 17 | override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { 18 | hints.reflection() 19 | // required for JSON serialization/deserialization 20 | .registerType(Book::class.java, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) 21 | .registerType(Author::class.java, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) 22 | .registerType(BookLoan::class.java, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) 23 | .registerType(CurrentAndPreviousState::class.java, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) 24 | .registerType(Notification::class.java, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) 25 | .registerType(OutboxMessage::class.java, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) 26 | // probably, this should be removed sometime in the future 27 | .registerTypeIfPresent(classLoader, "kotlin.collections.EmptyList", MemberCategory.DECLARED_FIELDS) 28 | // required to persist entities 29 | // TODO: remove after https://hibernate.atlassian.net/browse/HHH-16809 30 | .registerType(Array::class.java, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) 31 | // required to use Flyway 32 | // TODO: remove after https://github.com/oracle/graalvm-reachability-metadata/issues/424 33 | .registerType(PublishingConfigurationExtension::class.java, MemberCategory.INVOKE_PUBLIC_METHODS) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /compose.override.yaml: -------------------------------------------------------------------------------- 1 | # development configuration 2 | services: 3 | 4 | book-service: 5 | ports: 6 | # this is needed to access the service's endpoints from a host machine 7 | - "8091:8080" 8 | 9 | notification-service: 10 | ports: 11 | # this is needed to access the service's endpoints from a host machine 12 | - "8093:8080" 13 | 14 | book-db: 15 | ports: 16 | # this is needed to access the database from a host machine (when the appropriate service is running not in a Docker container) 17 | - "5491:5432" 18 | 19 | user-db: 20 | ports: 21 | # this is needed to access the database from a host machine (when the appropriate service is running not in a Docker container) 22 | - "5492:5432" 23 | 24 | notification-db: 25 | ports: 26 | # this is needed to access the database from a host machine (when the appropriate service is running not in a Docker container) 27 | - "5493:5432" 28 | 29 | kafka-connect: 30 | ports: 31 | # this is needed to access Kafka Connect REST API from a host machine 32 | - "8083:8083" 33 | 34 | schema-registry: 35 | environment: 36 | QUARKUS_HTTP_CORS_ORIGINS: http://localhost:8103 37 | ports: 38 | # this is needed to access Apicurio Registry from a host machine 39 | - "8080:8080" 40 | 41 | schema-registry-ui: 42 | image: apicurio/apicurio-registry-ui:3.0.7 43 | container_name: schema-registry-ui 44 | restart: always 45 | ports: 46 | # this is needed to access Apicurio Registry UI from a host machine 47 | - "8103:8080" 48 | 49 | # MONITORING TOOLS 50 | kafka-ui: 51 | image: provectuslabs/kafka-ui:v0.7.2 52 | container_name: kafka-ui 53 | restart: always 54 | environment: 55 | KAFKA_CLUSTERS_0_NAME: local 56 | KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 57 | ports: 58 | # this is needed to access the UI from a host machine 59 | - "8101:8080" 60 | 61 | pgadmin: 62 | image: dpage/pgadmin4:9.3.0 63 | container_name: pgadmin 64 | restart: always 65 | # DO NOT USE SUCH A SETUP FOR PROD! 66 | environment: 67 | PGADMIN_DEFAULT_EMAIL: admin@admin.com 68 | PGADMIN_DEFAULT_PASSWORD: some_password 69 | PGADMIN_CONFIG_SERVER_MODE: "False" 70 | PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" 71 | ports: 72 | # this is needed to access pgAdmin UI from a host machine 73 | - "8102:80" 74 | volumes: 75 | - ./misc/pgadmin/servers.json:/pgadmin4/servers.json:ro 76 | - ./misc/pgadmin/pgpass:/tmp/pgpass:ro 77 | - ./misc/pgadmin/preferences.json:/pgadmin4/preferences.json:ro 78 | # DO NOT PASS CREDENTIALS THIS WAY IN REAL ENVIRONMENTS! 79 | # Using `pgpass` and passing it as follows allows you to save yourself the trouble of entering Postgres DB credentials in pgAdmin to connect to the databases every time you restart the project 80 | entrypoint: > 81 | /bin/sh -c " 82 | cp -f /tmp/pgpass /var/lib/pgadmin/; 83 | chmod 600 /var/lib/pgadmin/pgpass; 84 | /entrypoint.sh 85 | " 86 | 87 | caddy: 88 | build: 89 | context: ./misc/caddy/ 90 | environment: 91 | DOMAIN: localhost 92 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | # base configuration 2 | services: 3 | 4 | # MICROSERVICES 5 | book-service: 6 | image: kudryashovroman/event-driven-architecture:book-service 7 | container_name: book-service 8 | restart: always 9 | depends_on: 10 | - book-db 11 | environment: 12 | THC_PATH: "/actuator/health" 13 | healthcheck: 14 | test: [ "CMD", "/workspace/health-check" ] 15 | interval: 1m 16 | retries: 3 17 | start_period: 10s 18 | timeout: 3s 19 | 20 | user-service: 21 | image: kudryashovroman/event-driven-architecture:user-service 22 | container_name: user-service 23 | restart: always 24 | depends_on: 25 | - user-db 26 | environment: 27 | THC_PATH: "/actuator/health" 28 | healthcheck: 29 | test: [ "CMD", "/workspace/health-check" ] 30 | interval: 1m 31 | retries: 3 32 | start_period: 10s 33 | timeout: 3s 34 | 35 | user-service-2: 36 | image: kudryashovroman/event-driven-architecture:user-service 37 | container_name: user-service-2 38 | restart: always 39 | depends_on: 40 | - user-db 41 | environment: 42 | SPRING_APPLICATION_NAME: user-service-2 43 | THC_PATH: "/actuator/health" 44 | healthcheck: 45 | test: [ "CMD", "/workspace/health-check" ] 46 | interval: 1m 47 | retries: 3 48 | start_period: 10s 49 | timeout: 3s 50 | 51 | notification-service: 52 | image: kudryashovroman/event-driven-architecture:notification-service 53 | container_name: notification-service 54 | restart: always 55 | depends_on: 56 | - notification-db 57 | environment: 58 | THC_PATH: "/actuator/health" 59 | healthcheck: 60 | test: [ "CMD", "/workspace/health-check" ] 61 | interval: 1m 62 | retries: 3 63 | start_period: 10s 64 | timeout: 3s 65 | 66 | # DATABASES FOR MICROSERVICES 67 | book-db: 68 | image: postgres:17.5 69 | container_name: book-db 70 | restart: always 71 | environment: 72 | POSTGRES_DB: book 73 | POSTGRES_PASSWORD: ${BOOK_DB_PASSWORD} 74 | healthcheck: 75 | test: "pg_isready -U postgres" 76 | interval: 10s 77 | retries: 3 78 | start_period: 10s 79 | timeout: 3s 80 | command: > 81 | postgres -c wal_level=logical 82 | -c timezone=Europe/Moscow 83 | 84 | user-db: 85 | image: postgres:17.5 86 | container_name: user-db 87 | restart: always 88 | environment: 89 | POSTGRES_DB: user 90 | POSTGRES_PASSWORD: ${USER_DB_PASSWORD} 91 | healthcheck: 92 | test: "pg_isready -U postgres" 93 | interval: 10s 94 | retries: 3 95 | start_period: 10s 96 | timeout: 3s 97 | command: > 98 | postgres -c wal_level=logical 99 | -c timezone=Europe/Moscow 100 | 101 | notification-db: 102 | image: postgres:17.5 103 | container_name: notification-db 104 | restart: always 105 | environment: 106 | POSTGRES_DB: notification 107 | POSTGRES_PASSWORD: ${NOTIFICATION_DB_PASSWORD} 108 | healthcheck: 109 | test: "pg_isready -U postgres" 110 | interval: 10s 111 | retries: 3 112 | start_period: 10s 113 | timeout: 3s 114 | command: > 115 | postgres -c wal_level=logical 116 | -c timezone=Europe/Moscow 117 | 118 | # INFRASTRUCTURE 119 | # One Kafka Connect instance is for the purposes of simplicity. Do your own research to set up a cluster 120 | kafka-connect: 121 | image: quay.io/debezium/connect:3.1.1.Final 122 | container_name: kafka-connect 123 | restart: always 124 | depends_on: [ kafka, schema-registry, book-db, user-db, notification-db ] 125 | environment: 126 | BOOTSTRAP_SERVERS: kafka:9092 127 | GROUP_ID: kafka-connect-cluster 128 | CONFIG_STORAGE_TOPIC: kafka-connect.config 129 | OFFSET_STORAGE_TOPIC: kafka-connect.offset 130 | STATUS_STORAGE_TOPIC: kafka-connect.status 131 | ENABLE_APICURIO_CONVERTERS: true 132 | ENABLE_DEBEZIUM_SCRIPTING: true 133 | CONNECT_EXACTLY_ONCE_SOURCE_SUPPORT: enabled 134 | CONNECT_CONFIG_PROVIDERS: "file" 135 | CONNECT_CONFIG_PROVIDERS_FILE_CLASS: "org.apache.kafka.common.config.provider.FileConfigProvider" 136 | CONNECT_LOG4J_LOGGER_org.apache.kafka.clients: ERROR 137 | volumes: 138 | - ./kafka-connect/filtering/groovy:/kafka/connect/debezium-connector-postgres/filtering/groovy 139 | - ./kafka-connect/postgres.properties:/secrets/postgres.properties:ro 140 | - ./kafka-connect/logs/:/kafka/logs/ 141 | 142 | connectors-loader: 143 | image: bash:5.2 144 | container_name: connectors-loader 145 | depends_on: [ kafka-connect ] 146 | volumes: 147 | - ./kafka-connect/connectors/:/usr/connectors:ro 148 | - ./kafka-connect/load-connectors.sh/:/usr/load-connectors.sh:ro 149 | command: bash /usr/load-connectors.sh 150 | 151 | schema-registry: 152 | image: apicurio/apicurio-registry:3.0.7 153 | container_name: schema-registry 154 | restart: always 155 | 156 | # One Kafka instance is for the purposes of simplicity. Do your own research to set up a cluster 157 | kafka: 158 | image: bitnami/kafka:4.0.0 159 | container_name: kafka 160 | restart: always 161 | environment: 162 | # KRaft settings 163 | KAFKA_CFG_NODE_ID: 0 164 | KAFKA_CFG_PROCESS_ROLES: controller,broker 165 | KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 166 | # listeners 167 | KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 168 | KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://:9092 169 | KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT 170 | KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER 171 | KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT 172 | # other 173 | KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: false 174 | volumes: 175 | - ./misc/kafka_data:/bitnami 176 | 177 | caddy: 178 | image: kudryashovroman/event-driven-architecture:caddy 179 | container_name: caddy 180 | restart: always 181 | depends_on: [ book-service, notification-service ] 182 | environment: 183 | DOMAIN: eda-demo.romankudryashov.com 184 | ports: 185 | - "80:80" 186 | - "443:443" 187 | volumes: 188 | - ./misc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro 189 | - ./misc/caddy/data:/data 190 | - ./misc/caddy/config:/config 191 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.parallel=true 2 | kotlin.code.style=official 3 | # plugins 4 | springBootVersion=3.5.0 5 | springDependencyManagementVersion=1.1.7 6 | openApiGeneratorVersion=7.13.0 7 | nativeBuildToolsVersion=0.10.6 8 | kotlinVersion=2.1.21 9 | # dependencies for `user-service` 10 | guavaVersion=33.4.8-jre 11 | kotlinxHtmlVersion=0.12.0 12 | # dependencies for `common-model` 13 | springCoreVersion=6.2.7 14 | # TODO: remove after https://github.com/oracle/graalvm-reachability-metadata/issues/424 15 | flywayVersion=11.8.2 16 | # `webjars` dependencies for `notification-service` 17 | sockjsClientVersion=1.5.1 18 | stompWebsocketVersion=2.3.4 19 | bootstrapVersion=5.3.5 20 | jqueryVersion=3.7.1 21 | # docker 22 | dockerRepository=docker.io/kudryashovroman/event-driven-architecture 23 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rkudryashov/event-driven-architecture/359bf94715879bbbbcf68d1936cf3fa7f666fc54/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /kafka-connect/connectors/book.sink.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "book.sink", 3 | "config": { 4 | "connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector", 5 | "tasks.max": "1", 6 | "topics": "library.rollback", 7 | "connection.url": "jdbc:postgresql://book-db:5432/book", 8 | "connection.username": "${file:/secrets/postgres.properties:username}", 9 | "connection.password": "${file:/secrets/postgres.properties:password}", 10 | "insert.mode": "upsert", 11 | "primary.key.mode": "record_value", 12 | "primary.key.fields": "id", 13 | "table.name.format": "inbox", 14 | "max.retries": 1, 15 | "transforms": "convertCloudEvent", 16 | "transforms.convertCloudEvent.type": "io.debezium.connector.jdbc.transforms.ConvertCloudEventToSaveableForm", 17 | "transforms.convertCloudEvent.fields.mapping": "id,source,type,data:payload", 18 | "transforms.convertCloudEvent.serializer.type": "avro", 19 | "transforms.convertCloudEvent.schema.cloudevents.name": "CloudEvents", 20 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 21 | "key.converter.schemas.enable": false, 22 | "value.converter": "io.debezium.converters.CloudEventsConverter", 23 | "value.converter.serializer.type": "avro", 24 | "value.converter.data.serializer.type": "avro", 25 | "value.converter.avro.apicurio.registry.url": "http://schema-registry:8080", 26 | "value.converter.schema.cloudevents.name": "CloudEvents" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /kafka-connect/connectors/book.sink.streaming.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "book.sink.streaming", 3 | "config": { 4 | "connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector", 5 | "tasks.max": "1", 6 | "topics": "streaming.users", 7 | "connection.url": "jdbc:postgresql://book-db:5432/book", 8 | "connection.username": "${file:/secrets/postgres.properties:username}", 9 | "connection.password": "${file:/secrets/postgres.properties:password}", 10 | "insert.mode": "upsert", 11 | "primary.key.mode": "record_value", 12 | "primary.key.fields": "id", 13 | "table.name.format": "public.user_replica", 14 | "field.include.list": "streaming.users:id,streaming.users:status", 15 | "max.retries": 1, 16 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 17 | "value.converter": "org.apache.kafka.connect.json.JsonConverter" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /kafka-connect/connectors/book.source.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "book.source", 3 | "config": { 4 | "connector.class": "io.debezium.connector.postgresql.PostgresConnector", 5 | "plugin.name": "pgoutput", 6 | "database.hostname": "book-db", 7 | "database.port": "5432", 8 | "database.user": "${file:/secrets/postgres.properties:username}", 9 | "database.password": "${file:/secrets/postgres.properties:password}", 10 | "database.dbname": "book", 11 | "exactly.once.support": "required", 12 | "topic.prefix": "book.source", 13 | "topic.creation.default.replication.factor": 1, 14 | "topic.creation.default.partitions": 6, 15 | "table.include.list": "public.outbox", 16 | "heartbeat.interval.ms": "5000", 17 | "tombstones.on.delete": false, 18 | "publication.autocreate.mode": "filtered", 19 | "transforms": "addMetadataHeaders,outbox", 20 | "transforms.addMetadataHeaders.type": "org.apache.kafka.connect.transforms.HeaderFrom$Value", 21 | "transforms.addMetadataHeaders.fields": "source,op,transaction", 22 | "transforms.addMetadataHeaders.headers": "source,op,transaction", 23 | "transforms.addMetadataHeaders.operation": "copy", 24 | "transforms.addMetadataHeaders.predicate": "isHeartbeat", 25 | "transforms.addMetadataHeaders.negate": true, 26 | "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter", 27 | "transforms.outbox.table.field.event.key": "aggregate_id", 28 | "transforms.outbox.table.expand.json.payload": true, 29 | "transforms.outbox.table.fields.additional.placement": "type:header,aggregate_type:header:dataSchemaName", 30 | "transforms.outbox.route.by.field": "aggregate_type", 31 | "transforms.outbox.route.topic.replacement": "library.events", 32 | "predicates": "isHeartbeat", 33 | "predicates.isHeartbeat.type": "org.apache.kafka.connect.transforms.predicates.TopicNameMatches", 34 | "predicates.isHeartbeat.pattern": "__debezium-heartbeat.*", 35 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 36 | "key.converter.schemas.enable": false, 37 | "value.converter": "io.debezium.converters.CloudEventsConverter", 38 | "value.converter.serializer.type": "avro", 39 | "value.converter.data.serializer.type": "avro", 40 | "value.converter.avro.apicurio.registry.url": "http://schema-registry:8080", 41 | "value.converter.avro.apicurio.registry.auto-register": true, 42 | "value.converter.avro.apicurio.registry.find-latest": true, 43 | "value.converter.avro.apicurio.registry.artifact-resolver-strategy": "io.apicurio.registry.serde.avro.strategy.RecordIdStrategy", 44 | "value.converter.metadata.source": "header", 45 | "value.converter.schema.cloudevents.name": "CloudEvents", 46 | "value.converter.schema.data.name.source.header.enable": true, 47 | "value.converter.extension.attributes.enable": false, 48 | "header.converter": "org.apache.kafka.connect.json.JsonConverter", 49 | "header.converter.schemas.enable": true 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /kafka-connect/connectors/notification.sink.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notification.sink", 3 | "config": { 4 | "connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector", 5 | "tasks.max": "1", 6 | "topics": "library.notifications", 7 | "connection.url": "jdbc:postgresql://notification-db:5432/notification", 8 | "connection.username": "${file:/secrets/postgres.properties:username}", 9 | "connection.password": "${file:/secrets/postgres.properties:password}", 10 | "insert.mode": "upsert", 11 | "primary.key.mode": "record_value", 12 | "primary.key.fields": "id", 13 | "table.name.format": "inbox", 14 | "max.retries": 1, 15 | "transforms": "convertCloudEvent", 16 | "transforms.convertCloudEvent.type": "io.debezium.connector.jdbc.transforms.ConvertCloudEventToSaveableForm", 17 | "transforms.convertCloudEvent.fields.mapping": "id,source,type,data:payload", 18 | "transforms.convertCloudEvent.serializer.type": "avro", 19 | "transforms.convertCloudEvent.schema.cloudevents.name": "CloudEvents", 20 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 21 | "key.converter.schemas.enable": false, 22 | "value.converter": "io.debezium.converters.CloudEventsConverter", 23 | "value.converter.serializer.type": "avro", 24 | "value.converter.data.serializer.type": "avro", 25 | "value.converter.avro.apicurio.registry.url": "http://schema-registry:8080", 26 | "value.converter.schema.cloudevents.name": "CloudEvents" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /kafka-connect/connectors/user.sink.dlq-ce-json.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user.sink.dlq-ce-json", 3 | "config": { 4 | "connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector", 5 | "tasks.max": "1", 6 | "topics": "library.events.dlq.ce-json", 7 | "connection.url": "jdbc:postgresql://user-db:5432/user", 8 | "connection.username": "${file:/secrets/postgres.properties:username}", 9 | "connection.password": "${file:/secrets/postgres.properties:password}", 10 | "insert.mode": "upsert", 11 | "primary.key.mode": "record_value", 12 | "primary.key.fields": "id", 13 | "table.name.format": "inbox", 14 | "max.retries": 1, 15 | "transforms": "convertCloudEvent", 16 | "transforms.convertCloudEvent.type": "io.debezium.connector.jdbc.transforms.ConvertCloudEventToSaveableForm", 17 | "transforms.convertCloudEvent.fields.mapping": "id,source,type,data:payload", 18 | "transforms.convertCloudEvent.serializer.type": "json", 19 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 20 | "key.converter.schemas.enable": false, 21 | "value.converter": "io.debezium.converters.CloudEventsConverter", 22 | "value.converter.serializer.type": "json", 23 | "value.converter.data.serializer.type": "json", 24 | "errors.tolerance": "all", 25 | "errors.log.enable": true, 26 | "errors.deadletterqueue.topic.name": "library.events.dlq.unprocessed", 27 | "errors.deadletterqueue.topic.replication.factor": 1, 28 | "errors.deadletterqueue.context.headers.enable": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /kafka-connect/connectors/user.sink.dlq-unprocessed.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user.sink.dlq-unprocessed", 3 | "config": { 4 | "connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector", 5 | "tasks.max": "1", 6 | "topics": "library.events.dlq.unprocessed", 7 | "connection.url": "jdbc:postgresql://user-db:5432/user", 8 | "connection.username": "${file:/secrets/postgres.properties:username}", 9 | "connection.password": "${file:/secrets/postgres.properties:password}", 10 | "insert.mode": "insert", 11 | "table.name.format": "inbox_unprocessed", 12 | "max.retries": 1, 13 | "transforms": "hoistField,copyHeadersToValue", 14 | "transforms.hoistField.type": "org.apache.kafka.connect.transforms.HoistField$Value", 15 | "transforms.hoistField.field": "message", 16 | "transforms.copyHeadersToValue.type": "io.debezium.transforms.HeaderToValue", 17 | "transforms.copyHeadersToValue.headers": "__connect.errors.exception.stacktrace", 18 | "transforms.copyHeadersToValue.fields": "error", 19 | "transforms.copyHeadersToValue.operation": "copy", 20 | "key.converter": "org.apache.kafka.connect.storage.StringConverter", 21 | "value.converter": "org.apache.kafka.connect.storage.StringConverter", 22 | "header.converter": "org.apache.kafka.connect.storage.StringConverter", 23 | "errors.tolerance": "all", 24 | "errors.log.enable": true, 25 | "errors.log.include.messages": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /kafka-connect/connectors/user.sink.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user.sink", 3 | "config": { 4 | "connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector", 5 | "tasks.max": "1", 6 | "topics": "library.events", 7 | "connection.url": "jdbc:postgresql://user-db:5432/user", 8 | "connection.username": "${file:/secrets/postgres.properties:username}", 9 | "connection.password": "${file:/secrets/postgres.properties:password}", 10 | "insert.mode": "upsert", 11 | "primary.key.mode": "record_value", 12 | "primary.key.fields": "id", 13 | "table.name.format": "inbox", 14 | "max.retries": 1, 15 | "transforms": "convertCloudEvent", 16 | "transforms.convertCloudEvent.type": "io.debezium.connector.jdbc.transforms.ConvertCloudEventToSaveableForm", 17 | "transforms.convertCloudEvent.fields.mapping": "id,source,type,data:payload", 18 | "transforms.convertCloudEvent.serializer.type": "avro", 19 | "transforms.convertCloudEvent.schema.cloudevents.name": "CloudEvents", 20 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 21 | "key.converter.schemas.enable": false, 22 | "value.converter": "io.debezium.converters.CloudEventsConverter", 23 | "value.converter.serializer.type": "avro", 24 | "value.converter.data.serializer.type": "avro", 25 | "value.converter.avro.apicurio.registry.url": "http://schema-registry:8080", 26 | "value.converter.schema.cloudevents.name": "CloudEvents", 27 | "errors.tolerance": "all", 28 | "errors.log.enable": true, 29 | "errors.deadletterqueue.topic.name": "library.events.dlq.ce-json", 30 | "errors.deadletterqueue.topic.replication.factor": 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /kafka-connect/connectors/user.source.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user.source", 3 | "config": { 4 | "connector.class": "io.debezium.connector.postgresql.PostgresConnector", 5 | "plugin.name": "pgoutput", 6 | "database.hostname": "user-db", 7 | "database.port": "5432", 8 | "database.user": "${file:/secrets/postgres.properties:username}", 9 | "database.password": "${file:/secrets/postgres.properties:password}", 10 | "database.dbname": "user", 11 | "exactly.once.support": "required", 12 | "topic.prefix": "user.source", 13 | "topic.creation.default.replication.factor": 1, 14 | "topic.creation.default.partitions": 6, 15 | "schema.exclude.list": "public", 16 | "heartbeat.interval.ms": "5000", 17 | "tombstones.on.delete": false, 18 | "publication.autocreate.mode": "no_tables", 19 | "transforms": "addMetadataHeaders,decode,outbox", 20 | "transforms.addMetadataHeaders.type": "org.apache.kafka.connect.transforms.HeaderFrom$Value", 21 | "transforms.addMetadataHeaders.fields": "source,op,transaction", 22 | "transforms.addMetadataHeaders.headers": "source,op,transaction", 23 | "transforms.addMetadataHeaders.operation": "copy", 24 | "transforms.addMetadataHeaders.predicate": "isHeartbeat", 25 | "transforms.addMetadataHeaders.negate": true, 26 | "transforms.decode.type": "io.debezium.connector.postgresql.transforms.DecodeLogicalDecodingMessageContent", 27 | "transforms.decode.fields.null.include": true, 28 | "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter", 29 | "transforms.outbox.table.field.event.key": "aggregateId", 30 | "transforms.outbox.table.expand.json.payload": true, 31 | "transforms.outbox.table.fields.additional.placement": "type:header,aggregateType:header:dataSchemaName", 32 | "transforms.outbox.route.by.field": "topic", 33 | "transforms.outbox.route.topic.replacement": "library.${routedByValue}", 34 | "predicates": "isHeartbeat", 35 | "predicates.isHeartbeat.type": "org.apache.kafka.connect.transforms.predicates.TopicNameMatches", 36 | "predicates.isHeartbeat.pattern": "__debezium-heartbeat.*", 37 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 38 | "key.converter.schemas.enable": false, 39 | "value.converter": "io.debezium.converters.CloudEventsConverter", 40 | "value.converter.serializer.type": "avro", 41 | "value.converter.data.serializer.type": "avro", 42 | "value.converter.avro.apicurio.registry.url": "http://schema-registry:8080", 43 | "value.converter.avro.apicurio.registry.auto-register": true, 44 | "value.converter.avro.apicurio.registry.find-latest": true, 45 | "value.converter.avro.apicurio.registry.artifact-resolver-strategy": "io.apicurio.registry.serde.avro.strategy.RecordIdStrategy", 46 | "value.converter.metadata.source": "header", 47 | "value.converter.schema.cloudevents.name": "CloudEvents", 48 | "value.converter.schema.data.name.source.header.enable": true, 49 | "value.converter.extension.attributes.enable": false, 50 | "header.converter": "org.apache.kafka.connect.json.JsonConverter", 51 | "header.converter.schemas.enable": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /kafka-connect/connectors/user.source.streaming.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user.source.streaming", 3 | "config": { 4 | "connector.class": "io.debezium.connector.postgresql.PostgresConnector", 5 | "plugin.name": "pgoutput", 6 | "database.hostname": "user-db", 7 | "database.port": "5432", 8 | "database.user": "${file:/secrets/postgres.properties:username}", 9 | "database.password": "${file:/secrets/postgres.properties:password}", 10 | "database.dbname": "user", 11 | "exactly.once.support": "required", 12 | "slot.name": "debezium_streaming", 13 | "publication.name": "dbz_publication_streaming", 14 | "topic.prefix": "streaming", 15 | "topic.creation.default.replication.factor": 1, 16 | "topic.creation.default.partitions": 6, 17 | "table.include.list": "public.library_user", 18 | "column.exclude.list": "public.library_user.created_at,public.library_user.updated_at", 19 | "heartbeat.interval.ms": "5000", 20 | "publication.autocreate.mode": "filtered", 21 | "transforms": "filter,renameTopic", 22 | "transforms.filter.type": "io.debezium.transforms.Filter", 23 | "transforms.filter.language": "jsr223.groovy", 24 | "transforms.filter.condition": "valueSchema.field('op') != null && value.op != 'm'", 25 | "transforms.renameTopic.type": "io.debezium.transforms.ByLogicalTableRouter", 26 | "transforms.renameTopic.topic.regex": "(.*)public.(.*)", 27 | "transforms.renameTopic.topic.replacement": "$1users", 28 | "key.converter": "org.apache.kafka.connect.json.JsonConverter", 29 | "value.converter": "org.apache.kafka.connect.json.JsonConverter" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /kafka-connect/filtering/groovy/groovy-4.0.24.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rkudryashov/event-driven-architecture/359bf94715879bbbbcf68d1936cf3fa7f666fc54/kafka-connect/filtering/groovy/groovy-4.0.24.jar -------------------------------------------------------------------------------- /kafka-connect/filtering/groovy/groovy-jsr223-4.0.24.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rkudryashov/event-driven-architecture/359bf94715879bbbbcf68d1936cf3fa7f666fc54/kafka-connect/filtering/groovy/groovy-jsr223-4.0.24.jar -------------------------------------------------------------------------------- /kafka-connect/load-connectors.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | apk --no-cache add curl 4 | 5 | KAFKA_CONNECT_URL='http://kafka-connect:8083/connectors' 6 | 7 | echo -e "\nWaiting for Kafka Connect to start listening on kafka-connect ⏳" 8 | while [ $(curl -s -o /dev/null -w %{http_code} $KAFKA_CONNECT_URL) -eq 000 ] ; do 9 | echo -e $(date) " Kafka Connect HTTP listener state: "$(curl -s -o /dev/null -w %{http_code} $KAFKA_CONNECT_URL)" (waiting for 200)" 10 | sleep 5 11 | done 12 | nc -vz kafka-connect 8083 13 | 14 | echo -e "\nStart connectors loading" 15 | 16 | CONNECTORS=( 17 | 'book.sink.json' 18 | 'book.sink.streaming.json' 19 | 'book.source.json' 20 | 'notification.sink.json' 21 | 'user.sink.dlq-ce-json.json' 22 | 'user.sink.dlq-unprocessed.json' 23 | 'user.sink.json' 24 | 'user.source.json' 25 | 'user.source.streaming.json' 26 | ) 27 | 28 | for connector in "${CONNECTORS[@]}" 29 | do 30 | echo -e "\n\nCreating a connector: $connector..." 31 | while [ $(curl -s -o /dev/null -w %{http_code} -v -H 'Content-Type: application/json' -X POST --data @/usr/connectors/$connector $KAFKA_CONNECT_URL) -ne 201 ] ; do 32 | echo -e $(date) " repeat loading '$connector'" 33 | sleep 5 34 | done 35 | echo "Connector '$connector' loaded" 36 | done 37 | 38 | echo -e "\nAll connectors loaded" 39 | -------------------------------------------------------------------------------- /kafka-connect/postgres.properties: -------------------------------------------------------------------------------- 1 | username=postgres 2 | password=password 3 | -------------------------------------------------------------------------------- /misc/caddy/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | order rate_limit before basicauth 3 | } 4 | 5 | {$DOMAIN} { 6 | handle_path /book-service* { 7 | rate_limit { 8 | zone book_srv { 9 | key {remote_host} 10 | events 6 11 | window 1m 12 | } 13 | } 14 | 15 | reverse_proxy book-service:8080 16 | } 17 | 18 | handle_path /* { 19 | rate_limit { 20 | zone notification_srv { 21 | key {remote_host} 22 | events 30 23 | window 1m 24 | } 25 | } 26 | 27 | reverse_proxy notification-service:8080 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /misc/caddy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM caddy:2.10.0-builder AS builder 2 | 3 | RUN xcaddy build --with github.com/mholt/caddy-ratelimit 4 | 5 | FROM caddy:2.10.0 6 | 7 | COPY --from=builder /usr/bin/caddy /usr/bin/caddy 8 | -------------------------------------------------------------------------------- /misc/pgadmin/pgpass: -------------------------------------------------------------------------------- 1 | book-db:5432:book:postgres:password 2 | user-db:5432:user:postgres:password 3 | notification-db:5432:notification:postgres:password 4 | -------------------------------------------------------------------------------- /misc/pgadmin/preferences.json: -------------------------------------------------------------------------------- 1 | { 2 | "preferences": { 3 | "browser:display:show_system_objects": true, 4 | "browser:display:show_user_defined_templates": true, 5 | "browser:display:confirm_on_refresh_close": false, 6 | "misc:user_interface:theme": "system" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /misc/pgadmin/servers.json: -------------------------------------------------------------------------------- 1 | { 2 | "Servers": { 3 | "book-db": { 4 | "Name": "book-db", 5 | "Group": "Servers", 6 | "Host": "book-db", 7 | "Port": 5432, 8 | "MaintenanceDB": "book", 9 | "Username": "postgres", 10 | "PassFile": "/var/lib/pgadmin/pgpass", 11 | "SSLMode": "prefer" 12 | }, 13 | "user-db": { 14 | "Name": "user-db", 15 | "Group": "Servers", 16 | "Host": "user-db", 17 | "Port": 5432, 18 | "MaintenanceDB": "user", 19 | "Username": "postgres", 20 | "PassFile": "/var/lib/pgadmin/pgpass", 21 | "SSLMode": "prefer" 22 | }, 23 | "notification-db": { 24 | "Name": "notification-db", 25 | "Group": "Servers", 26 | "Host": "notification-db", 27 | "Port": 5432, 28 | "MaintenanceDB": "notification", 29 | "Username": "postgres", 30 | "PassFile": "/var/lib/pgadmin/pgpass", 31 | "SSLMode": "prefer" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /misc/postgres/batch_insert.sql: -------------------------------------------------------------------------------- 1 | do $$ 2 | begin 3 | for i in 1..10000 loop 4 | insert into public.inbox(id, source, type, payload) values( 5 | gen_random_uuid(), 6 | 'batch_insert', 7 | 'BookChanged', 8 | '{"current": {"id": 3, "name": "The Cherry Orchard", "authors": [{"id": 2, "country": "Russia", "lastName": "Chekhov", "firstName": "Anton", "middleName": "Pavlovich", "dateOfBirth": "1860-01-29"}], "currentLoan": {"id": 3, "userId": 2}, "publicationYear": 1905}, "previous": {"id": 3, "name": "The Cherry Orchard", "authors": [{"id": 2, "country": "Russia", "lastName": "Chekhov", "firstName": "Anton", "middleName": "Pavlovich", "dateOfBirth": "1860-01-29"}], "currentLoan": {"id": 3, "userId": 2}, "publicationYear": 1904}}' 9 | ); 10 | end loop; 11 | end; 12 | $$; 13 | -------------------------------------------------------------------------------- /misc/postman/testing.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Event-driven architecture demo", 4 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 5 | }, 6 | "item": [ 7 | { 8 | "name": "kafka-connect.get-connectors", 9 | "request": { 10 | "method": "GET", 11 | "header": [], 12 | "url": { 13 | "raw": "http://localhost:8083/connectors", 14 | "protocol": "http", 15 | "host": [ 16 | "localhost" 17 | ], 18 | "port": "8083", 19 | "path": [ 20 | "connectors" 21 | ] 22 | } 23 | }, 24 | "response": [] 25 | }, 26 | { 27 | "name": "kafka-connect.get-connector-plugins", 28 | "request": { 29 | "method": "GET", 30 | "header": [], 31 | "url": { 32 | "raw": "http://localhost:8083/connector-plugins", 33 | "protocol": "http", 34 | "host": [ 35 | "localhost" 36 | ], 37 | "port": "8083", 38 | "path": [ 39 | "connector-plugins" 40 | ] 41 | } 42 | }, 43 | "response": [] 44 | }, 45 | { 46 | "name": "book-service.get-books", 47 | "request": { 48 | "method": "GET", 49 | "header": [], 50 | "url": { 51 | "raw": "{{baseUrl}}/book-service/books", 52 | "host": [ 53 | "{{baseUrl}}" 54 | ], 55 | "path": [ 56 | "book-service", 57 | "books" 58 | ] 59 | } 60 | }, 61 | "response": [] 62 | }, 63 | { 64 | "name": "book-service.create-book", 65 | "request": { 66 | "method": "POST", 67 | "header": [], 68 | "body": { 69 | "mode": "raw", 70 | "raw": "{\r\n \"name\": \"The White Guard\",\r\n \"authorIds\": [3],\r\n \"publicationYear\": 1925\r\n}", 71 | "options": { 72 | "raw": { 73 | "language": "json" 74 | } 75 | } 76 | }, 77 | "url": { 78 | "raw": "{{baseUrl}}/book-service/books", 79 | "host": [ 80 | "{{baseUrl}}" 81 | ], 82 | "path": [ 83 | "book-service", 84 | "books" 85 | ] 86 | } 87 | }, 88 | "response": [] 89 | }, 90 | { 91 | "name": "book-service.update-book", 92 | "request": { 93 | "method": "PUT", 94 | "header": [], 95 | "body": { 96 | "mode": "raw", 97 | "raw": "{\r\n \"name\": \"The Cherry Orchard\",\r\n \"authorIds\": [2],\r\n \"publicationYear\": 1905\r\n}", 98 | "options": { 99 | "raw": { 100 | "language": "json" 101 | } 102 | } 103 | }, 104 | "url": { 105 | "raw": "{{baseUrl}}/book-service/books/3", 106 | "host": [ 107 | "{{baseUrl}}" 108 | ], 109 | "path": [ 110 | "book-service", 111 | "books", 112 | "3" 113 | ] 114 | } 115 | }, 116 | "response": [] 117 | }, 118 | { 119 | "name": "book-service.delete-book", 120 | "request": { 121 | "method": "DELETE", 122 | "header": [], 123 | "body": { 124 | "mode": "raw", 125 | "raw": "{\r\n \"name\": \"The Captain's Daughter\",\r\n \"authorId\": 2,\r\n \"publicationYear\": 1838\r\n}", 126 | "options": { 127 | "raw": { 128 | "language": "json" 129 | } 130 | } 131 | }, 132 | "url": { 133 | "raw": "{{baseUrl}}/book-service/books/6", 134 | "host": [ 135 | "{{baseUrl}}" 136 | ], 137 | "path": [ 138 | "book-service", 139 | "books", 140 | "6" 141 | ] 142 | } 143 | }, 144 | "response": [] 145 | }, 146 | { 147 | "name": "book-service.get-authors", 148 | "request": { 149 | "method": "GET", 150 | "header": [], 151 | "url": { 152 | "raw": "{{baseUrl}}/book-service/authors", 153 | "host": [ 154 | "{{baseUrl}}" 155 | ], 156 | "path": [ 157 | "book-service", 158 | "authors" 159 | ] 160 | } 161 | }, 162 | "response": [] 163 | }, 164 | { 165 | "name": "book-service.update-author", 166 | "request": { 167 | "method": "PUT", 168 | "header": [], 169 | "body": { 170 | "mode": "raw", 171 | "raw": "{\r\n \"firstName\": \"Fyodor\",\r\n \"middleName\": \"Mikhailovich\",\r\n \"lastName\": \"Dostoyevskiy\",\r\n \"country\": \"Russia\",\r\n \"dateOfBirth\": \"1821-10-30\"\r\n}", 172 | "options": { 173 | "raw": { 174 | "language": "json" 175 | } 176 | } 177 | }, 178 | "url": { 179 | "raw": "{{baseUrl}}/book-service/authors/1", 180 | "host": [ 181 | "{{baseUrl}}" 182 | ], 183 | "path": [ 184 | "book-service", 185 | "authors", 186 | "1" 187 | ] 188 | } 189 | }, 190 | "response": [] 191 | }, 192 | { 193 | "name": "book-service.create-book-loan", 194 | "request": { 195 | "method": "POST", 196 | "header": [], 197 | "body": { 198 | "mode": "raw", 199 | "raw": "{\r\n \"userId\": 2\r\n}", 200 | "options": { 201 | "raw": { 202 | "language": "json" 203 | } 204 | } 205 | }, 206 | "url": { 207 | "raw": "{{baseUrl}}/book-service/books/5/loans", 208 | "host": [ 209 | "{{baseUrl}}" 210 | ], 211 | "path": [ 212 | "book-service", 213 | "books", 214 | "5", 215 | "loans" 216 | ] 217 | } 218 | }, 219 | "response": [] 220 | }, 221 | { 222 | "name": "book-service.delete-book-loan", 223 | "request": { 224 | "method": "DELETE", 225 | "header": [], 226 | "url": { 227 | "raw": "{{baseUrl}}/book-service/books/5/loans/4", 228 | "host": [ 229 | "{{baseUrl}}" 230 | ], 231 | "path": [ 232 | "book-service", 233 | "books", 234 | "5", 235 | "loans", 236 | "4" 237 | ] 238 | } 239 | }, 240 | "response": [] 241 | } 242 | ], 243 | "event": [ 244 | { 245 | "listen": "prerequest", 246 | "script": { 247 | "type": "text/javascript", 248 | "exec": [ 249 | "" 250 | ] 251 | } 252 | }, 253 | { 254 | "listen": "test", 255 | "script": { 256 | "type": "text/javascript", 257 | "exec": [ 258 | "" 259 | ] 260 | } 261 | } 262 | ], 263 | "variable": [ 264 | { 265 | "key": "baseUrl", 266 | "value": "https://localhost", 267 | "type": "default" 268 | } 269 | ] 270 | } -------------------------------------------------------------------------------- /notification-service/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.springframework.boot.gradle.tasks.aot.ProcessAot 2 | import org.springframework.boot.gradle.tasks.bundling.BootBuildImage 3 | 4 | plugins { 5 | id("org.springframework.boot") 6 | id("io.spring.dependency-management") 7 | // TODO: remove after https://github.com/gradle/gradle/issues/17559 8 | id("org.openapi.generator") 9 | id("org.graalvm.buildtools.native") 10 | kotlin("jvm") 11 | kotlin("plugin.spring") 12 | kotlin("plugin.jpa") 13 | } 14 | 15 | java { 16 | toolchain { 17 | languageVersion = JavaLanguageVersion.of(21) 18 | } 19 | } 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | val sockjsClientVersion: String by project 26 | val stompWebsocketVersion: String by project 27 | val bootstrapVersion: String by project 28 | val jqueryVersion: String by project 29 | val dockerRepository: String by project 30 | 31 | dependencies { 32 | implementation(project(":common-model")) 33 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 34 | implementation("org.springframework.boot:spring-boot-starter-web") 35 | implementation("org.springframework.boot:spring-boot-starter-actuator") 36 | implementation("org.springframework.boot:spring-boot-starter-mail") 37 | implementation("org.springframework.boot:spring-boot-starter-websocket") 38 | implementation("org.flywaydb:flyway-database-postgresql") 39 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 40 | implementation("org.jetbrains.kotlin:kotlin-reflect") 41 | // "webjars" dependencies are needed to serve `index.html` and its resources 42 | implementation("org.webjars:webjars-locator-lite") 43 | implementation("org.webjars:sockjs-client:$sockjsClientVersion") 44 | implementation("org.webjars:stomp-websocket:$stompWebsocketVersion") 45 | implementation("org.webjars:bootstrap:$bootstrapVersion") 46 | implementation("org.webjars:jquery:$jqueryVersion") 47 | runtimeOnly("org.postgresql:postgresql") 48 | testImplementation("org.springframework.boot:spring-boot-starter-test") 49 | } 50 | 51 | kotlin { 52 | compilerOptions { 53 | freeCompilerArgs.addAll("-Xjsr305=strict") 54 | } 55 | } 56 | 57 | tasks.withType { 58 | args = mutableListOf("--spring.profiles.active=") 59 | } 60 | 61 | tasks.withType { 62 | buildpacks = setOf("paketobuildpacks/java-native-image", "paketobuildpacks/health-checker") 63 | environment = mapOf("BP_HEALTH_CHECKER_ENABLED" to "true") 64 | imageName = "$dockerRepository:${project.name}" 65 | } 66 | 67 | tasks.withType { 68 | useJUnitPlatform() 69 | } 70 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/NotificationServiceApplication.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.spring.CommonRuntimeHints 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.boot.runApplication 6 | import org.springframework.context.annotation.ImportRuntimeHints 7 | import org.springframework.scheduling.annotation.EnableScheduling 8 | 9 | @SpringBootApplication 10 | @ImportRuntimeHints(CommonRuntimeHints::class) 11 | @EnableScheduling 12 | class NotificationServiceApplication 13 | 14 | fun main(args: Array) { 15 | runApplication(*args) 16 | } 17 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/config/Config.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.config 2 | 3 | import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.core.task.AsyncTaskExecutor 7 | import org.springframework.core.task.support.TaskExecutorAdapter 8 | import java.util.concurrent.Executors 9 | 10 | @Configuration 11 | class Config { 12 | 13 | @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) 14 | fun asyncTaskExecutor(): AsyncTaskExecutor = TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()) 15 | } 16 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/config/TestConfig.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.config 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.EmailService 4 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.impl.EmailServiceTestImpl 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.context.annotation.Primary 9 | import org.springframework.context.annotation.Profile 10 | import org.springframework.mail.javamail.JavaMailSender 11 | 12 | @Configuration 13 | @Profile("test") 14 | class TestConfig( 15 | @Value("\${spring.mail.username}") 16 | private val emailFrom: String 17 | ) { 18 | 19 | @Bean 20 | @Primary 21 | fun emailService(emailSender: JavaMailSender): EmailService = EmailServiceTestImpl(emailSender, emailFrom) 22 | } 23 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/config/WebSocketConfig.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.messaging.simp.config.MessageBrokerRegistry 5 | import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker 6 | import org.springframework.web.socket.config.annotation.StompEndpointRegistry 7 | import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer 8 | 9 | @Configuration 10 | @EnableWebSocketMessageBroker 11 | class WebSocketConfig : WebSocketMessageBrokerConfigurer { 12 | 13 | override fun registerStompEndpoints(registry: StompEndpointRegistry) { 14 | registry.addEndpoint("/ws-notifications").setAllowedOriginPatterns("*").withSockJS() 15 | } 16 | 17 | override fun configureMessageBroker(config: MessageBrokerRegistry) { 18 | config.enableSimpleBroker("/topic") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/exception/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.exception 2 | 3 | class NotificationServiceException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/persistence/Repositories.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.persistence 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.persistence.entity.InboxMessageEntity 4 | import org.springframework.data.domain.Pageable 5 | import org.springframework.data.jpa.repository.JpaRepository 6 | import org.springframework.data.jpa.repository.Lock 7 | import java.util.UUID 8 | import jakarta.persistence.LockModeType 9 | 10 | interface InboxMessageRepository : JpaRepository { 11 | 12 | @Lock(LockModeType.PESSIMISTIC_WRITE) 13 | fun findAllByStatusOrderByCreatedAtAsc(status: InboxMessageEntity.Status, pageable: Pageable): List 14 | 15 | fun findAllByStatusAndProcessedByOrderByCreatedAtAsc(status: InboxMessageEntity.Status, processedBy: String, pageable: Pageable): List 16 | } 17 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/persistence/entity/AbstractEntity.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.persistence.entity 2 | 3 | import org.hibernate.annotations.SourceType 4 | import org.hibernate.annotations.UpdateTimestamp 5 | import java.time.ZonedDateTime 6 | import jakarta.persistence.Column 7 | import jakarta.persistence.MappedSuperclass 8 | 9 | @MappedSuperclass 10 | abstract class AbstractEntity( 11 | @Column(insertable = false, updatable = false) 12 | val createdAt: ZonedDateTime? = null, 13 | @Column(insertable = false) 14 | @UpdateTimestamp(source = SourceType.DB) 15 | val updatedAt: ZonedDateTime? = null 16 | ) 17 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/persistence/entity/Entities.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.persistence.entity 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | import com.fasterxml.jackson.databind.JsonNode 5 | import org.hibernate.annotations.JdbcTypeCode 6 | import org.hibernate.type.SqlTypes 7 | import java.util.UUID 8 | import jakarta.persistence.Entity 9 | import jakarta.persistence.EnumType 10 | import jakarta.persistence.Enumerated 11 | import jakarta.persistence.Id 12 | import jakarta.persistence.Table 13 | import jakarta.persistence.Version 14 | 15 | @Entity 16 | @Table(name = "inbox") 17 | class InboxMessageEntity( 18 | @Id 19 | val id: UUID, 20 | val source: String, 21 | @Enumerated(value = EnumType.STRING) 22 | val type: EventType, 23 | @JdbcTypeCode(SqlTypes.JSON) 24 | val payload: JsonNode, 25 | @Enumerated(value = EnumType.STRING) 26 | var status: Status, 27 | var error: String?, 28 | var processedBy: String?, 29 | @Version 30 | val version: Int 31 | ) : AbstractEntity() { 32 | 33 | enum class Status { 34 | New, 35 | ReadyForProcessing, 36 | Completed, 37 | Error 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/service/EmailService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.service 2 | 3 | interface EmailService { 4 | fun send(to: String, subject: String, text: String) 5 | } 6 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/service/InboxMessageService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.persistence.entity.InboxMessageEntity 4 | 5 | interface InboxMessageService { 6 | 7 | fun markInboxMessagesAsReadyForProcessingByInstance(batchSize: Int): Int 8 | 9 | fun getBatchForProcessing(batchSize: Int): List 10 | 11 | fun process(inboxMessage: InboxMessageEntity) 12 | } 13 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/service/IncomingEventService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | import com.fasterxml.jackson.databind.JsonNode 5 | 6 | interface IncomingEventService { 7 | fun process(eventType: EventType, payload: JsonNode) 8 | } 9 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/service/impl/EmailServiceTestImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.EmailService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.mail.javamail.JavaMailSender 6 | import jakarta.mail.Message 7 | import jakarta.mail.internet.MimeMessage 8 | 9 | class EmailServiceTestImpl( 10 | private val mailSender: JavaMailSender, 11 | private val emailFrom: String 12 | ) : EmailService { 13 | 14 | private val log = LoggerFactory.getLogger(this.javaClass) 15 | 16 | override fun send(to: String, subject: String, text: String) { 17 | val mimeMessage: MimeMessage = mailSender.createMimeMessage().apply { 18 | // use `emailFrom` instead of `to`, that is, an email will be sent to the sender 19 | setRecipients(Message.RecipientType.TO, emailFrom) 20 | setSubject(subject) 21 | setContent(text, "text/html") 22 | } 23 | 24 | mailSender.send(mimeMessage) 25 | 26 | log.debug("Email has been sent") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/service/impl/EmailServiceWebSocketStub.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 4 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.EmailService 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.messaging.simp.SimpMessagingTemplate 7 | import org.springframework.stereotype.Service 8 | import java.time.LocalDateTime 9 | 10 | @Service 11 | class EmailServiceWebSocketStub( 12 | private val simpMessagingTemplate: SimpMessagingTemplate 13 | ) : EmailService { 14 | 15 | private val log = LoggerFactory.getLogger(this.javaClass) 16 | 17 | override fun send(to: String, subject: String, text: String) { 18 | // convert back to Notification 19 | val notification = Notification(Notification.Channel.Email, to, subject, text, LocalDateTime.now()) 20 | simpMessagingTemplate.convertAndSend("/topic/library", notification) 21 | log.debug("A message over WebSocket has been sent") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/service/impl/InboxMessageServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.exception.NotificationServiceException 4 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.persistence.InboxMessageRepository 5 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.persistence.entity.InboxMessageEntity 6 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.InboxMessageService 7 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.IncomingEventService 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.beans.factory.annotation.Value 10 | import org.springframework.data.domain.PageRequest 11 | import org.springframework.stereotype.Service 12 | import org.springframework.transaction.annotation.Propagation 13 | import org.springframework.transaction.annotation.Transactional 14 | 15 | @Service 16 | class InboxMessageServiceImpl( 17 | private val incomingEventService: IncomingEventService, 18 | private val inboxMessageRepository: InboxMessageRepository, 19 | @Value("\${spring.application.name}") 20 | private val applicationName: String 21 | ) : InboxMessageService { 22 | 23 | private val log = LoggerFactory.getLogger(this.javaClass) 24 | 25 | @Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = [RuntimeException::class]) 26 | override fun markInboxMessagesAsReadyForProcessingByInstance(batchSize: Int): Int { 27 | fun saveReadyForProcessing(inboxMessage: InboxMessageEntity) { 28 | log.debug("Start saving a message ready for processing, id={}", inboxMessage.id) 29 | 30 | if (inboxMessage.status != InboxMessageEntity.Status.New) throw NotificationServiceException("Inbox message with id=${inboxMessage.id} is not in 'New' status") 31 | 32 | inboxMessage.status = InboxMessageEntity.Status.ReadyForProcessing 33 | inboxMessage.processedBy = applicationName 34 | 35 | inboxMessageRepository.save(inboxMessage) 36 | } 37 | 38 | val newInboxMessages = inboxMessageRepository.findAllByStatusOrderByCreatedAtAsc(InboxMessageEntity.Status.New, PageRequest.of(0, batchSize)) 39 | 40 | return if (newInboxMessages.isNotEmpty()) { 41 | newInboxMessages.forEach { inboxMessage -> saveReadyForProcessing(inboxMessage) } 42 | newInboxMessages.size 43 | } else 0 44 | } 45 | 46 | override fun getBatchForProcessing(batchSize: Int): List = 47 | inboxMessageRepository.findAllByStatusAndProcessedByOrderByCreatedAtAsc(InboxMessageEntity.Status.ReadyForProcessing, applicationName, PageRequest.of(0, batchSize)) 48 | 49 | @Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = [RuntimeException::class]) 50 | override fun process(inboxMessage: InboxMessageEntity) { 51 | log.debug("Start processing an inbox message with id={}", inboxMessage.id) 52 | 53 | if (inboxMessage.status != InboxMessageEntity.Status.ReadyForProcessing) 54 | throw NotificationServiceException("Inbox message with id=${inboxMessage.id} is not in 'ReadyForProcessing' status") 55 | if (inboxMessage.processedBy == null) 56 | throw NotificationServiceException("'processedBy' field should be set for an inbox message with id=${inboxMessage.id}") 57 | 58 | try { 59 | incomingEventService.process(inboxMessage.type, inboxMessage.payload) 60 | inboxMessage.status = InboxMessageEntity.Status.Completed 61 | } catch (e: Exception) { 62 | log.error("Exception while processing an incoming event (type=${inboxMessage.type}, payload=${inboxMessage.payload})", e) 63 | inboxMessage.status = InboxMessageEntity.Status.Error 64 | inboxMessage.error = e.stackTraceToString() 65 | } 66 | 67 | inboxMessageRepository.save(inboxMessage) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/service/impl/IncomingEventServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.SendNotificationCommand 5 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 6 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.exception.NotificationServiceException 7 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.EmailService 8 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.IncomingEventService 9 | import com.fasterxml.jackson.databind.JsonNode 10 | import com.fasterxml.jackson.databind.ObjectMapper 11 | import com.fasterxml.jackson.module.kotlin.treeToValue 12 | import org.slf4j.LoggerFactory 13 | import org.springframework.stereotype.Service 14 | import org.springframework.transaction.annotation.Propagation 15 | import org.springframework.transaction.annotation.Transactional 16 | 17 | @Service 18 | class IncomingEventServiceImpl( 19 | private val emailService: EmailService, 20 | private val objectMapper: ObjectMapper 21 | ) : IncomingEventService { 22 | 23 | private val log = LoggerFactory.getLogger(this.javaClass) 24 | 25 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 26 | override fun process(eventType: EventType, payload: JsonNode) { 27 | log.debug("Start processing an incoming event: type={}, payload={}", eventType, payload) 28 | 29 | when (eventType) { 30 | SendNotificationCommand -> processSendNotificationCommand(getData(payload)) 31 | else -> throw NotificationServiceException("Event type $eventType can't be processed") 32 | } 33 | 34 | log.debug("Event processed") 35 | } 36 | 37 | private inline fun getData(payload: JsonNode): T = objectMapper.treeToValue(payload) 38 | 39 | private fun processSendNotificationCommand(notification: Notification) { 40 | when (notification.channel) { 41 | Notification.Channel.Email -> { 42 | emailService.send(notification.recipient, notification.subject, notification.message) 43 | } 44 | 45 | else -> throw NotificationServiceException("Channel is not supported: {${notification.channel.name}}") 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /notification-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/task/InboxProcessingTask.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice.task 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.notificationservice.service.InboxMessageService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.core.task.AsyncTaskExecutor 7 | import org.springframework.scheduling.annotation.Scheduled 8 | import org.springframework.stereotype.Component 9 | import java.util.concurrent.CompletableFuture 10 | import java.util.concurrent.TimeUnit 11 | 12 | @Component 13 | class InboxProcessingTask( 14 | private val inboxMessageService: InboxMessageService, 15 | private val applicationTaskExecutor: AsyncTaskExecutor, 16 | @Value("\${inbox.processing.task.batch.size}") 17 | private val batchSize: Int, 18 | @Value("\${inbox.processing.task.subtask.timeout}") 19 | private val subtaskTimeout: Long 20 | ) { 21 | 22 | private val log = LoggerFactory.getLogger(this.javaClass) 23 | 24 | @Scheduled(cron = "\${inbox.processing.task.cron}") 25 | fun execute() { 26 | log.debug("Start inbox processing task") 27 | 28 | val newInboxMessagesCount = inboxMessageService.markInboxMessagesAsReadyForProcessingByInstance(batchSize) 29 | log.debug("{} new inbox message(s) marked as ready for processing", newInboxMessagesCount) 30 | 31 | val inboxMessagesToProcess = inboxMessageService.getBatchForProcessing(batchSize) 32 | if (inboxMessagesToProcess.isNotEmpty()) { 33 | log.debug("Start processing {} inbox message(s)", inboxMessagesToProcess.size) 34 | val subtasks = inboxMessagesToProcess.map { inboxMessage -> 35 | applicationTaskExecutor 36 | .submitCompletable { inboxMessageService.process(inboxMessage) } 37 | .orTimeout(subtaskTimeout, TimeUnit.SECONDS) 38 | } 39 | CompletableFuture.allOf(*subtasks.toTypedArray()).join() 40 | } 41 | 42 | log.debug("Inbox processing task completed") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/application-local.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://localhost:5493/notification 4 | 5 | server: 6 | port: 8093 7 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/application-test.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | mail: 3 | host: smtp.gmail.com 4 | port: 587 5 | username: 6 | password: 7 | properties.mail.smtp.auth: true 8 | properties.mail.smtp.starttls.enable: true 9 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: notification-service 4 | 5 | datasource: 6 | driverClassName: org.postgresql.Driver 7 | url: jdbc:postgresql://notification-db:5432/notification 8 | username: postgres 9 | password: password 10 | 11 | logging.level.com.romankudryashov.eventdrivenarchitecture: debug 12 | 13 | inbox: 14 | processing: 15 | task: 16 | cron: "*/5 * * * * *" 17 | batch.size: 50 18 | subtask.timeout: 10000 19 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/db/migration/V1_0_0__structure.sql: -------------------------------------------------------------------------------- 1 | create table inbox( 2 | id uuid primary key, 3 | source varchar not null, 4 | type varchar not null, 5 | payload jsonb not null, 6 | status varchar not null default 'New', 7 | error varchar, 8 | processed_by varchar, 9 | version smallint not null default 0, 10 | created_at timestamptz not null default current_timestamp, 11 | updated_at timestamptz not null default current_timestamp 12 | ); 13 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/static/app.js: -------------------------------------------------------------------------------- 1 | var stompClient = null; 2 | 3 | function setConnected(connected) { 4 | $("#connect").prop("disabled", connected); 5 | $("#disconnect").prop("disabled", !connected); 6 | if (connected) { 7 | $("#notifications").show(); 8 | } 9 | else { 10 | $("#notifications").hide(); 11 | } 12 | $("#messages").html(""); 13 | } 14 | 15 | function connect() { 16 | var socket = new SockJS('/ws-notifications'); 17 | stompClient = Stomp.over(socket); 18 | stompClient.connect({}, function (frame) { 19 | setConnected(true); 20 | console.log('Connected: ' + frame); 21 | stompClient.subscribe('/topic/library', function (notification) { 22 | showNotification(JSON.parse(notification.body)); 23 | }); 24 | }); 25 | } 26 | 27 | function disconnect() { 28 | if (stompClient !== null) { 29 | stompClient.disconnect(); 30 | } 31 | setConnected(false); 32 | console.log("Disconnected"); 33 | } 34 | 35 | function showNotification(notification) { 36 | let message = ` 37 | 38 |
To: ${notification.recipient} | Subject: ${notification.subject} | Notification created at: ${parseTime(notification.createdAt)}
39 |
40 |
${notification.message}
41 | `; 42 | 43 | $("#messages").append(message); 44 | } 45 | 46 | function parseTime(dateString) { 47 | return new Date(Date.parse(dateString)).toLocaleTimeString(); 48 | } 49 | 50 | $(function () { 51 | $("form").on('submit', function (e) { 52 | e.preventDefault(); 53 | }); 54 | $("#connect").click(function() { connect(); }); 55 | $("#disconnect").click(function() { disconnect(); }); 56 | }); 57 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Event-driven architecture demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 |
17 |
18 |
19 |
20 |
21 | 22 | 23 | 25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
Notifications
40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f5f5f5; 3 | } 4 | 5 | #main-content { 6 | max-width: 900px; 7 | padding: 2em 3em; 8 | margin: 0 auto 20px; 9 | background-color: #fff; 10 | border: 1px solid #e5e5e5; 11 | -webkit-border-radius: 5px; 12 | -moz-border-radius: 5px; 13 | border-radius: 5px; 14 | } 15 | -------------------------------------------------------------------------------- /notification-service/src/test/kotlin/com/romankudryashov/eventdrivenarchitecture/notificationservice/NotificationServiceApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.notificationservice 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.boot.test.context.SpringBootTest 5 | 6 | @SpringBootTest 7 | class NotificationServiceApplicationTests { 8 | 9 | @Test 10 | fun contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /readme.adoc: -------------------------------------------------------------------------------- 1 | = Event-driven architecture on the modern stack of Java technologies 2 | 3 | image:https://github.com/rkudryashov/event-driven-architecture/actions/workflows/workflow.yaml/badge.svg[CI/CD,link=https://github.com/rkudryashov/event-driven-architecture/actions] 4 | 5 | image:https://romankudryashov.com/blog/2024/07/event-driven-architecture/images/architecture.png[Architecture,800] 6 | 7 | https://romankudryashov.com/blog/2024/07/event-driven-architecture[Article] 8 | 9 | https://eda-demo.romankudryashov.com[Live demo] 10 | 11 | The source code is available on https://github.com/rkudryashov/event-driven-architecture[GitHub] and https://gitlab.com/romankudryashov/event-driven-architecture[GitLab]. 12 | -------------------------------------------------------------------------------- /restart.bat: -------------------------------------------------------------------------------- 1 | docker compose down --volumes 2 | rmdir /s /q misc\kafka_data 3 | @REM build all Docker images in parallel 4 | call gradlew :book-service:bootBuildImage :user-service:bootBuildImage :notification-service:bootBuildImage || exit /b 5 | @REM build all Docker images sequentially 6 | @REM call gradlew :book-service:bootBuildImage || exit /b 7 | @REM call gradlew :user-service:bootBuildImage || exit /b 8 | @REM call gradlew :notification-service:bootBuildImage || exit /b 9 | docker compose up --build 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "event-driven-architecture" 2 | 3 | include( 4 | "book-service", 5 | "user-service", 6 | "notification-service", 7 | "common-model" 8 | ) 9 | 10 | pluginManagement { 11 | val springBootVersion: String by settings 12 | val springDependencyManagementVersion: String by settings 13 | val openApiGeneratorVersion: String by settings 14 | val nativeBuildToolsVersion: String by settings 15 | val kotlinVersion: String by settings 16 | 17 | plugins { 18 | id("org.springframework.boot") version springBootVersion 19 | id("io.spring.dependency-management") version springDependencyManagementVersion 20 | id("org.openapi.generator") version openApiGeneratorVersion 21 | id("org.graalvm.buildtools.native") version nativeBuildToolsVersion 22 | kotlin("jvm") version kotlinVersion 23 | kotlin("plugin.spring") version kotlinVersion 24 | kotlin("plugin.jpa") version kotlinVersion 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /user-service/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.springframework.boot.gradle.tasks.bundling.BootBuildImage 2 | 3 | plugins { 4 | id("org.springframework.boot") 5 | id("io.spring.dependency-management") 6 | // TODO: remove after https://github.com/gradle/gradle/issues/17559 7 | id("org.openapi.generator") 8 | id("org.graalvm.buildtools.native") 9 | kotlin("jvm") 10 | kotlin("plugin.spring") 11 | kotlin("plugin.jpa") 12 | } 13 | 14 | java { 15 | toolchain { 16 | languageVersion = JavaLanguageVersion.of(21) 17 | } 18 | } 19 | 20 | repositories { 21 | mavenCentral() 22 | } 23 | 24 | val guavaVersion: String by project 25 | val kotlinxHtmlVersion: String by project 26 | val dockerRepository: String by project 27 | 28 | dependencies { 29 | implementation(project(":common-model")) 30 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 31 | implementation("org.springframework.boot:spring-boot-starter-web") 32 | implementation("org.springframework.boot:spring-boot-starter-actuator") 33 | implementation("org.flywaydb:flyway-database-postgresql") 34 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 35 | implementation("org.jetbrains.kotlin:kotlin-reflect") 36 | implementation("org.jetbrains.kotlinx:kotlinx-html:$kotlinxHtmlVersion") 37 | implementation("com.google.guava:guava:$guavaVersion") 38 | runtimeOnly("org.postgresql:postgresql") 39 | testImplementation("org.springframework.boot:spring-boot-starter-test") 40 | } 41 | 42 | kotlin { 43 | compilerOptions { 44 | freeCompilerArgs.addAll("-Xjsr305=strict") 45 | } 46 | } 47 | 48 | tasks.withType { 49 | buildpacks = setOf("paketobuildpacks/java-native-image", "paketobuildpacks/health-checker") 50 | environment = mapOf("BP_HEALTH_CHECKER_ENABLED" to "true") 51 | imageName = "$dockerRepository:${project.name}" 52 | } 53 | 54 | tasks.withType { 55 | useJUnitPlatform() 56 | } 57 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/UserServiceApplication.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.spring.CommonRuntimeHints 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.boot.runApplication 6 | import org.springframework.context.annotation.ImportRuntimeHints 7 | import org.springframework.scheduling.annotation.EnableScheduling 8 | 9 | @SpringBootApplication 10 | @ImportRuntimeHints(CommonRuntimeHints::class) 11 | @EnableScheduling 12 | class UserServiceApplication 13 | 14 | fun main(args: Array) { 15 | runApplication(*args) 16 | } 17 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/config/Config.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.config 2 | 3 | import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.core.task.AsyncTaskExecutor 7 | import org.springframework.core.task.support.TaskExecutorAdapter 8 | import java.util.concurrent.Executors 9 | 10 | @Configuration 11 | class Config { 12 | 13 | @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) 14 | fun asyncTaskExecutor(): AsyncTaskExecutor = TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()) 15 | } 16 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/exception/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.exception 2 | 3 | class UserServiceException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/model/NotificationMessageParams.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.model 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | 5 | open class BaseNotificationMessageParams( 6 | val eventType: EventType, 7 | val bookName: String, 8 | val delta: Map>, 9 | ) 10 | 11 | class NotificationMessageParams( 12 | eventType: EventType, 13 | bookName: String, 14 | delta: Map>, 15 | val userLastName: String, 16 | val userFirstName: String, 17 | val userMiddleName: String, 18 | ) : BaseNotificationMessageParams(eventType, bookName, delta) 19 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/persistence/Repositories.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.persistence 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.OutboxMessage 4 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity.InboxMessageEntity 5 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity.UserEntity 6 | import com.fasterxml.jackson.databind.ObjectMapper 7 | import org.springframework.data.domain.Pageable 8 | import org.springframework.data.jpa.repository.JpaRepository 9 | import org.springframework.data.jpa.repository.Lock 10 | import org.springframework.stereotype.Repository 11 | import java.util.UUID 12 | import jakarta.persistence.EntityManager 13 | import jakarta.persistence.LockModeType 14 | 15 | interface UserRepository : JpaRepository { 16 | fun findAllByStatus(status: UserEntity.Status): List 17 | } 18 | 19 | interface InboxMessageRepository : JpaRepository { 20 | 21 | @Lock(LockModeType.PESSIMISTIC_WRITE) 22 | fun findAllByStatusOrderByCreatedAtAsc(status: InboxMessageEntity.Status, pageable: Pageable): List 23 | 24 | fun findAllByStatusAndProcessedByOrderByCreatedAtAsc(status: InboxMessageEntity.Status, processedBy: String, pageable: Pageable): List 25 | } 26 | 27 | @Repository 28 | class OutboxMessageRepository( 29 | private val entityManager: EntityManager, 30 | private val objectMapper: ObjectMapper 31 | ) { 32 | fun writeOutboxMessageToWalInsideTransaction(outboxMessage: OutboxMessage) { 33 | val outboxMessageJson = objectMapper.writeValueAsString(outboxMessage) 34 | entityManager.createQuery("SELECT pg_logical_emit_message(true, 'outbox', :outboxMessage)") 35 | .setParameter("outboxMessage", outboxMessageJson) 36 | .singleResult 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/persistence/entity/AbstractEntity.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity 2 | 3 | import org.hibernate.annotations.SourceType 4 | import org.hibernate.annotations.UpdateTimestamp 5 | import java.time.ZonedDateTime 6 | import jakarta.persistence.Column 7 | import jakarta.persistence.MappedSuperclass 8 | 9 | @MappedSuperclass 10 | abstract class AbstractEntity( 11 | @Column(insertable = false, updatable = false) 12 | val createdAt: ZonedDateTime? = null, 13 | @Column(insertable = false) 14 | @UpdateTimestamp(source = SourceType.DB) 15 | val updatedAt: ZonedDateTime? = null 16 | ) 17 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/persistence/entity/Entities.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | import com.fasterxml.jackson.databind.JsonNode 5 | import org.hibernate.annotations.JdbcTypeCode 6 | import org.hibernate.type.SqlTypes 7 | import java.util.UUID 8 | import jakarta.persistence.Entity 9 | import jakarta.persistence.EnumType 10 | import jakarta.persistence.Enumerated 11 | import jakarta.persistence.GeneratedValue 12 | import jakarta.persistence.GenerationType 13 | import jakarta.persistence.Id 14 | import jakarta.persistence.Table 15 | import jakarta.persistence.Version 16 | 17 | @Entity 18 | @Table(name = "library_user") 19 | class UserEntity( 20 | @Id 21 | @GeneratedValue(strategy = GenerationType.IDENTITY) 22 | val id: Long = 0, 23 | val firstName: String, 24 | val middleName: String, 25 | val lastName: String, 26 | val email: String, 27 | @Enumerated(value = EnumType.STRING) 28 | var status: Status, 29 | ) : AbstractEntity() { 30 | 31 | enum class Status { 32 | Active, 33 | Inactive 34 | } 35 | } 36 | 37 | @Entity 38 | @Table(name = "inbox") 39 | class InboxMessageEntity( 40 | @Id 41 | val id: UUID, 42 | val source: String, 43 | @Enumerated(value = EnumType.STRING) 44 | val type: EventType, 45 | @JdbcTypeCode(SqlTypes.JSON) 46 | val payload: JsonNode, 47 | @Enumerated(value = EnumType.STRING) 48 | var status: Status, 49 | var error: String?, 50 | var processedBy: String?, 51 | @Version 52 | val version: Int 53 | ) : AbstractEntity() { 54 | 55 | enum class Status { 56 | New, 57 | ReadyForProcessing, 58 | Completed, 59 | Error 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/DeltaService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service 2 | 3 | interface DeltaService { 4 | fun getDelta(currentObjectState: T?, previousObjectState: T?): Map> 5 | } 6 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/InboxMessageService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity.InboxMessageEntity 4 | 5 | interface InboxMessageService { 6 | 7 | fun markInboxMessagesAsReadyForProcessingByInstance(batchSize: Int): Int 8 | 9 | fun getBatchForProcessing(batchSize: Int): List 10 | 11 | fun process(inboxMessage: InboxMessageEntity) 12 | } 13 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/IncomingEventService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | import com.fasterxml.jackson.databind.JsonNode 5 | 6 | interface IncomingEventService { 7 | fun process(eventType: EventType, payload: JsonNode) 8 | } 9 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/NotificationService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 4 | import com.romankudryashov.eventdrivenarchitecture.userservice.model.BaseNotificationMessageParams 5 | 6 | interface NotificationService { 7 | 8 | fun createNotification(userId: Long, channel: Notification.Channel, baseMessageParams: BaseNotificationMessageParams): Notification 9 | 10 | fun createNotificationsForAll(channel: Notification.Channel, baseMessageParams: BaseNotificationMessageParams): List> 11 | } 12 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/OutboxMessageService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 5 | 6 | interface OutboxMessageService { 7 | 8 | fun saveSendNotificationCommandMessage(payload: Notification, aggregateId: Long) 9 | 10 | fun saveRollbackBookLentCommandMessage(payload: Book) 11 | } 12 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/UserService.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity.UserEntity 4 | 5 | interface UserService { 6 | 7 | fun getAll(): List 8 | 9 | fun getById(id: Long): UserEntity? 10 | } 11 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/impl/DeltaServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Author 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 5 | import com.romankudryashov.eventdrivenarchitecture.userservice.exception.UserServiceException 6 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.DeltaService 7 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties 8 | import com.fasterxml.jackson.databind.ObjectMapper 9 | import com.google.common.collect.Maps 10 | import org.springframework.stereotype.Service 11 | 12 | @Service 13 | class DeltaServiceImpl( 14 | objectMapper: ObjectMapper 15 | ) : DeltaService { 16 | 17 | private val deltaObjectMapper = objectMapper.copy().apply { 18 | configOverride(Book::class.java).setIgnorals(JsonIgnoreProperties.Value.forIgnoredProperties("id", "currentLoan")) 19 | configOverride(Author::class.java).setIgnorals(JsonIgnoreProperties.Value.forIgnoredProperties("id", "books")) 20 | } 21 | 22 | override fun getDelta(currentObjectState: T?, previousObjectState: T?): Map> { 23 | if (currentObjectState == null && previousObjectState == null) throw UserServiceException("To retrieve delta, current or previous state should not be null") 24 | 25 | if (currentObjectState == null) return convertToDelta(convertToMap(previousObjectState), false) 26 | if (previousObjectState == null) return convertToDelta(convertToMap(currentObjectState), true) 27 | 28 | val difference = Maps.difference(convertToMap(currentObjectState), convertToMap(previousObjectState)) 29 | return difference.entriesDiffering().map { (key, value) -> Pair(key, Pair(value.leftValue(), value.rightValue())) }.toMap() 30 | } 31 | 32 | private fun convertToMap(objectState: T): Map = deltaObjectMapper.convertValue(objectState, Map::class.java) 33 | .map { (key, value) -> Pair(key as String, value) } 34 | .toMap() 35 | 36 | // rename param 37 | private fun convertToDelta(objectStateMap: Map, isCurrentState: Boolean): Map> = objectStateMap 38 | .map { (key, value) -> 39 | Pair( 40 | key, Pair( 41 | if (isCurrentState) value else null, 42 | if (isCurrentState) null else value 43 | ) 44 | ) 45 | }.toMap() 46 | } 47 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/impl/InboxMessageServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.userservice.exception.UserServiceException 4 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.InboxMessageRepository 5 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity.InboxMessageEntity 6 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.InboxMessageService 7 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.IncomingEventService 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.beans.factory.annotation.Value 10 | import org.springframework.data.domain.PageRequest 11 | import org.springframework.stereotype.Service 12 | import org.springframework.transaction.annotation.Propagation 13 | import org.springframework.transaction.annotation.Transactional 14 | 15 | @Service 16 | class InboxMessageServiceImpl( 17 | private val incomingEventService: IncomingEventService, 18 | private val inboxMessageRepository: InboxMessageRepository, 19 | @Value("\${spring.application.name}") 20 | private val applicationName: String 21 | ) : InboxMessageService { 22 | 23 | private val log = LoggerFactory.getLogger(this.javaClass) 24 | 25 | @Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = [RuntimeException::class]) 26 | override fun markInboxMessagesAsReadyForProcessingByInstance(batchSize: Int): Int { 27 | fun saveReadyForProcessing(inboxMessage: InboxMessageEntity) { 28 | log.debug("Start saving a message ready for processing, id={}", inboxMessage.id) 29 | 30 | if (inboxMessage.status != InboxMessageEntity.Status.New) throw UserServiceException("Inbox message with id=${inboxMessage.id} is not in 'New' status") 31 | 32 | inboxMessage.status = InboxMessageEntity.Status.ReadyForProcessing 33 | inboxMessage.processedBy = applicationName 34 | 35 | inboxMessageRepository.save(inboxMessage) 36 | } 37 | 38 | val newInboxMessages = inboxMessageRepository.findAllByStatusOrderByCreatedAtAsc(InboxMessageEntity.Status.New, PageRequest.of(0, batchSize)) 39 | 40 | return if (newInboxMessages.isNotEmpty()) { 41 | newInboxMessages.forEach { inboxMessage -> saveReadyForProcessing(inboxMessage) } 42 | newInboxMessages.size 43 | } else 0 44 | } 45 | 46 | override fun getBatchForProcessing(batchSize: Int): List = 47 | inboxMessageRepository.findAllByStatusAndProcessedByOrderByCreatedAtAsc(InboxMessageEntity.Status.ReadyForProcessing, applicationName, PageRequest.of(0, batchSize)) 48 | 49 | @Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = [RuntimeException::class]) 50 | override fun process(inboxMessage: InboxMessageEntity) { 51 | log.debug("Start processing an inbox message with id={}", inboxMessage.id) 52 | 53 | if (inboxMessage.status != InboxMessageEntity.Status.ReadyForProcessing) 54 | throw UserServiceException("Inbox message with id=${inboxMessage.id} is not in 'ReadyForProcessing' status") 55 | if (inboxMessage.processedBy == null) 56 | throw UserServiceException("'processedBy' field should be set for an inbox message with id=${inboxMessage.id}") 57 | 58 | try { 59 | incomingEventService.process(inboxMessage.type, inboxMessage.payload) 60 | inboxMessage.status = InboxMessageEntity.Status.Completed 61 | } catch (e: Exception) { 62 | log.error("Exception while processing an incoming event (type=${inboxMessage.type}, payload=${inboxMessage.payload})", e) 63 | inboxMessage.status = InboxMessageEntity.Status.Error 64 | inboxMessage.error = e.stackTraceToString() 65 | } 66 | 67 | inboxMessageRepository.save(inboxMessage) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/impl/IncomingEventServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Author 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 5 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.CurrentAndPreviousState 6 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.AuthorChanged 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookChanged 9 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookCreated 10 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookDeleted 11 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookLent 12 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookLoanCanceled 13 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookReturned 14 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 15 | import com.romankudryashov.eventdrivenarchitecture.userservice.exception.UserServiceException 16 | import com.romankudryashov.eventdrivenarchitecture.userservice.model.BaseNotificationMessageParams 17 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.DeltaService 18 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.IncomingEventService 19 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.NotificationService 20 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.OutboxMessageService 21 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.UserService 22 | import com.fasterxml.jackson.databind.JsonNode 23 | import com.fasterxml.jackson.databind.ObjectMapper 24 | import com.fasterxml.jackson.module.kotlin.treeToValue 25 | import org.slf4j.LoggerFactory 26 | import org.springframework.stereotype.Service 27 | import org.springframework.transaction.annotation.Propagation 28 | import org.springframework.transaction.annotation.Transactional 29 | 30 | @Service 31 | class IncomingEventServiceImpl( 32 | private val userService: UserService, 33 | private val deltaService: DeltaService, 34 | private val notificationService: NotificationService, 35 | private val outboxMessageService: OutboxMessageService, 36 | private val objectMapper: ObjectMapper 37 | ) : IncomingEventService { 38 | 39 | private val log = LoggerFactory.getLogger(this.javaClass) 40 | 41 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 42 | override fun process(eventType: EventType, payload: JsonNode) { 43 | log.debug("Start processing an incoming event: type={}, payload={}", eventType, payload) 44 | 45 | when (eventType) { 46 | BookCreated -> processBookCreatedEvent(getData(payload)) 47 | BookChanged -> processBookChangedEvent(getData(payload)) 48 | BookDeleted -> processBookDeletedEvent(getData(payload)) 49 | BookLent -> processBookLentEvent(getData(payload)) 50 | BookLoanCanceled -> processBookLoanCanceledEvent(getData(payload)) 51 | BookReturned -> processBookReturnedEvent(getData(payload)) 52 | AuthorChanged -> processAuthorChangedEvent(getData(payload)) 53 | else -> throw UserServiceException("Event type $eventType can't be processed") 54 | } 55 | } 56 | 57 | private inline fun getData(payload: JsonNode): T = objectMapper.treeToValue(payload) 58 | 59 | private fun processBookCreatedEvent(book: Book) { 60 | val bookData = deltaService.getDelta(book, null) 61 | val notifications = notificationService.createNotificationsForAll( 62 | Notification.Channel.Email, 63 | BaseNotificationMessageParams(BookCreated, book.name, bookData) 64 | ) 65 | notifications.forEach { outboxMessageService.saveSendNotificationCommandMessage(it.first, it.second) } 66 | } 67 | 68 | private fun processBookChangedEvent(currentAndPreviousState: CurrentAndPreviousState) { 69 | val oldBook = currentAndPreviousState.previous 70 | val newBook = currentAndPreviousState.current 71 | val currentLoan = newBook.currentLoan 72 | if (currentLoan != null) { 73 | val bookDelta = deltaService.getDelta(newBook, oldBook) 74 | val notification = notificationService.createNotification( 75 | currentLoan.userId, 76 | Notification.Channel.Email, 77 | BaseNotificationMessageParams(BookChanged, newBook.name, bookDelta) 78 | ) 79 | outboxMessageService.saveSendNotificationCommandMessage(notification, currentLoan.userId) 80 | } 81 | } 82 | 83 | private fun processBookDeletedEvent(book: Book) { 84 | val bookData = deltaService.getDelta(null, book) 85 | val notifications = notificationService.createNotificationsForAll( 86 | Notification.Channel.Email, 87 | BaseNotificationMessageParams(BookDeleted, book.name, bookData) 88 | ) 89 | notifications.forEach { outboxMessageService.saveSendNotificationCommandMessage(it.first, it.second) } 90 | } 91 | 92 | private fun processBookLentEvent(book: Book) { 93 | val bookLoan = book.currentLoan ?: throw UserServiceException("The lent book: $book doesn't contain current loan") 94 | val user = userService.getById(bookLoan.userId) 95 | // book can't be borrowed because the user doesn't exist 96 | if (user == null) { 97 | outboxMessageService.saveRollbackBookLentCommandMessage(book) 98 | } else { 99 | val bookData = deltaService.getDelta(book, null) 100 | val notification = notificationService.createNotification( 101 | user.id, 102 | Notification.Channel.Email, 103 | BaseNotificationMessageParams(BookLent, book.name, bookData) 104 | ) 105 | outboxMessageService.saveSendNotificationCommandMessage(notification, user.id) 106 | } 107 | } 108 | 109 | private fun processBookLoanCanceledEvent(book: Book) { 110 | val bookLoan = book.currentLoan ?: throw UserServiceException("The book: $book doesn't contain canceled loan") 111 | val bookData = deltaService.getDelta(book, null) 112 | val notification = notificationService.createNotification( 113 | bookLoan.userId, 114 | Notification.Channel.Email, 115 | BaseNotificationMessageParams(BookLoanCanceled, book.name, bookData) 116 | ) 117 | outboxMessageService.saveSendNotificationCommandMessage(notification, bookLoan.userId) 118 | } 119 | 120 | private fun processBookReturnedEvent(book: Book) { 121 | val bookLoan = book.currentLoan ?: throw UserServiceException("The returned book: $book doesn't contain previous loan") 122 | val bookData = deltaService.getDelta(book, null) 123 | val notification = notificationService.createNotification( 124 | bookLoan.userId, 125 | Notification.Channel.Email, 126 | BaseNotificationMessageParams(BookReturned, book.name, bookData) 127 | ) 128 | outboxMessageService.saveSendNotificationCommandMessage(notification, bookLoan.userId) 129 | } 130 | 131 | private fun processAuthorChangedEvent(currentAndPreviousState: CurrentAndPreviousState) { 132 | val oldAuthor = currentAndPreviousState.previous 133 | val newAuthor = currentAndPreviousState.current 134 | if (newAuthor.books.isNotEmpty()) { 135 | val authorDelta = deltaService.getDelta(newAuthor, oldAuthor) 136 | newAuthor.books.forEach { book -> 137 | val currentLoan = book.currentLoan 138 | if (currentLoan != null) { 139 | val notification = notificationService.createNotification( 140 | currentLoan.userId, 141 | Notification.Channel.Email, 142 | BaseNotificationMessageParams(AuthorChanged, book.name, authorDelta) 143 | ) 144 | outboxMessageService.saveSendNotificationCommandMessage(notification, currentLoan.userId) 145 | } 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/impl/NotificationServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.AuthorChanged 5 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookChanged 6 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookCreated 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookDeleted 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookLent 9 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookLoanCanceled 10 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.BookReturned 11 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 12 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification.Channel.Email 13 | import com.romankudryashov.eventdrivenarchitecture.userservice.exception.UserServiceException 14 | import com.romankudryashov.eventdrivenarchitecture.userservice.model.BaseNotificationMessageParams 15 | import com.romankudryashov.eventdrivenarchitecture.userservice.model.NotificationMessageParams 16 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity.UserEntity 17 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.NotificationService 18 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.UserService 19 | import kotlinx.html.body 20 | import kotlinx.html.br 21 | import kotlinx.html.div 22 | import kotlinx.html.dom.append 23 | import kotlinx.html.dom.document 24 | import kotlinx.html.dom.serialize 25 | import kotlinx.html.head 26 | import kotlinx.html.html 27 | import kotlinx.html.p 28 | import kotlinx.html.span 29 | import kotlinx.html.strong 30 | import kotlinx.html.style 31 | import org.springframework.stereotype.Service 32 | import java.time.LocalDateTime 33 | 34 | @Service 35 | class NotificationServiceImpl( 36 | private val userService: UserService 37 | ) : NotificationService { 38 | 39 | override fun createNotification(userId: Long, channel: Notification.Channel, baseMessageParams: BaseNotificationMessageParams): Notification { 40 | val bookUser = userService.getById(userId) ?: createFakeUserForNotificationToAdmin(userId) 41 | return createNotificationInternal(channel, bookUser, baseMessageParams) 42 | } 43 | 44 | override fun createNotificationsForAll(channel: Notification.Channel, baseMessageParams: BaseNotificationMessageParams): List> = userService 45 | .getAll() 46 | .map { user -> Pair(createNotificationInternal(channel, user, baseMessageParams), user.id) } 47 | 48 | private fun createNotificationInternal(channel: Notification.Channel, user: UserEntity, baseMessageParams: BaseNotificationMessageParams): Notification { 49 | val recipient = when (channel) { 50 | Email -> user.email 51 | } 52 | val messageParams = NotificationMessageParams( 53 | baseMessageParams.eventType, 54 | baseMessageParams.bookName, 55 | baseMessageParams.delta, 56 | user.lastName, 57 | user.firstName, 58 | user.middleName 59 | ) 60 | return Notification(channel, recipient, getEmailSubject(messageParams.eventType), getMessage(messageParams), LocalDateTime.now()) 61 | } 62 | 63 | private fun createFakeUserForNotificationToAdmin(userId: Long): UserEntity = UserEntity( 64 | email = "admin@library.com", 65 | firstName = "User ID not found: $userId", 66 | middleName = "", 67 | lastName = "", 68 | status = UserEntity.Status.Inactive 69 | ) 70 | 71 | fun getEmailSubject(eventType: EventType) = when (eventType) { 72 | BookCreated -> "A new book was added" 73 | BookChanged -> "Changes in your book" 74 | BookDeleted -> "A book was deleted" 75 | BookLent -> "You've borrowed a book" 76 | BookLoanCanceled -> "Your book loan has been canceled" 77 | BookReturned -> "You've returned a book" 78 | AuthorChanged -> "Changes in the data of the author of your book" 79 | else -> throw UserServiceException("Event type $eventType can't be processed") 80 | } 81 | 82 | fun getMessage(notificationMessageParams: NotificationMessageParams): String { 83 | val descriptionMessage = when (notificationMessageParams.eventType) { 84 | BookCreated -> "A new book was added to the library:" 85 | BookChanged -> "There were changes in your book \"${notificationMessageParams.bookName}\". The delta is:" 86 | BookDeleted -> "The book \"${notificationMessageParams.bookName}\" was deleted:" 87 | BookLent -> "You've borrowed a book \"${notificationMessageParams.bookName}\"" 88 | BookLoanCanceled -> "Your book loan (\"${notificationMessageParams.bookName}\") has been canceled" 89 | BookReturned -> "You've returned a book \"${notificationMessageParams.bookName}\"" 90 | AuthorChanged -> "There were changes in the data of the author of your book \"${notificationMessageParams.bookName}\". The delta is:" 91 | else -> throw UserServiceException("Event type ${notificationMessageParams.eventType} can't be processed") 92 | } 93 | 94 | val userName = "${notificationMessageParams.userLastName} ${notificationMessageParams.userFirstName} ${notificationMessageParams.userMiddleName}".trim() 95 | 96 | val css = """ 97 | .delta-key { 98 | font-family: courier new, courier, verdana; 99 | } 100 | 101 | .delta-current { 102 | font-family: courier new, courier, verdana; 103 | background-color: #9af09d; 104 | } 105 | 106 | .delta-previous { 107 | font-family: courier new, courier, verdana; 108 | background-color: #c0c0c0; 109 | text-decoration: line-through; 110 | } 111 | """.trimIndent() 112 | 113 | val document = document {}.apply { 114 | this.append { 115 | html { 116 | head { 117 | style { +css } 118 | } 119 | 120 | body { 121 | p { strong { +"Hi $userName," } } 122 | 123 | p { +descriptionMessage } 124 | 125 | div { 126 | for (deltaItem in notificationMessageParams.delta) { 127 | div { 128 | span(classes = "delta-key") { +"${deltaItem.key}: " } 129 | 130 | var previousStateExists = false 131 | val previousState = deltaItem.value.second 132 | if (previousState != null) { 133 | previousStateExists = true 134 | getLinesFromNestedObjectState(previousState).forEach { line -> 135 | span(classes = "delta-previous") { +line } 136 | if (previousState is Collection<*>) { 137 | br 138 | } 139 | } 140 | } 141 | 142 | val currentState = deltaItem.value.first 143 | if (currentState != null) { 144 | getLinesFromNestedObjectState(currentState).forEach { line -> 145 | val charToPrepend = if (previousStateExists) " " else "" 146 | span(classes = "delta-current") { +"$charToPrepend$line" } 147 | if (currentState is Collection<*>) { 148 | br 149 | } 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | 160 | return document.serialize() 161 | } 162 | 163 | private fun getLinesFromNestedObjectState(deltaItemValue: T): List { 164 | fun convertNestedValueToString(nestedValue: Any?): String = when (nestedValue) { 165 | null -> "null" 166 | is Map<*, *> -> nestedValue.map { (key, value) -> "$key: $value" }.joinToString() 167 | else -> nestedValue.toString() 168 | } 169 | 170 | return if (deltaItemValue is Collection<*>) deltaItemValue.mapIndexed { index, element -> "${index + 1}. " + convertNestedValueToString(element) } 171 | else listOf(convertNestedValueToString(deltaItemValue)) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/impl/OutboxMessageServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.AggregateType 4 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Book 5 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType 6 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.RollbackBookLentCommand 7 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.EventType.SendNotificationCommand 8 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.Notification 9 | import com.romankudryashov.eventdrivenarchitecture.commonmodel.OutboxMessage 10 | import com.romankudryashov.eventdrivenarchitecture.userservice.exception.UserServiceException 11 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.OutboxMessageRepository 12 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.OutboxMessageService 13 | import com.fasterxml.jackson.databind.JsonNode 14 | import com.fasterxml.jackson.databind.ObjectMapper 15 | import org.slf4j.LoggerFactory 16 | import org.springframework.stereotype.Service 17 | import org.springframework.transaction.annotation.Propagation 18 | import org.springframework.transaction.annotation.Transactional 19 | 20 | @Service 21 | class OutboxMessageServiceImpl( 22 | private val outboxMessageRepository: OutboxMessageRepository, 23 | private val objectMapper: ObjectMapper 24 | ) : OutboxMessageService { 25 | 26 | private val log = LoggerFactory.getLogger(this.javaClass) 27 | 28 | private val outboxEventTypeToTopic = mapOf( 29 | SendNotificationCommand to "notifications", 30 | RollbackBookLentCommand to "rollback" 31 | ) 32 | 33 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 34 | override fun saveRollbackBookLentCommandMessage(payload: Book) = save(createOutboxMessage(AggregateType.Book, payload.id, RollbackBookLentCommand, payload)) 35 | 36 | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) 37 | override fun saveSendNotificationCommandMessage(payload: Notification, aggregateId: Long) = 38 | save(createOutboxMessage(AggregateType.Notification, null, SendNotificationCommand, payload)) 39 | 40 | private fun createOutboxMessage(aggregateType: AggregateType, aggregateId: Long?, type: EventType, payload: T) = OutboxMessage( 41 | aggregateType = aggregateType, 42 | aggregateId = aggregateId, 43 | type = type, 44 | topic = outboxEventTypeToTopic[type] ?: throw UserServiceException("Can't determine topic for outbox event type `$type`"), 45 | payload = objectMapper.convertValue(payload, JsonNode::class.java) 46 | ) 47 | 48 | private fun save(outboxMessage: OutboxMessage) { 49 | log.debug("Start saving an outbox message: {}", outboxMessage) 50 | outboxMessageRepository.writeOutboxMessageToWalInsideTransaction(outboxMessage) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/service/impl/UserServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.service.impl 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.UserRepository 4 | import com.romankudryashov.eventdrivenarchitecture.userservice.persistence.entity.UserEntity 5 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.UserService 6 | import org.springframework.data.repository.findByIdOrNull 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class UserServiceImpl( 11 | private val userRepository: UserRepository 12 | ) : UserService { 13 | 14 | override fun getAll(): List = userRepository.findAllByStatus(UserEntity.Status.Active) 15 | 16 | override fun getById(id: Long): UserEntity? { 17 | val user = userRepository.findByIdOrNull(id) 18 | return if (user != null && user.status == UserEntity.Status.Active) { 19 | user 20 | } else null 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /user-service/src/main/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/task/InboxProcessingTask.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice.task 2 | 3 | import com.romankudryashov.eventdrivenarchitecture.userservice.service.InboxMessageService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.core.task.AsyncTaskExecutor 7 | import org.springframework.scheduling.annotation.Scheduled 8 | import org.springframework.stereotype.Component 9 | import java.util.concurrent.CompletableFuture 10 | import java.util.concurrent.TimeUnit 11 | 12 | @Component 13 | class InboxProcessingTask( 14 | private val inboxMessageService: InboxMessageService, 15 | private val applicationTaskExecutor: AsyncTaskExecutor, 16 | @Value("\${inbox.processing.task.batch.size}") 17 | private val batchSize: Int, 18 | @Value("\${inbox.processing.task.subtask.timeout}") 19 | private val subtaskTimeout: Long 20 | ) { 21 | 22 | private val log = LoggerFactory.getLogger(this.javaClass) 23 | 24 | @Scheduled(cron = "\${inbox.processing.task.cron}") 25 | fun execute() { 26 | log.debug("Start inbox processing task") 27 | 28 | val newInboxMessagesCount = inboxMessageService.markInboxMessagesAsReadyForProcessingByInstance(batchSize) 29 | log.debug("{} new inbox message(s) marked as ready for processing", newInboxMessagesCount) 30 | 31 | val inboxMessagesToProcess = inboxMessageService.getBatchForProcessing(batchSize) 32 | if (inboxMessagesToProcess.isNotEmpty()) { 33 | log.debug("Start processing {} inbox message(s)", inboxMessagesToProcess.size) 34 | val subtasks = inboxMessagesToProcess.map { inboxMessage -> 35 | applicationTaskExecutor 36 | .submitCompletable { inboxMessageService.process(inboxMessage) } 37 | .orTimeout(subtaskTimeout, TimeUnit.SECONDS) 38 | } 39 | CompletableFuture.allOf(*subtasks.toTypedArray()).join() 40 | } 41 | 42 | log.debug("Inbox processing task completed") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /user-service/src/main/resources/application-local.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://localhost:5492/user 4 | 5 | server: 6 | port: 8092 7 | -------------------------------------------------------------------------------- /user-service/src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: user-service 4 | 5 | datasource: 6 | driverClassName: org.postgresql.Driver 7 | url: jdbc:postgresql://user-db:5432/user 8 | username: postgres 9 | password: password 10 | 11 | logging.level.com.romankudryashov.eventdrivenarchitecture: debug 12 | 13 | inbox: 14 | processing: 15 | task: 16 | cron: "*/5 * * * * *" 17 | batch.size: 50 18 | subtask.timeout: 10000 19 | -------------------------------------------------------------------------------- /user-service/src/main/resources/db/migration/V1_0_0__structure.sql: -------------------------------------------------------------------------------- 1 | create table library_user( 2 | id bigint primary key generated always as identity, 3 | last_name varchar not null, 4 | first_name varchar not null, 5 | middle_name varchar, 6 | email varchar not null, 7 | status varchar not null default 'Active', 8 | created_at timestamptz not null default current_timestamp, 9 | updated_at timestamptz not null default current_timestamp 10 | ); 11 | 12 | create table inbox( 13 | id uuid primary key, 14 | source varchar not null, 15 | type varchar not null, 16 | payload jsonb not null, 17 | status varchar not null default 'New', 18 | error varchar, 19 | processed_by varchar, 20 | version smallint not null default 0, 21 | created_at timestamptz not null default current_timestamp, 22 | updated_at timestamptz not null default current_timestamp 23 | ); 24 | 25 | create table inbox_unprocessed( 26 | id bigint primary key generated always as identity, 27 | message varchar not null, 28 | error varchar not null, 29 | created_at timestamptz not null default current_timestamp, 30 | updated_at timestamptz not null default current_timestamp 31 | ); 32 | -------------------------------------------------------------------------------- /user-service/src/main/resources/db/migration/V1_0_1__data.sql: -------------------------------------------------------------------------------- 1 | insert into library_user (last_name, first_name, middle_name, email, status) values ('Ivanov', 'Ivan', 'Ivanovich', 'ivanov@domain.com', 'Active'); 2 | insert into library_user (last_name, first_name, middle_name, email, status) values ('Petrov', 'Pyotr', 'Petrovich', 'petrov@domain.com', 'Active'); 3 | -------------------------------------------------------------------------------- /user-service/src/test/kotlin/com/romankudryashov/eventdrivenarchitecture/userservice/UserServiceApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package com.romankudryashov.eventdrivenarchitecture.userservice 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.boot.test.context.SpringBootTest 5 | 6 | @SpringBootTest 7 | class UserServiceApplicationTests { 8 | 9 | @Test 10 | fun contextLoads() { 11 | } 12 | 13 | } 14 | --------------------------------------------------------------------------------