move and refactor city and product services to core

This commit is contained in:
Savosin Denis
2025-05-06 14:09:01 +07:00
parent b855aba506
commit f92ba7795d
18 changed files with 264 additions and 202 deletions

View File

@@ -2,6 +2,9 @@ group = "com.github.dannecron.demo"
version = "single-version"
dependencies {
implementation(project(":db"))
implementation(rootProject.libs.spring.boot.starter.jdbc)
implementation(rootProject.libs.spring.boot.starter.validation)
implementation(rootProject.libs.json.schema.validator)
}

View File

@@ -0,0 +1,15 @@
package com.github.dannecron.demo.core.dto
import java.time.OffsetDateTime
import java.util.UUID
data class City(
val id: Long,
val guid: UUID,
val name: String,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime?,
val deletedAt: OffsetDateTime?,
) {
fun isDeleted(): Boolean = deletedAt != null
}

View File

@@ -0,0 +1,11 @@
package com.github.dannecron.demo.core.dto
import java.time.OffsetDateTime
data class CityCreate(
val guid: String,
val name: String,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime?,
val deletedAt: OffsetDateTime?,
)

View File

@@ -0,0 +1,26 @@
package com.github.dannecron.demo.core.dto
import java.time.OffsetDateTime
import java.util.UUID
import kotlin.math.pow
import kotlin.math.roundToInt
data class Product(
val id: Long,
val guid: UUID,
val name: String,
val description: String?,
val price: Long,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime?,
val deletedAt: OffsetDateTime?,
) {
fun getPriceDouble(): Double = (price.toDouble() / 100).roundTo(2)
fun isDeleted(): Boolean = deletedAt != null
private fun Double.roundTo(numFractionDigits: Int): Double {
val factor = 10.0.pow(numFractionDigits.toDouble())
return (this * factor).roundToInt() / factor
}
}

View File

@@ -0,0 +1,3 @@
package com.github.dannecron.demo.core.exceptions
class AlreadyDeletedException: RuntimeException()

View File

@@ -0,0 +1,3 @@
package com.github.dannecron.demo.core.exceptions
class CityNotFoundException: ModelNotFoundException("city")

View File

@@ -0,0 +1,3 @@
package com.github.dannecron.demo.core.exceptions
open class ModelNotFoundException(entityName: String): RuntimeException("$entityName not found")

View File

@@ -0,0 +1,3 @@
package com.github.dannecron.demo.core.exceptions
class ProductNotFoundException: ModelNotFoundException("product")

View File

@@ -0,0 +1,19 @@
package com.github.dannecron.demo.core.services.city
import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.CityCreate
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.CityNotFoundException
import org.springframework.stereotype.Service
import java.util.UUID
@Service
interface CityService {
fun findByGuid(guid: UUID): City?
fun create(name: String): City
fun create(cityCreate: CityCreate): City
@Throws(CityNotFoundException::class, AlreadyDeletedException::class)
fun delete(guid: UUID): City
}

View File

