Skip to content

Commit 40afffc

Browse files
authored
functional domain modeling (#63)
* domain * fix tests * rename ValidationError to UserValidationError * Validated<E, A>.valueOrThrow: A * up * update UserRepositoryImpl.kt * update UserRepositoryImpl.kt * fix compiler error * [skip ci] * Update unit-test.yml * up * up
1 parent ce0b5bc commit 40afffc

File tree

47 files changed

+776
-323
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+776
-323
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ trim_trailing_whitespace=true
88
insert_final_newline=true
99

1010
[*.{kt,kts}]
11-
kotlin_imports_layout=ascii
11+
ij_kotlin_imports_layout=*

.github/workflows/unit-test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
pull_request:
88
branches: [ master ]
99
paths-ignore: [ '**.md', '**.MD' ]
10+
workflow_dispatch:
1011

1112
env:
1213
CI: true

app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt renamed to app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import io.mockk.mockkClass
66
import kotlinx.coroutines.ExperimentalCoroutinesApi
77
import kotlinx.coroutines.FlowPreview
88
import org.junit.Rule
9+
import org.koin.test.AutoCloseKoinTest
910
import org.koin.test.check.checkKoinModules
1011
import org.koin.test.mock.MockProviderRule
1112
import kotlin.test.Test
@@ -15,7 +16,7 @@ import kotlin.time.ExperimentalTime
1516
@FlowPreview
1617
@ExperimentalCoroutinesApi
1718
@ExperimentalTime
18-
class ExampleUnitTest {
19+
class CheckModulesTest : AutoCloseKoinTest() {
1920
@get:Rule
2021
val mockProvider = MockProviderRule.create { clazz ->
2122
mockkClass(clazz).also { o ->

build.gradle.kts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import java.util.EnumSet
2+
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
3+
import org.gradle.api.tasks.testing.logging.TestLogEvent
14
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
25

36
// Top-level build file where you can add configuration options common to all sub-projects/modules.
@@ -33,7 +36,9 @@ subprojects {
3336
// TODO this should all come from editorconfig https://github.com/diffplug/spotless/issues/142
3437
mapOf(
3538
"indent_size" to "2",
36-
"kotlin_imports_layout" to "ascii"
39+
"ij_kotlin_imports_layout" to "*",
40+
"end_of_line" to "lf",
41+
"charset" to "utf-8"
3742
)
3843
)
3944

@@ -56,7 +61,9 @@ subprojects {
5661
ktlint(ktlintVersion).userData(
5762
mapOf(
5863
"indent_size" to "2",
59-
"kotlin_imports_layout" to "ascii"
64+
"ij_kotlin_imports_layout" to "*",
65+
"end_of_line" to "lf",
66+
"charset" to "utf-8"
6067
)
6168
)
6269

@@ -83,6 +90,21 @@ subprojects {
8390
isIncludeNoLocationClasses = true
8491
excludes = listOf("jdk.internal.*")
8592
}
93+
94+
testLogging {
95+
showExceptions = true
96+
showCauses = true
97+
showStackTraces = true
98+
showStandardStreams = true
99+
events = EnumSet.of(
100+
TestLogEvent.PASSED,
101+
TestLogEvent.FAILED,
102+
TestLogEvent.SKIPPED,
103+
TestLogEvent.STANDARD_OUT,
104+
TestLogEvent.STANDARD_ERROR
105+
)
106+
exceptionFormat = TestExceptionFormat.FULL
107+
}
86108
}
87109
}
88110
}

buildSrc/src/main/kotlin/deps.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import org.gradle.kotlin.dsl.project
66
import org.gradle.plugin.use.PluginDependenciesSpec
77
import org.gradle.plugin.use.PluginDependencySpec
88

9-
const val ktlintVersion = "0.41.0"
9+
const val ktlintVersion = "0.43.0"
1010
const val kotlinVersion = "1.5.31"
1111

1212
object appConfig {

data/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,5 @@ dependencies {
5656

5757
addUnitTest()
5858
testImplementation(testUtils)
59+
testImplementation(deps.koin.testJunit4)
5960
}
Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
package com.hoc.flowmvi.data
22

3-
import arrow.core.Either
3+
import arrow.core.ValidatedNel
4+
import arrow.core.computations.either
45
import arrow.core.left
56
import arrow.core.leftWiden
67
import arrow.core.right
8+
import arrow.core.valueOr
79
import com.hoc.flowmvi.core.Mapper
810
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
911
import com.hoc.flowmvi.core.retrySuspend
1012
import com.hoc.flowmvi.data.remote.UserApiService
1113
import com.hoc.flowmvi.data.remote.UserBody
1214
import com.hoc.flowmvi.data.remote.UserResponse
13-
import com.hoc.flowmvi.domain.entity.User
14-
import com.hoc.flowmvi.domain.repository.UserError
15+
import com.hoc.flowmvi.domain.model.User
16+
import com.hoc.flowmvi.domain.model.UserError
17+
import com.hoc.flowmvi.domain.model.UserValidationError
1518
import com.hoc.flowmvi.domain.repository.UserRepository
1619
import kotlinx.coroutines.ExperimentalCoroutinesApi
1720
import kotlinx.coroutines.delay
@@ -27,13 +30,14 @@ import timber.log.Timber
2730
import java.io.IOException
2831
import kotlin.time.Duration
2932
import kotlin.time.ExperimentalTime
33+
import arrow.core.Either.Companion.catch as catchEither
3034

3135
@ExperimentalTime
3236
@ExperimentalCoroutinesApi
3337
internal class UserRepositoryImpl(
3438
private val userApiService: UserApiService,
3539
private val dispatchers: CoroutineDispatchers,
36-
private val responseToDomain: Mapper<UserResponse, User>,
40+
private val responseToDomain: Mapper<UserResponse, ValidatedNel<UserValidationError, User>>,
3741
private val domainToBody: Mapper<User, UserBody>,
3842
private val errorMapper: Mapper<Throwable, UserError>,
3943
) : UserRepository {
@@ -44,18 +48,35 @@ internal class UserRepositoryImpl(
4448
class Added(val user: User) : Change()
4549
}
4650

51+
private val responseToDomainThrows: (UserResponse) -> User = { response ->
52+
responseToDomain(response).let { validated ->
53+
validated.valueOr {
54+
val t = UserError.ValidationFailed(it.toSet())
55+
logError(t, "Map $response to user")
56+
throw t
57+
}
58+
}
59+
}
60+
4761
private val changesFlow = MutableSharedFlow<Change>(extraBufferCapacity = 64)
4862

63+
private suspend inline fun sendChange(change: Change) = changesFlow.emit(change)
64+
65+
@Suppress("NOTHING_TO_INLINE")
66+
private inline fun logError(t: Throwable, message: String) = Timber.tag(TAG).e(t, message)
67+
4968
private suspend fun getUsersFromRemote(): List<User> {
5069
return withContext(dispatchers.io) {
5170
retrySuspend(
5271
times = 3,
5372
initialDelay = Duration.milliseconds(500),
5473
factor = 2.0,
5574
shouldRetry = { it is IOException }
56-
) {
57-
Timber.d("[USER_REPO] Retry times=$it")
58-
userApiService.getUsers().map(responseToDomain)
75+
) { times ->
76+
Timber.d("[USER_REPO] Retry times=$times")
77+
userApiService
78+
.getUsers()
79+
.map(responseToDomainThrows)
5980
}
6081
}
6182
}
@@ -77,40 +98,57 @@ internal class UserRepositoryImpl(
7798
}
7899
.map { it.right().leftWiden<UserError, Nothing, List<User>>() }
79100
.catch {
80-
Timber.tag("UserRepositoryImpl").e(it, "getUsers")
101+
logError(it, "getUsers")
81102
emit(errorMapper(it).left())
82103
}
83104

84-
override suspend fun refresh() = Either.catch {
85-
getUsersFromRemote().let { changesFlow.emit(Change.Refreshed(it)) }
86-
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "refresh") }
105+
override suspend fun refresh() = catchEither { getUsersFromRemote() }
106+
.tap { sendChange(Change.Refreshed(it)) }
107+
.map { }
108+
.tapLeft { logError(it, "refresh") }
87109
.mapLeft(errorMapper)
88110

89-
override suspend fun remove(user: User) = Either.catch {
111+
override suspend fun remove(user: User) = either<UserError, Unit> {
90112
withContext(dispatchers.io) {
91-
val response = userApiService.remove(user.id)
92-
changesFlow.emit(Change.Removed(responseToDomain(response)))
93-
}
94-
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "remove user=$user") }
95-
.mapLeft(errorMapper)
113+
val response = catchEither { userApiService.remove(user.id) }
114+
.tapLeft { logError(it, "remove user=$user") }
115+
.mapLeft(errorMapper)
116+
.bind()
96117

97-
override suspend fun add(user: User) = Either.catch {
98-
withContext(dispatchers.io) {
99-
val body = domainToBody(user)
100-
val response = userApiService.add(body)
101-
changesFlow.emit(Change.Added(responseToDomain(response)))
102-
extraDelay()
118+
val deleted = responseToDomain(response)
119+
.mapLeft { UserError.ValidationFailed(it.toSet()) }
120+
.tapInvalid { logError(it, "remove user=$user") }
121+
.bind()
122+
123+
sendChange(Change.Removed(deleted))
103124
}
104-
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "add user=$user") }
105-
.mapLeft(errorMapper)
125+
}
106126

107-
override suspend fun search(query: String) = Either.catch {
127+
override suspend fun add(user: User) = either<UserError, Unit> {
108128
withContext(dispatchers.io) {
109-
extraDelay()
110-
userApiService.search(query).map(responseToDomain)
129+
val response = catchEither { userApiService.add(domainToBody(user)) }
130+
.tapLeft { logError(it, "add user=$user") }
131+
.mapLeft(errorMapper)
132+
.bind()
133+
134+
delay(400) // TODO
135+
136+
val added = responseToDomain(response)
137+
.mapLeft { UserError.ValidationFailed(it.toSet()) }
138+
.tapInvalid { logError(it, "add user=$user") }
139+
.bind()
140+
141+
sendChange(Change.Added(added))
111142
}
112-
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "search query=$query") }
113-
.mapLeft(errorMapper)
143+
}
114144

115-
private suspend inline fun extraDelay() = delay(400)
145+
override suspend fun search(query: String) = withContext(dispatchers.io) {
146+
catchEither { userApiService.search(query).map(responseToDomainThrows) }
147+
.tapLeft { logError(it, "search query=$query") }
148+
.mapLeft(errorMapper)
149+
}
150+
151+
private companion object {
152+
private val TAG = UserRepositoryImpl::class.java.simpleName
153+
}
116154
}

