Skip to content

Commit 2c90b94

Browse files
authored
Merge pull request #8 from The-Self-Taught-Software-Engineer/idiomatic-kotlin/#03_result
Idiomatic Kotlin (#03) – Result mechanism
2 parents 7021e47 + 9264acd commit 2c90b94

7 files changed

Lines changed: 314 additions & 0 deletions

File tree

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies {
1717
implementation("com.sun.mail:javax.mail:1.5.5")
1818
implementation("io.github.microutils:kotlin-logging:2.0.6")
1919
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1")
20+
implementation("org.junit.jupiter:junit-jupiter:5.7.0")
2021
implementation("org.slf4j:slf4j-api:1.7.30")
2122

2223
testImplementation(kotlin("test"))
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package codes.jakob.tstse.example.common
2+
3+
import java.math.BigDecimal
4+
import java.time.LocalDate
5+
6+
interface HumanResourcesClient {
7+
/**
8+
* Returns the hourly rate of the given [developer].
9+
*
10+
* @throws UnknownException If the [developer] is not known by the external HR system.
11+
* @throws TimeoutException If the request to the external HR system timed out.
12+
*/
13+
fun getHourlyRate(developer: Developer): BigDecimal
14+
15+
/**
16+
* Returns the hourly worked by the given [developer] within the given [period].
17+
*
18+
* @throws UnknownException If the [developer] is not known by the external HR system.
19+
* @throws UnavailableException If the [developer] did not work within the given [period].
20+
* @throws TimeoutException If the request to the external HR system timed out.
21+
*/
22+
fun getHoursWorked(developer: Developer, period: ClosedRange<LocalDate>): BigDecimal
23+
24+
open class HumanResourcesException(
25+
override val message: String?,
26+
override val cause: Throwable?,
27+
) : RuntimeException()
28+
29+
class UnknownException(
30+
val developer: Developer,
31+
cause: Throwable? = null,
32+
) : HumanResourcesException("The developer '$developer' is unknown to the HR system", cause)
33+
34+
class UnavailableException(
35+
val developer: Developer,
36+
message: String?,
37+
cause: Throwable?,
38+
) : HumanResourcesException(message, cause)
39+
40+
class TimeoutException(
41+
val developer: Developer,
42+
message: String?,
43+
cause: Throwable?,
44+
) : HumanResourcesException(message, cause)
45+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package codes.jakob.tstse.example.common
2+
3+
import java.time.LocalDate
4+
5+
@JvmInline
6+
value class LocalDateRange(val range: ClosedRange<LocalDate>)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package codes.jakob.tstse.example.common
2+
3+
import java.math.BigDecimal
4+
import java.time.LocalDate
5+
6+
data class Paycheck(
7+
val date: LocalDate,
8+
val period: LocalDateRange,
9+
val developer: Developer,
10+
val hourlyRate: BigDecimal,
11+
val hoursWorked: BigDecimal,
12+
) {
13+
val salary: BigDecimal
14+
get() {
15+
return hoursWorked.multiply(hourlyRate)
16+
}
17+
18+
class Builder {
19+
var date: LocalDate? = null
20+
var period: LocalDateRange? = null
21+
var developer: Developer? = null
22+
var hourlyRate: BigDecimal? = null
23+
var hoursWorked: BigDecimal? = null
24+
25+
fun build(): Paycheck = Paycheck(
26+
date = date ?: error("A date is required"),
27+
period = period ?: error("A period is required"),
28+
developer = developer ?: error("A developer is required"),
29+
hoursWorked = hoursWorked ?: error("The worked hours are required"),
30+
hourlyRate = hourlyRate ?: error("A hourly rate is required"),
31+
)
32+
}
33+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package codes.jakob.tstse.example.common
2+
3+
import java.time.LocalDate
4+
5+
class PaycheckRepository {
6+
fun save(paychecks: List<Paycheck>) {
7+
TODO("Not yet implemented")
8+
}
9+
10+
fun saveFailed(developers: List<Developer>, paycheckPeriod: ClosedRange<LocalDate>) {
11+
TODO("Not yet implemented")
12+
}
13+
14+
fun save(paychecks: Paycheck) {
15+
TODO("Not yet implemented")
16+
}
17+
18+
fun saveFailed(developer: Developer, paycheckPeriod: ClosedRange<LocalDate>) {
19+
TODO("Not yet implemented")
20+
}
21+
22+
fun exists(developer: Developer, paycheckPeriod: ClosedRange<LocalDate>): Boolean {
23+
TODO("Not yet implemented")
24+
}
25+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
@file:Suppress("unused", "UNUSED_VARIABLE")
2+
3+
package codes.jakob.tstse.example.idiomatic.result
4+
5+
import codes.jakob.tstse.example.common.*
6+
import codes.jakob.tstse.example.idiomatic.scopefunctions.DeveloperRepository
7+
import mu.KLogger
8+
import mu.KotlinLogging
9+
import java.time.Clock
10+
import java.time.LocalDate
11+
import java.time.Month
12+
import java.time.Year
13+
14+
class PaycheckService(
15+
private val humanResourcesClient: HumanResourcesClient,
16+
private val paycheckRepository: PaycheckRepository,
17+
private val developerRepository: DeveloperRepository,
18+
private val clock: Clock,
19+
) {
20+
/**
21+
* Returns an ordered list of [Paycheck]; sorted lexicographically by the [Name] of the [Developer].
22+
* Additionally, each paycheck, successfully generated or not, is persisted into the database.
23+
*/
24+
fun generateAndPersistPaychecks(developerType: DeveloperType, year: Year, month: Month): List<Paycheck> {
25+
val today: LocalDate = LocalDate.now(clock)
26+
val period: LocalDateRange = determinePeriod(year, month)
27+
28+
return developerRepository.findDevelopersByType(developerType)
29+
.filterNot { paycheckRepository.exists(it, period.range) }
30+
.mapNotNull { dev: Developer ->
31+
val paycheckBuilder = Paycheck.Builder().apply {
32+
this.date = today
33+
this.period = period
34+
this.developer = dev
35+
}
36+
Result.success(paycheckBuilder)
37+
.mapCatching { paycheck ->
38+
paycheck.apply {
39+
hourlyRate = humanResourcesClient.getHourlyRate(dev)
40+
hoursWorked = humanResourcesClient.getHoursWorked(dev, period.range)
41+
}
42+
}
43+
.mapCatching { paycheck ->
44+
paycheck.build()
45+
}
46+
.fold(
47+
{ paycheck ->
48+
paycheckRepository.save(paycheck)
49+
logger.info { "Generated and persisted paycheck for '$dev' in period '$period'" }
50+
return@fold paycheck
51+
},
52+
{ exception ->
53+
logger.error(exception) { "Generation of paycheck for '$dev' in period '$period' failed; persisting this" }
54+
paycheckRepository.saveFailed(dev, period.range)
55+
return@fold null
56+
}
57+
)
58+
}
59+
.sortedBy { it.developer.name }
60+
.also { logger.info { "Generated and persisted paychecks for ${it.count()} developers" } }
61+
62+
// val (successfulPaychecks: List<Paycheck>, failedPaychecks: List<Developer>) =
63+
// developerRepository.findDevelopersByType(developerType)
64+
// .asSequence()
65+
// .filterNot { paycheckRepository.exists(it, period.range) }
66+
// .map { dev ->
67+
// dev to Paycheck.Builder().apply {
68+
// this.date = today
69+
// this.period = period
70+
// this.developer = dev
71+
// }
72+
// }
73+
// .map { (dev, paycheckBuilder) ->
74+
// try {
75+
// dev to paycheckBuilder.apply {
76+
// hourlyRate = humanResourcesClient.getHourlyRate(dev)
77+
// hoursWorked = humanResourcesClient.getHoursWorked(dev, period.range)
78+
// }
79+
// } catch (exception: HumanResourcesClient.HumanResourcesException) {
80+
// logger.error(exception) {
81+
// "Generation of ${Paycheck::class.simpleName} for '$dev' in period '$period' failed"
82+
// }
83+
// dev to null
84+
// }
85+
// }
86+
// .map { (dev, paycheckBuilder) ->
87+
// try {
88+
// dev to paycheckBuilder?.build()
89+
// } catch (exception: RuntimeException) {
90+
// logger.error(exception) {
91+
// "Generation of ${Paycheck::class.simpleName} for '$dev' in period '$period' failed"
92+
// }
93+
// dev to null
94+
// }
95+
// }
96+
// .partition { (dev, paycheck) ->
97+
// paycheck != null
98+
// }
99+
// .let { (success, failure) ->
100+
// success.map { it.second!! } to failure.map { it.first }
101+
// }
102+
//
103+
// successfulPaychecks.forEach { paycheck ->
104+
// paycheckRepository.save(paycheck)
105+
// .also { logger.info { "Generated and persisted paycheck for '${paycheck.developer}' in period '$period'" } }
106+
// }
107+
// failedPaychecks.forEach { dev ->
108+
// paycheckRepository.saveFailed(dev, period.range)
109+
// }
110+
//
111+
// return successfulPaychecks
112+
// .sortedBy { it.developer.name }
113+
// .also { logger.info { "Generated and persisted paychecks for ${it.count()} developers" } }
114+
}
115+
116+
private fun determinePeriod(year: Year, month: Month): LocalDateRange {
117+
val firstDayInPeriod: LocalDate = LocalDate.of(year.value, month, 1)
118+
val lastDayInPeriod: LocalDate = firstDayInPeriod.withDayOfMonth(firstDayInPeriod.lengthOfMonth())
119+
return LocalDateRange(firstDayInPeriod.rangeTo(lastDayInPeriod))
120+
}
121+
122+
companion object {
123+
private val logger: KLogger = KotlinLogging.logger {}
124+
}
125+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package codes.jakob.tstse.example.idiomatic.result
2+
3+
import codes.jakob.tstse.example.common.*
4+
import codes.jakob.tstse.example.idiomatic.scopefunctions.DeveloperRepository
5+
import com.github.javafaker.Faker
6+
import io.mockk.every
7+
import io.mockk.mockk
8+
import org.junit.jupiter.api.Test
9+
import java.math.BigDecimal
10+
import java.net.SocketTimeoutException
11+
import java.time.Clock
12+
import java.time.Month
13+
import java.time.Year
14+
import java.util.*
15+
16+
internal class PaycheckServiceTest {
17+
private val faker = Faker()
18+
private val exists: List<Boolean> = listOf(true, false, true, false, true, true, false, false)
19+
private val willFail: List<Boolean> = listOf(false, false, false, false, false, false, true, false)
20+
private val state: Map<Developer, Pair<Boolean, Boolean>> =
21+
exists.zip(willFail).map { (paycheckExists, willFail) ->
22+
Triple(backendDeveloper(), paycheckExists, willFail)
23+
}.groupBy({ it.first }, { it.second to it.third }).mapValues { it.value.first() }
24+
25+
@Test
26+
fun generatePaychecks() {
27+
// Given
28+
val humanResourcesClient = mockk<HumanResourcesClient>()
29+
every {
30+
humanResourcesClient.getHourlyRate(any())
31+
} returns BigDecimal("21.00")
32+
every {
33+
humanResourcesClient.getHoursWorked(any(), any())
34+
} answers {
35+
val developer = firstArg<Developer>()
36+
if (state[developer]!!.second) {
37+
throw HumanResourcesClient.TimeoutException(
38+
developer = developer,
39+
message = "Could not retrieve worked hours for '$developer'",
40+
cause = SocketTimeoutException("Request to external HR service timed out after 10000ms"),
41+
)
42+
} else BigDecimal("155.0")
43+
}
44+
45+
val paycheckRepository = mockk<PaycheckRepository>(relaxed = true)
46+
every {
47+
paycheckRepository.exists(any(), any())
48+
} answers { state[firstArg()]!!.first }
49+
50+
val developerRepository = mockk<DeveloperRepository>()
51+
every {
52+
developerRepository.findDevelopersByType(DeveloperType.BACK_END)
53+
} returns state.keys.toSet()
54+
55+
// When
56+
println("\n\nDevelopers:\n")
57+
state.keys.forEach { dev ->
58+
println("Developer: ${dev.name} (exists: ${state[dev]!!.first}, fails: ${state[dev]!!.second})")
59+
}
60+
61+
println("\nResults:\n")
62+
val sut = PaycheckService(humanResourcesClient, paycheckRepository, developerRepository, Clock.systemUTC())
63+
sut.generateAndPersistPaychecks(DeveloperType.BACK_END, Year.of(2021), Month.JULY)
64+
65+
println("\n")
66+
}
67+
68+
private fun backendDeveloper(): Developer {
69+
val firstName = faker.name().firstName()
70+
val lastName = faker.name().lastName()
71+
return Developer(
72+
id = UUID.randomUUID(),
73+
name = Name("$firstName $lastName"),
74+
type = DeveloperType.BACK_END,
75+
emailAddress = "${firstName.lowercase(Locale.getDefault())}@example.com",
76+
assignment = null
77+
)
78+
}
79+
}

0 commit comments

Comments
 (0)