Skip to content

Commit b15318b

Browse files
committed
Validate accounts don't allow a negative balance
Use result type to express error case when withdrawing funds. This is one example where driving the solution outside-in makes sense, since the UI being the ultimate consumer should define what details the domain needs to provide.
1 parent 2e45d6d commit b15318b

13 files changed

Lines changed: 177 additions & 41 deletions

File tree

domain/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ plugins {
33
}
44

55
dependencies {
6+
api 'dev.forkhandles:result4k:1.6.0.0'
7+
68
testFixturesImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
79
testFixturesImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
810
testFixturesImplementation 'com.natpryce:hamkrest:1.8.0.1'
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package lmirabal.bank
22

3+
import dev.forkhandles.result4k.Result
34
import lmirabal.bank.model.Amount
45
import lmirabal.bank.model.BankAccount
56
import lmirabal.bank.model.BankAccountId
7+
import lmirabal.bank.model.NotEnoughFunds
68

79
interface Bank {
810
fun createAccount(): BankAccount
911
fun listAccounts(): List<BankAccount>
1012
fun deposit(id: BankAccountId, amount: Amount): BankAccount
11-
fun withdraw(id: BankAccountId, amount: Amount): BankAccount
13+
fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds>
1214
}

domain/src/main/kotlin/lmirabal/bank/BankService.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package lmirabal.bank
22

3+
import dev.forkhandles.result4k.Result
4+
import dev.forkhandles.result4k.peek
35
import lmirabal.bank.data.BankAccountRepository
46
import lmirabal.bank.model.Amount
57
import lmirabal.bank.model.BankAccount
68
import lmirabal.bank.model.BankAccountId
9+
import lmirabal.bank.model.NotEnoughFunds
710

811
class BankService(
912
private val accountRepository: BankAccountRepository,
@@ -22,9 +25,9 @@ class BankService(
2225
.also { updatedAccount -> accountRepository.update(updatedAccount) }
2326
}
2427

25-
override fun withdraw(id: BankAccountId, amount: Amount): BankAccount {
28+
override fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds> {
2629
val account = accountRepository.list().first { it.id == id }
2730
return account.withdraw(amount)
28-
.also { updatedAccount -> accountRepository.update(updatedAccount) }
31+
.peek { updatedAccount -> accountRepository.update(updatedAccount) }
2932
}
3033
}

domain/src/main/kotlin/lmirabal/bank/model/Amount.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package lmirabal.bank.model
22

3-
data class Amount(val minorUnits: Long) {
3+
data class Amount(val minorUnits: Long) : Comparable<Amount> {
44
init {
55
require(minorUnits >= 0)
66
}
@@ -13,6 +13,10 @@ data class Amount(val minorUnits: Long) {
1313
return Amount(minorUnits - other.minorUnits)
1414
}
1515

16+
override fun compareTo(other: Amount): Int {
17+
return minorUnits.compareTo(other.minorUnits)
18+
}
19+
1620
companion object {
1721
val ZERO = Amount(0)
1822
}
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package lmirabal.bank.model
22

3+
import dev.forkhandles.result4k.Failure
4+
import dev.forkhandles.result4k.Result
5+
import dev.forkhandles.result4k.Success
36
import java.util.UUID
47

58
data class BankAccount(val id: BankAccountId, val balance: Amount) {
69
fun deposit(amount: Amount): BankAccount {
710
return copy(balance = balance + amount)
811
}
912

10-
fun withdraw(amount: Amount): BankAccount {
11-
return copy(balance = balance - amount)
13+
fun withdraw(amount: Amount): Result<BankAccount, NotEnoughFunds> {
14+
return if (amount <= balance) Success(copy(balance = balance - amount))
15+
else Failure(NotEnoughFunds(id, balance, amount - balance))
1216
}
1317
}
1418

@@ -18,3 +22,5 @@ data class BankAccountId(val value: UUID) {
1822
fun random() = BankAccountId(UUID.randomUUID())
1923
}
2024
}
25+
26+
data class NotEnoughFunds(val id: BankAccountId, val balance: Amount, val additionalFundsRequired: Amount)

domain/src/test/kotlin/lmirabal/bank/model/AmountTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package lmirabal.bank.model
22

33
import com.natpryce.hamkrest.assertion.assertThat
44
import com.natpryce.hamkrest.equalTo
5+
import com.natpryce.hamkrest.greaterThan
6+
import com.natpryce.hamkrest.greaterThanOrEqualTo
7+
import com.natpryce.hamkrest.lessThan
8+
import com.natpryce.hamkrest.lessThanOrEqualTo
59
import com.natpryce.hamkrest.throws
610
import org.junit.jupiter.api.Test
11+
import org.junit.jupiter.api.assertAll
712

813
class AmountTest {
914
@Test
@@ -24,4 +29,14 @@ class AmountTest {
2429

2530
assertThat(result, equalTo(Amount(20)))
2631
}
32+
33+
@Test
34+
fun `can be compared`() {
35+
assertAll(
36+
{ assertThat(Amount(10), lessThan(Amount(20))) },
37+
{ assertThat(Amount(20), lessThanOrEqualTo(Amount(20))) },
38+
{ assertThat(Amount(20), greaterThan(Amount(10))) },
39+
{ assertThat(Amount(20), greaterThanOrEqualTo(Amount(20))) },
40+
)
41+
}
2742
}

domain/src/test/kotlin/lmirabal/bank/model/BankAccountTest.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package lmirabal.bank.model
22

33
import com.natpryce.hamkrest.assertion.assertThat
44
import com.natpryce.hamkrest.equalTo
5+
import com.natpryce.hamkrest.present
6+
import dev.forkhandles.result4k.failureOrNull
7+
import dev.forkhandles.result4k.valueOrNull
58
import org.junit.jupiter.api.Test
69

710
class BankAccountTest {
@@ -20,8 +23,21 @@ class BankAccountTest {
2023
val id = BankAccountId.random()
2124
val account = BankAccount(id, Amount(300))
2225

23-
val updatedAccount = account.withdraw(Amount(200))
26+
val result = account.withdraw(Amount(200))
2427

25-
assertThat(updatedAccount, equalTo(BankAccount(id, Amount(100))))
28+
assertThat(result.valueOrNull(), present(equalTo(BankAccount(id, Amount(100)))))
29+
}
30+
31+
@Test
32+
fun `cannot withdraw more than balance`() {
33+
val id = BankAccountId.random()
34+
val account = BankAccount(id, Amount(300))
35+
36+
val result = account.withdraw(Amount(500))
37+
38+
assertThat(
39+
result.failureOrNull(),
40+
present(equalTo(NotEnoughFunds(id, Amount(300), Amount(200))))
41+
)
2642
}
2743
}

domain/src/testFixtures/kotlin/lmirabal/bank/BankTest.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package lmirabal.bank
22

33
import com.natpryce.hamkrest.assertion.assertThat
44
import com.natpryce.hamkrest.equalTo
5+
import com.natpryce.hamkrest.present
6+
import dev.forkhandles.result4k.failureOrNull
7+
import dev.forkhandles.result4k.valueOrNull
58
import lmirabal.bank.model.Amount
9+
import lmirabal.bank.model.NotEnoughFunds
610
import org.junit.jupiter.api.Test
711

812
abstract class BankTest {
@@ -35,6 +39,19 @@ abstract class BankTest {
3539
bank.deposit(bankAccount.id, Amount(300))
3640
val updatedAccount = bank.withdraw(bankAccount.id, Amount(200))
3741

38-
assertThat(updatedAccount.balance, equalTo(Amount(100)))
42+
assertThat(updatedAccount.valueOrNull()?.balance, present(equalTo(Amount(100))))
43+
}
44+
45+
@Test
46+
fun `cannot withdraw from account more than balance`() {
47+
val bankAccount = bank.createAccount()
48+
49+
bank.deposit(bankAccount.id, Amount(300))
50+
val updatedAccount = bank.withdraw(bankAccount.id, Amount(500))
51+
52+
assertThat(
53+
updatedAccount.failureOrNull(),
54+
present(equalTo(NotEnoughFunds(bankAccount.id, Amount(300), Amount(200))))
55+
)
3956
}
4057
}

http/src/main/kotlin/lmirabal/bank/http/BankHttp.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
package lmirabal.bank.http
22

3+
import dev.forkhandles.result4k.map
4+
import dev.forkhandles.result4k.recover
35
import lmirabal.bank.Bank
46
import lmirabal.bank.BankService
57
import lmirabal.bank.data.InMemoryBankAccountRepository
68
import lmirabal.bank.model.Amount
79
import lmirabal.bank.model.BankAccount
810
import lmirabal.bank.model.BankAccountId
11+
import lmirabal.bank.model.NotEnoughFunds
912
import org.http4k.core.Body
1013
import org.http4k.core.HttpHandler
1114
import org.http4k.core.Method.GET
1215
import org.http4k.core.Method.POST
1316
import org.http4k.core.Response
17+
import org.http4k.core.Status.Companion.BAD_REQUEST
1418
import org.http4k.core.Status.Companion.OK
1519
import org.http4k.core.with
1620
import org.http4k.format.Jackson.auto
@@ -28,6 +32,7 @@ internal val bankAccountLens = Body.auto<BankAccount>().toLens()
2832
internal val bankAccountListLens = Body.auto<List<BankAccount>>().toLens()
2933
internal val accountIdLens = Path.map({ BankAccountId(UUID.fromString(it)) }, { it.value.toString() }).of("id")
3034
internal val amountLens = Body.auto<Amount>().toLens()
35+
internal val notEnoughFundsLens = Body.auto<NotEnoughFunds>().toLens()
3136

3237
internal fun bankHttp(bank: Bank): HttpHandler {
3338
return routes(
@@ -46,7 +51,9 @@ internal fun bankHttp(bank: Bank): HttpHandler {
4651
val accountId = accountIdLens(request)
4752
val amount = amountLens(request)
4853

49-
Response(OK).with(bankAccountLens of bank.withdraw(accountId, amount))
54+
bank.withdraw(accountId, amount)
55+
.map { updatedAccount -> Response(OK).with(bankAccountLens of updatedAccount) }
56+
.recover { failure: NotEnoughFunds -> Response(BAD_REQUEST).with(notEnoughFundsLens of failure) }
5057
}
5158
),
5259
)

http/src/main/kotlin/lmirabal/bank/http/BankHttpClient.kt

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package lmirabal.bank.http
22

3+
import dev.forkhandles.result4k.Failure
4+
import dev.forkhandles.result4k.Result
5+
import dev.forkhandles.result4k.Success
36
import lmirabal.bank.Bank
47
import lmirabal.bank.model.Amount
58
import lmirabal.bank.model.BankAccount
69
import lmirabal.bank.model.BankAccountId
10+
import lmirabal.bank.model.NotEnoughFunds
711
import org.http4k.core.HttpHandler
812
import org.http4k.core.Method.GET
913
import org.http4k.core.Method.POST
1014
import org.http4k.core.Request
15+
import org.http4k.core.Response
16+
import org.http4k.core.Status
1117
import org.http4k.core.with
1218

1319
class BankHttpClient(val http: HttpHandler) : Bank {
@@ -22,18 +28,23 @@ class BankHttpClient(val http: HttpHandler) : Bank {
2228
}
2329

2430
override fun deposit(id: BankAccountId, amount: Amount): BankAccount {
25-
return changeBalanceAction(BANK_ACCOUNT_DEPOSIT_PATH, id, amount)
31+
val response = changeBalanceAction(BANK_ACCOUNT_DEPOSIT_PATH, id, amount)
32+
return bankAccountLens(response)
2633
}
2734

28-
override fun withdraw(id: BankAccountId, amount: Amount): BankAccount {
29-
return changeBalanceAction(BANK_ACCOUNT_WITHDRAWAL_PATH, id, amount)
35+
override fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds> {
36+
val response = changeBalanceAction(BANK_ACCOUNT_WITHDRAWAL_PATH, id, amount)
37+
return when (response.status) {
38+
Status.OK -> Success(bankAccountLens(response))
39+
Status.BAD_REQUEST -> Failure(notEnoughFundsLens(response))
40+
else -> throw Exception("Not expected: $response")
41+
}
3042
}
3143

32-
private fun changeBalanceAction(actionPath: String, id: BankAccountId, amount: Amount): BankAccount {
33-
val response = http(
44+
private fun changeBalanceAction(actionPath: String, id: BankAccountId, amount: Amount): Response {
45+
return http(
3446
Request(POST, BANK_ACCOUNTS_BASE_URL + actionPath)
3547
.with(accountIdLens of id, amountLens of amount)
3648
)
37-
return bankAccountLens(response)
3849
}
3950
}

0 commit comments

Comments
 (0)