data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ package com.hoc.flowmvi.data.mapper
22

33
import com.hoc.flowmvi.core.Mapper
44
import com.hoc.flowmvi.data.remote.UserBody
5-
import com.hoc.flowmvi.domain.entity.User
5+
import com.hoc.flowmvi.domain.model.User
66

77
internal class UserDomainToUserBodyMapper : Mapper<User, UserBody> {
88
override fun invoke(domain: User): UserBody {
99
return UserBody(
10-
email = domain.email,
11-
firstName = domain.firstName,
12-
lastName = domain.lastName
10+
email = domain.email.value,
11+
firstName = domain.firstName.value,
12+
lastName = domain.lastName.value
1313
)
1414
}
1515
}

data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package com.hoc.flowmvi.data.mapper
33
import arrow.core.nonFatalOrThrow
44
import com.hoc.flowmvi.core.Mapper
55
import com.hoc.flowmvi.data.remote.ErrorResponse
6-
import com.hoc.flowmvi.domain.repository.UserError
6+
import com.hoc.flowmvi.domain.model.UserError
77
import com.squareup.moshi.JsonAdapter
88
import okhttp3.ResponseBody
99
import retrofit2.HttpException
@@ -19,6 +19,7 @@ internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter
1919

2020
return runCatching {
2121
when (throwable) {
22+
is UserError -> throwable
2223
is IOException -> when (throwable) {
2324
is UnknownHostException -> UserError.NetworkError
2425
is SocketTimeoutException -> UserError.NetworkError
@@ -47,7 +48,7 @@ internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter
4748
"internal-error" -> UserError.ServerError
4849
"invalid-id" -> UserError.InvalidId(id = errorResponse.data as String)
4950
"user-not-found" -> UserError.UserNotFound(id = errorResponse.data as String)
50-
"validation-failed" -> UserError.ValidationFailed
51+
"validation-failed" -> UserError.ValidationFailed(errors = emptySet())
5152
else -> UserError.Unexpected
5253
}
5354
}

data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.hoc.flowmvi.data.mapper
22

3+
import arrow.core.ValidatedNel
34
import com.hoc.flowmvi.core.Mapper
45
import com.hoc.flowmvi.data.remote.UserResponse
5-
import com.hoc.flowmvi.domain.entity.User
6+
import com.hoc.flowmvi.domain.model.User
7+
import com.hoc.flowmvi.domain.model.UserValidationError
68

7-
internal class UserResponseToUserDomainMapper : Mapper<UserResponse, User> {
8-
override fun invoke(response: UserResponse): User {
9-
return User(
9+
internal class UserResponseToUserDomainMapper : Mapper<UserResponse, ValidatedNel<UserValidationError, User>> {
10+
override fun invoke(response: UserResponse): ValidatedNel<UserValidationError, User> {
11+
return User.create(
1012
id = response.id,
1113
avatar = response.avatar,
1214
email = response.email,

0 commit comments

Comments
 (0)