├── settings.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── kotlin │ │ └── com │ │ │ └── alexbryksin │ │ │ └── ordersmicroservice │ │ │ ├── order │ │ │ ├── events │ │ │ │ ├── BaseEvent.kt │ │ │ │ ├── OrderCreatedEvent.kt │ │ │ │ ├── OrderCompletedEvent.kt │ │ │ │ ├── OrderSubmittedEvent.kt │ │ │ │ ├── OrderEventProcessor.kt │ │ │ │ ├── OrderPaidEvent.kt │ │ │ │ ├── OrderCancelledEvent.kt │ │ │ │ ├── ProductItemRemovedEvent.kt │ │ │ │ ├── ProductItemAddedEvent.kt │ │ │ │ └── OrderEventProcessorImpl.kt │ │ │ ├── domain │ │ │ │ ├── OrderStatus.kt │ │ │ │ ├── ProductItem.kt │ │ │ │ ├── OutboxRecord.kt │ │ │ │ ├── OrderDocument.kt │ │ │ │ ├── OrderEntity.kt │ │ │ │ ├── ProductItemEntity.kt │ │ │ │ └── Order.kt │ │ │ ├── exceptions │ │ │ │ ├── OrderNotPaidException.kt │ │ │ │ ├── InvalidVersionException.kt │ │ │ │ ├── ProductItemNotFoundException.kt │ │ │ │ ├── OrderHasNotProductItemsException.kt │ │ │ │ ├── OrderNotFoundException.kt │ │ │ │ ├── AlreadyProcessedVersionException.kt │ │ │ │ ├── InvalidPaymentIdException.kt │ │ │ │ ├── CancelOrderException.kt │ │ │ │ ├── CompleteOrderException.kt │ │ │ │ └── SubmitOrderException.kt │ │ │ ├── dto │ │ │ │ ├── CancelOrderDTO.kt │ │ │ │ ├── PayOrderDTO.kt │ │ │ │ ├── CreateProductItemDTO.kt │ │ │ │ ├── CreateOrderDTO.kt │ │ │ │ ├── OrderSuccessResponse.kt │ │ │ │ └── ProductItemSuccessResponse.kt │ │ │ ├── repository │ │ │ │ ├── ProductItemRepository.kt │ │ │ │ ├── OrderRepository.kt │ │ │ │ ├── OrderOutboxRepository.kt │ │ │ │ ├── OutboxBaseRepository.kt │ │ │ │ ├── OrderMongoRepository.kt │ │ │ │ ├── ProductItemBaseRepository.kt │ │ │ │ ├── OrderBaseRepository.kt │ │ │ │ ├── ProductItemBaseRepositoryImpl.kt │ │ │ │ ├── OutboxBaseRepositoryImpl.kt │ │ │ │ ├── OrderMongoRepositoryImpl.kt │ │ │ │ └── OrderBaseRepositoryImpl.kt │ │ │ ├── service │ │ │ │ ├── OrderService.kt │ │ │ │ ├── OutboxEventSerializer.kt │ │ │ │ └── OrderServiceImpl.kt │ │ │ ├── controllers │ │ │ │ └── OrderController.kt │ │ │ └── consumer │ │ │ │ └── OrderConsumer.kt │ │ │ ├── utils │ │ │ ├── serializer │ │ │ │ ├── SerializationException.kt │ │ │ │ └── Serializer.kt │ │ │ └── tracing │ │ │ │ └── TracingUtils.kt │ │ │ ├── configuration │ │ │ ├── TopicConfiguration.kt │ │ │ ├── ObjectMapperConfig.kt │ │ │ ├── KafkaAdminConfiguration.kt │ │ │ ├── SwaggerConfiguration.kt │ │ │ ├── KafkaTopicsInitializer.kt │ │ │ ├── KafkaTopicsConfiguration.kt │ │ │ ├── MongoConfiguration.kt │ │ │ ├── KafkaProducerConfiguration.kt │ │ │ └── KafkaConsumerConfiguration.kt │ │ │ ├── exceptions │ │ │ ├── UnknownEventTypeException.kt │ │ │ └── ErrorHttpResponse.kt │ │ │ ├── OrdersMicroserviceApplication.kt │ │ │ ├── eventPublisher │ │ │ ├── EventsPublisher.kt │ │ │ └── KafkaEventsPublisher.kt │ │ │ ├── filters │ │ │ ├── LoggingFilter.kt │ │ │ └── GlobalControllerAdvice.kt │ │ │ └── schedulers │ │ │ └── OutboxScheduler.kt │ └── resources │ │ ├── application.yaml │ │ └── db │ │ └── migration │ │ └── V1__initial_setup.sql └── test │ └── kotlin │ └── com │ └── alexbryksin │ └── ordersmicroservice │ └── OrdersMicroserviceApplicationTests.kt ├── monitoring └── prometheus.yml ├── Dockerfile ├── .gitignore ├── Makefile ├── gradlew.bat ├── docker-compose.local.yaml ├── docker-compose.yaml ├── gradlew └── README.md /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "orders-microservice" 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksK1NG/Transactional_Outbox_with_Spring_and_Kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/BaseEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | sealed class BaseEvent(val aggregateId: String, open val version: Long) { 4 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/domain/OrderStatus.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.domain 2 | 3 | enum class OrderStatus { 4 | NEW, PAID, SUBMITTED, CANCELLED, COMPLETED 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/utils/serializer/SerializationException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.utils.serializer 2 | 3 | data class SerializationException(val ex: Throwable) : RuntimeException(ex) 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/TopicConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | data class TopicConfiguration(var name: String = "", var partitions: Int = 1, var replication: Int = 1) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/exceptions/UnknownEventTypeException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.exceptions 2 | 3 | data class UnknownEventTypeException(val eventType: Any?) : RuntimeException("unknown event type: $eventType") 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/OrderNotPaidException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | data class OrderNotPaidException(val id: String) : RuntimeException("order with id: $id not paid") 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/exceptions/ErrorHttpResponse.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.exceptions 2 | 3 | data class ErrorHttpResponse( 4 | val status: Int, 5 | val message: String, 6 | val timestamp: String 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/InvalidVersionException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | data class InvalidVersionException(val version: Long) : RuntimeException("invalid version: $version") 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/dto/CancelOrderDTO.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.dto 2 | 3 | import jakarta.validation.constraints.Size 4 | 5 | data class CancelOrderDTO(@field:Size(min = 6, max = 1000) val reason: String? = null) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/ProductItemNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | data class ProductItemNotFoundException(val id: Any) : RuntimeException("product item with id: $id not found") { 4 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/OrderHasNotProductItemsException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | data class OrderHasNotProductItemsException(val orderId: String): RuntimeException("order with id: $orderId has not products") -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/OrderNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | import java.util.* 4 | 5 | data class OrderNotFoundException(val orderId: UUID?) : RuntimeException("order with id: $orderId not found") 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/dto/PayOrderDTO.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.dto 2 | 3 | import jakarta.validation.constraints.NotBlank 4 | import jakarta.validation.constraints.Size 5 | 6 | data class PayOrderDTO(@field:NotBlank @field:Size(min = 6, max = 250) val paymentId: String) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/AlreadyProcessedVersionException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | data class AlreadyProcessedVersionException(val id: Any, val version: Long) : RuntimeException("event for id: $id and version: $version is already processed") 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/InvalidPaymentIdException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | data class InvalidPaymentIdException(val orderId: String, val paymentId: String) : 4 | RuntimeException("invalid payment id: $paymentId for order with id: $orderId") 5 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/CancelOrderException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OrderStatus 4 | 5 | data class CancelOrderException(val id: String, val status: OrderStatus) : RuntimeException("cant cancel order with id: $id and status: $status") 6 | -------------------------------------------------------------------------------- /src/test/kotlin/com/alexbryksin/ordersmicroservice/OrdersMicroserviceApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.boot.test.context.SpringBootTest 5 | 6 | @SpringBootTest 7 | class OrdersMicroserviceApplicationTests { 8 | 9 | @Test 10 | fun contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/CompleteOrderException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OrderStatus 4 | 5 | data class CompleteOrderException(val id: String, val status: OrderStatus) : RuntimeException("cant complete order with id: $id and status: $status") 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/exceptions/SubmitOrderException.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.exceptions 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OrderStatus 4 | 5 | data class SubmitOrderException(val id: String, val status: OrderStatus) : RuntimeException("cannot submit order with id: $id and status: $status") { 6 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/OrderCreatedEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | 5 | data class OrderCreatedEvent(val order: Order): BaseEvent(order.id, order.version) { 6 | companion object { 7 | const val ORDER_CREATED_EVENT = "ORDER_CREATED" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/ProductItemRepository.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItemEntity 4 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 5 | import java.util.* 6 | 7 | interface ProductItemRepository : CoroutineCrudRepository, ProductItemBaseRepository { 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OrderRepository.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OrderEntity 4 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 5 | import org.springframework.stereotype.Repository 6 | import java.util.* 7 | 8 | @Repository 9 | interface OrderRepository : CoroutineCrudRepository, OrderBaseRepository -------------------------------------------------------------------------------- /monitoring/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 10s 3 | evaluation_interval: 10s 4 | 5 | scrape_configs: 6 | - job_name: 'prometheus' 7 | static_configs: 8 | - targets: [ 'localhost:9090' ] 9 | 10 | - job_name: 'system' 11 | static_configs: 12 | - targets: [ 'host.docker.internal:9101' ] 13 | 14 | - job_name: 'orders-microservice' 15 | metrics_path: '/actuator/prometheus' 16 | static_configs: 17 | - targets: [ 'host.docker.internal:8080' ] -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OrderOutboxRepository.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OutboxRecord 4 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 5 | import org.springframework.stereotype.Repository 6 | import java.util.* 7 | 8 | @Repository 9 | interface OrderOutboxRepository : CoroutineCrudRepository, OutboxBaseRepository -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/domain/ProductItem.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.domain 2 | 3 | import java.math.BigDecimal 4 | import java.time.LocalDateTime 5 | 6 | data class ProductItem( 7 | val id: String = "", 8 | val orderId: String = "", 9 | val title: String = "", 10 | val price: BigDecimal = BigDecimal.ZERO, 11 | val quantity: Long = 0, 12 | val version: Long = 0, 13 | val createdAt: LocalDateTime? = null, 14 | val updatedAt: LocalDateTime? = null 15 | ) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OutboxBaseRepository.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OutboxRecord 4 | import org.springframework.stereotype.Repository 5 | import java.util.* 6 | 7 | 8 | @Repository 9 | interface OutboxBaseRepository { 10 | suspend fun deleteOutboxRecordByID(id: UUID, callback: suspend () -> Unit): Long 11 | suspend fun deleteOutboxRecordsWithLock(callback: suspend (outboxRecord: OutboxRecord) -> Unit) 12 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/arm64 azul/zulu-openjdk-alpine:17 as builder 2 | ARG JAR_FILE=build/libs/orders-microservice-0.0.1-SNAPSHOT.jar 3 | COPY ${JAR_FILE} application.jar 4 | RUN java -Djarmode=layertools -jar application.jar extract 5 | 6 | FROM azul/zulu-openjdk-alpine:17 7 | COPY --from=builder dependencies/ ./ 8 | COPY --from=builder snapshot-dependencies/ ./ 9 | COPY --from=builder spring-boot-loader/ ./ 10 | COPY --from=builder application/ ./ 11 | ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher", "-XX:MaxRAMPercentage=75", "-XX:+UseG1GC"] -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OrderMongoRepository.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import org.springframework.data.domain.Page 5 | import org.springframework.data.domain.Pageable 6 | 7 | 8 | interface OrderMongoRepository { 9 | suspend fun insert(order: Order): Order 10 | suspend fun update(order: Order): Order 11 | suspend fun getByID(id: String): Order 12 | suspend fun getAllOrders(pageable: Pageable): Page 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/OrderCompletedEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | 5 | data class OrderCompletedEvent(val orderId: String, override val version: Long) : BaseEvent(orderId, version) { 6 | companion object { 7 | const val ORDER_COMPLETED_EVENT = "ORDER_COMPLETED" 8 | } 9 | } 10 | 11 | fun OrderCompletedEvent.Companion.of(order: Order): OrderCompletedEvent = OrderCompletedEvent( 12 | orderId = order.id, 13 | version = order.version 14 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/OrderSubmittedEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | 5 | data class OrderSubmittedEvent(val orderId: String, override val version: Long) : BaseEvent(orderId, version) { 6 | companion object { 7 | const val ORDER_SUBMITTED_EVENT = "ORDER_SUBMITTED" 8 | } 9 | } 10 | 11 | fun OrderSubmittedEvent.Companion.of(order: Order): OrderSubmittedEvent = OrderSubmittedEvent( 12 | orderId = order.id, 13 | version = order.version 14 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/ProductItemBaseRepository.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItem 4 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItemEntity 5 | 6 | interface ProductItemBaseRepository { 7 | 8 | suspend fun insert(productItemEntity: ProductItemEntity): ProductItemEntity 9 | suspend fun insertAll(productItemEntities: List): List 10 | 11 | suspend fun upsert(productItem: ProductItem): ProductItem 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/OrderEventProcessor.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | interface OrderEventProcessor { 4 | suspend fun on(orderCreatedEvent: OrderCreatedEvent) 5 | suspend fun on(productItemAddedEvent: ProductItemAddedEvent) 6 | suspend fun on(productItemRemovedEvent: ProductItemRemovedEvent) 7 | suspend fun on(orderPaidEvent: OrderPaidEvent) 8 | suspend fun on(orderCancelledEvent: OrderCancelledEvent) 9 | suspend fun on(orderSubmittedEvent: OrderSubmittedEvent) 10 | suspend fun on(orderCompletedEvent: OrderCompletedEvent) 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/OrderPaidEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | 5 | data class OrderPaidEvent(val orderId: String, override val version: Long, val paymentId: String) : BaseEvent(orderId, version) { 6 | companion object { 7 | const val ORDER_PAID_EVENT = "ORDER_PAID" 8 | } 9 | } 10 | 11 | fun OrderPaidEvent.Companion.of(order: Order, paymentId: String): OrderPaidEvent = OrderPaidEvent( 12 | orderId = order.id, 13 | version = order.version, 14 | paymentId = paymentId 15 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OrderBaseRepository.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import org.springframework.stereotype.Repository 5 | import java.util.* 6 | 7 | @Repository 8 | interface OrderBaseRepository { 9 | 10 | suspend fun getOrderWithProductItemsByID(id: UUID): Order 11 | 12 | suspend fun updateVersion(id: UUID, newVersion: Long): Long 13 | 14 | suspend fun findOrderByID(id: UUID): Order 15 | 16 | suspend fun insert(order: Order): Order 17 | 18 | suspend fun update(order: Order): Order 19 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | docker_data 39 | kafka_data 40 | zookeeper 41 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/OrderCancelledEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | 5 | 6 | data class OrderCancelledEvent(val orderId: String, override val version: Long, val reason: String? = "") : BaseEvent(orderId, version) { 7 | companion object { 8 | const val ORDER_CANCELLED_EVENT = "ORDER_CANCELLED_EVENT" 9 | } 10 | } 11 | 12 | fun OrderCancelledEvent.Companion.of(order: Order, reason: String? = ""): OrderCancelledEvent = OrderCancelledEvent( 13 | orderId = order.id, 14 | version = order.version, 15 | reason = reason 16 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/OrdersMicroserviceApplication.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.data.mongodb.config.EnableReactiveMongoAuditing 6 | import org.springframework.data.r2dbc.config.EnableR2dbcAuditing 7 | import org.springframework.scheduling.annotation.EnableScheduling 8 | 9 | @SpringBootApplication 10 | @EnableR2dbcAuditing 11 | @EnableScheduling 12 | @EnableReactiveMongoAuditing 13 | class OrdersMicroserviceApplication 14 | 15 | fun main(args: Array) { 16 | runApplication(*args) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/ObjectMapperConfig.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 6 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | 10 | @Configuration 11 | class ObjectMapperConfig { 12 | 13 | @Bean 14 | fun objectMapper(): ObjectMapper = jacksonObjectMapper() 15 | .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) 16 | .registerModule(JavaTimeModule()) 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/ProductItemRemovedEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import java.util.* 5 | 6 | data class ProductItemRemovedEvent( 7 | val orderId: String, 8 | override val version: Long, 9 | val productItemId: String, 10 | ) : BaseEvent(orderId, version) { 11 | companion object { 12 | const val PRODUCT_ITEM_REMOVED_EVENT = "PRODUCT_ITEM_REMOVED" 13 | } 14 | } 15 | 16 | fun ProductItemRemovedEvent.Companion.of(order: Order, itemId: UUID): ProductItemRemovedEvent = ProductItemRemovedEvent( 17 | orderId = order.id, 18 | version = order.version, 19 | productItemId = itemId.toString() 20 | ) -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: 2 | 3 | # ============================================================================== 4 | # Docker 5 | 6 | local: 7 | @echo Starting local docker compose 8 | docker-compose -f docker-compose.local.yaml up -d --build 9 | 10 | develop: 11 | @echo Building application 12 | ./gradlew clean build -x test 13 | @echo Starting docker compose 14 | docker-compose -f docker-compose.yaml up -d --build 15 | 16 | 17 | # ============================================================================== 18 | # Docker and k8s support grafana - prom-operator 19 | 20 | FILES := $(shell docker ps -aq) 21 | 22 | down-local: 23 | docker stop $(FILES) 24 | docker rm $(FILES) 25 | 26 | clean: 27 | docker system prune -f 28 | 29 | logs-local: 30 | docker logs -f $(FILES) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/ProductItemAddedEvent.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItem 5 | 6 | data class ProductItemAddedEvent( 7 | val orderId: String, 8 | override val version: Long, 9 | val productItem: ProductItem 10 | ) : BaseEvent(orderId, version) { 11 | companion object { 12 | const val PRODUCT_ITEM_ADDED_EVENT = "PRODUCT_ITEM_ADDED" 13 | } 14 | } 15 | 16 | fun ProductItemAddedEvent.Companion.of(order: Order, item: ProductItem): ProductItemAddedEvent = ProductItemAddedEvent( 17 | orderId = order.id, 18 | version = order.version, 19 | item 20 | ) 21 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/KafkaAdminConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import org.apache.kafka.clients.admin.AdminClientConfig 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.kafka.core.KafkaAdmin 8 | 9 | @Configuration 10 | class KafkaAdminConfiguration( 11 | @Value(value = "\${spring.kafka.bootstrap-servers:localhost:9092}") 12 | private val bootstrapServers: String, 13 | ) { 14 | 15 | @Bean 16 | fun kafkaAdmin(): KafkaAdmin { 17 | val configs: MutableMap = hashMapOf(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers) 18 | return KafkaAdmin(configs) 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/SwaggerConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition 4 | import io.swagger.v3.oas.annotations.info.Contact 5 | import io.swagger.v3.oas.annotations.info.Info 6 | import org.springframework.context.annotation.Configuration 7 | 8 | 9 | @OpenAPIDefinition( 10 | info = Info( 11 | title = "Kotlin Spring Microservice Outbox implementation", 12 | description = "Kotlin Spring Microservice Outbox implementation example", 13 | version = "1.0.0", 14 | contact = Contact( 15 | name = "Alexander Bryksin", 16 | email = "alexander.bryksin@yandex.ru", 17 | url = "https://github.com/AleksK1NG" 18 | ) 19 | ) 20 | ) 21 | @Configuration 22 | class SwaggerConfiguration -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/eventPublisher/EventsPublisher.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.eventPublisher 2 | 3 | import org.apache.kafka.clients.consumer.ConsumerRecord 4 | 5 | interface EventsPublisher { 6 | suspend fun publish(topic: String?, data: Any) 7 | suspend fun publish(topic: String?, key: String, data: Any) 8 | suspend fun publish(topic: String?, data: Any, headers: Map) 9 | suspend fun publish(topic: String?, key: String, data: Any, headers: Map) 10 | suspend fun publishRetryRecord(topic: String?, data: ByteArray, headers: Map) 11 | suspend fun publishRetryRecord(topic: String?, key: String, data: ByteArray, headers: Map) 12 | suspend fun publishRetryRecord(topic: String?, key: String, record: ConsumerRecord) 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/dto/CreateProductItemDTO.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.dto 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItem 4 | import jakarta.validation.constraints.DecimalMin 5 | import jakarta.validation.constraints.Min 6 | import jakarta.validation.constraints.Size 7 | import java.math.BigDecimal 8 | import java.util.* 9 | 10 | data class CreateProductItemDTO( 11 | val id: UUID, 12 | @field:Size(min = 6, max = 250) val title: String, 13 | @field:DecimalMin(value = "0.0") val price: BigDecimal, 14 | @field:Min(value = 0) val quantity: Long = 0, 15 | ) { 16 | fun toProductItem(orderId: UUID): ProductItem = ProductItem( 17 | id = this.id.toString(), 18 | title = this.title, 19 | price = this.price, 20 | quantity = this.quantity, 21 | orderId = orderId.toString() 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/dto/CreateOrderDTO.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.dto 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItem 5 | import jakarta.validation.Valid 6 | import jakarta.validation.constraints.NotBlank 7 | import jakarta.validation.constraints.Size 8 | 9 | data class CreateOrderDTO( 10 | @field:NotBlank @field:Size(min = 6, max = 250) val email: String, 11 | @field:NotBlank @field:Size(min = 10, max = 1000) val address: String, 12 | @field:Valid var productItems: MutableList = arrayListOf(), 13 | ) { 14 | companion object 15 | 16 | fun toOrder(): Order = Order( 17 | email = this.email, 18 | address = this.address, 19 | productItems = this.productItems.map { ProductItem(title = it.title, price = it.price, quantity = it.quantity, id = it.id.toString()) } 20 | .associateBy { it.id }.toMutableMap() 21 | ) 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/service/OrderService.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.service 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItem 5 | import org.springframework.data.domain.Page 6 | import org.springframework.data.domain.Pageable 7 | import java.util.* 8 | 9 | interface OrderService { 10 | 11 | suspend fun createOrder(order: Order): Order 12 | suspend fun getOrderByID(id: UUID): Order 13 | suspend fun addProductItem(productItem: ProductItem) 14 | suspend fun removeProductItem(orderID: UUID, productItemId: UUID) 15 | suspend fun pay(id: UUID, paymentId: String): Order 16 | suspend fun cancel(id: UUID, reason: String?): Order 17 | suspend fun submit(id: UUID): Order 18 | suspend fun complete(id: UUID): Order 19 | 20 | suspend fun getOrderWithProductsByID(id: UUID): Order 21 | suspend fun getAllOrders(pageable: Pageable): Page 22 | 23 | suspend fun deleteOutboxRecordsWithLock() 24 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/KafkaTopicsInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import org.apache.kafka.clients.admin.NewTopic 4 | import org.springframework.boot.CommandLineRunner 5 | import org.springframework.kafka.core.KafkaAdmin 6 | import org.springframework.stereotype.Component 7 | import reactor.util.Loggers 8 | 9 | @Component 10 | class KafkaTopicsInitializer( 11 | private val kafkaAdmin: KafkaAdmin, 12 | private val kafkaTopicsConfiguration: KafkaTopicsConfiguration, 13 | ) : CommandLineRunner { 14 | 15 | override fun run(vararg args: String?) { 16 | kafkaTopicsConfiguration.getTopics().filterNotNull().forEach { createOrModifyTopic(it) } 17 | log.info("topics created or modified") 18 | } 19 | 20 | private fun createOrModifyTopic(topicConfiguration: TopicConfiguration) { 21 | return try { 22 | val topic = NewTopic(topicConfiguration.name, topicConfiguration.partitions, topicConfiguration.replication.toShort()) 23 | kafkaAdmin.createOrModifyTopics(topic).also { log.info("(KafkaTopicsInitializer) created or modified topic: $topic") } 24 | } catch (ex: Exception) { 25 | log.error("KafkaTopicsInitializer createTopic", ex) 26 | } 27 | } 28 | 29 | companion object { 30 | private val log = Loggers.getLogger(KafkaTopicsInitializer::class.java) 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/filters/LoggingFilter.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.filters 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.core.annotation.Order 5 | import org.springframework.stereotype.Component 6 | import org.springframework.web.server.ServerWebExchange 7 | import org.springframework.web.server.WebFilter 8 | import org.springframework.web.server.WebFilterChain 9 | import reactor.core.publisher.Mono 10 | 11 | @Component 12 | @Order(5) 13 | class LoggingFilter : WebFilter { 14 | 15 | override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { 16 | val startTime = System.currentTimeMillis() 17 | 18 | return chain.filter(exchange) 19 | .doOnSuccess { 20 | if (!exchange.request.path.value().contains("actuator")) { 21 | log.info( 22 | "{} {} {} {} {}ms", 23 | exchange.request.method.name().uppercase(), 24 | exchange.request.path.value(), 25 | exchange.response.statusCode?.value(), 26 | exchange.response.headers, 27 | (System.currentTimeMillis() - startTime) 28 | ) 29 | } 30 | } 31 | } 32 | 33 | companion object { 34 | private val log = LoggerFactory.getLogger(LoggingFilter::class.java) 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/utils/serializer/Serializer.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.utils.serializer 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.stereotype.Component 6 | 7 | @Component 8 | class Serializer(private val objectMapper: ObjectMapper) { 9 | 10 | fun deserialize(data: ByteArray, clazz: Class): T { 11 | return try { 12 | objectMapper.readValue(data, clazz) 13 | } catch (ex: Exception) { 14 | log.error("error while deserializing data: ${ex.localizedMessage}") 15 | throw SerializationException(ex) 16 | } 17 | } 18 | 19 | fun serializeToBytes(data: Any): ByteArray { 20 | return try { 21 | objectMapper.writeValueAsBytes(data) 22 | } catch (ex: Exception) { 23 | log.error("error while serializing data: ${ex.localizedMessage}") 24 | throw SerializationException(ex) 25 | } 26 | } 27 | 28 | fun serializeToString(data: Any): String { 29 | return try { 30 | objectMapper.writeValueAsString(data) 31 | } catch (ex: Exception) { 32 | log.error("error while serializing data: ${ex.localizedMessage}") 33 | throw SerializationException(ex) 34 | } 35 | } 36 | 37 | companion object { 38 | private val log = LoggerFactory.getLogger(Serializer::class.java) 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/dto/OrderSuccessResponse.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.dto 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import com.alexbryksin.ordersmicroservice.order.domain.OrderStatus 5 | 6 | data class OrderSuccessResponse( 7 | val id: String, 8 | val email: String, 9 | val address: String, 10 | val paymentId: String, 11 | val status: OrderStatus, 12 | val version: Long, 13 | val productItems: List, 14 | val createdAt: String, 15 | val updatedAt: String 16 | ) { 17 | companion object 18 | } 19 | 20 | fun OrderSuccessResponse.Companion.of(order: Order): OrderSuccessResponse = OrderSuccessResponse( 21 | id = order.id, 22 | email = order.email, 23 | address = order.address, 24 | status = order.status, 25 | version = order.version, 26 | paymentId = order.paymentId, 27 | productItems = order.productsList().map { ProductItemSuccessResponse.of(it) }.toList(), 28 | createdAt = order.createdAt.toString(), 29 | updatedAt = order.updatedAt.toString(), 30 | ) 31 | 32 | fun Order.toSuccessResponse(): OrderSuccessResponse = OrderSuccessResponse( 33 | id = this.id, 34 | email = this.email, 35 | address = this.address, 36 | status = this.status, 37 | version = this.version, 38 | paymentId = this.paymentId, 39 | productItems = this.productsList().map { ProductItemSuccessResponse.of(it) }.toList(), 40 | createdAt = this.createdAt.toString(), 41 | updatedAt = this.updatedAt.toString(), 42 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/schedulers/OutboxScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.schedulers 2 | 3 | import com.alexbryksin.ordersmicroservice.order.service.OrderService 4 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 5 | import io.micrometer.observation.ObservationRegistry 6 | import kotlinx.coroutines.runBlocking 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 9 | import org.springframework.scheduling.annotation.Scheduled 10 | import org.springframework.stereotype.Component 11 | 12 | 13 | @Component 14 | @ConditionalOnProperty(prefix = "schedulers", value = ["outbox.enable"], havingValue = "true") 15 | class OutboxScheduler(private val orderService: OrderService, private val or: ObservationRegistry) { 16 | 17 | @Scheduled(initialDelayString = "\${schedulers.outbox.initialDelayMillis}", fixedRateString = "\${schedulers.outbox.fixedRate}") 18 | fun publishAndDeleteOutboxRecords() = runBlocking { 19 | coroutineScopeWithObservation(PUBLISH_AND_DELETE_OUTBOX_RECORDS, or) { 20 | log.debug("starting scheduled outbox table publishing") 21 | orderService.deleteOutboxRecordsWithLock() 22 | log.debug("completed scheduled outbox table publishing") 23 | } 24 | } 25 | 26 | companion object { 27 | private val log = LoggerFactory.getLogger(OutboxScheduler::class.java) 28 | 29 | private const val PUBLISH_AND_DELETE_OUTBOX_RECORDS = "OutboxScheduler.publishAndDeleteOutboxRecords" 30 | } 31 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/dto/ProductItemSuccessResponse.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.dto 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItem 4 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItemEntity 5 | import java.math.BigDecimal 6 | 7 | data class ProductItemSuccessResponse( 8 | val id: String, 9 | var orderId: String, 10 | val title: String?, 11 | val price: BigDecimal, 12 | val quantity: Long, 13 | val version: Long, 14 | val createdAt: String, 15 | val updatedAt: String 16 | ) { 17 | companion object 18 | } 19 | 20 | fun ProductItemSuccessResponse.Companion.of(productItemEntity: ProductItemEntity): ProductItemSuccessResponse = ProductItemSuccessResponse( 21 | id = productItemEntity.id.toString(), 22 | orderId = productItemEntity.orderId.toString(), 23 | title = productItemEntity.title, 24 | price = productItemEntity.price, 25 | quantity = productItemEntity.quantity, 26 | version = productItemEntity.version, 27 | createdAt = productItemEntity.createdAt.toString(), 28 | updatedAt = productItemEntity.updatedAt.toString() 29 | ) 30 | 31 | fun ProductItemSuccessResponse.Companion.of(productItem: ProductItem): ProductItemSuccessResponse = ProductItemSuccessResponse( 32 | id = productItem.id, 33 | orderId = productItem.orderId, 34 | title = productItem.title, 35 | price = productItem.price, 36 | quantity = productItem.quantity, 37 | version = productItem.version, 38 | createdAt = productItem.createdAt.toString(), 39 | updatedAt = productItem.updatedAt.toString() 40 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/KafkaTopicsConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import jakarta.annotation.PostConstruct 4 | import org.springframework.boot.context.properties.ConfigurationProperties 5 | import org.springframework.context.annotation.Configuration 6 | import reactor.util.Loggers 7 | 8 | @Configuration 9 | @ConfigurationProperties(prefix = "topics") 10 | class KafkaTopicsConfiguration { 11 | 12 | var retryTopic: TopicConfiguration = TopicConfiguration() 13 | var deadLetterQueue: TopicConfiguration = TopicConfiguration() 14 | 15 | var orderCreated: TopicConfiguration = TopicConfiguration() 16 | var orderPaid: TopicConfiguration = TopicConfiguration() 17 | var orderCancelled: TopicConfiguration = TopicConfiguration() 18 | var orderSubmitted: TopicConfiguration = TopicConfiguration() 19 | var orderCompleted: TopicConfiguration = TopicConfiguration() 20 | var productAdded: TopicConfiguration = TopicConfiguration() 21 | var productRemoved: TopicConfiguration = TopicConfiguration() 22 | 23 | 24 | fun getTopics() = listOf( 25 | retryTopic, 26 | deadLetterQueue, 27 | orderCreated, 28 | productRemoved, 29 | productAdded, 30 | orderCancelled, 31 | orderPaid, 32 | orderSubmitted, 33 | orderCompleted, 34 | ) 35 | 36 | @PostConstruct 37 | fun logConfigProperties() { 38 | log.info("KafkaTopicsConfiguration created topics: ${getTopics()}") 39 | } 40 | 41 | companion object { 42 | private val log = Loggers.getLogger(KafkaTopicsConfiguration::class.java) 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/domain/OutboxRecord.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.domain 2 | 3 | import io.r2dbc.spi.Row 4 | import org.springframework.data.annotation.Id 5 | import org.springframework.data.relational.core.mapping.Column 6 | import org.springframework.data.relational.core.mapping.Table 7 | import java.math.BigInteger 8 | import java.time.LocalDateTime 9 | import java.util.* 10 | 11 | 12 | @Table(schema = "microservices", name = "outbox_table") 13 | data class OutboxRecord( 14 | @Id @Column(EVENT_ID) var eventId: UUID? = null, 15 | @Column(EVENT_TYPE) var eventType: String?, 16 | @Column(AGGREGATE_ID) var aggregateId: String?, 17 | @Column(DATA) var data: ByteArray = byteArrayOf(), 18 | @Column(VERSION) var version: Long = 0, 19 | @Column(TIMESTAMP) var timestamp: LocalDateTime?, 20 | ) { 21 | 22 | 23 | 24 | companion object { 25 | const val EVENT_ID = "event_id" 26 | const val EVENT_TYPE = "event_type" 27 | const val AGGREGATE_ID = "aggregate_id" 28 | const val DATA = "data" 29 | const val VERSION = "version" 30 | const val TIMESTAMP = "timestamp" 31 | } 32 | 33 | override fun toString(): String { 34 | return "OutboxRecord(eventId=$eventId, eventType=$eventType, aggregateId=$aggregateId, data=${String(data)}, version=$version, timestamp=$timestamp)" 35 | } 36 | } 37 | 38 | fun OutboxRecord.Companion.of(row: Row): OutboxRecord = OutboxRecord( 39 | eventId = row[EVENT_ID, UUID::class.java], 40 | aggregateId = row[AGGREGATE_ID, String::class.java], 41 | eventType = row[EVENT_TYPE, String::class.java], 42 | data = row[DATA, ByteArray::class.java] ?: byteArrayOf(), 43 | version = row[VERSION, BigInteger::class.java]?.toLong() ?: 0, 44 | timestamp = row[TIMESTAMP, LocalDateTime::class.java], 45 | ) 46 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/MongoConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument 4 | import jakarta.annotation.PostConstruct 5 | import kotlinx.coroutines.reactor.awaitSingle 6 | import kotlinx.coroutines.runBlocking 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.data.domain.Sort 10 | import org.springframework.data.mongodb.core.ReactiveMongoTemplate 11 | import org.springframework.data.mongodb.core.index.Index 12 | 13 | @Configuration 14 | class MongoConfiguration(private val mongoTemplate: ReactiveMongoTemplate) { 15 | 16 | @PostConstruct 17 | fun init() = runBlocking { 18 | val mongoDatabase = mongoTemplate.mongoDatabase.awaitSingle() 19 | log.info("mongoDatabase: ${mongoDatabase.name}") 20 | val collectionsList = mongoTemplate.collectionNames.collectList().awaitSingle() 21 | log.info("collectionsList: $collectionsList") 22 | 23 | 24 | val orderCollectionExists = mongoTemplate.collectionExists(OrderDocument::class.java).awaitSingle() 25 | if (!orderCollectionExists) { 26 | val ordersCollection = mongoTemplate.createCollection(OrderDocument::class.java).awaitSingle() 27 | log.info("order collection: ${ordersCollection.namespace.fullName}") 28 | } 29 | 30 | val ordersIndexInfo = mongoTemplate.indexOps(OrderDocument::class.java).indexInfo.collectList().awaitSingle() 31 | log.info("ordersIndexInfo: $ordersIndexInfo") 32 | 33 | val orderEmailIndex = mongoTemplate.indexOps(OrderDocument::class.java) 34 | .ensureIndex(Index().sparse().on("email", Sort.DEFAULT_DIRECTION).unique()).awaitSingle() 35 | log.info("orderEmailIndex: $orderEmailIndex") 36 | 37 | log.info("mongodb initialized") 38 | } 39 | 40 | 41 | companion object { 42 | private val log = LoggerFactory.getLogger(MongoConfiguration::class.java) 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/KafkaProducerConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import org.apache.kafka.clients.producer.ProducerConfig 4 | import org.apache.kafka.common.serialization.ByteArraySerializer 5 | import org.apache.kafka.common.serialization.StringSerializer 6 | import org.springframework.beans.factory.annotation.Value 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.kafka.core.DefaultKafkaProducerFactory 10 | import org.springframework.kafka.core.KafkaTemplate 11 | import org.springframework.kafka.core.ProducerFactory 12 | 13 | @Configuration 14 | class KafkaProducerConfiguration( 15 | @Value(value = "\${spring.kafka.bootstrap-servers:localhost:9092}") 16 | private val bootstrapServers: String, 17 | ) { 18 | 19 | private fun senderProps(): Map { 20 | return hashMapOf( 21 | ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, 22 | ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, 23 | ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to ByteArraySerializer::class.java, 24 | ProducerConfig.ACKS_CONFIG to "all", 25 | ProducerConfig.RETRIES_CONFIG to 5, 26 | ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG to 120000, 27 | ProducerConfig.MAX_REQUEST_SIZE_CONFIG to 1068576, 28 | ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG to 30000, 29 | ) 30 | } 31 | 32 | @Bean 33 | fun producerFactory(): ProducerFactory { 34 | return DefaultKafkaProducerFactory(senderProps()) 35 | } 36 | 37 | @Bean 38 | fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate { 39 | val kafkaTemplate = KafkaTemplate(producerFactory) 40 | kafkaTemplate.setObservationEnabled(true) 41 | kafkaTemplate.setMicrometerEnabled(true) 42 | return kafkaTemplate 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | shutdown: graceful 4 | 5 | spring: 6 | application: 7 | name: order-microservice 8 | 9 | r2dbc: 10 | url: r2dbc:postgresql://localhost:5432/microservices 11 | username: postgres 12 | password: postgres 13 | pool: 14 | initial-size: 10 15 | max-size: 20 16 | 17 | flyway: 18 | url: jdbc:postgresql://localhost:5432/microservices 19 | user: postgres 20 | password: postgres 21 | schemas: [ "microservices" ] 22 | 23 | kafka: 24 | consumer: 25 | group-id: order-service-group-id 26 | bootstrap-servers: localhost:9092 27 | 28 | data: 29 | mongodb: 30 | host: localhost 31 | port: 27017 32 | authentication-database: admin 33 | username: admin 34 | password: admin 35 | database: bank_accounts 36 | 37 | #springdoc: http://localhost:8080/webjars/swagger-ui/index.html#/ 38 | springdoc: 39 | swagger-ui: 40 | path: /swagger-ui.html 41 | 42 | 43 | schedulers: 44 | outbox: 45 | enable: true 46 | initialDelayMillis: 3000 47 | fixedRate: 1000 48 | 49 | management: 50 | tracing: 51 | sampling: 52 | probability: 1.0 53 | endpoints: 54 | web: 55 | exposure: 56 | include: '*' 57 | endpoint: 58 | health: 59 | probes: 60 | enabled: true 61 | health: 62 | livenessstate: 63 | enabled: true 64 | readinessstate: 65 | enabled: true 66 | 67 | logging: 68 | level: 69 | io.r2dbc.postgresql.QUERY: INFO 70 | com.alexbryksin.ordersmicroservice.order.repository.OutboxBaseRepositoryImpl: INFO 71 | 72 | 73 | topics: 74 | orderCreated: 75 | name: order-created 76 | partitions: 3 77 | replication: 1 78 | productAdded: 79 | name: product-added 80 | partitions: 3 81 | replication: 1 82 | productRemoved: 83 | name: product-added 84 | partitions: 3 85 | replication: 1 86 | orderPaid: 87 | name: order-paid 88 | partitions: 3 89 | replication: 1 90 | orderCancelled: 91 | name: order-cancelled 92 | partitions: 3 93 | replication: 1 94 | orderSubmitted: 95 | name: order-submitted 96 | partitions: 3 97 | replication: 1 98 | orderCompleted: 99 | name: order-completed 100 | partitions: 3 101 | replication: 1 102 | 103 | retryTopic: 104 | name: retry-topic 105 | partitions: 3 106 | replication: 1 107 | 108 | deadLetterQueue: 109 | name: dead-letter-queue 110 | partitions: 3 111 | replication: 1 112 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__initial_setup.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS citext; 2 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 3 | CREATE SCHEMA IF NOT EXISTS microservices; 4 | 5 | 6 | DROP TABLE IF EXISTS microservices.orders CASCADE; 7 | DROP TABLE IF EXISTS microservices.product_items CASCADE; 8 | 9 | 10 | CREATE TABLE IF NOT EXISTS microservices.orders 11 | ( 12 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 13 | email VARCHAR(60) UNIQUE NOT NULL CHECK ( email <> '' ), 14 | address VARCHAR(500) NOT NULL CHECK ( address <> '' ), 15 | payment_id VARCHAR(255), 16 | status VARCHAR(20) NOT NULL CHECK ( status <> '' ), 17 | version BIGINT NOT NULL DEFAULT 0, 18 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 20 | ); 21 | 22 | CREATE INDEX IF NOT EXISTS orders_email_idx ON microservices.orders (email); 23 | 24 | 25 | CREATE TABLE IF NOT EXISTS microservices.product_items 26 | ( 27 | id UUID DEFAULT uuid_generate_v4(), 28 | order_id UUID REFERENCES microservices.orders (id) NOT NULL, 29 | title VARCHAR(250) NOT NULL CHECK ( title <> '' ), 30 | quantity BIGINT NOT NULL DEFAULT 0, 31 | price DECIMAL(16, 2) NOT NULL DEFAULT 0.00, 32 | version BIGINT NOT NULL DEFAULT 0, 33 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 34 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 35 | ); 36 | 37 | CREATE INDEX IF NOT EXISTS product_items_order_id_idx ON microservices.product_items (order_id); 38 | 39 | ALTER TABLE microservices.product_items 40 | ADD CONSTRAINT product_items_id_order_id_unique UNIQUE (id, order_id); 41 | 42 | 43 | 44 | CREATE TABLE IF NOT EXISTS microservices.outbox_table 45 | ( 46 | event_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 47 | event_type VARCHAR(250) NOT NULL CHECK ( event_type <> '' ), 48 | aggregate_id VARCHAR(250) NOT NULL CHECK ( aggregate_id <> '' ), 49 | version SERIAL NOT NULL, 50 | data BYTEA, 51 | timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP 52 | ); 53 | 54 | CREATE INDEX IF NOT EXISTS outbox_table_aggregate_id_idx ON microservices.outbox_table (aggregate_id); 55 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/utils/tracing/TracingUtils.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.utils.tracing 2 | 3 | import io.micrometer.context.ContextSnapshot 4 | import io.micrometer.observation.Observation 5 | import io.micrometer.observation.ObservationRegistry 6 | import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor 7 | import kotlinx.coroutines.coroutineScope 8 | import kotlinx.coroutines.reactor.awaitSingle 9 | import kotlinx.coroutines.reactor.mono 10 | import reactor.core.publisher.Mono 11 | 12 | 13 | suspend fun coroutineScopeWithObservation(name: String, or: ObservationRegistry, block: suspend (obs: Observation) -> T): T = coroutineScope { 14 | Mono.deferContextual { ctxView -> 15 | ContextSnapshot.setThreadLocalsFrom(ctxView, ObservationThreadLocalAccessor.KEY).use { _: ContextSnapshot.Scope -> 16 | val obs = Observation.start(name, or) 17 | mono { block(obs) } 18 | .doOnError { ex -> obs.error(ex) } 19 | .doFinally { obs.stop() } 20 | } 21 | } 22 | .contextCapture() 23 | .awaitSingle() 24 | } 25 | 26 | suspend fun coroutineScopeWithObservation(name: String, or: ObservationRegistry, vararg fields: Pair, block: suspend (obs: Observation) -> T): T = 27 | coroutineScope { 28 | Mono.deferContextual { ctxView -> 29 | ContextSnapshot.setThreadLocalsFrom(ctxView, ObservationThreadLocalAccessor.KEY).use { _: ContextSnapshot.Scope -> 30 | val obs = Observation.start(name, or) 31 | fields.forEach { obs.highCardinalityKeyValue(it.first, it.second) } 32 | mono { block(obs) } 33 | .doOnError { ex -> obs.error(ex) } 34 | .doFinally { obs.stop() } 35 | } 36 | } 37 | .contextCapture() 38 | .awaitSingle() 39 | } 40 | 41 | 42 | suspend fun coroutineScopeWithObservationMetrics(name: String, or: ObservationRegistry, vararg fields: Pair, block: suspend () -> T): T = 43 | coroutineScope { 44 | Mono.deferContextual { ctxView -> 45 | ContextSnapshot.setThreadLocalsFrom(ctxView, ObservationThreadLocalAccessor.KEY).use { _: ContextSnapshot.Scope -> 46 | val obs = Observation.start(name, or) 47 | fields.forEach { obs.highCardinalityKeyValue(it.first, it.second) } 48 | mono { block() } 49 | .doOnError { ex -> obs.error(ex) } 50 | .doFinally { obs.stop() } 51 | } 52 | } 53 | .contextCapture() 54 | .awaitSingle() 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/domain/OrderDocument.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.domain 2 | 3 | import org.springframework.data.annotation.CreatedDate 4 | import org.springframework.data.annotation.Id 5 | import org.springframework.data.annotation.LastModifiedDate 6 | import org.springframework.data.mongodb.core.mapping.Document 7 | import org.springframework.data.mongodb.core.mapping.Field 8 | import java.time.LocalDateTime 9 | 10 | 11 | @Document(collection = "orders") 12 | data class OrderDocument( 13 | @Id @Field(name = ID) var id: String = "", 14 | @Field(name = EMAIL) var email: String = "", 15 | @Field(name = ADDRESS) var address: String = "", 16 | @Field(name = PAYMENT_ID) var paymentId: String = "", 17 | @Field(name = STATUS) var status: OrderStatus = OrderStatus.NEW, 18 | @Field(name = VERSION) var version: Long = 0, 19 | @Field(name = PRODUCT_ITEMS) val productItems: MutableList = arrayListOf(), 20 | @CreatedDate @Field(name = CREATED_AT) var createdAt: LocalDateTime? = null, 21 | @LastModifiedDate @Field(name = UPDATED_AT) var updatedAt: LocalDateTime? = null 22 | ) { 23 | 24 | fun toOrder() = Order( 25 | id = id, 26 | email = this.email, 27 | address = this.address, 28 | status = this.status, 29 | version = this.version, 30 | paymentId = this.paymentId, 31 | productItems = this.productItems.associateBy { it.id }.toMutableMap(), 32 | createdAt = this.createdAt, 33 | updatedAt = this.updatedAt 34 | ) 35 | 36 | companion object { 37 | const val ID = "id" 38 | const val EMAIL = "email" 39 | const val ADDRESS = "address" 40 | const val STATUS = "status" 41 | const val VERSION = "version" 42 | const val PAYMENT_ID = "paymentId" 43 | const val PRODUCT_ITEMS = "productItems" 44 | const val CREATED_AT = "createdAt" 45 | const val UPDATED_AT = "updatedAt" 46 | } 47 | } 48 | 49 | 50 | fun OrderDocument.Companion.of(order: Order): OrderDocument = OrderDocument( 51 | id = order.id, 52 | email = order.email, 53 | address = order.address, 54 | status = order.status, 55 | version = order.version, 56 | paymentId = order.paymentId, 57 | productItems = order.productsList().toMutableList(), 58 | createdAt = order.createdAt, 59 | updatedAt = order.updatedAt 60 | ) 61 | 62 | fun Order.toDocument(): OrderDocument = OrderDocument( 63 | id = this.id, 64 | email = this.email, 65 | address = this.address, 66 | status = this.status, 67 | version = this.version, 68 | paymentId = this.paymentId, 69 | productItems = this.productsList().toMutableList(), 70 | createdAt = this.createdAt, 71 | updatedAt = this.updatedAt 72 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/service/OutboxEventSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.service 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import com.alexbryksin.ordersmicroservice.order.domain.OutboxRecord 5 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItemEntity 6 | import com.alexbryksin.ordersmicroservice.order.events.* 7 | import com.alexbryksin.ordersmicroservice.utils.serializer.Serializer 8 | import org.springframework.stereotype.Component 9 | import java.time.LocalDateTime 10 | import java.util.* 11 | 12 | @Component 13 | class OutboxEventSerializer(private val serializer: Serializer) { 14 | 15 | fun orderCreatedEventOf(order: Order) = outboxRecord( 16 | order.id, 17 | order.version, 18 | OrderCreatedEvent(order), 19 | OrderCreatedEvent.ORDER_CREATED_EVENT, 20 | ) 21 | 22 | fun productItemAddedEventOf(order: Order, productItemEntity: ProductItemEntity) = outboxRecord( 23 | order.id, 24 | order.version, 25 | ProductItemAddedEvent.of(order, productItemEntity.toProductItem()), 26 | ProductItemAddedEvent.PRODUCT_ITEM_ADDED_EVENT, 27 | ) 28 | 29 | fun productItemRemovedEventOf(order: Order, productItemId: UUID) = outboxRecord( 30 | order.id, 31 | order.version, 32 | ProductItemRemovedEvent.of(order, productItemId), 33 | ProductItemRemovedEvent.PRODUCT_ITEM_REMOVED_EVENT 34 | ) 35 | 36 | fun orderPaidEventOf(order: Order, paymentId: String) = outboxRecord( 37 | order.id, 38 | order.version, 39 | OrderPaidEvent.of(order, paymentId), 40 | OrderPaidEvent.ORDER_PAID_EVENT 41 | ) 42 | 43 | fun orderCancelledEventOf(order: Order, reason: String?) = outboxRecord( 44 | order.id, 45 | order.version, 46 | OrderCancelledEvent.of(order, reason), 47 | OrderCancelledEvent.ORDER_CANCELLED_EVENT, 48 | ) 49 | 50 | fun orderSubmittedEventOf(order: Order) = outboxRecord( 51 | order.id, 52 | order.version, 53 | OrderSubmittedEvent.of(order), 54 | OrderSubmittedEvent.ORDER_SUBMITTED_EVENT 55 | ) 56 | 57 | fun orderCompletedEventOf(order: Order) = outboxRecord( 58 | order.id, 59 | order.version, 60 | OrderCompletedEvent.of(order), 61 | OrderCompletedEvent.ORDER_COMPLETED_EVENT 62 | ) 63 | 64 | 65 | fun outboxRecord(aggregateId: String, version: Long, data: Any, eventType: String): OutboxRecord = 66 | OutboxRecord( 67 | eventId = null, 68 | aggregateId = aggregateId, 69 | eventType = eventType, 70 | data = serializer.serializeToBytes(data), 71 | version = version, 72 | timestamp = LocalDateTime.now() 73 | ) 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/configuration/KafkaConsumerConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.configuration 2 | 3 | import org.apache.kafka.clients.consumer.ConsumerConfig 4 | import org.apache.kafka.common.serialization.ByteArrayDeserializer 5 | import org.apache.kafka.common.serialization.StringDeserializer 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.beans.factory.annotation.Value 8 | import org.springframework.context.annotation.Bean 9 | import org.springframework.context.annotation.Configuration 10 | import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory 11 | import org.springframework.kafka.core.ConsumerFactory 12 | import org.springframework.kafka.core.DefaultKafkaConsumerFactory 13 | import org.springframework.kafka.listener.ContainerProperties 14 | 15 | @Configuration 16 | class KafkaConsumerConfiguration( 17 | @Value(value = "\${spring.kafka.bootstrap-servers:localhost:9092}") 18 | private val bootstrapServers: String, 19 | 20 | @Value(value = "\${spring.kafka.consumer.group-id:order-microservice-group-id}") 21 | private val groupId: String, 22 | ) { 23 | @Bean 24 | fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory): ConcurrentKafkaListenerContainerFactory { 25 | return ConcurrentKafkaListenerContainerFactory().apply { 26 | this.consumerFactory = consumerFactory 27 | setConcurrency(Runtime.getRuntime().availableProcessors()) 28 | this.containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE 29 | this.containerProperties.isMicrometerEnabled = true 30 | this.containerProperties.isObservationEnabled = true 31 | this.setRecordInterceptor { record, consumer -> 32 | log.info("RecordInterceptor record: $record consumer: ${consumer.metrics()}") 33 | return@setRecordInterceptor record 34 | } 35 | } 36 | } 37 | 38 | 39 | @Bean 40 | fun consumerFactory(): ConsumerFactory { 41 | return DefaultKafkaConsumerFactory(consumerProps()) 42 | } 43 | 44 | private fun consumerProps(): Map { 45 | return hashMapOf( 46 | ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, 47 | ConsumerConfig.GROUP_ID_CONFIG to groupId, 48 | ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, 49 | ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ByteArrayDeserializer::class.java, 50 | ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to "false" 51 | ) 52 | } 53 | 54 | companion object { 55 | private val log = LoggerFactory.getLogger(KafkaAdminConfiguration::class.java) 56 | } 57 | } -------------------------------------------------------------------------------- /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 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/domain/OrderEntity.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.domain 2 | 3 | import io.r2dbc.spi.Row 4 | import org.springframework.data.annotation.CreatedDate 5 | import org.springframework.data.annotation.Id 6 | import org.springframework.data.annotation.LastModifiedDate 7 | import org.springframework.data.annotation.Version 8 | import org.springframework.data.relational.core.mapping.Column 9 | import org.springframework.data.relational.core.mapping.Table 10 | import java.math.BigInteger 11 | import java.time.LocalDateTime 12 | import java.util.* 13 | 14 | @Table(schema = "microservices", name = "orders") 15 | data class OrderEntity( 16 | @Id @Column(ID) var id: UUID?, 17 | @Column(EMAIL) var email: String?, 18 | @Column(ADDRESS) var address: String? = null, 19 | @Column(PAYMENT_ID) var paymentId: String? = null, 20 | @Column(STATUS) var status: OrderStatus = OrderStatus.NEW, 21 | @Version @Column(VERSION) var version: Long = 0, 22 | @CreatedDate @Column(CREATED_AT) var createdAt: LocalDateTime? = null, 23 | @LastModifiedDate @Column(UPDATED_AT) var updatedAt: LocalDateTime? = null 24 | ) { 25 | 26 | 27 | fun toOrder() = Order( 28 | id = this.id.toString(), 29 | email = this.email ?: "", 30 | address = this.address ?: "", 31 | status = this.status, 32 | version = this.version, 33 | paymentId = this.paymentId ?: "", 34 | createdAt = this.createdAt, 35 | updatedAt = this.updatedAt 36 | ) 37 | 38 | companion object { 39 | const val ID = "id" 40 | const val EMAIL = "email" 41 | const val ADDRESS = "address" 42 | const val STATUS = "status" 43 | const val VERSION = "version" 44 | const val PAYMENT_ID = "payment_id" 45 | const val CREATED_AT = "created_at" 46 | const val UPDATED_AT = "updated_at" 47 | } 48 | } 49 | 50 | fun OrderEntity.Companion.of(order: Order): OrderEntity = OrderEntity( 51 | id = order.id.toUUID(), 52 | email = order.email, 53 | address = order.address, 54 | status = order.status, 55 | version = order.version, 56 | paymentId = order.paymentId, 57 | createdAt = order.createdAt, 58 | updatedAt = order.updatedAt 59 | ) 60 | 61 | fun OrderEntity.Companion.of(row: Row) = OrderEntity( 62 | id = row[ID, UUID::class.java], 63 | email = row[EMAIL, String::class.java], 64 | status = OrderStatus.valueOf(row[STATUS, String::class.java] ?: ""), 65 | address = row[ADDRESS, String::class.java], 66 | paymentId = row[PAYMENT_ID, String::class.java], 67 | version = row[VERSION, BigInteger::class.java]?.toLong() ?: 0, 68 | createdAt = row[CREATED_AT, LocalDateTime::class.java], 69 | updatedAt = row[UPDATED_AT, LocalDateTime::class.java], 70 | ) 71 | 72 | fun Order.toEntity(): OrderEntity = OrderEntity( 73 | id = this.id.toUUID(), 74 | email = this.email, 75 | address = this.address, 76 | status = this.status, 77 | version = this.version, 78 | paymentId = this.paymentId, 79 | createdAt = this.createdAt, 80 | updatedAt = this.updatedAt 81 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/domain/ProductItemEntity.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.domain 2 | 3 | import io.r2dbc.spi.Row 4 | import org.springframework.data.annotation.CreatedDate 5 | import org.springframework.data.annotation.Id 6 | import org.springframework.data.annotation.LastModifiedDate 7 | import org.springframework.data.annotation.Version 8 | import org.springframework.data.relational.core.mapping.Column 9 | import org.springframework.data.relational.core.mapping.Table 10 | import java.math.BigDecimal 11 | import java.math.BigInteger 12 | import java.time.LocalDateTime 13 | import java.util.* 14 | 15 | 16 | @Table(schema = "microservices", name = "product_items") 17 | data class ProductItemEntity( 18 | @Id @Column("id") var id: UUID? = null, 19 | @Column("order_id") var orderId: UUID?, 20 | @Column("title") var title: String = "", 21 | @Column("price") var price: BigDecimal = BigDecimal.ZERO, 22 | @Column("quantity") var quantity: Long = 0, 23 | @Version @Column("version") var version: Long = 0, 24 | @CreatedDate @Column("created_at") var createdAt: LocalDateTime? = null, 25 | @LastModifiedDate @Column("updated_at") var updatedAt: LocalDateTime? = null 26 | ) { 27 | companion object 28 | 29 | fun toProductItem() = ProductItem( 30 | id = this.id.toString(), 31 | orderId = this.orderId.toString(), 32 | title = this.title, 33 | price = this.price, 34 | quantity = this.quantity, 35 | version = this.version, 36 | createdAt = this.createdAt, 37 | updatedAt = this.updatedAt 38 | ) 39 | } 40 | 41 | fun ProductItemEntity.Companion.of(row: Row) = ProductItemEntity( 42 | id = row["productId", UUID::class.java], 43 | title = row["title", String::class.java] ?: "", 44 | orderId = row["order_id", UUID::class.java], 45 | price = row["price", BigDecimal::class.java] ?: BigDecimal.ZERO, 46 | quantity = row["quantity", BigInteger::class.java]?.toLong() ?: 0, 47 | version = row[OrderEntity.VERSION, BigInteger::class.java]?.toLong() ?: 0, 48 | createdAt = row["itemCreatedAt", LocalDateTime::class.java], 49 | updatedAt = row["itemUpdatedAt", LocalDateTime::class.java], 50 | ) 51 | 52 | 53 | fun ProductItemEntity.Companion.of(productItem: ProductItem): ProductItemEntity = ProductItemEntity( 54 | id = UUID.fromString(productItem.id), 55 | orderId = UUID.fromString(productItem.orderId), 56 | title = productItem.title, 57 | price = productItem.price, 58 | quantity = productItem.quantity, 59 | version = productItem.version, 60 | createdAt = productItem.createdAt, 61 | updatedAt = productItem.updatedAt 62 | ) 63 | 64 | fun ProductItem.toEntity(): ProductItemEntity = ProductItemEntity( 65 | id = UUID.fromString(this.id), 66 | orderId = UUID.fromString(this.orderId), 67 | title = this.title, 68 | price = this.price, 69 | quantity = this.quantity, 70 | version = this.version, 71 | createdAt = this.createdAt, 72 | updatedAt = this.updatedAt 73 | ) 74 | 75 | fun ProductItemEntity.Companion.listOf(productItems: List, orderId: UUID?) = productItems 76 | .map { item -> ProductItemEntity.of(item.copy(orderId = orderId.toString())) } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/domain/Order.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.domain 2 | 3 | import com.alexbryksin.ordersmicroservice.order.exceptions.* 4 | import java.time.LocalDateTime 5 | import java.util.* 6 | 7 | class Order( 8 | var id: String = "", 9 | var email: String = "", 10 | var address: String = "", 11 | var status: OrderStatus = OrderStatus.NEW, 12 | var version: Long = 0, 13 | var productItems: MutableMap = linkedMapOf(), 14 | var paymentId: String = "", 15 | var createdAt: LocalDateTime? = null, 16 | var updatedAt: LocalDateTime? = null 17 | ) { 18 | 19 | fun addProductItem(productItem: ProductItem): Order { 20 | if (productItems.containsKey(productItem.id)) { 21 | val item = productItems[productItem.id]!! 22 | productItems[productItem.id] = item.copy(quantity = (item.quantity + productItem.quantity), version = productItem.version) 23 | return this 24 | } 25 | 26 | productItems[productItem.id] = productItem 27 | return this 28 | } 29 | 30 | fun addProductItems(items: List): Order { 31 | items.forEach { addProductItem(it) } 32 | return this 33 | } 34 | 35 | fun removeProductItem(id: String): Order { 36 | productItems.remove(id) 37 | return this 38 | } 39 | 40 | fun productsList() = productItems.toList().map { it.second } 41 | 42 | fun pay(paymentId: String) { 43 | if (productItems.isEmpty()) throw OrderHasNotProductItemsException(id) 44 | if (paymentId.isBlank()) throw InvalidPaymentIdException(id, paymentId) 45 | this.paymentId = paymentId 46 | status = OrderStatus.PAID 47 | } 48 | 49 | fun submit() { 50 | if (productItems.isEmpty()) throw OrderHasNotProductItemsException(id) 51 | if (status == OrderStatus.COMPLETED || status == OrderStatus.CANCELLED) throw SubmitOrderException(id, status) 52 | if (status != OrderStatus.PAID) throw OrderNotPaidException(id) 53 | status = OrderStatus.SUBMITTED 54 | } 55 | 56 | fun cancel() { 57 | if (status == OrderStatus.COMPLETED || status == OrderStatus.CANCELLED) throw CancelOrderException(id, status) 58 | status = OrderStatus.CANCELLED 59 | } 60 | 61 | fun complete() { 62 | if (status == OrderStatus.CANCELLED || status != OrderStatus.SUBMITTED) throw CompleteOrderException(id, status) 63 | status = OrderStatus.COMPLETED 64 | } 65 | 66 | fun incVersion(): Order { 67 | version++ 68 | return this 69 | } 70 | 71 | fun decVersion(): Order { 72 | version-- 73 | return this 74 | } 75 | 76 | override fun equals(other: Any?): Boolean { 77 | if (this === other) return true 78 | if (javaClass != other?.javaClass) return false 79 | 80 | other as Order 81 | 82 | return id == other.id 83 | } 84 | 85 | override fun hashCode(): Int { 86 | return id.hashCode() 87 | } 88 | 89 | override fun toString(): String { 90 | return "Order(id='$id', email='$email', address='$address', status=$status, version=$version, productItems=${productItems.size}, paymentId='$paymentId', createdAt=$createdAt, updatedAt=$updatedAt)" 91 | } 92 | 93 | 94 | companion object 95 | } 96 | 97 | fun String.toUUID(): UUID? { 98 | if (this == "") return null 99 | return UUID.fromString(this) 100 | } -------------------------------------------------------------------------------- /docker-compose.local.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | microservices_postgresql: 5 | image: postgres:latest 6 | container_name: microservices_postgresql 7 | expose: 8 | - "5432" 9 | ports: 10 | - "5432:5432" 11 | restart: always 12 | environment: 13 | - POSTGRES_USER=postgres 14 | - POSTGRES_PASSWORD=postgres 15 | - POSTGRES_DB=microservices 16 | - POSTGRES_HOST=5432 17 | command: -p 5432 18 | volumes: 19 | - ./docker_data/microservices_pgdata:/var/lib/postgresql/data 20 | networks: [ "microservices" ] 21 | 22 | zoo1: 23 | image: confluentinc/cp-zookeeper:7.3.0 24 | hostname: zoo1 25 | container_name: zoo1 26 | ports: 27 | - "2181:2181" 28 | environment: 29 | ZOOKEEPER_CLIENT_PORT: 2181 30 | ZOOKEEPER_SERVER_ID: 1 31 | ZOOKEEPER_SERVERS: zoo1:2888:3888 32 | volumes: 33 | - "./zookeeper:/zookeeper" 34 | networks: [ "microservices" ] 35 | 36 | kafka1: 37 | image: confluentinc/cp-kafka:7.3.0 38 | hostname: kafka1 39 | container_name: kafka1 40 | ports: 41 | - "9092:9092" 42 | - "29092:29092" 43 | - "9999:9999" 44 | environment: 45 | KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092 46 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT 47 | KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL 48 | KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181" 49 | KAFKA_BROKER_ID: 1 50 | KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO" 51 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 52 | KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 53 | KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 54 | KAFKA_JMX_PORT: 9999 55 | KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} 56 | KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer 57 | KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true" 58 | depends_on: 59 | - zoo1 60 | volumes: 61 | - "./kafka_data:/kafka" 62 | networks: [ "microservices" ] 63 | 64 | kafka-ui: 65 | image: provectuslabs/kafka-ui 66 | container_name: kafka-ui 67 | ports: 68 | - "8086:8080" 69 | restart: always 70 | environment: 71 | - KAFKA_CLUSTERS_0_NAME=local 72 | - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka1:19092 73 | networks: [ "microservices" ] 74 | 75 | zipkin-all-in-one: 76 | image: openzipkin/zipkin:latest 77 | restart: always 78 | ports: 79 | - "9411:9411" 80 | networks: [ "microservices" ] 81 | 82 | mongo: 83 | image: mongo 84 | restart: always 85 | ports: 86 | - "27017:27017" 87 | environment: 88 | MONGO_INITDB_ROOT_USERNAME: admin 89 | MONGO_INITDB_ROOT_PASSWORD: admin 90 | MONGODB_DATABASE: bank_accounts 91 | networks: [ "microservices" ] 92 | 93 | prometheus: 94 | image: prom/prometheus:latest 95 | container_name: prometheus 96 | ports: 97 | - "9090:9090" 98 | command: 99 | - --config.file=/etc/prometheus/prometheus.yml 100 | volumes: 101 | - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro 102 | networks: [ "microservices" ] 103 | 104 | node_exporter: 105 | container_name: microservices_node_exporter 106 | restart: always 107 | image: prom/node-exporter 108 | ports: 109 | - '9101:9100' 110 | networks: [ "microservices" ] 111 | 112 | grafana: 113 | container_name: microservices_grafana 114 | restart: always 115 | image: grafana/grafana 116 | ports: 117 | - '3000:3000' 118 | networks: [ "microservices" ] 119 | 120 | 121 | networks: 122 | microservices: 123 | name: microservices -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/ProductItemBaseRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItem 4 | import com.alexbryksin.ordersmicroservice.order.domain.ProductItemEntity 5 | import com.alexbryksin.ordersmicroservice.order.domain.of 6 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 7 | import io.micrometer.observation.ObservationRegistry 8 | import kotlinx.coroutines.reactor.awaitSingle 9 | import kotlinx.coroutines.reactor.awaitSingleOrNull 10 | import org.slf4j.LoggerFactory 11 | import org.springframework.data.r2dbc.core.R2dbcEntityTemplate 12 | import org.springframework.data.relational.core.query.Criteria 13 | import org.springframework.data.relational.core.query.Query 14 | import org.springframework.data.relational.core.query.Update 15 | import org.springframework.stereotype.Repository 16 | import java.time.LocalDateTime 17 | import java.util.* 18 | 19 | 20 | @Repository 21 | class ProductItemBaseRepositoryImpl( 22 | private val entityTemplate: R2dbcEntityTemplate, 23 | private val or: ObservationRegistry, 24 | ) : ProductItemBaseRepository { 25 | 26 | override suspend fun upsert(productItem: ProductItem): ProductItem = coroutineScopeWithObservation(UPDATE, or) { observation -> 27 | val query = Query.query( 28 | Criteria.where("id").`is`(UUID.fromString(productItem.id)) 29 | .and("order_id").`is`(UUID.fromString(productItem.orderId)) 30 | ) 31 | 32 | val product = entityTemplate.selectOne(query, ProductItemEntity::class.java).awaitSingleOrNull() 33 | if (product != null) { 34 | val update = Update 35 | .update("quantity", (productItem.quantity + product.quantity)) 36 | .set("version", product.version + 1) 37 | .set("updated_at", LocalDateTime.now()) 38 | 39 | val updatedProduct = product.copy(quantity = (productItem.quantity + product.quantity), version = product.version + 1) 40 | val updateResult = entityTemplate.update(query, update, ProductItemEntity::class.java).awaitSingle() 41 | log.info("updateResult product: $updateResult") 42 | log.info("updateResult updatedProduct: $updatedProduct") 43 | return@coroutineScopeWithObservation updatedProduct.toProductItem() 44 | } 45 | 46 | entityTemplate.insert(ProductItemEntity.of(productItem)).awaitSingle().toProductItem() 47 | .also { productItem -> 48 | log.info("saved productItem: $productItem") 49 | observation.highCardinalityKeyValue("productItem", productItem.toString()) 50 | } 51 | } 52 | 53 | override suspend fun insert(productItemEntity: ProductItemEntity): ProductItemEntity = coroutineScopeWithObservation(INSERT, or) { observation -> 54 | val product = entityTemplate.insert(productItemEntity).awaitSingle() 55 | 56 | log.info("saved product: $product") 57 | observation.highCardinalityKeyValue("product", product.toString()) 58 | product 59 | } 60 | 61 | override suspend fun insertAll(productItemEntities: List) = coroutineScopeWithObservation(INSERT_ALL, or) { observation -> 62 | val result = productItemEntities.map { entityTemplate.insert(it) }.map { it.awaitSingle() } 63 | log.info("inserted product items: $result") 64 | observation.highCardinalityKeyValue("result", result.toString()) 65 | result 66 | } 67 | 68 | companion object { 69 | private val log = LoggerFactory.getLogger(ProductItemBaseRepositoryImpl::class.java) 70 | 71 | private const val INSERT = "ProductItemBaseRepository.insert" 72 | private const val INSERT_ALL = "ProductItemBaseRepository.insertAll" 73 | private const val UPDATE = "ProductItemBaseRepository.update" 74 | } 75 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OutboxBaseRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.OutboxRecord 4 | import com.alexbryksin.ordersmicroservice.order.domain.of 5 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 6 | import io.micrometer.observation.ObservationRegistry 7 | import kotlinx.coroutines.flow.collect 8 | import kotlinx.coroutines.flow.onEach 9 | import kotlinx.coroutines.reactor.awaitSingle 10 | import kotlinx.coroutines.withTimeout 11 | import org.slf4j.LoggerFactory 12 | import org.springframework.r2dbc.core.DatabaseClient 13 | import org.springframework.r2dbc.core.flow 14 | import org.springframework.transaction.reactive.TransactionalOperator 15 | import org.springframework.transaction.reactive.executeAndAwait 16 | import java.util.* 17 | 18 | class OutboxBaseRepositoryImpl( 19 | private val dbClient: DatabaseClient, 20 | private val txOp: TransactionalOperator, 21 | private val or: ObservationRegistry, 22 | private val transactionalOperator: TransactionalOperator 23 | ) : OutboxBaseRepository { 24 | 25 | override suspend fun deleteOutboxRecordByID(id: UUID, callback: suspend () -> Unit): Long = 26 | coroutineScopeWithObservation(DELETE_OUTBOX_RECORD_BY_ID, or) { observation -> 27 | withTimeout(DELETE_OUTBOX_RECORD_TIMEOUT_MILLIS) { 28 | txOp.executeAndAwait { 29 | 30 | callback() 31 | 32 | dbClient.sql("DELETE FROM microservices.outbox_table WHERE event_id = :eventId") 33 | .bind("eventId", id) 34 | .fetch() 35 | .rowsUpdated() 36 | .awaitSingle() 37 | .also { 38 | log.info("outbox event with id: $it deleted") 39 | observation.highCardinalityKeyValue("id", it.toString()) 40 | } 41 | } 42 | } 43 | } 44 | 45 | override suspend fun deleteOutboxRecordsWithLock(callback: suspend (outboxRecord: OutboxRecord) -> Unit) = 46 | coroutineScopeWithObservation(DELETE_OUTBOX_RECORD_WITH_LOCK, or) { observation -> 47 | withTimeout(DELETE_OUTBOX_RECORD_TIMEOUT_MILLIS) { 48 | txOp.executeAndAwait { 49 | log.debug("starting delete outbox events") 50 | 51 | dbClient.sql("SELECT * FROM microservices.outbox_table ORDER BY timestamp ASC LIMIT 10 FOR UPDATE SKIP LOCKED") 52 | .map { row, _ -> OutboxRecord.of(row) } 53 | .flow() 54 | .onEach { 55 | log.info("deleting outboxEvent with id: ${it.eventId}") 56 | 57 | callback(it) 58 | 59 | dbClient.sql("DELETE FROM microservices.outbox_table WHERE event_id = :eventId") 60 | .bind("eventId", it.eventId!!) 61 | .fetch() 62 | .rowsUpdated() 63 | .awaitSingle() 64 | 65 | log.info("outboxEvent with id: ${it.eventId} published and deleted") 66 | observation.highCardinalityKeyValue("eventId", it.eventId.toString()) 67 | } 68 | .collect() 69 | log.debug("complete delete outbox events") 70 | } 71 | } 72 | } 73 | 74 | companion object { 75 | private val log = LoggerFactory.getLogger(OutboxBaseRepositoryImpl::class.java) 76 | private const val DELETE_OUTBOX_RECORD_TIMEOUT_MILLIS = 3000L 77 | 78 | private const val DELETE_OUTBOX_RECORD_WITH_LOCK = "OutboxBaseRepository.deleteOutboxRecordsWithLock" 79 | private const val DELETE_OUTBOX_RECORD_BY_ID = "OutboxBaseRepository.deleteOutboxRecordByID" 80 | } 81 | } -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | microservice: 5 | platform: linux/arm64 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | container_name: microservice 10 | expose: 11 | - "8000" 12 | ports: 13 | - "8000:8000" 14 | - "8080:8080" 15 | environment: 16 | - MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://host.docker.internal:9411/api/v2/spans 17 | - SPRING_APPLICATION_NAME=microservice 18 | - SERVER_PORT=8080 19 | - SPRING_R2DBC_URL=r2dbc:postgresql://host.docker.internal:5432/microservices 20 | - SPRING_FLYWAY_URL=jdbc:postgresql://host.docker.internal:5432/microservices 21 | - SPRING_KAFKA_BOOTSTRAP_SERVERS=host.docker.internal:29092 22 | - SPRING_DATA_MONGODB_HOST=host.docker.internal 23 | - JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75" 24 | depends_on: 25 | - microservices_postgresql 26 | - zoo1 27 | - kafka-ui 28 | - kafka1 29 | - grafana 30 | - zipkin-all-in-one 31 | - node_exporter 32 | - mongo 33 | - prometheus 34 | networks: [ "microservices" ] 35 | 36 | microservices_postgresql: 37 | image: postgres:latest 38 | container_name: microservices_postgresql 39 | expose: 40 | - "5432" 41 | ports: 42 | - "5432:5432" 43 | restart: always 44 | environment: 45 | - POSTGRES_USER=postgres 46 | - POSTGRES_PASSWORD=postgres 47 | - POSTGRES_DB=microservices 48 | - POSTGRES_HOST=5432 49 | command: -p 5432 50 | volumes: 51 | - ./docker_data/microservices_pgdata:/var/lib/postgresql/data 52 | networks: [ "microservices" ] 53 | 54 | zoo1: 55 | image: confluentinc/cp-zookeeper:7.3.0 56 | hostname: zoo1 57 | container_name: zoo1 58 | ports: 59 | - "2181:2181" 60 | environment: 61 | ZOOKEEPER_CLIENT_PORT: 2181 62 | ZOOKEEPER_SERVER_ID: 1 63 | ZOOKEEPER_SERVERS: zoo1:2888:3888 64 | volumes: 65 | - "./zookeeper:/zookeeper" 66 | networks: [ "microservices" ] 67 | 68 | kafka1: 69 | image: confluentinc/cp-kafka:7.3.0 70 | hostname: kafka1 71 | container_name: kafka1 72 | ports: 73 | - "9092:9092" 74 | - "29092:29092" 75 | - "9999:9999" 76 | environment: 77 | KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092 78 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT 79 | KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL 80 | KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181" 81 | KAFKA_BROKER_ID: 1 82 | KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO" 83 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 84 | KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 85 | KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 86 | KAFKA_JMX_PORT: 9999 87 | KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} 88 | KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer 89 | KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true" 90 | depends_on: 91 | - zoo1 92 | volumes: 93 | - "./kafka_data:/kafka" 94 | networks: [ "microservices" ] 95 | 96 | kafka-ui: 97 | image: provectuslabs/kafka-ui 98 | container_name: kafka-ui 99 | ports: 100 | - "8086:8080" 101 | restart: always 102 | environment: 103 | - KAFKA_CLUSTERS_0_NAME=local 104 | - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka1:19092 105 | networks: [ "microservices" ] 106 | 107 | zipkin-all-in-one: 108 | image: openzipkin/zipkin:latest 109 | restart: always 110 | ports: 111 | - "9411:9411" 112 | networks: [ "microservices" ] 113 | 114 | mongo: 115 | image: mongo 116 | restart: always 117 | ports: 118 | - "27017:27017" 119 | environment: 120 | MONGO_INITDB_ROOT_USERNAME: admin 121 | MONGO_INITDB_ROOT_PASSWORD: admin 122 | MONGODB_DATABASE: bank_accounts 123 | networks: [ "microservices" ] 124 | 125 | prometheus: 126 | image: prom/prometheus:latest 127 | container_name: prometheus 128 | ports: 129 | - "9090:9090" 130 | command: 131 | - --config.file=/etc/prometheus/prometheus.yml 132 | volumes: 133 | - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro 134 | networks: [ "microservices" ] 135 | 136 | node_exporter: 137 | container_name: microservices_node_exporter 138 | restart: always 139 | image: prom/node-exporter 140 | ports: 141 | - '9101:9100' 142 | networks: [ "microservices" ] 143 | 144 | grafana: 145 | container_name: microservices_grafana 146 | restart: always 147 | image: grafana/grafana 148 | ports: 149 | - '3000:3000' 150 | networks: [ "microservices" ] 151 | 152 | 153 | networks: 154 | microservices: 155 | name: microservices -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OrderMongoRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.Order 4 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument 5 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument.Companion.ADDRESS 6 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument.Companion.EMAIL 7 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument.Companion.ID 8 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument.Companion.PAYMENT_ID 9 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument.Companion.PRODUCT_ITEMS 10 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument.Companion.STATUS 11 | import com.alexbryksin.ordersmicroservice.order.domain.OrderDocument.Companion.VERSION 12 | import com.alexbryksin.ordersmicroservice.order.domain.toDocument 13 | import com.alexbryksin.ordersmicroservice.order.domain.toUUID 14 | import com.alexbryksin.ordersmicroservice.order.exceptions.OrderNotFoundException 15 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 16 | import io.micrometer.observation.ObservationRegistry 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.async 19 | import kotlinx.coroutines.reactor.awaitSingle 20 | import kotlinx.coroutines.reactor.awaitSingleOrNull 21 | import kotlinx.coroutines.withContext 22 | import org.slf4j.LoggerFactory 23 | import org.springframework.data.domain.Page 24 | import org.springframework.data.domain.Pageable 25 | import org.springframework.data.mongodb.core.FindAndModifyOptions 26 | import org.springframework.data.mongodb.core.ReactiveMongoTemplate 27 | import org.springframework.data.mongodb.core.query.Criteria 28 | import org.springframework.data.mongodb.core.query.Query 29 | import org.springframework.data.mongodb.core.query.Update 30 | import org.springframework.data.support.PageableExecutionUtils 31 | import org.springframework.stereotype.Repository 32 | 33 | 34 | @Repository 35 | class OrderMongoRepositoryImpl( 36 | private val mongoTemplate: ReactiveMongoTemplate, 37 | private val or: ObservationRegistry, 38 | ) : OrderMongoRepository { 39 | 40 | override suspend fun insert(order: Order): Order = coroutineScopeWithObservation(INSERT, or) { observation -> 41 | withContext(Dispatchers.IO) { 42 | mongoTemplate.insert(order.toDocument()).awaitSingle().toOrder() 43 | .also { log.info("inserted order: $it") } 44 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 45 | } 46 | } 47 | 48 | override suspend fun update(order: Order): Order = coroutineScopeWithObservation(UPDATE, or) { observation -> 49 | withContext(Dispatchers.IO) { 50 | val query = Query.query(Criteria.where(ID).`is`(order.id).and(VERSION).`is`(order.version - 1)) 51 | 52 | val update = Update() 53 | .set(EMAIL, order.email) 54 | .set(ADDRESS, order.address) 55 | .set(STATUS, order.status) 56 | .set(VERSION, order.version) 57 | .set(PAYMENT_ID, order.paymentId) 58 | .set(PRODUCT_ITEMS, order.productsList()) 59 | 60 | val options = FindAndModifyOptions.options().returnNew(true).upsert(false) 61 | val updatedOrderDocument = mongoTemplate.findAndModify(query, update, options, OrderDocument::class.java) 62 | .awaitSingleOrNull() ?: throw OrderNotFoundException(order.id.toUUID()) 63 | 64 | observation.highCardinalityKeyValue("order", updatedOrderDocument.toString()) 65 | updatedOrderDocument.toOrder().also { orderDocument -> log.info("updated order: $orderDocument") } 66 | } 67 | } 68 | 69 | override suspend fun getByID(id: String): Order = coroutineScopeWithObservation(GET_BY_ID, or) { observation -> 70 | withContext(Dispatchers.IO) { 71 | mongoTemplate.findById(id, OrderDocument::class.java).awaitSingle().toOrder() 72 | .also { log.info("found order: $it") } 73 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 74 | } 75 | } 76 | 77 | override suspend fun getAllOrders(pageable: Pageable): Page = coroutineScopeWithObservation(GET_ALL_ORDERS, or) { observation -> 78 | withContext(Dispatchers.IO) { 79 | val query = Query().with(pageable) 80 | val data = async { mongoTemplate.find(query, OrderDocument::class.java).collectList().awaitSingle() }.await() 81 | val count = async { mongoTemplate.count(Query(), OrderDocument::class.java).awaitSingle() }.await() 82 | PageableExecutionUtils.getPage(data.map { it.toOrder() }, pageable) { count } 83 | .also { observation.highCardinalityKeyValue("pageResult", it.pageable.toString()) } 84 | } 85 | } 86 | 87 | 88 | companion object { 89 | private val log = LoggerFactory.getLogger(OrderMongoRepositoryImpl::class.java) 90 | 91 | private const val GET_BY_ID = "OrderMongoRepository.getByID" 92 | private const val GET_ALL_ORDERS = "OrderMongoRepository.getAllOrders" 93 | private const val UPDATE = "OrderMongoRepository.update" 94 | private const val INSERT = "OrderMongoRepository.insert" 95 | } 96 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/eventPublisher/KafkaEventsPublisher.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.eventPublisher 2 | 3 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import io.micrometer.observation.ObservationRegistry 6 | import kotlinx.coroutines.future.await 7 | import org.apache.kafka.clients.consumer.ConsumerRecord 8 | import org.apache.kafka.clients.producer.ProducerRecord 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.kafka.core.KafkaTemplate 11 | import org.springframework.stereotype.Component 12 | 13 | 14 | @Component 15 | class KafkaEventsPublisher( 16 | private val kafkaTemplate: KafkaTemplate, 17 | private val objectMapper: ObjectMapper, 18 | private val or: ObservationRegistry 19 | ) : EventsPublisher { 20 | 21 | override suspend fun publish(topic: String?, data: Any): Unit = coroutineScopeWithObservation(PUBLISH, or) { observation -> 22 | val msg = ProducerRecord(topic, objectMapper.writeValueAsBytes(data)) 23 | observation.highCardinalityKeyValue("msg", msg.toString()) 24 | 25 | val result = kafkaTemplate.send(msg).await() 26 | log.info("publish sendResult: $result") 27 | observation.highCardinalityKeyValue("result", result.toString()) 28 | } 29 | 30 | override suspend fun publish(topic: String?, key: String, data: Any): Unit = coroutineScopeWithObservation(PUBLISH, or) { observation -> 31 | val msg = ProducerRecord(topic, key, objectMapper.writeValueAsBytes(data)) 32 | observation.highCardinalityKeyValue("msg", msg.toString()) 33 | 34 | val result = kafkaTemplate.send(msg).await() 35 | log.info("publish sendResult: $result") 36 | observation.highCardinalityKeyValue("result", result.toString()) 37 | } 38 | 39 | override suspend fun publish(topic: String?, data: Any, headers: Map): Unit = 40 | coroutineScopeWithObservation(PUBLISH, or) { observation -> 41 | val msg = ProducerRecord(topic, objectMapper.writeValueAsBytes(data)) 42 | headers.forEach { (key, value) -> msg.headers().add(key, value) } 43 | observation.highCardinalityKeyValue("msg", msg.toString()) 44 | 45 | val result = kafkaTemplate.send(msg).await() 46 | log.info("publish sendResult: $result") 47 | observation.highCardinalityKeyValue("result", result.toString()) 48 | } 49 | 50 | override suspend fun publish(topic: String?, key: String, data: Any, headers: Map): Unit = 51 | coroutineScopeWithObservation(PUBLISH, or) { observation -> 52 | val msg = ProducerRecord(topic, key, objectMapper.writeValueAsBytes(data)) 53 | headers.forEach { (key, value) -> msg.headers().add(key, value) } 54 | observation.highCardinalityKeyValue("msg", msg.toString()) 55 | 56 | val result = kafkaTemplate.send(msg).await() 57 | log.info("publish sendResult: $result") 58 | observation.highCardinalityKeyValue("result", result.toString()) 59 | } 60 | 61 | override suspend fun publishRetryRecord(topic: String?, data: ByteArray, headers: Map): Unit = 62 | coroutineScopeWithObservation(PUBLISH, or) { observation -> 63 | val msg = ProducerRecord(topic, data) 64 | headers.forEach { (key, value) -> msg.headers().add(key, value) } 65 | observation.highCardinalityKeyValue("msg", msg.toString()) 66 | 67 | val result = kafkaTemplate.send(msg).await() 68 | log.info("publish sendResult: $result") 69 | observation.highCardinalityKeyValue("result", result.toString()) 70 | } 71 | 72 | override suspend fun publishRetryRecord( 73 | topic: String?, 74 | key: String, 75 | data: ByteArray, 76 | headers: Map 77 | ): Unit = coroutineScopeWithObservation(PUBLISH, or) { observation -> 78 | val msg = ProducerRecord(topic, key, data) 79 | headers.forEach { (key, value) -> msg.headers().add(key, value) } 80 | 81 | msg.headers().forEach { log.info("HEADER: >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> ${it.key()}: ${String(it.value())}") } 82 | 83 | observation.highCardinalityKeyValue("msg", msg.toString()) 84 | observation.highCardinalityKeyValue("headers", headers.toString()) 85 | 86 | val result = kafkaTemplate.send(msg).await() 87 | log.info("publish sendResult: $result") 88 | observation.highCardinalityKeyValue("result", result.toString()) 89 | } 90 | 91 | override suspend fun publishRetryRecord(topic: String?, key: String, record: ConsumerRecord): Unit = 92 | coroutineScopeWithObservation(PUBLISH, or) { 93 | val msg = ProducerRecord(topic, key, record.value()) 94 | record.headers().forEach { msg.headers().add(it) } 95 | val result = kafkaTemplate.send(msg).await() 96 | log.info("publish sendResult: $result") 97 | } 98 | 99 | 100 | companion object { 101 | private val log = LoggerFactory.getLogger(KafkaEventsPublisher::class.java) 102 | 103 | private const val PUBLISH = "EventsPublisher.publish" 104 | } 105 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/filters/GlobalControllerAdvice.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.filters 2 | 3 | import com.alexbryksin.ordersmicroservice.exceptions.ErrorHttpResponse 4 | import com.alexbryksin.ordersmicroservice.order.exceptions.* 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.core.annotation.Order 7 | import org.springframework.http.HttpStatus 8 | import org.springframework.http.MediaType 9 | import org.springframework.http.ResponseEntity 10 | import org.springframework.http.server.reactive.ServerHttpRequest 11 | import org.springframework.web.bind.annotation.ControllerAdvice 12 | import org.springframework.web.bind.annotation.ExceptionHandler 13 | import org.springframework.web.bind.annotation.ResponseStatus 14 | import org.springframework.web.bind.support.WebExchangeBindException 15 | import org.springframework.web.server.ServerWebInputException 16 | import java.time.LocalDateTime 17 | 18 | 19 | @ControllerAdvice 20 | @Order(2) 21 | class GlobalControllerAdvice { 22 | 23 | @ExceptionHandler(value = [RuntimeException::class]) 24 | fun handleRuntimeException(ex: RuntimeException, request: ServerHttpRequest): ResponseEntity { 25 | val errorHttpResponse = ErrorHttpResponse( 26 | HttpStatus.INTERNAL_SERVER_ERROR.value(), 27 | ex.message ?: "", 28 | LocalDateTime.now().toString() 29 | ) 30 | return ResponseEntity 31 | .status(HttpStatus.INTERNAL_SERVER_ERROR) 32 | .contentType(MediaType.APPLICATION_JSON) 33 | .body(errorHttpResponse) 34 | .also { log.error("(GlobalControllerAdvice) INTERNAL_SERVER_ERROR RuntimeException", ex) } 35 | } 36 | 37 | @ExceptionHandler( 38 | value = [ 39 | OrderNotPaidException::class, 40 | CompleteOrderException::class, 41 | OrderHasNotProductItemsException::class, 42 | CancelOrderException::class, 43 | SubmitOrderException::class, 44 | InvalidPaymentIdException::class 45 | ] 46 | ) 47 | fun handleOrderException(ex: RuntimeException, request: ServerHttpRequest): ResponseEntity { 48 | val errorHttpResponse = ErrorHttpResponse( 49 | HttpStatus.BAD_REQUEST.value(), 50 | ex.message ?: "", 51 | LocalDateTime.now().toString() 52 | ) 53 | return ResponseEntity 54 | .status(HttpStatus.BAD_REQUEST) 55 | .contentType(MediaType.APPLICATION_JSON) 56 | .body(errorHttpResponse) 57 | .also { log.error("(GlobalControllerAdvice) BAD_REQUEST RuntimeException", ex) } 58 | } 59 | 60 | @ExceptionHandler(value = [OrderNotFoundException::class]) 61 | fun handleOrderNotFoundException(ex: OrderNotFoundException, request: ServerHttpRequest): ResponseEntity { 62 | val errorHttpResponse = ErrorHttpResponse( 63 | HttpStatus.NOT_FOUND.value(), 64 | ex.message ?: "", 65 | LocalDateTime.now().toString() 66 | ) 67 | return ResponseEntity 68 | .status(HttpStatus.NOT_FOUND) 69 | .contentType(MediaType.APPLICATION_JSON) 70 | .body(errorHttpResponse) 71 | .also { log.error("(GlobalControllerAdvice) NOT_FOUND OrderNotFoundException", ex) } 72 | } 73 | 74 | @ExceptionHandler(value = [ProductItemNotFoundException::class]) 75 | fun handleProductItemNotFoundException(ex: ProductItemNotFoundException, request: ServerHttpRequest): ResponseEntity { 76 | val errorHttpResponse = ErrorHttpResponse( 77 | HttpStatus.NOT_FOUND.value(), 78 | ex.message ?: "", 79 | LocalDateTime.now().toString() 80 | ) 81 | return ResponseEntity 82 | .status(HttpStatus.NOT_FOUND) 83 | .contentType(MediaType.APPLICATION_JSON) 84 | .body(errorHttpResponse) 85 | .also { log.error("(GlobalControllerAdvice) NOT_FOUND ProductItemNotFoundException", ex) } 86 | } 87 | 88 | @ExceptionHandler(value = [ServerWebInputException::class]) 89 | fun handleServerWebInputException(ex: ServerWebInputException, request: ServerHttpRequest): ResponseEntity { 90 | val errorHttpResponse = ErrorHttpResponse( 91 | HttpStatus.BAD_REQUEST.value(), 92 | ex.message, 93 | LocalDateTime.now().toString() 94 | ) 95 | return ResponseEntity 96 | .status(HttpStatus.BAD_REQUEST) 97 | .contentType(MediaType.APPLICATION_JSON) 98 | .body(errorHttpResponse) 99 | .also { log.error("(GlobalControllerAdvice) BAD_REQUEST ServerWebInputException", ex) } 100 | } 101 | 102 | @ResponseStatus(HttpStatus.BAD_REQUEST) 103 | @ExceptionHandler(value = [WebExchangeBindException::class]) 104 | fun handleWebExchangeInvalidArgument(ex: WebExchangeBindException): ResponseEntity> { 105 | val errorMap = mutableMapOf() 106 | ex.bindingResult.fieldErrors.forEach { error -> 107 | error.defaultMessage?.let { 108 | errorMap[error.field] = mapOf( 109 | "reason" to it, 110 | "rejectedValue" to error.rejectedValue, 111 | ) 112 | } 113 | } 114 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON).body(errorMap) 115 | .also { log.error("(GlobalControllerAdvice) WebExchangeBindException BAD_REQUEST", ex) } 116 | } 117 | 118 | 119 | companion object { 120 | private val log = LoggerFactory.getLogger(GlobalControllerAdvice::class.java) 121 | } 122 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/repository/OrderBaseRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.repository 2 | 3 | import com.alexbryksin.ordersmicroservice.order.domain.* 4 | import com.alexbryksin.ordersmicroservice.order.domain.OrderEntity.Companion.ID 5 | import com.alexbryksin.ordersmicroservice.order.domain.OrderEntity.Companion.VERSION 6 | import com.alexbryksin.ordersmicroservice.order.exceptions.OrderNotFoundException 7 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 8 | import io.micrometer.observation.ObservationRegistry 9 | import kotlinx.coroutines.flow.toList 10 | import kotlinx.coroutines.reactor.awaitSingle 11 | import kotlinx.coroutines.reactor.awaitSingleOrNull 12 | import org.slf4j.LoggerFactory 13 | import org.springframework.data.r2dbc.core.R2dbcEntityTemplate 14 | import org.springframework.data.relational.core.query.Criteria 15 | import org.springframework.data.relational.core.query.Query 16 | import org.springframework.r2dbc.core.DatabaseClient 17 | import org.springframework.r2dbc.core.flow 18 | import org.springframework.stereotype.Repository 19 | import java.util.* 20 | 21 | 22 | @Repository 23 | class OrderBaseRepositoryImpl( 24 | private val dbClient: DatabaseClient, 25 | private val entityTemplate: R2dbcEntityTemplate, 26 | private val or: ObservationRegistry 27 | ) : OrderBaseRepository { 28 | 29 | override suspend fun updateVersion(id: UUID, newVersion: Long): Long = coroutineScopeWithObservation(UPDATE_VERSION, or) { observation -> 30 | dbClient.sql("UPDATE microservices.orders SET version = (version + 1) WHERE id = :id AND version = :version") 31 | .bind(ID, id) 32 | .bind(VERSION, newVersion - 1) 33 | .fetch() 34 | .rowsUpdated() 35 | .awaitSingle() 36 | .also { log.info("for order with id: $id version updated to $newVersion") } 37 | .also { 38 | observation.highCardinalityKeyValue("id", id.toString()) 39 | observation.highCardinalityKeyValue("newVersion", newVersion.toString()) 40 | } 41 | } 42 | 43 | override suspend fun getOrderWithProductItemsByID(id: UUID): Order = coroutineScopeWithObservation(GET_ORDER_WITH_PRODUCTS_BY_ID, or) { observation -> 44 | dbClient.sql( 45 | """SELECT o.id, o.email, o.status, o.address, o.version, o.payment_id, o.created_at, o.updated_at, 46 | |pi.id as productId, pi.price, pi.title, pi.quantity, pi.order_id, pi.version as itemVersion, pi.created_at as itemCreatedAt, pi.updated_at as itemUpdatedAt 47 | |FROM microservices.orders o 48 | |LEFT JOIN microservices.product_items pi on o.id = pi.order_id 49 | |WHERE o.id = :id""".trimMargin() 50 | ) 51 | .bind(ID, id) 52 | .map { row, _ -> Pair(OrderEntity.of(row), ProductItemEntity.of(row)) } 53 | .flow() 54 | .toList() 55 | .let { orderFromList(it) } 56 | .also { 57 | log.info("getOrderWithProductItemsByID order: $it") 58 | observation.highCardinalityKeyValue("order", it.toString()) 59 | } 60 | } 61 | 62 | override suspend fun findOrderByID(id: UUID): Order = coroutineScopeWithObservation(FIND_ORDER_BY_ID, or) { observation -> 63 | val query = Query.query(Criteria.where(ID).`is`(id)) 64 | entityTemplate.selectOne(query, OrderEntity::class.java).awaitSingleOrNull()?.toOrder() 65 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 66 | ?: throw OrderNotFoundException(id) 67 | } 68 | 69 | override suspend fun insert(order: Order): Order = coroutineScopeWithObservation(INSERT, or) { observation -> 70 | entityTemplate.insert(order.toEntity()).awaitSingle().toOrder() 71 | .also { 72 | log.info("inserted order: $it") 73 | observation.highCardinalityKeyValue("order", it.toString()) 74 | } 75 | } 76 | 77 | override suspend fun update(order: Order): Order = coroutineScopeWithObservation(UPDATE, or) { observation -> 78 | entityTemplate.update(order.toEntity()).awaitSingle().toOrder() 79 | .also { 80 | log.info("updated order: $it") 81 | observation.highCardinalityKeyValue("order", it.toString()) 82 | } 83 | } 84 | 85 | private fun orderFromList(list: List>): Order = Order( 86 | id = list[0].first.id.toString(), 87 | email = list[0].first.email ?: "", 88 | status = list[0].first.status, 89 | address = list[0].first.address ?: "", 90 | version = list[0].first.version, 91 | paymentId = list[0].first.paymentId ?: "", 92 | createdAt = list[0].first.createdAt, 93 | updatedAt = list[0].first.updatedAt, 94 | productItems = getProductItemsList(list).associateBy { it.id }.toMutableMap() 95 | ) 96 | 97 | private fun getProductItemsList(list: List>): MutableList { 98 | if (list[0].second.id == null) return mutableListOf() 99 | return list.map { item -> item.second.toProductItem() }.toMutableList() 100 | } 101 | 102 | companion object { 103 | private val log = LoggerFactory.getLogger(OrderBaseRepositoryImpl::class.java) 104 | 105 | private const val UPDATE = "OrderBaseRepository.update" 106 | private const val INSERT = "OrderBaseRepository.insert" 107 | private const val FIND_ORDER_BY_ID = "OrderBaseRepository.findOrderByID" 108 | private const val GET_ORDER_WITH_PRODUCTS_BY_ID = "OrderBaseRepository.getOrderWithProductsByID" 109 | private const val UPDATE_VERSION = "OrderBaseRepository.updateVersion" 110 | } 111 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/events/OrderEventProcessorImpl.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.events 2 | 3 | import com.alexbryksin.ordersmicroservice.order.exceptions.AlreadyProcessedVersionException 4 | import com.alexbryksin.ordersmicroservice.order.exceptions.InvalidVersionException 5 | import com.alexbryksin.ordersmicroservice.order.repository.OrderMongoRepository 6 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 7 | import io.micrometer.observation.ObservationRegistry 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.stereotype.Service 10 | 11 | 12 | @Service 13 | class OrderEventProcessorImpl( 14 | private val orderMongoRepository: OrderMongoRepository, 15 | private val or: ObservationRegistry, 16 | ) : OrderEventProcessor { 17 | 18 | override suspend fun on(orderCreatedEvent: OrderCreatedEvent): Unit = coroutineScopeWithObservation(ON_ORDER_CREATED_EVENT, or) { observation -> 19 | orderMongoRepository.insert(orderCreatedEvent.order).also { 20 | log.info("created order: $it") 21 | observation.highCardinalityKeyValue("order", it.toString()) 22 | } 23 | } 24 | 25 | override suspend fun on(productItemAddedEvent: ProductItemAddedEvent): Unit = 26 | coroutineScopeWithObservation(ON_ORDER_PRODUCT_ADDED_EVENT, or) { observation -> 27 | val order = orderMongoRepository.getByID(productItemAddedEvent.orderId) 28 | validateVersion(order.id, order.version, productItemAddedEvent.version) 29 | 30 | order.addProductItem(productItemAddedEvent.productItem) 31 | order.version = productItemAddedEvent.version 32 | 33 | orderMongoRepository.update(order).also { 34 | log.info("productItemAddedEvent updatedOrder: $it") 35 | observation.highCardinalityKeyValue("order", it.toString()) 36 | } 37 | } 38 | 39 | override suspend fun on(productItemRemovedEvent: ProductItemRemovedEvent): Unit = 40 | coroutineScopeWithObservation(ON_ORDER_PRODUCT_REMOVED_EVENT, or) { observation -> 41 | val order = orderMongoRepository.getByID(productItemRemovedEvent.orderId) 42 | validateVersion(order.id, order.version, productItemRemovedEvent.version) 43 | 44 | order.removeProductItem(productItemRemovedEvent.productItemId) 45 | order.version = productItemRemovedEvent.version 46 | 47 | orderMongoRepository.update(order).also { 48 | log.info("productItemRemovedEvent updatedOrder: $it") 49 | observation.highCardinalityKeyValue("order", it.toString()) 50 | } 51 | } 52 | 53 | override suspend fun on(orderPaidEvent: OrderPaidEvent): Unit = coroutineScopeWithObservation(ON_ORDER_PAID_EVENT, or) { observation -> 54 | val order = orderMongoRepository.getByID(orderPaidEvent.orderId) 55 | validateVersion(order.id, order.version, orderPaidEvent.version) 56 | 57 | order.pay(orderPaidEvent.paymentId) 58 | order.version = orderPaidEvent.version 59 | 60 | orderMongoRepository.update(order).also { 61 | log.info("orderPaidEvent updatedOrder: $it") 62 | observation.highCardinalityKeyValue("order", it.toString()) 63 | } 64 | } 65 | 66 | override suspend fun on(orderCancelledEvent: OrderCancelledEvent): Unit = coroutineScopeWithObservation(ON_ORDER_CANCELLED_EVENT, or) { observation -> 67 | val order = orderMongoRepository.getByID(orderCancelledEvent.orderId) 68 | validateVersion(order.id, order.version, orderCancelledEvent.version) 69 | 70 | order.cancel() 71 | order.version = orderCancelledEvent.version 72 | 73 | orderMongoRepository.update(order).also { 74 | log.info("orderCancelledEvent updatedOrder: $it") 75 | observation.highCardinalityKeyValue("order", it.toString()) 76 | } 77 | } 78 | 79 | override suspend fun on(orderSubmittedEvent: OrderSubmittedEvent): Unit = coroutineScopeWithObservation(ON_ORDER_SUBMITTED_EVENT, or) { observation -> 80 | val order = orderMongoRepository.getByID(orderSubmittedEvent.orderId) 81 | validateVersion(order.id, order.version, orderSubmittedEvent.version) 82 | 83 | order.submit() 84 | order.version = orderSubmittedEvent.version 85 | 86 | orderMongoRepository.update(order).also { 87 | log.info("orderSubmittedEvent updatedOrder: $it") 88 | observation.highCardinalityKeyValue("order", it.toString()) 89 | } 90 | } 91 | 92 | override suspend fun on(orderCompletedEvent: OrderCompletedEvent): Unit = coroutineScopeWithObservation(ON_ORDER_COMPLETED_EVENT, or) { observation -> 93 | val order = orderMongoRepository.getByID(orderCompletedEvent.orderId) 94 | validateVersion(order.id, order.version, orderCompletedEvent.version) 95 | 96 | order.complete() 97 | order.version = orderCompletedEvent.version 98 | 99 | orderMongoRepository.update(order).also { 100 | log.info("orderCompletedEvent updatedOrder: $it") 101 | observation.highCardinalityKeyValue("order", it.toString()) 102 | } 103 | } 104 | 105 | private fun validateVersion(id: Any, currentDomainVersion: Long, eventVersion: Long) { 106 | log.info("validating version for id: $id, currentDomainVersion: $currentDomainVersion, eventVersion: $eventVersion") 107 | if (currentDomainVersion >= eventVersion) { 108 | log.warn("currentDomainVersion >= eventVersion validating version for id: $id, currentDomainVersion: $currentDomainVersion, eventVersion: $eventVersion") 109 | throw AlreadyProcessedVersionException(id, eventVersion) 110 | } 111 | if ((currentDomainVersion + 1) < eventVersion) { 112 | log.warn("currentDomainVersion + 1) < eventVersion validating version for id: $id, currentDomainVersion: $currentDomainVersion, eventVersion: $eventVersion") 113 | throw InvalidVersionException(eventVersion) 114 | } 115 | } 116 | 117 | companion object { 118 | private val log = LoggerFactory.getLogger(OrderEventProcessorImpl::class.java) 119 | 120 | private const val ON_ORDER_COMPLETED_EVENT = "OrderEventProcessor.OrderCompletedEvent" 121 | private const val ON_ORDER_SUBMITTED_EVENT = "OrderEventProcessor.OrderSubmittedEvent" 122 | private const val ON_ORDER_CANCELLED_EVENT = "OrderEventProcessor.OrderCancelledEvent" 123 | private const val ON_ORDER_PAID_EVENT = "OrderEventProcessor.OrderPaidEvent" 124 | private const val ON_ORDER_PRODUCT_REMOVED_EVENT = "OrderEventProcessor.ProductItemRemovedEvent" 125 | private const val ON_ORDER_PRODUCT_ADDED_EVENT = "OrderEventProcessor.ProductItemAddedEvent" 126 | private const val ON_ORDER_CREATED_EVENT = "OrderEventProcessor.OrderCreatedEvent" 127 | } 128 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/controllers/OrderController.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.controllers 2 | 3 | import com.alexbryksin.ordersmicroservice.order.dto.* 4 | import com.alexbryksin.ordersmicroservice.order.service.OrderService 5 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 6 | import io.micrometer.observation.ObservationRegistry 7 | import io.swagger.v3.oas.annotations.Operation 8 | import jakarta.validation.Valid 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.data.domain.PageRequest 11 | import org.springframework.http.HttpStatus 12 | import org.springframework.http.ResponseEntity 13 | import org.springframework.web.bind.annotation.* 14 | import java.util.* 15 | 16 | 17 | @RestController 18 | @RequestMapping(path = ["/api/v1/orders"]) 19 | class OrderController(private val orderService: OrderService, private val or: ObservationRegistry) { 20 | 21 | @GetMapping 22 | @Operation(method = "getOrders", summary = "get order with pagination", operationId = "getOrders") 23 | suspend fun getOrders( 24 | @RequestParam(name = "page", defaultValue = "0") page: Int, 25 | @RequestParam(name = "size", defaultValue = "20") size: Int, 26 | ) = coroutineScopeWithObservation(GET_ORDERS, or) { observation -> 27 | ResponseEntity.ok() 28 | .body(orderService.getAllOrders(PageRequest.of(page, size)) 29 | .map { it.toSuccessResponse() } 30 | .also { response -> observation.highCardinalityKeyValue("response", response.toString()) } 31 | ) 32 | } 33 | 34 | @GetMapping(path = ["{id}"]) 35 | @Operation(method = "getOrderByID", summary = "get order by id", operationId = "getOrderByID") 36 | suspend fun getOrderByID(@PathVariable id: String) = coroutineScopeWithObservation(GET_ORDER_BY_ID, or) { observation -> 37 | ResponseEntity.ok().body(orderService.getOrderWithProductsByID(UUID.fromString(id)).toSuccessResponse()) 38 | .also { response -> 39 | observation.highCardinalityKeyValue("response", response.toString()) 40 | log.info("getOrderByID response: $response") 41 | } 42 | } 43 | 44 | @PostMapping 45 | @Operation(method = "createOrder", summary = "create new order", operationId = "createOrder") 46 | suspend fun createOrder(@Valid @RequestBody createOrderDTO: CreateOrderDTO) = coroutineScopeWithObservation(CREATE_ORDER, or) { observation -> 47 | ResponseEntity.status(HttpStatus.CREATED).body(orderService.createOrder(createOrderDTO.toOrder()).toSuccessResponse()) 48 | .also { 49 | log.info("created order: $it") 50 | observation.highCardinalityKeyValue("response", it.toString()) 51 | } 52 | } 53 | 54 | @PutMapping(path = ["/add/{id}"]) 55 | @Operation(method = "addProductItem", summary = "add to the order product item", operationId = "addProductItem") 56 | suspend fun addProductItem( 57 | @PathVariable id: UUID, 58 | @Valid @RequestBody dto: CreateProductItemDTO 59 | ) = coroutineScopeWithObservation(ADD_PRODUCT, or) { observation -> 60 | ResponseEntity.ok().body(orderService.addProductItem(dto.toProductItem(id))) 61 | .also { 62 | observation.highCardinalityKeyValue("CreateProductItemDTO", dto.toString()) 63 | observation.highCardinalityKeyValue("id", id.toString()) 64 | log.info("addProductItem id: $id, dto: $dto") 65 | } 66 | } 67 | 68 | @PutMapping(path = ["/remove/{orderId}/{productItemId}"]) 69 | @Operation(method = "removeProductItem", summary = "remove product from the order", operationId = "removeProductItem") 70 | suspend fun removeProductItem( 71 | @PathVariable orderId: UUID, 72 | @PathVariable productItemId: UUID 73 | ) = coroutineScopeWithObservation(REMOVE_PRODUCT, or) { observation -> 74 | ResponseEntity.ok().body(orderService.removeProductItem(orderId, productItemId)) 75 | .also { 76 | observation.highCardinalityKeyValue("productItemId", productItemId.toString()) 77 | observation.highCardinalityKeyValue("orderId", orderId.toString()) 78 | log.info("removeProductItem orderId: $orderId, productItemId: $productItemId") 79 | } 80 | } 81 | 82 | @PutMapping(path = ["/pay/{id}"]) 83 | @Operation(method = "payOrder", summary = "pay order", operationId = "payOrder") 84 | suspend fun payOrder(@PathVariable id: UUID, @Valid @RequestBody dto: PayOrderDTO) = coroutineScopeWithObservation(PAY_ORDER, or) { observation -> 85 | ResponseEntity.ok().body(orderService.pay(id, dto.paymentId).toSuccessResponse()) 86 | .also { 87 | observation.highCardinalityKeyValue("response", it.toString()) 88 | log.info("payOrder result: $it") 89 | } 90 | } 91 | 92 | @PutMapping(path = ["/cancel/{id}"]) 93 | @Operation(method = "cancelOrder", summary = "cancel order", operationId = "cancelOrder") 94 | suspend fun cancelOrder(@PathVariable id: UUID, @Valid @RequestBody dto: CancelOrderDTO) = coroutineScopeWithObservation(CANCEL_ORDER, or) { observation -> 95 | ResponseEntity.ok().body(orderService.cancel(id, dto.reason).toSuccessResponse()) 96 | .also { 97 | observation.highCardinalityKeyValue("response", it.toString()) 98 | log.info("cancelOrder result: $it") 99 | } 100 | } 101 | 102 | @PutMapping(path = ["/submit/{id}"]) 103 | @Operation(method = "submitOrder", summary = "submit order", operationId = "submitOrder") 104 | suspend fun submitOrder(@PathVariable id: UUID) = coroutineScopeWithObservation(SUBMIT_ORDER, or) { observation -> 105 | ResponseEntity.ok().body(orderService.submit(id).toSuccessResponse()) 106 | .also { 107 | observation.highCardinalityKeyValue("response", it.toString()) 108 | log.info("submitOrder result: $it") 109 | } 110 | } 111 | 112 | @PutMapping(path = ["/complete/{id}"]) 113 | @Operation(method = "completeOrder", summary = "complete order", operationId = "completeOrder") 114 | suspend fun completeOrder(@PathVariable id: UUID) = coroutineScopeWithObservation(COMPLETE_ORDER, or) { observation -> 115 | ResponseEntity.ok().body(orderService.complete(id).toSuccessResponse()) 116 | .also { 117 | observation.highCardinalityKeyValue("response", it.toString()) 118 | log.info("completeOrder result: $it") 119 | } 120 | } 121 | 122 | 123 | companion object { 124 | private val log = LoggerFactory.getLogger(OrderController::class.java) 125 | 126 | private const val COMPLETE_ORDER = "OrderController.completeOrder" 127 | private const val SUBMIT_ORDER = "OrderController.submitOrder" 128 | private const val CANCEL_ORDER = "OrderController.cancelOrder" 129 | private const val PAY_ORDER = "OrderController.payOrder" 130 | private const val CREATE_ORDER = "OrderController.createOrder" 131 | private const val REMOVE_PRODUCT = "OrderController.removeProductItem" 132 | private const val ADD_PRODUCT = "OrderController.addProductItem" 133 | private const val GET_ORDER_BY_ID = "OrderController.getOrderByID" 134 | private const val GET_ORDERS = "OrderController.getOrders" 135 | } 136 | } -------------------------------------------------------------------------------- /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 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 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 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/service/OrderServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.service 2 | 3 | import com.alexbryksin.ordersmicroservice.configuration.KafkaTopicsConfiguration 4 | import com.alexbryksin.ordersmicroservice.eventPublisher.EventsPublisher 5 | import com.alexbryksin.ordersmicroservice.exceptions.UnknownEventTypeException 6 | import com.alexbryksin.ordersmicroservice.order.domain.* 7 | import com.alexbryksin.ordersmicroservice.order.events.OrderCancelledEvent.Companion.ORDER_CANCELLED_EVENT 8 | import com.alexbryksin.ordersmicroservice.order.events.OrderCompletedEvent.Companion.ORDER_COMPLETED_EVENT 9 | import com.alexbryksin.ordersmicroservice.order.events.OrderCreatedEvent.Companion.ORDER_CREATED_EVENT 10 | import com.alexbryksin.ordersmicroservice.order.events.OrderPaidEvent.Companion.ORDER_PAID_EVENT 11 | import com.alexbryksin.ordersmicroservice.order.events.OrderSubmittedEvent.Companion.ORDER_SUBMITTED_EVENT 12 | import com.alexbryksin.ordersmicroservice.order.events.ProductItemAddedEvent.Companion.PRODUCT_ITEM_ADDED_EVENT 13 | import com.alexbryksin.ordersmicroservice.order.events.ProductItemRemovedEvent.Companion.PRODUCT_ITEM_REMOVED_EVENT 14 | import com.alexbryksin.ordersmicroservice.order.exceptions.ProductItemNotFoundException 15 | import com.alexbryksin.ordersmicroservice.order.repository.OrderMongoRepository 16 | import com.alexbryksin.ordersmicroservice.order.repository.OrderOutboxRepository 17 | import com.alexbryksin.ordersmicroservice.order.repository.OrderRepository 18 | import com.alexbryksin.ordersmicroservice.order.repository.ProductItemRepository 19 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 20 | import io.micrometer.observation.ObservationRegistry 21 | import org.slf4j.LoggerFactory 22 | import org.springframework.data.domain.Page 23 | import org.springframework.data.domain.Pageable 24 | import org.springframework.stereotype.Service 25 | import org.springframework.transaction.annotation.Transactional 26 | import org.springframework.transaction.reactive.TransactionalOperator 27 | import org.springframework.transaction.reactive.executeAndAwait 28 | import java.util.* 29 | 30 | 31 | @Service 32 | class OrderServiceImpl( 33 | private val orderRepository: OrderRepository, 34 | private val productItemRepository: ProductItemRepository, 35 | private val outboxRepository: OrderOutboxRepository, 36 | private val orderMongoRepository: OrderMongoRepository, 37 | private val txOp: TransactionalOperator, 38 | private val eventsPublisher: EventsPublisher, 39 | private val kafkaTopicsConfiguration: KafkaTopicsConfiguration, 40 | private val or: ObservationRegistry, 41 | private val outboxEventSerializer: OutboxEventSerializer 42 | ) : OrderService { 43 | 44 | override suspend fun createOrder(order: Order): Order = coroutineScopeWithObservation(CREATE, or) { observation -> 45 | txOp.executeAndAwait { 46 | orderRepository.insert(order).let { 47 | val productItemsEntityList = ProductItemEntity.listOf(order.productsList(), UUID.fromString(it.id)) 48 | val insertedItems = productItemRepository.insertAll(productItemsEntityList).toList() 49 | 50 | it.addProductItems(insertedItems.map { item -> item.toProductItem() }) 51 | 52 | Pair(it, outboxRepository.save(outboxEventSerializer.orderCreatedEventOf(it))) 53 | } 54 | }.run { 55 | observation.highCardinalityKeyValue("order", first.toString()) 56 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 57 | 58 | publishOutboxEvent(second) 59 | first 60 | } 61 | } 62 | 63 | override suspend fun addProductItem(productItem: ProductItem): Unit = coroutineScopeWithObservation(ADD_PRODUCT, or) { observation -> 64 | txOp.executeAndAwait { 65 | val order = orderRepository.findOrderByID(UUID.fromString(productItem.orderId)) 66 | order.incVersion() 67 | 68 | val updatedProductItem = productItemRepository.upsert(productItem) 69 | 70 | val savedRecord = outboxRepository.save( 71 | outboxEventSerializer.productItemAddedEventOf( 72 | order, 73 | productItem.copy(version = updatedProductItem.version).toEntity() 74 | ) 75 | ) 76 | 77 | orderRepository.updateVersion(UUID.fromString(order.id), order.version) 78 | .also { result -> log.info("addOrderItem result: $result, version: ${order.version}") } 79 | 80 | savedRecord 81 | }.run { 82 | observation.highCardinalityKeyValue("outboxEvent", this.toString()) 83 | publishOutboxEvent(this) 84 | } 85 | } 86 | 87 | override suspend fun removeProductItem(orderID: UUID, productItemId: UUID): Unit = coroutineScopeWithObservation(REMOVE_PRODUCT, or) { observation -> 88 | txOp.executeAndAwait { 89 | if (!productItemRepository.existsById(productItemId)) throw ProductItemNotFoundException(productItemId) 90 | 91 | val order = orderRepository.findOrderByID(orderID) 92 | productItemRepository.deleteById(productItemId) 93 | 94 | order.incVersion() 95 | 96 | val savedRecord = outboxRepository.save(outboxEventSerializer.productItemRemovedEventOf(order, productItemId)) 97 | 98 | orderRepository.updateVersion(UUID.fromString(order.id), order.version) 99 | .also { log.info("removeProductItem update order result: $it, version: ${order.version}") } 100 | 101 | savedRecord 102 | }.run { 103 | observation.highCardinalityKeyValue("outboxEvent", this.toString()) 104 | publishOutboxEvent(this) 105 | } 106 | } 107 | 108 | override suspend fun pay(id: UUID, paymentId: String): Order = coroutineScopeWithObservation(PAY, or) { observation -> 109 | txOp.executeAndAwait { 110 | val order = orderRepository.getOrderWithProductItemsByID(id) 111 | order.pay(paymentId) 112 | 113 | val updatedOrder = orderRepository.update(order) 114 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderPaidEventOf(updatedOrder, paymentId))) 115 | }.run { 116 | observation.highCardinalityKeyValue("order", first.toString()) 117 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 118 | 119 | publishOutboxEvent(second) 120 | first 121 | } 122 | } 123 | 124 | override suspend fun cancel(id: UUID, reason: String?): Order = coroutineScopeWithObservation(CANCEL, or) { observation -> 125 | txOp.executeAndAwait { 126 | val order = orderRepository.findOrderByID(id) 127 | order.cancel() 128 | 129 | val updatedOrder = orderRepository.update(order) 130 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderCancelledEventOf(updatedOrder, reason))) 131 | }.run { 132 | observation.highCardinalityKeyValue("order", first.toString()) 133 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 134 | 135 | publishOutboxEvent(second) 136 | first 137 | } 138 | } 139 | 140 | override suspend fun submit(id: UUID): Order = coroutineScopeWithObservation(SUBMIT, or) { observation -> 141 | txOp.executeAndAwait { 142 | val order = orderRepository.getOrderWithProductItemsByID(id) 143 | order.submit() 144 | 145 | val updatedOrder = orderRepository.update(order) 146 | updatedOrder.addProductItems(order.productsList()) 147 | 148 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderSubmittedEventOf(updatedOrder))) 149 | }.run { 150 | observation.highCardinalityKeyValue("order", first.toString()) 151 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 152 | 153 | publishOutboxEvent(second) 154 | first 155 | } 156 | } 157 | 158 | override suspend fun complete(id: UUID): Order = coroutineScopeWithObservation(COMPLETE, or) { observation -> 159 | txOp.executeAndAwait { 160 | val order = orderRepository.findOrderByID(id) 161 | order.complete() 162 | 163 | val updatedOrder = orderRepository.update(order) 164 | log.info("order submitted: ${updatedOrder.status} for id: $id") 165 | 166 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderCompletedEventOf(updatedOrder))) 167 | }.run { 168 | observation.highCardinalityKeyValue("order", first.toString()) 169 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 170 | 171 | publishOutboxEvent(second) 172 | first 173 | } 174 | } 175 | 176 | @Transactional(readOnly = true) 177 | override suspend fun getOrderWithProductsByID(id: UUID): Order = coroutineScopeWithObservation(GET_ORDER_WITH_PRODUCTS_BY_ID, or) { observation -> 178 | orderRepository.getOrderWithProductItemsByID(id).also { observation.highCardinalityKeyValue("order", it.toString()) } 179 | } 180 | 181 | override suspend fun getAllOrders(pageable: Pageable): Page = coroutineScopeWithObservation(GET_ALL_ORDERS, or) { observation -> 182 | orderMongoRepository.getAllOrders(pageable).also { observation.highCardinalityKeyValue("pageResult", it.toString()) } 183 | } 184 | 185 | override suspend fun deleteOutboxRecordsWithLock() = coroutineScopeWithObservation(DELETE_OUTBOX_RECORD_WITH_LOCK, or) { observation -> 186 | outboxRepository.deleteOutboxRecordsWithLock { 187 | observation.highCardinalityKeyValue("outboxEvent", it.toString()) 188 | eventsPublisher.publish(getTopicName(it.eventType), it) 189 | } 190 | } 191 | 192 | override suspend fun getOrderByID(id: UUID): Order = coroutineScopeWithObservation(GET_ORDER_BY_ID, or) { observation -> 193 | orderMongoRepository.getByID(id.toString()) 194 | .also { log.info("getOrderByID: $it") } 195 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 196 | } 197 | 198 | private suspend fun publishOutboxEvent(event: OutboxRecord) = coroutineScopeWithObservation(PUBLISH_OUTBOX_EVENT, or) { observation -> 199 | try { 200 | log.info("publishing outbox event: $event") 201 | 202 | outboxRepository.deleteOutboxRecordByID(event.eventId!!) { 203 | eventsPublisher.publish(getTopicName(event.eventType), event.aggregateId.toString(), event) 204 | } 205 | 206 | log.info("outbox event published and deleted: $event") 207 | observation.highCardinalityKeyValue("event", event.toString()) 208 | } catch (ex: Exception) { 209 | log.error("exception while publishing outbox event: ${ex.localizedMessage}") 210 | observation.error(ex) 211 | } 212 | } 213 | 214 | 215 | private fun getTopicName(eventType: String?) = when (eventType) { 216 | ORDER_CREATED_EVENT -> kafkaTopicsConfiguration.orderCreated.name 217 | PRODUCT_ITEM_ADDED_EVENT -> kafkaTopicsConfiguration.productAdded.name 218 | PRODUCT_ITEM_REMOVED_EVENT -> kafkaTopicsConfiguration.productRemoved.name 219 | ORDER_CANCELLED_EVENT -> kafkaTopicsConfiguration.orderCancelled.name 220 | ORDER_PAID_EVENT -> kafkaTopicsConfiguration.orderPaid.name 221 | ORDER_SUBMITTED_EVENT -> kafkaTopicsConfiguration.orderSubmitted.name 222 | ORDER_COMPLETED_EVENT -> kafkaTopicsConfiguration.orderCompleted.name 223 | else -> throw UnknownEventTypeException(eventType) 224 | } 225 | 226 | 227 | companion object { 228 | private val log = LoggerFactory.getLogger(OrderServiceImpl::class.java) 229 | 230 | private const val PUBLISH_OUTBOX_EVENT = "OrderService.getAllOrders" 231 | private const val GET_ORDER_BY_ID = "OrderService.getOrderByID" 232 | private const val DELETE_OUTBOX_RECORD_WITH_LOCK = "OrderService.deleteOutboxRecordsWithLock" 233 | private const val GET_ALL_ORDERS = "OrderService.getAllOrders" 234 | private const val GET_ORDER_WITH_PRODUCTS_BY_ID = "OrderService.getOrderWithProductsByID" 235 | private const val COMPLETE = "OrderService.complete" 236 | private const val SUBMIT = "OrderService.submit" 237 | private const val CANCEL = "OrderService.cancel" 238 | private const val PAY = "OrderService.pay" 239 | private const val ADD_PRODUCT = "OrderService.addProduct" 240 | private const val REMOVE_PRODUCT = "OrderService.removeProduct" 241 | private const val CREATE = "OrderService.create" 242 | } 243 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/alexbryksin/ordersmicroservice/order/consumer/OrderConsumer.kt: -------------------------------------------------------------------------------- 1 | package com.alexbryksin.ordersmicroservice.order.consumer 2 | 3 | 4 | import com.alexbryksin.ordersmicroservice.configuration.KafkaTopicsConfiguration 5 | import com.alexbryksin.ordersmicroservice.eventPublisher.EventsPublisher 6 | import com.alexbryksin.ordersmicroservice.exceptions.UnknownEventTypeException 7 | import com.alexbryksin.ordersmicroservice.order.domain.OutboxRecord 8 | import com.alexbryksin.ordersmicroservice.order.events.* 9 | import com.alexbryksin.ordersmicroservice.order.events.OrderCancelledEvent.Companion.ORDER_CANCELLED_EVENT 10 | import com.alexbryksin.ordersmicroservice.order.events.OrderCompletedEvent.Companion.ORDER_COMPLETED_EVENT 11 | import com.alexbryksin.ordersmicroservice.order.events.OrderCreatedEvent.Companion.ORDER_CREATED_EVENT 12 | import com.alexbryksin.ordersmicroservice.order.events.OrderPaidEvent.Companion.ORDER_PAID_EVENT 13 | import com.alexbryksin.ordersmicroservice.order.events.OrderSubmittedEvent.Companion.ORDER_SUBMITTED_EVENT 14 | import com.alexbryksin.ordersmicroservice.order.events.ProductItemAddedEvent.Companion.PRODUCT_ITEM_ADDED_EVENT 15 | import com.alexbryksin.ordersmicroservice.order.events.ProductItemRemovedEvent.Companion.PRODUCT_ITEM_REMOVED_EVENT 16 | import com.alexbryksin.ordersmicroservice.order.exceptions.AlreadyProcessedVersionException 17 | import com.alexbryksin.ordersmicroservice.order.exceptions.InvalidVersionException 18 | import com.alexbryksin.ordersmicroservice.order.exceptions.OrderNotFoundException 19 | import com.alexbryksin.ordersmicroservice.utils.serializer.SerializationException 20 | import com.alexbryksin.ordersmicroservice.utils.serializer.Serializer 21 | import com.alexbryksin.ordersmicroservice.utils.tracing.coroutineScopeWithObservation 22 | import io.micrometer.observation.ObservationRegistry 23 | import kotlinx.coroutines.reactor.awaitSingle 24 | import kotlinx.coroutines.reactor.mono 25 | import kotlinx.coroutines.runBlocking 26 | import org.apache.kafka.clients.consumer.ConsumerRecord 27 | import org.slf4j.LoggerFactory 28 | import org.springframework.kafka.annotation.KafkaListener 29 | import org.springframework.kafka.support.Acknowledgment 30 | import org.springframework.stereotype.Component 31 | import reactor.util.retry.Retry 32 | import java.time.Duration 33 | 34 | 35 | @Component 36 | class OrderConsumer( 37 | private val kafkaTopicsConfiguration: KafkaTopicsConfiguration, 38 | private val serializer: Serializer, 39 | private val eventsPublisher: EventsPublisher, 40 | private val orderEventProcessor: OrderEventProcessor, 41 | private val or: ObservationRegistry, 42 | ) { 43 | 44 | @KafkaListener( 45 | groupId = "\${kafka.consumer-group-id:order-service-group-id}", 46 | topics = [ 47 | "\${topics.orderCreated.name}", 48 | "\${topics.productAdded.name}", 49 | "\${topics.productRemoved.name}", 50 | "\${topics.orderPaid.name}", 51 | "\${topics.orderCancelled.name}", 52 | "\${topics.orderSubmitted.name}", 53 | "\${topics.orderCompleted.name}", 54 | ], 55 | id = "orders-consumer" 56 | ) 57 | fun process(ack: Acknowledgment, consumerRecord: ConsumerRecord) = runBlocking { 58 | coroutineScopeWithObservation(PROCESS, or) { observation -> 59 | try { 60 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 61 | 62 | processOutboxRecord(serializer.deserialize(consumerRecord.value(), OutboxRecord::class.java)) 63 | ack.acknowledge() 64 | 65 | log.info("committed record: ${getConsumerRecordInfo(consumerRecord)}") 66 | } catch (ex: Exception) { 67 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 68 | observation.error(ex) 69 | 70 | if (ex is SerializationException || ex is UnknownEventTypeException || ex is AlreadyProcessedVersionException) { 71 | log.error("ack not serializable, unknown or already processed record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 72 | ack.acknowledge() 73 | return@coroutineScopeWithObservation 74 | } 75 | 76 | if (ex is InvalidVersionException || ex is NoSuchElementException || ex is OrderNotFoundException) { 77 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, 1) 78 | ack.acknowledge() 79 | log.warn("ack concurrency write or version exception ${ex.localizedMessage}") 80 | return@coroutineScopeWithObservation 81 | } 82 | 83 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, 1) 84 | ack.acknowledge() 85 | log.error("ack exception while processing record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}", ex) 86 | } 87 | } 88 | } 89 | 90 | 91 | @KafkaListener(groupId = "\${kafka.consumer-group-id:order-service-group-id}", topics = ["\${topics.retryTopic.name}"], id = "orders-retry-consumer") 92 | fun processRetry(ack: Acknowledgment, consumerRecord: ConsumerRecord): Unit = runBlocking { 93 | coroutineScopeWithObservation(PROCESS_RETRY, or) { observation -> 94 | try { 95 | log.warn("processing retry topic record >>>>>>>>>>>>> : ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 96 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 97 | 98 | processOutboxRecord(serializer.deserialize(consumerRecord.value(), OutboxRecord::class.java)) 99 | ack.acknowledge() 100 | 101 | log.info("committed retry record: ${getConsumerRecordInfo(consumerRecord)}") 102 | } catch (ex: Exception) { 103 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 104 | observation.error(ex) 105 | 106 | val currentRetry = String(consumerRecord.headers().lastHeader(RETRY_COUNT_HEADER).value()).toInt() 107 | observation.highCardinalityKeyValue("currentRetry", currentRetry.toString()) 108 | 109 | if (ex is InvalidVersionException || ex is NoSuchElementException || ex is OrderNotFoundException) { 110 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, currentRetry) 111 | log.warn("ack concurrency write or version exception ${ex.localizedMessage},record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 112 | ack.acknowledge() 113 | return@coroutineScopeWithObservation 114 | } 115 | 116 | if (currentRetry > MAX_RETRY_COUNT) { 117 | publishRetryTopic(kafkaTopicsConfiguration.deadLetterQueue.name, consumerRecord, currentRetry + 1) 118 | ack.acknowledge() 119 | log.error("MAX_RETRY_COUNT exceed, send record to DLQ: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 120 | return@coroutineScopeWithObservation 121 | } 122 | 123 | if (ex is SerializationException || ex is UnknownEventTypeException || ex is AlreadyProcessedVersionException) { 124 | ack.acknowledge() 125 | log.error("commit not serializable, unknown or already processed record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 126 | return@coroutineScopeWithObservation 127 | } 128 | 129 | log.error("exception while processing: ${ex.localizedMessage}, record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 130 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, currentRetry + 1) 131 | ack.acknowledge() 132 | } 133 | } 134 | } 135 | 136 | 137 | private suspend fun publishRetryTopic(topic: String, record: ConsumerRecord, retryCount: Int) = 138 | coroutineScopeWithObservation(PUBLISH_RETRY_TOPIC, or) { observation -> 139 | observation.highCardinalityKeyValue("topic", record.topic()) 140 | .highCardinalityKeyValue("key", record.key()) 141 | .highCardinalityKeyValue("offset", record.offset().toString()) 142 | .highCardinalityKeyValue("value", String(record.value())) 143 | .highCardinalityKeyValue("retryCount", retryCount.toString()) 144 | 145 | record.headers().remove(RETRY_COUNT_HEADER) 146 | record.headers().add(RETRY_COUNT_HEADER, retryCount.toString().toByteArray()) 147 | 148 | mono { publishRetryRecord(topic, record, retryCount) } 149 | .retryWhen(Retry.backoff(PUBLISH_RETRY_COUNT, Duration.ofMillis(PUBLISH_RETRY_BACKOFF_DURATION_MILLIS)) 150 | .filter { it is SerializationException }) 151 | .awaitSingle() 152 | } 153 | 154 | 155 | private suspend fun publishRetryRecord(topic: String, record: ConsumerRecord, retryCount: Int) = 156 | coroutineScopeWithObservation(PUBLISH_RETRY_RECORD, or) { observation -> 157 | log.info("publishing retry record: ${String(record.value())}, retryCount: $retryCount") 158 | observation.highCardinalityKeyValue("headers", record.headers().toString()) 159 | observation.highCardinalityKeyValue("retryCount", retryCount.toString()) 160 | 161 | eventsPublisher.publishRetryRecord(topic, record.key(), record) 162 | } 163 | 164 | 165 | private suspend fun processOutboxRecord(outboxRecord: OutboxRecord) = coroutineScopeWithObservation(PROCESS_OUTBOX_RECORD, or) { observation -> 166 | observation.highCardinalityKeyValue("outboxRecord", outboxRecord.toString()) 167 | 168 | when (outboxRecord.eventType) { 169 | ORDER_CREATED_EVENT -> orderEventProcessor.on( 170 | serializer.deserialize( 171 | outboxRecord.data, 172 | OrderCreatedEvent::class.java 173 | ) 174 | ) 175 | 176 | PRODUCT_ITEM_ADDED_EVENT -> orderEventProcessor.on( 177 | serializer.deserialize( 178 | outboxRecord.data, 179 | ProductItemAddedEvent::class.java 180 | ) 181 | ) 182 | 183 | PRODUCT_ITEM_REMOVED_EVENT -> orderEventProcessor.on( 184 | serializer.deserialize( 185 | outboxRecord.data, 186 | ProductItemRemovedEvent::class.java 187 | ) 188 | ) 189 | 190 | ORDER_PAID_EVENT -> orderEventProcessor.on( 191 | serializer.deserialize( 192 | outboxRecord.data, 193 | OrderPaidEvent::class.java 194 | ) 195 | ) 196 | 197 | ORDER_CANCELLED_EVENT -> orderEventProcessor.on( 198 | serializer.deserialize( 199 | outboxRecord.data, 200 | OrderCancelledEvent::class.java 201 | ) 202 | ) 203 | 204 | ORDER_SUBMITTED_EVENT -> orderEventProcessor.on( 205 | serializer.deserialize( 206 | outboxRecord.data, 207 | OrderSubmittedEvent::class.java 208 | ) 209 | ) 210 | 211 | ORDER_COMPLETED_EVENT -> orderEventProcessor.on( 212 | serializer.deserialize( 213 | outboxRecord.data, 214 | OrderCompletedEvent::class.java 215 | ) 216 | ) 217 | 218 | else -> throw UnknownEventTypeException(outboxRecord.eventType) 219 | } 220 | } 221 | 222 | private fun getConsumerRecordInfo(consumerRecord: ConsumerRecord): String { 223 | val topic = consumerRecord.topic() 224 | val offset = consumerRecord.offset() 225 | val key = consumerRecord.key() 226 | val partition = consumerRecord.partition() 227 | val timestamp = consumerRecord.timestamp() 228 | val value = String(consumerRecord.value()) 229 | return "topic: $topic key: $key partition: $partition offset: $offset timestamp: $timestamp value: $value" 230 | } 231 | 232 | private fun getConsumerRecordInfoWithHeaders(consumerRecord: ConsumerRecord): String { 233 | val headers = consumerRecord.headers() 234 | return "${getConsumerRecordInfo(consumerRecord)}, headers: $headers" 235 | } 236 | 237 | 238 | companion object { 239 | private val log = LoggerFactory.getLogger(OrderConsumer::class.java) 240 | private const val RETRY_COUNT_HEADER = "retryCount" 241 | private const val MAX_RETRY_COUNT = 5 242 | private const val PUBLISH_RETRY_COUNT = 5L 243 | private const val PUBLISH_RETRY_BACKOFF_DURATION_MILLIS = 3000L 244 | 245 | private const val PROCESS = "OrderConsumer.process" 246 | private const val PROCESS_RETRY = "OrderConsumer.processRetry" 247 | private const val PUBLISH_RETRY_TOPIC = "OrderConsumer.publishRetryTopic" 248 | private const val PUBLISH_RETRY_RECORD = "OrderConsumer.publishRetryRecord" 249 | private const val PROCESS_OUTBOX_RECORD = "OrderConsumer.processOutboxRecord" 250 | } 251 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Transactional Outbox pattern step by step with Spring and Kotlin 💫 2 | 3 | Transactional_Outbox_Logo 4 | 5 | The reason why we need **Transactional Outbox** is that a service often needs to publish messages as part of a transaction that updates the database. 6 | Both the database update and the sending of the message must happen within a transaction. 7 | Otherwise, if the service doesn’t perform these two operations atomically, a failure could leave the system in an inconsistent state. 8 | 9 | Transactional Outbox 10 | 11 | In this article we will implement it using Reactive Spring and Kotlin with coroutines. 12 | Full list of used dependencies: **Kotlin with coroutines**, **Spring Boot 3**, **WebFlux**, **R2DBC**, **Postgres**, **MongoDB**, 13 | **Kafka**, **Grafana**, **Prometheus**, **Zipkin** and **Micrometer** for observability. 14 | 15 | 16 | **Transactional Outbox** pattern is solving the problem of the implementation where usually the transaction tries to update the database table, then publish a message to the broker and commit the transaction. 17 | But here is the problem, if at the last step commit of the transaction fails, the transaction will rollback database changes, but the event has been already published to the broker. 18 | So we need to find a way how to guarantee both, database writing and publishing to the broker. 19 | The idea of how we can solve it is next: in the one transaction, save to the orders table, and in the same transaction, save to the outbox table, and commit the transaction. 20 | then we have to publish saved events from the outbox table to the broker, 21 | we have two ways to do that, **CDC(Change data capture)** tool like [**Debezium**](https://debezium.io/), which continuously monitors your databases 22 | and lets any of your applications stream every row-level change in the same order they were committed to the database, 23 | and [**Polling publisher**](https://microservices.io/patterns/data/polling-publisher.html), for this project used polling publisher. 24 | Highly recommend **Chris Richardson** Book: **Microservices patterns**, where Transactional Outbox pattern is very well explained. 25 | 26 | And one more important thing is we have to be ready for cases when the same event can be published more than one time, so the consumer must be **idempotent**. 27 | **Idempotence** describes the reliability of messages in a distributed system, specifically about the reception of duplicated messages. Because of retries or message broker features, a message sent once can be received multiple times by consumers. 28 | A service is idempotent if processing the same event multiple times results in the same state and output as processing that event just a single time. The reception of a duplicated event does not change the application state or behavior. 29 | Most of the time, an idempotent service detects these events and ignores them. Idempotence can be implemented using unique identifiers. 30 | 31 | 32 | So let's implement it, business logic of our example microservice is simple, orders with product items, it's two tables for simplicity and outbox table of course. 33 | Usually, an outbox table looks like, when at data field we store serialized event, most common is JSON format, but it's up to you and concrete microservice, 34 | we can put as data field state changes or can simply put every time the last updated full order domain entity, of course, state changes take much less size, but again it's up to you, 35 | other fields in the outbox table usually event type, timestamp, version, and other metadata, 36 | it again depends on each concrete implementation, but often it's required minimum, the version field is for concurrency control. 37 | 38 | All UI interfaces will be available on ports: 39 | 40 | #### Swagger UI: http://localhost:8000/webjars/swagger-ui/index.html 41 | Transactional Outbox 42 | 43 | #### Grafana UI: http://localhost:3000 44 | Grafana 45 | 46 | #### Zipkin UI: http://localhost:9411 47 | Zipkin 48 | 49 | #### Kafka UI: http://localhost:8086/ 50 | Topics 51 | 52 | #### Prometheus UI: http://localhost:9090 53 | Prometheus 54 | 55 | 56 | The docker-compose file for this article has postgres, mongodb, zookeeper, kafka, kafka-ui, zipkin, prometheus and grafana, 57 | for local development run: **make local** or **make develop**, first run only docker-compose, second same include the microservice image. 58 | 59 | ```yaml 60 | version: "3.9" 61 | 62 | services: 63 | microservices_postgresql: 64 | image: postgres:latest 65 | container_name: microservices_postgresql 66 | expose: 67 | - "5432" 68 | ports: 69 | - "5432:5432" 70 | restart: always 71 | environment: 72 | - POSTGRES_USER=postgres 73 | - POSTGRES_PASSWORD=postgres 74 | - POSTGRES_DB=microservices 75 | - POSTGRES_HOST=5432 76 | command: -p 5432 77 | volumes: 78 | - ./docker_data/microservices_pgdata:/var/lib/postgresql/data 79 | networks: [ "microservices" ] 80 | 81 | zoo1: 82 | image: confluentinc/cp-zookeeper:7.3.0 83 | hostname: zoo1 84 | container_name: zoo1 85 | ports: 86 | - "2181:2181" 87 | environment: 88 | ZOOKEEPER_CLIENT_PORT: 2181 89 | ZOOKEEPER_SERVER_ID: 1 90 | ZOOKEEPER_SERVERS: zoo1:2888:3888 91 | volumes: 92 | - "./zookeeper:/zookeeper" 93 | networks: [ "microservices" ] 94 | 95 | kafka1: 96 | image: confluentinc/cp-kafka:7.3.0 97 | hostname: kafka1 98 | container_name: kafka1 99 | ports: 100 | - "9092:9092" 101 | - "29092:29092" 102 | - "9999:9999" 103 | environment: 104 | KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092 105 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT 106 | KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL 107 | KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181" 108 | KAFKA_BROKER_ID: 1 109 | KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO" 110 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 111 | KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 112 | KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 113 | KAFKA_JMX_PORT: 9999 114 | KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} 115 | KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer 116 | KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true" 117 | depends_on: 118 | - zoo1 119 | volumes: 120 | - "./kafka_data:/kafka" 121 | networks: [ "microservices" ] 122 | 123 | kafka-ui: 124 | image: provectuslabs/kafka-ui 125 | container_name: kafka-ui 126 | ports: 127 | - "8086:8080" 128 | restart: always 129 | environment: 130 | - KAFKA_CLUSTERS_0_NAME=local 131 | - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka1:19092 132 | networks: [ "microservices" ] 133 | 134 | zipkin-all-in-one: 135 | image: openzipkin/zipkin:latest 136 | restart: always 137 | ports: 138 | - "9411:9411" 139 | networks: [ "microservices" ] 140 | 141 | mongo: 142 | image: mongo 143 | restart: always 144 | ports: 145 | - "27017:27017" 146 | environment: 147 | MONGO_INITDB_ROOT_USERNAME: admin 148 | MONGO_INITDB_ROOT_PASSWORD: admin 149 | MONGODB_DATABASE: bank_accounts 150 | networks: [ "microservices" ] 151 | 152 | prometheus: 153 | image: prom/prometheus:latest 154 | container_name: prometheus 155 | ports: 156 | - "9090:9090" 157 | command: 158 | - --config.file=/etc/prometheus/prometheus.yml 159 | volumes: 160 | - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro 161 | networks: [ "microservices" ] 162 | 163 | node_exporter: 164 | container_name: microservices_node_exporter 165 | restart: always 166 | image: prom/node-exporter 167 | ports: 168 | - '9101:9100' 169 | networks: [ "microservices" ] 170 | 171 | grafana: 172 | container_name: microservices_grafana 173 | restart: always 174 | image: grafana/grafana 175 | ports: 176 | - '3000:3000' 177 | networks: [ "microservices" ] 178 | 179 | 180 | networks: 181 | microservices: 182 | name: microservices 183 | ``` 184 | 185 | The Postgres database schema for this project is: 186 | 187 | schema 188 | 189 | 190 | Orders domain REST Controller has the following methods: 191 | 192 | ```kotlin 193 | @RestController 194 | @RequestMapping(path = ["/api/v1/orders"]) 195 | class OrderController(private val orderService: OrderService, private val or: ObservationRegistry) { 196 | 197 | @GetMapping 198 | @Operation(method = "getOrders", summary = "get order with pagination", operationId = "getOrders") 199 | suspend fun getOrders( 200 | @RequestParam(name = "page", defaultValue = "0") page: Int, 201 | @RequestParam(name = "size", defaultValue = "20") size: Int, 202 | ) = coroutineScopeWithObservation(GET_ORDERS, or) { observation -> 203 | ResponseEntity.ok() 204 | .body(orderService.getAllOrders(PageRequest.of(page, size)) 205 | .map { it.toSuccessResponse() } 206 | .also { response -> observation.highCardinalityKeyValue("response", response.toString()) } 207 | ) 208 | } 209 | 210 | @GetMapping(path = ["{id}"]) 211 | @Operation(method = "getOrderByID", summary = "get order by id", operationId = "getOrderByID") 212 | suspend fun getOrderByID(@PathVariable id: String) = coroutineScopeWithObservation(GET_ORDER_BY_ID, or) { observation -> 213 | ResponseEntity.ok().body(orderService.getOrderWithProductsByID(UUID.fromString(id)).toSuccessResponse()) 214 | .also { response -> 215 | observation.highCardinalityKeyValue("response", response.toString()) 216 | log.info("getOrderByID response: $response") 217 | } 218 | } 219 | 220 | @PostMapping 221 | @Operation(method = "createOrder", summary = "create new order", operationId = "createOrder") 222 | suspend fun createOrder(@Valid @RequestBody createOrderDTO: CreateOrderDTO) = coroutineScopeWithObservation(CREATE_ORDER, or) { observation -> 223 | ResponseEntity.status(HttpStatus.CREATED).body(orderService.createOrder(createOrderDTO.toOrder()).toSuccessResponse()) 224 | .also { 225 | log.info("created order: $it") 226 | observation.highCardinalityKeyValue("response", it.toString()) 227 | } 228 | } 229 | 230 | @PutMapping(path = ["/add/{id}"]) 231 | @Operation(method = "addProductItem", summary = "add to the order product item", operationId = "addProductItem") 232 | suspend fun addProductItem( 233 | @PathVariable id: UUID, 234 | @Valid @RequestBody dto: CreateProductItemDTO 235 | ) = coroutineScopeWithObservation(ADD_PRODUCT, or) { observation -> 236 | ResponseEntity.ok().body(orderService.addProductItem(dto.toProductItem(id))) 237 | .also { 238 | observation.highCardinalityKeyValue("CreateProductItemDTO", dto.toString()) 239 | observation.highCardinalityKeyValue("id", id.toString()) 240 | log.info("addProductItem id: $id, dto: $dto") 241 | } 242 | } 243 | 244 | @PutMapping(path = ["/remove/{orderId}/{productItemId}"]) 245 | @Operation(method = "removeProductItem", summary = "remove product from the order", operationId = "removeProductItem") 246 | suspend fun removeProductItem( 247 | @PathVariable orderId: UUID, 248 | @PathVariable productItemId: UUID 249 | ) = coroutineScopeWithObservation(REMOVE_PRODUCT, or) { observation -> 250 | ResponseEntity.ok().body(orderService.removeProductItem(orderId, productItemId)) 251 | .also { 252 | observation.highCardinalityKeyValue("productItemId", productItemId.toString()) 253 | observation.highCardinalityKeyValue("orderId", orderId.toString()) 254 | log.info("removeProductItem orderId: $orderId, productItemId: $productItemId") 255 | } 256 | } 257 | 258 | @PutMapping(path = ["/pay/{id}"]) 259 | @Operation(method = "payOrder", summary = "pay order", operationId = "payOrder") 260 | suspend fun payOrder(@PathVariable id: UUID, @Valid @RequestBody dto: PayOrderDTO) = coroutineScopeWithObservation(PAY_ORDER, or) { observation -> 261 | ResponseEntity.ok().body(orderService.pay(id, dto.paymentId).toSuccessResponse()) 262 | .also { 263 | observation.highCardinalityKeyValue("response", it.toString()) 264 | log.info("payOrder result: $it") 265 | } 266 | } 267 | 268 | @PutMapping(path = ["/cancel/{id}"]) 269 | @Operation(method = "cancelOrder", summary = "cancel order", operationId = "cancelOrder") 270 | suspend fun cancelOrder(@PathVariable id: UUID, @Valid @RequestBody dto: CancelOrderDTO) = coroutineScopeWithObservation(CANCEL_ORDER, or) { observation -> 271 | ResponseEntity.ok().body(orderService.cancel(id, dto.reason).toSuccessResponse()) 272 | .also { 273 | observation.highCardinalityKeyValue("response", it.toString()) 274 | log.info("cancelOrder result: $it") 275 | } 276 | } 277 | 278 | @PutMapping(path = ["/submit/{id}"]) 279 | @Operation(method = "submitOrder", summary = "submit order", operationId = "submitOrder") 280 | suspend fun submitOrder(@PathVariable id: UUID) = coroutineScopeWithObservation(SUBMIT_ORDER, or) { observation -> 281 | ResponseEntity.ok().body(orderService.submit(id).toSuccessResponse()) 282 | .also { 283 | observation.highCardinalityKeyValue("response", it.toString()) 284 | log.info("submitOrder result: $it") 285 | } 286 | } 287 | 288 | @PutMapping(path = ["/complete/{id}"]) 289 | @Operation(method = "completeOrder", summary = "complete order", operationId = "completeOrder") 290 | suspend fun completeOrder(@PathVariable id: UUID) = coroutineScopeWithObservation(COMPLETE_ORDER, or) { observation -> 291 | ResponseEntity.ok().body(orderService.complete(id).toSuccessResponse()) 292 | .also { 293 | observation.highCardinalityKeyValue("response", it.toString()) 294 | log.info("completeOrder result: $it") 295 | } 296 | } 297 | } 298 | ``` 299 | 300 | As typed earlier the main idea of implementation for the transactional outbox is at the first step in the one transaction write to orders and outbox tables and commit the transaction, additional, but not required optimization, we can in the same methods after successfully committed a transaction, then publish the event and delete it from the outbox table, 301 | but here if any one step of publishing to the broker or deleting from the outbox table fails, it's ok, because we have **polling producer** as scheduled process, which anyway will do that, 302 | again it's small optimization and improvement, and it's not mandatory to implement an outbox pattern, so do it or not, it's up to you, try both variants and chose the best for your case. 303 | In our case we use Kafka, so we have to remember that producers has **acks setting**, 304 | 305 | when **acks=0** producers consider messages as "written successfully" the moment the message was sent without waiting for the broker to accept it at all. 306 | If the broker goes offline or an exception happens, we won’t know and will lose data, so be careful with this setting and don't use **acks=0**, 307 | When **acks=1** , producers consider messages as "written successfully" when the message was acknowledged by only the leader. 308 | When **acks=all**, producers consider messages as "written successfully" when the message is accepted by all in-sync replicas (ISR). 309 | 310 | Swagger 311 | 312 | In the Simplified sequence diagram for service layer business logic, steps 5 and 6 are optional and not required optimization, because we have polling publisher anyway:
313 | 314 | Sequence 315 | 316 | The order service implementation: 317 | 318 | ```kotlin 319 | interface OrderService { 320 | suspend fun createOrder(order: Order): Order 321 | suspend fun getOrderByID(id: UUID): Order 322 | suspend fun addProductItem(productItem: ProductItem) 323 | suspend fun removeProductItem(orderID: UUID, productItemId: UUID) 324 | suspend fun pay(id: UUID, paymentId: String): Order 325 | suspend fun cancel(id: UUID, reason: String?): Order 326 | suspend fun submit(id: UUID): Order 327 | suspend fun complete(id: UUID): Order 328 | 329 | suspend fun getOrderWithProductsByID(id: UUID): Order 330 | suspend fun getAllOrders(pageable: Pageable): Page 331 | 332 | suspend fun deleteOutboxRecordsWithLock() 333 | } 334 | ``` 335 | 336 | ```kotlin 337 | @Service 338 | class OrderServiceImpl( 339 | private val orderRepository: OrderRepository, 340 | private val productItemRepository: ProductItemRepository, 341 | private val outboxRepository: OrderOutboxRepository, 342 | private val orderMongoRepository: OrderMongoRepository, 343 | private val txOp: TransactionalOperator, 344 | private val eventsPublisher: EventsPublisher, 345 | private val kafkaTopicsConfiguration: KafkaTopicsConfiguration, 346 | private val or: ObservationRegistry, 347 | private val outboxEventSerializer: OutboxEventSerializer 348 | ) : OrderService { 349 | 350 | override suspend fun createOrder(order: Order): Order = coroutineScopeWithObservation(CREATE, or) { observation -> 351 | txOp.executeAndAwait { 352 | orderRepository.insert(order).let { 353 | val productItemsEntityList = ProductItemEntity.listOf(order.productsList(), UUID.fromString(it.id)) 354 | val insertedItems = productItemRepository.insertAll(productItemsEntityList).toList() 355 | 356 | it.addProductItems(insertedItems.map { item -> item.toProductItem() }) 357 | 358 | Pair(it, outboxRepository.save(outboxEventSerializer.orderCreatedEventOf(it))) 359 | } 360 | }.run { 361 | observation.highCardinalityKeyValue("order", first.toString()) 362 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 363 | 364 | publishOutboxEvent(second) 365 | first 366 | } 367 | } 368 | 369 | override suspend fun addProductItem(productItem: ProductItem): Unit = coroutineScopeWithObservation(ADD_PRODUCT, or) { observation -> 370 | txOp.executeAndAwait { 371 | val order = orderRepository.findOrderByID(UUID.fromString(productItem.orderId)) 372 | order.incVersion() 373 | 374 | val updatedProductItem = productItemRepository.upsert(productItem) 375 | 376 | val savedRecord = outboxRepository.save( 377 | outboxEventSerializer.productItemAddedEventOf( 378 | order, 379 | productItem.copy(version = updatedProductItem.version).toEntity() 380 | ) 381 | ) 382 | 383 | orderRepository.updateVersion(UUID.fromString(order.id), order.version) 384 | .also { result -> log.info("addOrderItem result: $result, version: ${order.version}") } 385 | 386 | savedRecord 387 | }.run { 388 | observation.highCardinalityKeyValue("outboxEvent", this.toString()) 389 | publishOutboxEvent(this) 390 | } 391 | } 392 | 393 | override suspend fun removeProductItem(orderID: UUID, productItemId: UUID): Unit = coroutineScopeWithObservation(REMOVE_PRODUCT, or) { observation -> 394 | txOp.executeAndAwait { 395 | if (!productItemRepository.existsById(productItemId)) throw ProductItemNotFoundException(productItemId) 396 | 397 | val order = orderRepository.findOrderByID(orderID) 398 | productItemRepository.deleteById(productItemId) 399 | 400 | order.incVersion() 401 | 402 | val savedRecord = outboxRepository.save(outboxEventSerializer.productItemRemovedEventOf(order, productItemId)) 403 | 404 | orderRepository.updateVersion(UUID.fromString(order.id), order.version) 405 | .also { log.info("removeProductItem update order result: $it, version: ${order.version}") } 406 | 407 | savedRecord 408 | }.run { 409 | observation.highCardinalityKeyValue("outboxEvent", this.toString()) 410 | publishOutboxEvent(this) 411 | } 412 | } 413 | 414 | override suspend fun pay(id: UUID, paymentId: String): Order = coroutineScopeWithObservation(PAY, or) { observation -> 415 | txOp.executeAndAwait { 416 | val order = orderRepository.getOrderWithProductItemsByID(id) 417 | order.pay(paymentId) 418 | 419 | val updatedOrder = orderRepository.update(order) 420 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderPaidEventOf(updatedOrder, paymentId))) 421 | }.run { 422 | observation.highCardinalityKeyValue("order", first.toString()) 423 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 424 | 425 | publishOutboxEvent(second) 426 | first 427 | } 428 | } 429 | 430 | override suspend fun cancel(id: UUID, reason: String?): Order = coroutineScopeWithObservation(CANCEL, or) { observation -> 431 | txOp.executeAndAwait { 432 | val order = orderRepository.findOrderByID(id) 433 | order.cancel() 434 | 435 | val updatedOrder = orderRepository.update(order) 436 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderCancelledEventOf(updatedOrder, reason))) 437 | }.run { 438 | observation.highCardinalityKeyValue("order", first.toString()) 439 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 440 | 441 | publishOutboxEvent(second) 442 | first 443 | } 444 | } 445 | 446 | override suspend fun submit(id: UUID): Order = coroutineScopeWithObservation(SUBMIT, or) { observation -> 447 | txOp.executeAndAwait { 448 | val order = orderRepository.getOrderWithProductItemsByID(id) 449 | order.submit() 450 | 451 | val updatedOrder = orderRepository.update(order) 452 | updatedOrder.addProductItems(order.productsList()) 453 | 454 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderSubmittedEventOf(updatedOrder))) 455 | }.run { 456 | observation.highCardinalityKeyValue("order", first.toString()) 457 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 458 | 459 | publishOutboxEvent(second) 460 | first 461 | } 462 | } 463 | 464 | override suspend fun complete(id: UUID): Order = coroutineScopeWithObservation(COMPLETE, or) { observation -> 465 | txOp.executeAndAwait { 466 | val order = orderRepository.findOrderByID(id) 467 | order.complete() 468 | 469 | val updatedOrder = orderRepository.update(order) 470 | log.info("order submitted: ${updatedOrder.status} for id: $id") 471 | 472 | Pair(updatedOrder, outboxRepository.save(outboxEventSerializer.orderCompletedEventOf(updatedOrder))) 473 | }.run { 474 | observation.highCardinalityKeyValue("order", first.toString()) 475 | observation.highCardinalityKeyValue("outboxEvent", second.toString()) 476 | 477 | publishOutboxEvent(second) 478 | first 479 | } 480 | } 481 | 482 | @Transactional(readOnly = true) 483 | override suspend fun getOrderWithProductsByID(id: UUID): Order = coroutineScopeWithObservation(GET_ORDER_WITH_PRODUCTS_BY_ID, or) { observation -> 484 | orderRepository.getOrderWithProductItemsByID(id).also { observation.highCardinalityKeyValue("order", it.toString()) } 485 | } 486 | 487 | override suspend fun getAllOrders(pageable: Pageable): Page = coroutineScopeWithObservation(GET_ALL_ORDERS, or) { observation -> 488 | orderMongoRepository.getAllOrders(pageable).also { observation.highCardinalityKeyValue("pageResult", it.toString()) } 489 | } 490 | 491 | override suspend fun deleteOutboxRecordsWithLock() = coroutineScopeWithObservation(DELETE_OUTBOX_RECORD_WITH_LOCK, or) { observation -> 492 | outboxRepository.deleteOutboxRecordsWithLock { 493 | observation.highCardinalityKeyValue("outboxEvent", it.toString()) 494 | eventsPublisher.publish(getTopicName(it.eventType), it) 495 | } 496 | } 497 | 498 | override suspend fun getOrderByID(id: UUID): Order = coroutineScopeWithObservation(GET_ORDER_BY_ID, or) { observation -> 499 | orderMongoRepository.getByID(id.toString()) 500 | .also { log.info("getOrderByID: $it") } 501 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 502 | } 503 | 504 | private suspend fun publishOutboxEvent(event: OutboxRecord) = coroutineScopeWithObservation(PUBLISH_OUTBOX_EVENT, or) { observation -> 505 | try { 506 | log.info("publishing outbox event: $event") 507 | 508 | outboxRepository.deleteOutboxRecordByID(event.eventId!!) { 509 | eventsPublisher.publish(getTopicName(event.eventType), event.aggregateId.toString(), event) 510 | } 511 | 512 | log.info("outbox event published and deleted: $event") 513 | observation.highCardinalityKeyValue("event", event.toString()) 514 | } catch (ex: Exception) { 515 | log.error("exception while publishing outbox event: ${ex.localizedMessage}") 516 | observation.error(ex) 517 | } 518 | } 519 | } 520 | ``` 521 | 522 | Zipkin 523 | 524 | Kafka 525 | 526 | Order and product items postgres repositories is combination of **CoroutineCrudRepository** and custom implementation using **DatabaseClient** and **R2dbcEntityTemplate**, 527 | supports optimistic and pessimistic locking, depending on method requirements. 528 | 529 | ```kotlin 530 | @Repository 531 | interface OrderRepository : CoroutineCrudRepository, OrderBaseRepository 532 | 533 | @Repository 534 | interface OrderBaseRepository { 535 | suspend fun getOrderWithProductItemsByID(id: UUID): Order 536 | suspend fun updateVersion(id: UUID, newVersion: Long): Long 537 | suspend fun findOrderByID(id: UUID): Order 538 | suspend fun insert(order: Order): Order 539 | suspend fun update(order: Order): Order 540 | } 541 | 542 | @Repository 543 | class OrderBaseRepositoryImpl( 544 | private val dbClient: DatabaseClient, 545 | private val entityTemplate: R2dbcEntityTemplate, 546 | private val or: ObservationRegistry 547 | ) : OrderBaseRepository { 548 | 549 | override suspend fun updateVersion(id: UUID, newVersion: Long): Long = coroutineScopeWithObservation(UPDATE_VERSION, or) { observation -> 550 | dbClient.sql("UPDATE microservices.orders SET version = (version + 1) WHERE id = :id AND version = :version") 551 | .bind(ID, id) 552 | .bind(VERSION, newVersion - 1) 553 | .fetch() 554 | .rowsUpdated() 555 | .awaitSingle() 556 | .also { log.info("for order with id: $id version updated to $newVersion") } 557 | .also { 558 | observation.highCardinalityKeyValue("id", id.toString()) 559 | observation.highCardinalityKeyValue("newVersion", newVersion.toString()) 560 | } 561 | } 562 | 563 | override suspend fun getOrderWithProductItemsByID(id: UUID): Order = coroutineScopeWithObservation(GET_ORDER_WITH_PRODUCTS_BY_ID, or) { observation -> 564 | dbClient.sql( 565 | """SELECT o.id, o.email, o.status, o.address, o.version, o.payment_id, o.created_at, o.updated_at, 566 | |pi.id as productId, pi.price, pi.title, pi.quantity, pi.order_id, pi.version as itemVersion, pi.created_at as itemCreatedAt, pi.updated_at as itemUpdatedAt 567 | |FROM microservices.orders o 568 | |LEFT JOIN microservices.product_items pi on o.id = pi.order_id 569 | |WHERE o.id = :id""".trimMargin() 570 | ) 571 | .bind(ID, id) 572 | .map { row, _ -> Pair(OrderEntity.of(row), ProductItemEntity.of(row)) } 573 | .flow() 574 | .toList() 575 | .let { orderFromList(it) } 576 | .also { 577 | log.info("getOrderWithProductItemsByID order: $it") 578 | observation.highCardinalityKeyValue("order", it.toString()) 579 | } 580 | } 581 | 582 | override suspend fun findOrderByID(id: UUID): Order = coroutineScopeWithObservation(FIND_ORDER_BY_ID, or) { observation -> 583 | val query = Query.query(Criteria.where(ID).`is`(id)) 584 | entityTemplate.selectOne(query, OrderEntity::class.java).awaitSingleOrNull()?.toOrder() 585 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 586 | ?: throw OrderNotFoundException(id) 587 | } 588 | 589 | override suspend fun insert(order: Order): Order = coroutineScopeWithObservation(INSERT, or) { observation -> 590 | entityTemplate.insert(order.toEntity()).awaitSingle().toOrder() 591 | .also { 592 | log.info("inserted order: $it") 593 | observation.highCardinalityKeyValue("order", it.toString()) 594 | } 595 | } 596 | 597 | override suspend fun update(order: Order): Order = coroutineScopeWithObservation(UPDATE, or) { observation -> 598 | entityTemplate.update(order.toEntity()).awaitSingle().toOrder() 599 | .also { 600 | log.info("updated order: $it") 601 | observation.highCardinalityKeyValue("order", it.toString()) 602 | } 603 | } 604 | } 605 | ``` 606 | 607 | ```kotlin 608 | interface ProductItemBaseRepository { 609 | suspend fun insert(productItemEntity: ProductItemEntity): ProductItemEntity 610 | suspend fun insertAll(productItemEntities: List): List 611 | suspend fun upsert(productItem: ProductItem): ProductItem 612 | } 613 | 614 | @Repository 615 | class ProductItemBaseRepositoryImpl( 616 | private val entityTemplate: R2dbcEntityTemplate, 617 | private val or: ObservationRegistry, 618 | ) : ProductItemBaseRepository { 619 | 620 | override suspend fun upsert(productItem: ProductItem): ProductItem = coroutineScopeWithObservation(UPDATE, or) { observation -> 621 | val query = Query.query( 622 | Criteria.where("id").`is`(UUID.fromString(productItem.id)) 623 | .and("order_id").`is`(UUID.fromString(productItem.orderId)) 624 | ) 625 | 626 | val product = entityTemplate.selectOne(query, ProductItemEntity::class.java).awaitSingleOrNull() 627 | if (product != null) { 628 | val update = Update 629 | .update("quantity", (productItem.quantity + product.quantity)) 630 | .set("version", product.version + 1) 631 | .set("updated_at", LocalDateTime.now()) 632 | 633 | val updatedProduct = product.copy(quantity = (productItem.quantity + product.quantity), version = product.version + 1) 634 | val updateResult = entityTemplate.update(query, update, ProductItemEntity::class.java).awaitSingle() 635 | log.info("updateResult product: $updateResult") 636 | log.info("updateResult updatedProduct: $updatedProduct") 637 | return@coroutineScopeWithObservation updatedProduct.toProductItem() 638 | } 639 | 640 | entityTemplate.insert(ProductItemEntity.of(productItem)).awaitSingle().toProductItem() 641 | .also { productItem -> 642 | log.info("saved productItem: $productItem") 643 | observation.highCardinalityKeyValue("productItem", productItem.toString()) 644 | } 645 | } 646 | 647 | override suspend fun insert(productItemEntity: ProductItemEntity): ProductItemEntity = coroutineScopeWithObservation(INSERT, or) { observation -> 648 | val product = entityTemplate.insert(productItemEntity).awaitSingle() 649 | 650 | log.info("saved product: $product") 651 | observation.highCardinalityKeyValue("product", product.toString()) 652 | product 653 | } 654 | 655 | override suspend fun insertAll(productItemEntities: List) = coroutineScopeWithObservation(INSERT_ALL, or) { observation -> 656 | val result = productItemEntities.map { entityTemplate.insert(it) }.map { it.awaitSingle() } 657 | log.info("inserted product items: $result") 658 | observation.highCardinalityKeyValue("result", result.toString()) 659 | result 660 | } 661 | } 662 | ``` 663 | 664 | The **outbox repository**, important detail here is to be able to handle the case of multiple pod instances processing in parallel outbox table, 665 | of course, we have idempotent consumers, but as we can, we have to avoid processing the same table events more than one time, to prevent multiple instances select and publish the same events, 666 | we use here **FOR UPDATE SKIP LOCKED** - this combination does the next thing, when one instance tries to select a batch of outbox events if some other instance already selected these records, 667 | first, one will skip locked records and select the next available and not locked, and so on. 668 | 669 | Select_for_update 670 | 671 | 672 | ```kotlin 673 | @Repository 674 | interface OutboxBaseRepository { 675 | suspend fun deleteOutboxRecordByID(id: UUID, callback: suspend () -> Unit): Long 676 | suspend fun deleteOutboxRecordsWithLock(callback: suspend (outboxRecord: OutboxRecord) -> Unit) 677 | } 678 | 679 | class OutboxBaseRepositoryImpl( 680 | private val dbClient: DatabaseClient, 681 | private val txOp: TransactionalOperator, 682 | private val or: ObservationRegistry, 683 | private val transactionalOperator: TransactionalOperator 684 | ) : OutboxBaseRepository { 685 | 686 | override suspend fun deleteOutboxRecordByID(id: UUID, callback: suspend () -> Unit): Long = 687 | coroutineScopeWithObservation(DELETE_OUTBOX_RECORD_BY_ID, or) { observation -> 688 | withTimeout(DELETE_OUTBOX_RECORD_TIMEOUT_MILLIS) { 689 | txOp.executeAndAwait { 690 | 691 | callback() 692 | 693 | dbClient.sql("DELETE FROM microservices.outbox_table WHERE event_id = :eventId") 694 | .bind("eventId", id) 695 | .fetch() 696 | .rowsUpdated() 697 | .awaitSingle() 698 | .also { 699 | log.info("outbox event with id: $it deleted") 700 | observation.highCardinalityKeyValue("id", it.toString()) 701 | } 702 | } 703 | } 704 | } 705 | 706 | override suspend fun deleteOutboxRecordsWithLock(callback: suspend (outboxRecord: OutboxRecord) -> Unit) = 707 | coroutineScopeWithObservation(DELETE_OUTBOX_RECORD_WITH_LOCK, or) { observation -> 708 | withTimeout(DELETE_OUTBOX_RECORD_TIMEOUT_MILLIS) { 709 | txOp.executeAndAwait { 710 | 711 | dbClient.sql("SELECT * FROM microservices.outbox_table ORDER BY timestamp ASC LIMIT 10 FOR UPDATE SKIP LOCKED") 712 | .map { row, _ -> OutboxRecord.of(row) } 713 | .flow() 714 | .onEach { 715 | log.info("deleting outboxEvent with id: ${it.eventId}") 716 | 717 | callback(it) 718 | 719 | dbClient.sql("DELETE FROM microservices.outbox_table WHERE event_id = :eventId") 720 | .bind("eventId", it.eventId!!) 721 | .fetch() 722 | .rowsUpdated() 723 | .awaitSingle() 724 | 725 | log.info("outboxEvent with id: ${it.eventId} published and deleted") 726 | observation.highCardinalityKeyValue("eventId", it.eventId.toString()) 727 | } 728 | .collect() 729 | } 730 | } 731 | } 732 | } 733 | ``` 734 | 735 | The polling producer implementation is a scheduled process which doing the same job for publishing and delete events at the given interval as typed earlier and uses the same service method: 736 | 737 | ```kotlin 738 | @Component 739 | @ConditionalOnProperty(prefix = "schedulers", value = ["outbox.enable"], havingValue = "true") 740 | class OutboxScheduler(private val orderService: OrderService, private val or: ObservationRegistry) { 741 | 742 | @Scheduled(initialDelayString = "\${schedulers.outbox.initialDelayMillis}", fixedRateString = "\${schedulers.outbox.fixedRate}") 743 | fun publishAndDeleteOutboxRecords() = runBlocking { 744 | coroutineScopeWithObservation(PUBLISH_AND_DELETE_OUTBOX_RECORDS, or) { 745 | log.debug("starting scheduled outbox table publishing") 746 | orderService.deleteOutboxRecordsWithLock() 747 | log.debug("completed scheduled outbox table publishing") 748 | } 749 | } 750 | 751 | companion object { 752 | private val log = LoggerFactory.getLogger(OutboxScheduler::class.java) 753 | private const val PUBLISH_AND_DELETE_OUTBOX_RECORDS = "OutboxScheduler.publishAndDeleteOutboxRecords" 754 | } 755 | } 756 | ``` 757 | 758 | Usually, transactional outbox is more often required to guarantee data consistency between microservices, here, for example, consumers in the same microservice process it and save to mongodb, 759 | the one more important detail here, as we're processing kafka events in multiple consumer processes, it can randomize the order, 760 | in Kafka we have a keys feature, and it helps us because it sends messages with the same key to one partition. 761 | But if the broker has not had this feature, we have to handle it manually, cases when for example 762 | fist some of the consumers trying to process event #6 before events #4,#5 were processed, so, for this reason, have a domain entity version field in outbox events, 763 | so we can simply look at the version and validate, if in our database we have order version #3, but now processing event with version #6, we need first wait for #4,#5 and process them first, 764 | but of course, these details depend on each concrete business logic of the application, here shows only the idea that it's a possible case. 765 | And one more important detail - is retry topics, if we need to retry process of the messages, better to create a retry topic and process retry here, 766 | how much time to retry and other advanced logic detail depends on your concrete case. 767 | In the example, we have two listeners, where one of them is for retry topic message processing: 768 | 769 | ```kotlin 770 | @Component 771 | class OrderConsumer( 772 | private val kafkaTopicsConfiguration: KafkaTopicsConfiguration, 773 | private val serializer: Serializer, 774 | private val eventsPublisher: EventsPublisher, 775 | private val orderEventProcessor: OrderEventProcessor, 776 | private val or: ObservationRegistry, 777 | ) { 778 | 779 | @KafkaListener( 780 | groupId = "\${kafka.consumer-group-id:order-service-group-id}", 781 | topics = [ 782 | "\${topics.orderCreated.name}", 783 | "\${topics.productAdded.name}", 784 | "\${topics.productRemoved.name}", 785 | "\${topics.orderPaid.name}", 786 | "\${topics.orderCancelled.name}", 787 | "\${topics.orderSubmitted.name}", 788 | "\${topics.orderCompleted.name}", 789 | ], 790 | id = "orders-consumer" 791 | ) 792 | fun process(ack: Acknowledgment, consumerRecord: ConsumerRecord) = runBlocking { 793 | coroutineScopeWithObservation(PROCESS, or) { observation -> 794 | try { 795 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 796 | 797 | processOutboxRecord(serializer.deserialize(consumerRecord.value(), OutboxRecord::class.java)) 798 | ack.acknowledge() 799 | 800 | log.info("committed record: ${getConsumerRecordInfo(consumerRecord)}") 801 | } catch (ex: Exception) { 802 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 803 | observation.error(ex) 804 | 805 | if (ex is SerializationException || ex is UnknownEventTypeException || ex is AlreadyProcessedVersionException) { 806 | log.error("ack not serializable, unknown or already processed record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 807 | ack.acknowledge() 808 | return@coroutineScopeWithObservation 809 | } 810 | 811 | if (ex is InvalidVersionException || ex is NoSuchElementException || ex is OrderNotFoundException) { 812 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, 1) 813 | ack.acknowledge() 814 | log.warn("ack concurrency write or version exception ${ex.localizedMessage}") 815 | return@coroutineScopeWithObservation 816 | } 817 | 818 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, 1) 819 | ack.acknowledge() 820 | log.error("ack exception while processing record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}", ex) 821 | } 822 | } 823 | } 824 | 825 | 826 | @KafkaListener(groupId = "\${kafka.consumer-group-id:order-service-group-id}", topics = ["\${topics.retryTopic.name}"], id = "orders-retry-consumer") 827 | fun processRetry(ack: Acknowledgment, consumerRecord: ConsumerRecord): Unit = runBlocking { 828 | coroutineScopeWithObservation(PROCESS_RETRY, or) { observation -> 829 | try { 830 | log.warn("processing retry topic record >>>>>>>>>>>>> : ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 831 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 832 | 833 | processOutboxRecord(serializer.deserialize(consumerRecord.value(), OutboxRecord::class.java)) 834 | ack.acknowledge() 835 | 836 | log.info("committed retry record: ${getConsumerRecordInfo(consumerRecord)}") 837 | } catch (ex: Exception) { 838 | observation.highCardinalityKeyValue("consumerRecord", getConsumerRecordInfoWithHeaders(consumerRecord)) 839 | observation.error(ex) 840 | 841 | val currentRetry = String(consumerRecord.headers().lastHeader(RETRY_COUNT_HEADER).value()).toInt() 842 | observation.highCardinalityKeyValue("currentRetry", currentRetry.toString()) 843 | 844 | if (ex is InvalidVersionException || ex is NoSuchElementException || ex is OrderNotFoundException) { 845 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, currentRetry) 846 | log.warn("ack concurrency write or version exception ${ex.localizedMessage},record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 847 | ack.acknowledge() 848 | return@coroutineScopeWithObservation 849 | } 850 | 851 | if (currentRetry > MAX_RETRY_COUNT) { 852 | publishRetryTopic(kafkaTopicsConfiguration.deadLetterQueue.name, consumerRecord, currentRetry + 1) 853 | ack.acknowledge() 854 | log.error("MAX_RETRY_COUNT exceed, send record to DLQ: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 855 | return@coroutineScopeWithObservation 856 | } 857 | 858 | if (ex is SerializationException || ex is UnknownEventTypeException || ex is AlreadyProcessedVersionException) { 859 | ack.acknowledge() 860 | log.error("commit not serializable, unknown or already processed record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 861 | return@coroutineScopeWithObservation 862 | } 863 | 864 | log.error("exception while processing: ${ex.localizedMessage}, record: ${getConsumerRecordInfoWithHeaders(consumerRecord)}") 865 | publishRetryTopic(kafkaTopicsConfiguration.retryTopic.name, consumerRecord, currentRetry + 1) 866 | ack.acknowledge() 867 | } 868 | } 869 | } 870 | 871 | 872 | private suspend fun publishRetryTopic(topic: String, record: ConsumerRecord, retryCount: Int) = 873 | coroutineScopeWithObservation(PUBLISH_RETRY_TOPIC, or) { observation -> 874 | observation.highCardinalityKeyValue("topic", record.topic()) 875 | .highCardinalityKeyValue("key", record.key()) 876 | .highCardinalityKeyValue("offset", record.offset().toString()) 877 | .highCardinalityKeyValue("value", String(record.value())) 878 | .highCardinalityKeyValue("retryCount", retryCount.toString()) 879 | 880 | record.headers().remove(RETRY_COUNT_HEADER) 881 | record.headers().add(RETRY_COUNT_HEADER, retryCount.toString().toByteArray()) 882 | 883 | mono { publishRetryRecord(topic, record, retryCount) } 884 | .retryWhen(Retry.backoff(PUBLISH_RETRY_COUNT, Duration.ofMillis(PUBLISH_RETRY_BACKOFF_DURATION_MILLIS)) 885 | .filter { it is SerializationException }) 886 | .awaitSingle() 887 | } 888 | } 889 | ``` 890 | 891 | The role of the orders events processor at this microservice is validating of the events version and update mongodb: 892 | MongoDB 893 | 894 | ```kotlin 895 | interface OrderEventProcessor { 896 | suspend fun on(orderCreatedEvent: OrderCreatedEvent) 897 | suspend fun on(productItemAddedEvent: ProductItemAddedEvent) 898 | suspend fun on(productItemRemovedEvent: ProductItemRemovedEvent) 899 | suspend fun on(orderPaidEvent: OrderPaidEvent) 900 | suspend fun on(orderCancelledEvent: OrderCancelledEvent) 901 | suspend fun on(orderSubmittedEvent: OrderSubmittedEvent) 902 | suspend fun on(orderCompletedEvent: OrderCompletedEvent) 903 | } 904 | 905 | @Service 906 | class OrderEventProcessorImpl( 907 | private val orderMongoRepository: OrderMongoRepository, 908 | private val or: ObservationRegistry, 909 | ) : OrderEventProcessor { 910 | 911 | override suspend fun on(orderCreatedEvent: OrderCreatedEvent): Unit = coroutineScopeWithObservation(ON_ORDER_CREATED_EVENT, or) { observation -> 912 | orderMongoRepository.insert(orderCreatedEvent.order).also { 913 | log.info("created order: $it") 914 | observation.highCardinalityKeyValue("order", it.toString()) 915 | } 916 | } 917 | 918 | override suspend fun on(productItemAddedEvent: ProductItemAddedEvent): Unit = 919 | coroutineScopeWithObservation(ON_ORDER_PRODUCT_ADDED_EVENT, or) { observation -> 920 | val order = orderMongoRepository.getByID(productItemAddedEvent.orderId) 921 | validateVersion(order.id, order.version, productItemAddedEvent.version) 922 | 923 | order.addProductItem(productItemAddedEvent.productItem) 924 | order.version = productItemAddedEvent.version 925 | 926 | orderMongoRepository.update(order).also { 927 | log.info("productItemAddedEvent updatedOrder: $it") 928 | observation.highCardinalityKeyValue("order", it.toString()) 929 | } 930 | } 931 | 932 | override suspend fun on(productItemRemovedEvent: ProductItemRemovedEvent): Unit = 933 | coroutineScopeWithObservation(ON_ORDER_PRODUCT_REMOVED_EVENT, or) { observation -> 934 | val order = orderMongoRepository.getByID(productItemRemovedEvent.orderId) 935 | validateVersion(order.id, order.version, productItemRemovedEvent.version) 936 | 937 | order.removeProductItem(productItemRemovedEvent.productItemId) 938 | order.version = productItemRemovedEvent.version 939 | 940 | orderMongoRepository.update(order).also { 941 | log.info("productItemRemovedEvent updatedOrder: $it") 942 | observation.highCardinalityKeyValue("order", it.toString()) 943 | } 944 | } 945 | 946 | override suspend fun on(orderPaidEvent: OrderPaidEvent): Unit = coroutineScopeWithObservation(ON_ORDER_PAID_EVENT, or) { observation -> 947 | val order = orderMongoRepository.getByID(orderPaidEvent.orderId) 948 | validateVersion(order.id, order.version, orderPaidEvent.version) 949 | 950 | order.pay(orderPaidEvent.paymentId) 951 | order.version = orderPaidEvent.version 952 | 953 | orderMongoRepository.update(order).also { 954 | log.info("orderPaidEvent updatedOrder: $it") 955 | observation.highCardinalityKeyValue("order", it.toString()) 956 | } 957 | } 958 | 959 | override suspend fun on(orderCancelledEvent: OrderCancelledEvent): Unit = coroutineScopeWithObservation(ON_ORDER_CANCELLED_EVENT, or) { observation -> 960 | val order = orderMongoRepository.getByID(orderCancelledEvent.orderId) 961 | validateVersion(order.id, order.version, orderCancelledEvent.version) 962 | 963 | order.cancel() 964 | order.version = orderCancelledEvent.version 965 | 966 | orderMongoRepository.update(order).also { 967 | log.info("orderCancelledEvent updatedOrder: $it") 968 | observation.highCardinalityKeyValue("order", it.toString()) 969 | } 970 | } 971 | 972 | override suspend fun on(orderSubmittedEvent: OrderSubmittedEvent): Unit = coroutineScopeWithObservation(ON_ORDER_SUBMITTED_EVENT, or) { observation -> 973 | val order = orderMongoRepository.getByID(orderSubmittedEvent.orderId) 974 | validateVersion(order.id, order.version, orderSubmittedEvent.version) 975 | 976 | order.submit() 977 | order.version = orderSubmittedEvent.version 978 | 979 | orderMongoRepository.update(order).also { 980 | log.info("orderSubmittedEvent updatedOrder: $it") 981 | observation.highCardinalityKeyValue("order", it.toString()) 982 | } 983 | } 984 | 985 | override suspend fun on(orderCompletedEvent: OrderCompletedEvent): Unit = coroutineScopeWithObservation(ON_ORDER_COMPLETED_EVENT, or) { observation -> 986 | val order = orderMongoRepository.getByID(orderCompletedEvent.orderId) 987 | validateVersion(order.id, order.version, orderCompletedEvent.version) 988 | 989 | order.complete() 990 | order.version = orderCompletedEvent.version 991 | 992 | orderMongoRepository.update(order).also { 993 | log.info("orderCompletedEvent updatedOrder: $it") 994 | observation.highCardinalityKeyValue("order", it.toString()) 995 | } 996 | } 997 | 998 | private fun validateVersion(id: Any, currentDomainVersion: Long, eventVersion: Long) { 999 | log.info("validating version for id: $id, currentDomainVersion: $currentDomainVersion, eventVersion: $eventVersion") 1000 | if (currentDomainVersion >= eventVersion) { 1001 | log.warn("currentDomainVersion >= eventVersion validating version for id: $id, currentDomainVersion: $currentDomainVersion, eventVersion: $eventVersion") 1002 | throw AlreadyProcessedVersionException(id, eventVersion) 1003 | } 1004 | if ((currentDomainVersion + 1) < eventVersion) { 1005 | log.warn("currentDomainVersion + 1) < eventVersion validating version for id: $id, currentDomainVersion: $currentDomainVersion, eventVersion: $eventVersion") 1006 | throw InvalidVersionException(eventVersion) 1007 | } 1008 | } 1009 | } 1010 | ``` 1011 | 1012 | The mongodb repository code is quite simple: 1013 | 1014 | ```kotlin 1015 | interface OrderMongoRepository { 1016 | suspend fun insert(order: Order): Order 1017 | suspend fun update(order: Order): Order 1018 | suspend fun getByID(id: String): Order 1019 | suspend fun getAllOrders(pageable: Pageable): Page 1020 | } 1021 | 1022 | @Repository 1023 | class OrderMongoRepositoryImpl( 1024 | private val mongoTemplate: ReactiveMongoTemplate, 1025 | private val or: ObservationRegistry, 1026 | ) : OrderMongoRepository { 1027 | 1028 | override suspend fun insert(order: Order): Order = coroutineScopeWithObservation(INSERT, or) { observation -> 1029 | withContext(Dispatchers.IO) { 1030 | mongoTemplate.insert(OrderDocument.of(order)).awaitSingle().toOrder() 1031 | .also { log.info("inserted order: $it") } 1032 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 1033 | } 1034 | } 1035 | 1036 | override suspend fun update(order: Order): Order = coroutineScopeWithObservation(UPDATE, or) { observation -> 1037 | withContext(Dispatchers.IO) { 1038 | val query = Query.query(Criteria.where(ID).`is`(order.id).and(VERSION).`is`(order.version - 1)) 1039 | 1040 | val update = Update() 1041 | .set(EMAIL, order.email) 1042 | .set(ADDRESS, order.address) 1043 | .set(STATUS, order.status) 1044 | .set(VERSION, order.version) 1045 | .set(PAYMENT_ID, order.paymentId) 1046 | .set(PRODUCT_ITEMS, order.productsList()) 1047 | 1048 | val options = FindAndModifyOptions.options().returnNew(true).upsert(false) 1049 | val updatedOrderDocument = mongoTemplate.findAndModify(query, update, options, OrderDocument::class.java) 1050 | .awaitSingleOrNull() ?: throw OrderNotFoundException(order.id.toUUID()) 1051 | 1052 | observation.highCardinalityKeyValue("order", updatedOrderDocument.toString()) 1053 | updatedOrderDocument.toOrder().also { orderDocument -> log.info("updated order: $orderDocument") } 1054 | } 1055 | } 1056 | 1057 | override suspend fun getByID(id: String): Order = coroutineScopeWithObservation(GET_BY_ID, or) { observation -> 1058 | withContext(Dispatchers.IO) { 1059 | mongoTemplate.findById(id, OrderDocument::class.java).awaitSingle().toOrder() 1060 | .also { log.info("found order: $it") } 1061 | .also { observation.highCardinalityKeyValue("order", it.toString()) } 1062 | } 1063 | } 1064 | 1065 | override suspend fun getAllOrders(pageable: Pageable): Page = coroutineScopeWithObservation(GET_ALL_ORDERS, or) { observation -> 1066 | withContext(Dispatchers.IO) { 1067 | val query = Query().with(pageable) 1068 | val data = async { mongoTemplate.find(query, OrderDocument::class.java).collectList().awaitSingle() }.await() 1069 | val count = async { mongoTemplate.count(Query(), OrderDocument::class.java).awaitSingle() }.await() 1070 | PageableExecutionUtils.getPage(data.map { it.toOrder() }, pageable) { count } 1071 | .also { observation.highCardinalityKeyValue("pageResult", it.pageable.toString()) } 1072 | } 1073 | } 1074 | } 1075 | ``` 1076 | 1077 | of course in real-world applications, we have to implement many more necessary features, like k8s health checks, rate limiters, etc., 1078 | depending on the project it can be implemented in different ways, for example, you can use Kubernetes and Istio for some of them. 1079 | I hope this article is usefully and helpfully, and be happy to receive any feedback or questions, feel free to contact [me](https://www.linkedin.com/in/alexander-bryksin/) by [email](alexander.bryksin@yandex.ru) or any [messengers](https://t.me/AlexanderBryksin) :) 1080 | --------------------------------------------------------------------------------