@@ -0,0 +1,71 @@
package com.github.dannecron.demo.core.services.city
import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.CityCreate
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.CityNotFoundException
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.CityEntity
import com.github.dannecron.demo.db.repository.CityRepository
import org.springframework.stereotype.Service
import java.time.OffsetDateTime
import java.util.UUID
@Service
class CityServiceImpl(
private val cityRepository: CityRepository,
private val commonGenerator: CommonGenerator,
): CityService {
override fun findByGuid(guid: UUID): City? = cityRepository.findByGuid(guid)?.toCore()
override fun create(name: String): City = CityEntity(
id = null,
guid = commonGenerator.generateUUID(),
name = name,
createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null,
deletedAt = null,
).let(cityRepository::save).toCore()
override fun create(cityCreate: CityCreate): City = CityEntity(
id = null,
guid = UUID.fromString(cityCreate.guid),
name = cityCreate.name,
createdAt = cityCreate.createdAt,
updatedAt = cityCreate.updatedAt,
deletedAt = cityCreate.deletedAt,
).let(cityRepository::save).toCore()
@Throws(CityNotFoundException::class, AlreadyDeletedException::class)
override fun delete(guid: UUID): City {
val city = findByGuid(guid) ?: throw CityNotFoundException()
if (city.isDeleted()) {
throw AlreadyDeletedException()
}
return cityRepository.save(
city.copy(
deletedAt = OffsetDateTime.now(),
).toEntity()
).toCore()
}
private fun CityEntity.toCore() = City(
id = id!!,
guid = guid,
name = name,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
private fun City.toEntity() = CityEntity(
id = id,
guid = guid,
name = name,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}

View File

@@ -0,0 +1,19 @@
package com.github.dannecron.demo.core.services.product
import com.github.dannecron.demo.core.dto.Product
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.ProductNotFoundException
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import java.util.UUID
interface ProductService {
fun findByGuid(guid: UUID): Product?
fun findAll(pageable: Pageable): Page<Product>
fun create(name: String, price: Long, description: String?): Product
@Throws(ProductNotFoundException::class, AlreadyDeletedException::class)
fun delete(guid: UUID): Product
}

View File

@@ -1,30 +1,26 @@
package com.github.dannecron.demo.services.database.product
package com.github.dannecron.demo.core.services.product
import com.github.dannecron.demo.core.dto.Product
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.ProductNotFoundException
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.core.utils.LoggerDelegate
import com.github.dannecron.demo.db.entity.ProductEntity
import com.github.dannecron.demo.db.repository.ProductRepository
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.database.exceptions.ProductNotFoundException
import com.github.dannecron.demo.services.kafka.Producer
import com.github.dannecron.demo.services.kafka.dto.ProductDto
import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException
import com.github.dannecron.demo.utils.LoggerDelegate
import net.logstash.logback.marker.Markers
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.time.format.DateTimeFormatter
import java.util.UUID
@Service
class ProductServiceImpl(
private val productRepository: ProductRepository,
private val producer: Producer,
private val commonGenerator: CommonGenerator,
): ProductService {
private val logger by LoggerDelegate()
override fun findByGuid(guid: UUID): Product? = productRepository.findByGuid(guid)
override fun findByGuid(guid: UUID): Product? = productRepository.findByGuid(guid)?.toCore()
.also {
logger.debug(
Markers.appendEntries(mapOf("guid" to guid, "idResult" to it?.id)),
@@ -33,9 +29,10 @@ class ProductServiceImpl(
}
override fun findAll(pageable: Pageable): Page<Product> = productRepository.findAll(pageable)
.map { it.toCore() }
override fun create(name: String, price: Long, description: String?): Product {
val product = Product(
override fun create(name: String, price: Long, description: String?): Product =
ProductEntity(
id = null,
guid = commonGenerator.generateUUID(),
name = name,
@@ -44,11 +41,9 @@ class ProductServiceImpl(
createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null,
deletedAt = null,
)
return productRepository.save(product)
}
).let(productRepository::save).toCore()
@Throws(ProductNotFoundException::class, AlreadyDeletedException::class)
override fun delete(guid: UUID): Product {
val product = findByGuid(guid) ?: throw ProductNotFoundException()
@@ -56,27 +51,32 @@ class ProductServiceImpl(
throw AlreadyDeletedException()
}
val deletedProduct = product.copy(
return product.copy(
deletedAt = commonGenerator.generateCurrentTime(),
)
return productRepository.save(deletedProduct)
).toEntity()
.let(productRepository::save)
.toCore()
}
override fun syncToKafka(guid: UUID, topic: String?) {
val product = findByGuid(guid) ?: throw ProductNotFoundException()
producer.produceProductSync(product.toKafkaDto())
}
private fun Product.toKafkaDto() = ProductDto(
id = id ?: throw InvalidArgumentException("product.id"),
guid = guid.toString(),
private fun ProductEntity.toCore() = Product(
id = id!!,
guid = guid,
name = name,
description = description,
price = price,
createdAt = createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
updatedAt = updatedAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
deletedAt = deletedAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
private fun Product.toEntity() = ProductEntity(
id = id,
guid = guid,
name = name,
description = description,
price = price,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}

View File

@@ -1,4 +1,4 @@
package com.github.dannecron.demo.utils
package com.github.dannecron.demo.core.utils
import org.slf4j.Logger
import org.slf4j.LoggerFactory

View File

@@ -1,9 +1,10 @@
package com.github.dannecron.demo.services.database.city
package com.github.dannecron.demo.core.services.city
import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.CityCreate
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.City
import com.github.dannecron.demo.db.entity.CityEntity
import com.github.dannecron.demo.db.repository.CityRepository
import com.github.dannecron.demo.services.kafka.dto.CityCreateDto
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
@@ -11,7 +12,6 @@ import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -27,6 +27,14 @@ class CityServiceImplTest {
private val cityRepository: CityRepository = mock()
private val cityServiceImpl = CityServiceImpl(cityRepository, commonGenerator)
private val cityEntity = CityEntity(
id = 1000,
guid = mockGuid,
name = "name",
createdAt = mockCurrentTime,
updatedAt = null,
deletedAt = null,
)
private val city = City(
id = 1000,
guid = mockGuid,
@@ -38,33 +46,34 @@ class CityServiceImplTest {
@Test
fun `create - by name`() {
whenever(cityRepository.save(any<City>())).thenReturn(city)
whenever(cityRepository.save(any<CityEntity>())).thenReturn(cityEntity)
val result = cityServiceImpl.create("name")
assertEquals(city, result)
verify(cityRepository, times(1)).save(city.copy(id = null))
verify(cityRepository, times(1)).save(cityEntity.copy(id = null))
}
@Test
fun `create - by dto`() {
val cityGuid = UUID.randomUUID()
val createdAt = OffsetDateTime.now()
val cityCreate = CityCreateDto(
val cityCreate = CityCreate(
guid = cityGuid.toString(),
name = "name",
createdAt = createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
createdAt = createdAt,
updatedAt = null,
deletedAt = null,
)
val expectedCityEntity = cityEntity.copy(guid = cityGuid, createdAt = createdAt)
val expectedCity = city.copy(guid = cityGuid, createdAt = createdAt)
whenever(cityRepository.save(any<City>())).thenReturn(expectedCity)
whenever(cityRepository.save(any<CityEntity>())).thenReturn(expectedCityEntity)
val result = cityServiceImpl.create(cityCreate)
assertEquals(expectedCity, result)
verify(cityRepository, times(1)).save(expectedCity.copy(id = null))
verify(cityRepository, times(1)).save(expectedCityEntity.copy(id = null))
}
}

View File

@@ -1,12 +1,11 @@
package com.github.dannecron.demo.services.database.product
package com.github.dannecron.demo.core.services.product
import com.github.dannecron.demo.core.dto.Product
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.ProductNotFoundException
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.db.entity.ProductEntity
import com.github.dannecron.demo.db.repository.ProductRepository
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.database.exceptions.ProductNotFoundException
import com.github.dannecron.demo.services.kafka.Producer
import com.github.dannecron.demo.services.kafka.dto.ProductDto
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.kotlin.any
@@ -15,10 +14,8 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import kotlin.test.assertEquals
@@ -27,7 +24,6 @@ class ProductServiceImplTest {
private val mockCurrentTime = OffsetDateTime.now()
private val productRepository: ProductRepository = mock()
private val producer: Producer = mock()
private val commonGenerator: CommonGenerator = mock {
on { generateUUID() } doReturn mockGuid
on { generateCurrentTime() } doReturn mockCurrentTime
@@ -35,72 +31,79 @@ class ProductServiceImplTest {
private val productService = ProductServiceImpl(
productRepository = productRepository,
producer = producer,
commonGenerator = commonGenerator,
)
private val guid = UUID.randomUUID()
private val productEntity = ProductEntity(
id = 123,
guid = guid,
name = "name",
description = "description",
price = 10050,
createdAt = mockCurrentTime.minusDays(1),
updatedAt = mockCurrentTime.minusHours(2),
deletedAt = null,
)
private val product = Product(
id = 123,
guid = guid,
name = "name",
description = "description",
price = 10050,
createdAt = OffsetDateTime.now().minusDays(1),
updatedAt = OffsetDateTime.now().minusHours(2),
deletedAt = null,
)
private val kafkaProductDto = ProductDto(
id = 123,
guid = guid.toString(),
name = "name",
description = "description",
price = 10050,
createdAt = product.createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
updatedAt = product.updatedAt!!.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
createdAt = mockCurrentTime.minusDays(1),
updatedAt = mockCurrentTime.minusHours(2),
deletedAt = null,
)
@Test
fun create() {
val expectedProductForCreation = product.copy(
val expectedProductForCreation = productEntity.copy(
id = null,
guid = mockGuid,
createdAt = mockCurrentTime,
updatedAt = null,
)
val expectedCreatedProduct = expectedProductForCreation.copy(id = 1)
val expectedCreatedProductEntity = expectedProductForCreation.copy(id = 1)
val expectedProduct = product.copy(
id = 1,
guid = mockGuid,
createdAt = mockCurrentTime,
updatedAt = null,
)
whenever(productRepository.save<Product>(any())).thenReturn(expectedCreatedProduct)
whenever(productRepository.save<ProductEntity>(any())).thenReturn(expectedCreatedProductEntity)
val result = productService.create(
name = "name",
price = 10050,
description = "description",
)
assertEquals(expectedCreatedProduct, result)
assertEquals(expectedProduct, result)
verify(productRepository, times(1)).save(expectedProductForCreation)
}
@Test
fun `delete - success`() {
val deletedProduct = product.copy(
val deletedProductEntity = productEntity.copy(
deletedAt = mockCurrentTime,
)
whenever(productRepository.findByGuid(any())).thenReturn(product)
whenever(productRepository.save<Product>(any())).thenReturn(deletedProduct)
val expectedProduct = product.copy(deletedAt = mockCurrentTime)
whenever(productRepository.findByGuid(any())).thenReturn(productEntity)
whenever(productRepository.save<ProductEntity>(any())).thenReturn(deletedProductEntity)
val result = productService.delete(guid)
assertEquals(deletedProduct, result)
assertEquals(expectedProduct, result)
verify(productRepository, times(1)).findByGuid(guid)
verify(productRepository, times(1)).save(deletedProduct)
verify(productRepository, times(1)).save(deletedProductEntity)
}
@Test
fun `delete - fail - already deleted`() {
val deletedProduct = product.copy(
val deletedProduct = productEntity.copy(
deletedAt = mockCurrentTime,
)
whenever(productRepository.findByGuid(any())).thenReturn(deletedProduct)
@@ -124,26 +127,4 @@ class ProductServiceImplTest {
verify(productRepository, times(1)).findByGuid(guid)
verify(productRepository, never()).save(any())
}
@Test
fun `syncToKafka - success`() {
whenever(productRepository.findByGuid(any())) doReturn product
productService.syncToKafka(guid, null)
verify(productRepository, times(1)).findByGuid(guid)
verify(producer, times(1)).produceProductSync(kafkaProductDto)
}
@Test
fun `syncToKafka - not found`() {
whenever(productRepository.findByGuid(any())) doReturn null
assertThrows<ProductNotFoundException> {
productService.syncToKafka(guid, null)
}
verify(productRepository, times(1)).findByGuid(guid)
verifyNoInteractions(producer)
}
}

View File

@@ -1,19 +0,0 @@
package com.github.dannecron.demo.services.database.city
import com.github.dannecron.demo.db.entity.City
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.kafka.dto.CityCreateDto
import org.springframework.stereotype.Service
import java.util.*
@Service
interface CityService {
fun findByGuid(guid: UUID): City?
fun create(name: String): City
fun create(kafkaCityDto: CityCreateDto): City
@Throws(CityNotFoundException::class, AlreadyDeletedException::class)
fun delete(guid: UUID): City
}

View File

@@ -1,60 +0,0 @@
package com.github.dannecron.demo.services.database.city
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.City
import com.github.dannecron.demo.db.repository.CityRepository
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import com.github.dannecron.demo.services.kafka.dto.CityCreateDto
import org.springframework.stereotype.Service
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
@Service
class CityServiceImpl(
private val cityRepository: CityRepository,
private val commonGenerator: CommonGenerator,
): CityService {
override fun findByGuid(guid: UUID): City? = cityRepository.findByGuid(guid)
override fun create(name: String): City = City(
id = null,
guid = commonGenerator.generateUUID(),
name = name,
createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null,
deletedAt = null,
).let {
cityRepository.save(it)
}
override fun create(kafkaCityDto: CityCreateDto): City = City(
id = null,
guid = UUID.fromString(kafkaCityDto.guid),
name = kafkaCityDto.name,
createdAt = OffsetDateTime.parse(kafkaCityDto.createdAt, DateTimeFormatter.ISO_OFFSET_DATE_TIME),
updatedAt = kafkaCityDto.deletedAt?.let {
OffsetDateTime.parse(it, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
},
deletedAt = kafkaCityDto.deletedAt?.let {
OffsetDateTime.parse(it, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
},
).let {
cityRepository.save(it)
}
override fun delete(guid: UUID): City {
val city = findByGuid(guid) ?: throw CityNotFoundException()
if (city.isDeleted()) {
throw AlreadyDeletedException()
}
return cityRepository.save(
city.copy(
deletedAt = OffsetDateTime.now(),
)
)
}
}

View File

@@ -1,25 +0,0 @@
package com.github.dannecron.demo.services.database.product
import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.database.exceptions.ProductNotFoundException
import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.util.UUID
@Service
interface ProductService {
fun findByGuid(guid: UUID): Product?
fun findAll(pageable: Pageable): Page<Product>
fun create(name: String, price: Long, description: String?): Product
@Throws(ProductNotFoundException::class, AlreadyDeletedException::class)
fun delete(guid: UUID): Product
@Throws(ProductNotFoundException::class, InvalidArgumentException::class)
fun syncToKafka(guid: UUID, topic: String?)
}