From 2f260d7ec6b8f6e3df44e4fd9200e39668b063ff Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Tue, 20 May 2025 16:43:01 +0100 Subject: [PATCH 01/12] ReverseAuction.decideWinner refactored to pipeline --- .../example/auction/model/ReverseAuction.kt | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt index 3578962..d46fc1c 100644 --- a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt +++ b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt @@ -31,25 +31,12 @@ class ReverseAuction( override val rules = Reverse override fun decideWinner(): AuctionWinner? { - val bidsByAmount = mutableMapOf>() - for (bid in bids) if (bid.amount >= reserve) { - var bidGroup = bidsByAmount[bid.amount] - if (bidGroup == null) { - bidGroup = mutableListOf() - bidsByAmount[bid.amount] = bidGroup - } - bidGroup.add(bid) - } - - var lowestUniqueBid: Bid? = null - - for (bids in bidsByAmount.values) if (bids.size == 1) { - val bid = bids.single() - if (lowestUniqueBid == null || bid.amount < lowestUniqueBid.amount) { - lowestUniqueBid = bid - } - } - - return lowestUniqueBid?.toWinner() + return bids + .filter { it.amount >= reserve } + .groupBy { it.amount } + .values + .mapNotNull { it.singleOrNull() } + .minByOrNull { it.amount } + ?.toWinner() } } From 956e3ab51bf829a156ef31668d67af58b6861707 Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Fri, 23 May 2025 21:05:05 +0100 Subject: [PATCH 02/12] End of ReverseAuction.decideWinner refactor --- .../com/example/auction/model/ReverseAuction.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt index d46fc1c..92ada5c 100644 --- a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt +++ b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt @@ -30,13 +30,12 @@ class ReverseAuction( ) { override val rules = Reverse - override fun decideWinner(): AuctionWinner? { - return bids - .filter { it.amount >= reserve } - .groupBy { it.amount } - .values - .mapNotNull { it.singleOrNull() } + override fun decideWinner(): AuctionWinner? = + bids.filter { it.amount >= reserve } + .uniqueBy { it.amount } .minByOrNull { it.amount } ?.toWinner() - } } + +private fun List.uniqueBy(f: (T) -> U) = + groupBy(f).values.mapNotNull { it.singleOrNull() } From 508eafb0d587f8015e230dc57a20ff5fb0e9556d Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Fri, 23 May 2025 21:16:57 +0100 Subject: [PATCH 03/12] Eliminate unnecessary mutability --- .../kotlin/com/example/auction/model/Auction.kt | 16 ++++++++-------- src/main/kotlin/com/example/auction/model/Bid.kt | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index 0d5e553..237ab39 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -13,15 +13,15 @@ import java.math.RoundingMode.UP import java.util.Currency abstract class Auction( - var seller: UserId, - var description: String, - var currency: Currency, - var reserve: MonetaryAmount, - var commission: MonetaryAmount, - var chargePerBid: MonetaryAmount, - var id: AuctionId, + val seller: UserId, + val description: String, + val currency: Currency, + val reserve: MonetaryAmount, + val commission: MonetaryAmount, + val chargePerBid: MonetaryAmount, + val id: AuctionId, var state: AuctionState, - var bids: MutableList, + val bids: MutableList, var winner: AuctionWinner? ) { abstract val rules: AuctionRules diff --git a/src/main/kotlin/com/example/auction/model/Bid.kt b/src/main/kotlin/com/example/auction/model/Bid.kt index 5c101a2..f1dad36 100644 --- a/src/main/kotlin/com/example/auction/model/Bid.kt +++ b/src/main/kotlin/com/example/auction/model/Bid.kt @@ -3,9 +3,9 @@ package com.example.auction.model import com.example.pii.UserId class Bid( - var buyer: UserId, - var amount: MonetaryAmount, - var id: BidId = BidId.NONE + val buyer: UserId, + val amount: MonetaryAmount, + val id: BidId = BidId.NONE ) { override fun toString(): String { return "Bid(buyer=$buyer, amount=$amount, id=$id)" From 9b4862e7c07ead4df2834f0b1aa8c81fd708ab2c Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Tue, 20 May 2025 17:40:21 +0100 Subject: [PATCH 04/12] Use strategy for decideWinner --- .../com/example/auction/model/Auction.kt | 2 +- .../com/example/auction/model/AuctionRules.kt | 6 ++-- .../com/example/auction/model/BlindAuction.kt | 16 +++++----- .../example/auction/model/ReverseAuction.kt | 13 ++++---- .../example/auction/model/VickreyAuction.kt | 30 +++++++++---------- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index 237ab39..42e688d 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -48,7 +48,7 @@ abstract class Auction( winner = decideWinner() } - protected abstract fun decideWinner(): AuctionWinner? + fun decideWinner() = rules.decideWinner(this) fun settled() { if (state == open) { diff --git a/src/main/kotlin/com/example/auction/model/AuctionRules.kt b/src/main/kotlin/com/example/auction/model/AuctionRules.kt index 02a0573..0a61b5b 100644 --- a/src/main/kotlin/com/example/auction/model/AuctionRules.kt +++ b/src/main/kotlin/com/example/auction/model/AuctionRules.kt @@ -1,5 +1,7 @@ package com.example.auction.model -enum class AuctionRules { - Blind, Vickrey, Reverse +enum class AuctionRules(val decideWinner: (Auction) -> AuctionWinner?) { + Blind(::blindAuctionWinner), + Vickrey(::vickreyAuctionWinner), + Reverse(::reverseAuctionWinner) } diff --git a/src/main/kotlin/com/example/auction/model/BlindAuction.kt b/src/main/kotlin/com/example/auction/model/BlindAuction.kt index 9b8aa42..b6d3117 100644 --- a/src/main/kotlin/com/example/auction/model/BlindAuction.kt +++ b/src/main/kotlin/com/example/auction/model/BlindAuction.kt @@ -29,12 +29,12 @@ class BlindAuction( winner = winner ) { override val rules = Blind - - override fun decideWinner(): AuctionWinner? { - return bids - .associateBy { it.buyer } - .values - .maxByOrNull { it.amount } - ?.takeIf { it.amount >= reserve }?.toWinner() - } } + +fun blindAuctionWinner(auction: Auction) = + auction.bids + .associateBy { it.buyer } + .values + .maxByOrNull { it.amount } + ?.takeIf { it.amount >= auction.reserve } + ?.toWinner() diff --git a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt index 92ada5c..3a19a40 100644 --- a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt +++ b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt @@ -29,12 +29,13 @@ class ReverseAuction( winner = winner ) { override val rules = Reverse - - override fun decideWinner(): AuctionWinner? = - bids.filter { it.amount >= reserve } - .uniqueBy { it.amount } - .minByOrNull { it.amount } - ?.toWinner() +} + +fun reverseAuctionWinner(auction: Auction): AuctionWinner? { + return auction.bids.filter { it.amount >= auction.reserve } + .uniqueBy { it.amount } + .minByOrNull { it.amount } + ?.toWinner() } private fun List.uniqueBy(f: (T) -> U) = diff --git a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt index dc4e62b..0230afb 100644 --- a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt +++ b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt @@ -29,19 +29,19 @@ class VickreyAuction( winner = winner ) { override val rules = Vickrey - - override fun decideWinner(): AuctionWinner? { - return bids - .associateBy { it.buyer } - .values - .sortedByDescending { it.amount } - .take(2) - .run { - when { - isEmpty() -> null - last().amount < reserve -> null - else -> AuctionWinner(first().buyer, last().amount) - } +} + +fun vickreyAuctionWinner(auction: Auction): AuctionWinner? { + return auction.bids + .associateBy { it.buyer } + .values + .sortedByDescending { it.amount } + .take(2) + .run { + when { + isEmpty() -> null + last().amount < auction.reserve -> null + else -> AuctionWinner(first().buyer, last().amount) } - } -} \ No newline at end of file + } +} From 54b29750bd79053fb7247e28d5907ec4aca54039 Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Fri, 23 May 2025 21:26:48 +0100 Subject: [PATCH 05/12] Auction inheritance replaced by factory functions and Strategy pattern --- .../com/example/auction/model/Auction.kt | 10 +++--- .../com/example/auction/model/BlindAuction.kt | 13 ++++--- .../example/auction/model/ReverseAuction.kt | 11 +++--- .../example/auction/model/VickreyAuction.kt | 11 +++--- .../repository/SpringJdbcAuctionRepository.kt | 35 +++++++------------ 5 files changed, 32 insertions(+), 48 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index 42e688d..026596c 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -12,7 +12,7 @@ import java.math.RoundingMode.DOWN import java.math.RoundingMode.UP import java.util.Currency -abstract class Auction( +class Auction( val seller: UserId, val description: String, val currency: Currency, @@ -22,9 +22,9 @@ abstract class Auction( val id: AuctionId, var state: AuctionState, val bids: MutableList, - var winner: AuctionWinner? + var winner: AuctionWinner?, + val rules: AuctionRules ) { - abstract val rules: AuctionRules fun placeBid(buyer: UserId, bid: Money) { if (buyer == seller) { @@ -45,11 +45,9 @@ abstract class Auction( fun close() { state = closed - winner = decideWinner() + winner = rules.decideWinner(this) } - fun decideWinner() = rules.decideWinner(this) - fun settled() { if (state == open) { throw WrongStateException("auction $id not closed") diff --git a/src/main/kotlin/com/example/auction/model/BlindAuction.kt b/src/main/kotlin/com/example/auction/model/BlindAuction.kt index b6d3117..c03b3af 100644 --- a/src/main/kotlin/com/example/auction/model/BlindAuction.kt +++ b/src/main/kotlin/com/example/auction/model/BlindAuction.kt @@ -5,7 +5,7 @@ import com.example.auction.model.AuctionState.open import com.example.pii.UserId import java.util.Currency -class BlindAuction( +fun BlindAuction( seller: UserId, description: String, currency: Currency, @@ -15,8 +15,8 @@ class BlindAuction( id: AuctionId, bids: MutableList = mutableListOf(), state: AuctionState = open, - winner: AuctionWinner? = null -) : Auction( + winner: AuctionWinner? = null, +) = Auction( seller = seller, description = description, currency = currency, @@ -26,10 +26,9 @@ class BlindAuction( id = id, state = state, bids = bids, - winner = winner -) { - override val rules = Blind -} + winner = winner, + rules = Blind +) fun blindAuctionWinner(auction: Auction) = auction.bids diff --git a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt index 3a19a40..4bab31f 100644 --- a/src/main/kotlin/com/example/auction/model/ReverseAuction.kt +++ b/src/main/kotlin/com/example/auction/model/ReverseAuction.kt @@ -5,7 +5,7 @@ import com.example.auction.model.AuctionState.open import com.example.pii.UserId import java.util.Currency -class ReverseAuction( +fun ReverseAuction( seller: UserId, description: String, currency: Currency, @@ -16,7 +16,7 @@ class ReverseAuction( bids: MutableList = mutableListOf(), state: AuctionState = open, winner: AuctionWinner? = null -) : Auction( +) = Auction( seller = seller, description = description, currency = currency, @@ -26,10 +26,9 @@ class ReverseAuction( id = id, state = state, bids = bids, - winner = winner -) { - override val rules = Reverse -} + winner = winner, + rules = Reverse +) fun reverseAuctionWinner(auction: Auction): AuctionWinner? { return auction.bids.filter { it.amount >= auction.reserve } diff --git a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt index 0230afb..31b2a28 100644 --- a/src/main/kotlin/com/example/auction/model/VickreyAuction.kt +++ b/src/main/kotlin/com/example/auction/model/VickreyAuction.kt @@ -5,7 +5,7 @@ import com.example.auction.model.AuctionState.open import com.example.pii.UserId import java.util.Currency -class VickreyAuction( +fun VickreyAuction( seller: UserId, description: String, currency: Currency, @@ -16,7 +16,7 @@ class VickreyAuction( bids: MutableList = mutableListOf(), state: AuctionState = open, winner: AuctionWinner? = null -) : Auction( +) = Auction( seller = seller, description = description, currency = currency, @@ -26,10 +26,9 @@ class VickreyAuction( id = id, state = state, bids = bids, - winner = winner -) { - override val rules = Vickrey -} + winner = winner, + rules = Vickrey +) fun vickreyAuctionWinner(auction: Auction): AuctionWinner? { return auction.bids diff --git a/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt b/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt index 01832fc..7b4a0b5 100644 --- a/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt +++ b/src/main/kotlin/com/example/auction/repository/SpringJdbcAuctionRepository.kt @@ -3,17 +3,11 @@ package com.example.auction.repository import com.example.auction.model.Auction import com.example.auction.model.AuctionId import com.example.auction.model.AuctionRules -import com.example.auction.model.AuctionRules.Blind -import com.example.auction.model.AuctionRules.Reverse -import com.example.auction.model.AuctionRules.Vickrey import com.example.auction.model.AuctionState.valueOf import com.example.auction.model.AuctionWinner import com.example.auction.model.Bid import com.example.auction.model.BidId -import com.example.auction.model.BlindAuction import com.example.auction.model.MonetaryAmount -import com.example.auction.model.ReverseAuction -import com.example.auction.model.VickreyAuction import com.example.pii.UserId import org.springframework.jdbc.core.simple.JdbcClient import org.springframework.jdbc.support.GeneratedKeyHolder @@ -219,22 +213,17 @@ private fun ResultSet.toAuction(bids: MutableList): Auction { ) } else null - val constructor = when (rules) { - Blind -> ::BlindAuction - Vickrey -> ::VickreyAuction - Reverse -> ::ReverseAuction - } - - return constructor( - UserId(getString("SELLER")), - getString("DESCRIPTION"), - currency, - MonetaryAmount(getBigDecimal("RESERVE").setScale(currency.defaultFractionDigits)), - MonetaryAmount(getBigDecimal("COMMISSION")), - MonetaryAmount(getBigDecimal("CHARGE_PER_BID")), - AuctionId(getString("ID")), - bids, - valueOf(getString("STATE")), - winner + return Auction( + rules = rules, + seller = UserId(getString("SELLER")), + description = getString("DESCRIPTION"), + currency = currency, + reserve = MonetaryAmount(getBigDecimal("RESERVE").setScale(currency.defaultFractionDigits)), + commission = MonetaryAmount(getBigDecimal("COMMISSION")), + chargePerBid = MonetaryAmount(getBigDecimal("CHARGE_PER_BID")), + id = AuctionId(getString("ID")), + bids = bids, + state = valueOf(getString("STATE")), + winner = winner ) } From c54da90e0ac40ac5c4bbaa39b667be3d1ef18df8 Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Sat, 24 May 2025 08:08:42 +0100 Subject: [PATCH 06/12] Make Auction.bids immutable --- .../com/example/auction/model/Auction.kt | 8 ++++---- .../auction/service/AuctionServiceImpl.kt | 7 +++---- .../repository/AuctionRepositoryContract.kt | 19 +++++++++++-------- .../repository/InMemoryAuctionRepository.kt | 3 +-- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index 026596c..449ed88 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -12,7 +12,7 @@ import java.math.RoundingMode.DOWN import java.math.RoundingMode.UP import java.util.Currency -class Auction( +data class Auction( val seller: UserId, val description: String, val currency: Currency, @@ -21,12 +21,12 @@ class Auction( val chargePerBid: MonetaryAmount, val id: AuctionId, var state: AuctionState, - val bids: MutableList, + val bids: List, var winner: AuctionWinner?, val rules: AuctionRules ) { - fun placeBid(buyer: UserId, bid: Money) { + fun placeBid(buyer: UserId, bid: Money): Auction { if (buyer == seller) { throw BadRequestException("shill bidding detected by $seller") } @@ -40,7 +40,7 @@ class Auction( throw WrongStateException("auction $id is closed") } - bids.add(Bid(buyer, bid.amount)) + return copy(bids = bids + Bid(buyer, bid.amount)) } fun close() { diff --git a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt index 4f80f2a..1f2d39f 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt @@ -122,10 +122,9 @@ class AuctionServiceImpl( throw BadRequestException("invalid user id ${bid.buyer}") } - val auction = loadAuction(auctionId) - auction.placeBid(bid.buyer, bid.amount) - - repository.updateAuction(auction) + loadAuction(auctionId) + .placeBid(bid.buyer, bid.amount) + .also { repository.updateAuction(it) } } @ApiTransaction diff --git a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt index 9ef0fe6..861b064 100644 --- a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt +++ b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt @@ -46,11 +46,12 @@ interface AuctionRepositoryContract { @Test fun `adding bids`() { val auction = newBlindAuction() - repository.addAuction(auction) + .also { repository.addAuction(it) } - auction.placeBid(alice, 1.EUR) - auction.placeBid(bob, 2.EUR) - repository.updateAuction(auction) + load(auction.id) + .placeBid(alice, 1.EUR) + .placeBid(bob, 2.EUR) + .also { repository.updateAuction(it) } val loaded = repository.getAuction(auction.id) ?: fail("could not reload auction ${auction.id}") @@ -65,12 +66,14 @@ interface AuctionRepositoryContract { @Test fun `saving and loading the winner`() { val auction = newBlindAuction() - repository.addAuction(auction) + .also { repository.addAuction(it) } + val auctionId = auction.id - auction.placeBid(alice, 1.EUR) - auction.placeBid(bob, 2.EUR) - repository.updateAuction(auction) + load(auction.id) + .placeBid(alice, 1.EUR) + .placeBid(bob, 2.EUR) + .also { repository.updateAuction(it) } run { val loaded = load(auctionId) diff --git a/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt b/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt index c71e37a..be66c18 100644 --- a/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt +++ b/src/test/kotlin/com/example/auction/repository/InMemoryAuctionRepository.kt @@ -18,8 +18,7 @@ class InMemoryAuctionRepository : AuctionRepository { auctions[id] override fun updateAuction(auction: Auction) { - // TODO: ask Nat if I can delete this line. It doesn't seem needed because we mutate the auction - // auctions[auction.id] = auction + auctions[auction.id] = auction } override fun listOpenAuctions(count: Int, after: AuctionId) = From 00a0eb6a9733e3b8d7482466e8fe771cdfd33f79 Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Sat, 24 May 2025 08:18:42 +0100 Subject: [PATCH 07/12] Auction is entirely immutable --- .../com/example/auction/model/Auction.kt | 20 +++++++------------ .../auction/service/AuctionServiceImpl.kt | 5 ++--- .../service/AuctionSettlementService.kt | 12 +++++------ .../repository/AuctionRepositoryContract.kt | 18 +++++++---------- 4 files changed, 21 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index 449ed88..d28be70 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -2,6 +2,7 @@ package com.example.auction.model import com.example.auction.model.AuctionState.closed import com.example.auction.model.AuctionState.open +import com.example.auction.model.AuctionState.settled import com.example.auction.model.MonetaryAmount.Companion.ZERO import com.example.pii.UserId import com.example.settlement.Charge @@ -20,12 +21,11 @@ data class Auction( val commission: MonetaryAmount, val chargePerBid: MonetaryAmount, val id: AuctionId, - var state: AuctionState, + val state: AuctionState, val bids: List, - var winner: AuctionWinner?, + val winner: AuctionWinner?, val rules: AuctionRules ) { - fun placeBid(buyer: UserId, bid: Money): Auction { if (buyer == seller) { throw BadRequestException("shill bidding detected by $seller") @@ -43,17 +43,15 @@ data class Auction( return copy(bids = bids + Bid(buyer, bid.amount)) } - fun close() { - state = closed - winner = rules.decideWinner(this) - } + fun closed(): Auction = + copy(state = closed, winner = rules.decideWinner(this)) - fun settled() { + fun settled(): Auction { if (state == open) { throw WrongStateException("auction $id not closed") } - state = AuctionState.settled + return copy(state = settled) } fun settlement(): SettlementInstruction? { @@ -97,8 +95,4 @@ data class Auction( charges = commissionCharges + bidCharges ) } - - override fun toString(): String { - return "${this::class.simpleName}(seller=$seller, description='$description', currency=$currency, reserve=$reserve, commission=$commission, chargePerBid=$chargePerBid, id=$id, state=$state, rules=$rules, winner=$winner)" - } } diff --git a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt index 1f2d39f..15adde0 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt @@ -130,9 +130,8 @@ class AuctionServiceImpl( @ApiTransaction override fun closeAuction(auctionId: AuctionId): AuctionResult { val auction = loadAuction(auctionId) - - auction.close() - repository.updateAuction(auction) + .closed() + .also { repository.updateAuction(it) } return when (val result = auction.winner) { null -> Passed diff --git a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt index 0dd887d..cb95296 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt @@ -21,20 +21,18 @@ class AuctionSettlementService( lastAuctionId = settleAuctions(batchSize, after = lastAuctionId) } } - + fun settleAuctions(batchSize: Int, after: AuctionId = AuctionId.NONE): AuctionId? { val batch = repository.listForSettlement(batchSize, after = after) - + for (id in batch) { val auction = repository.getAuction(id) ?: continue val instruction = auction.settlement() ?: continue - - settlement.settle(instruction) - auction.settled() - repository.updateAuction(auction) + settlement.settle(instruction) + repository.updateAuction(auction.settled()) } - + return batch.lastOrNull() } } diff --git a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt index 861b064..9afa37b 100644 --- a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt +++ b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt @@ -75,17 +75,13 @@ interface AuctionRepositoryContract { .placeBid(bob, 2.EUR) .also { repository.updateAuction(it) } - run { - val loaded = load(auctionId) - loaded.winner = AuctionWinner(winner = bob, owed = MonetaryAmount("2.00")) - repository.updateAuction(loaded) - } + load(auctionId) + .copy(winner = AuctionWinner(winner = bob, owed = MonetaryAmount("2.00"))) + .also { repository.updateAuction(it) } - run { - val loaded = load(auctionId) - assertNotNull(loaded.winner) - assertEquals(AuctionWinner(bob, MonetaryAmount("2.00")), loaded.winner) - } + val loaded = load(auctionId) + assertNotNull(loaded.winner) + assertEquals(AuctionWinner(bob, MonetaryAmount("2.00")), loaded.winner) } @Test @@ -118,7 +114,7 @@ interface AuctionRepositoryContract { auctionIds.forEach { id -> val auction = load(id) - auction.close() + auction.closed() repository.updateAuction(auction) } From a576abe7a966a0cb386239593e0515ddffbb8c63 Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Sat, 24 May 2025 15:50:00 +0100 Subject: [PATCH 08/12] Make Auction.placeBid return Result --- .../com/example/auction/model/Auction.kt | 24 ++++++++----------- .../auction/service/AuctionServiceImpl.kt | 3 ++- .../repository/AuctionRepositoryContract.kt | 10 ++++---- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index d28be70..b75c3da 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -9,6 +9,9 @@ import com.example.settlement.Charge import com.example.settlement.Collection import com.example.settlement.OrderId import com.example.settlement.SettlementInstruction +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.asFailure +import dev.forkhandles.result4k.asSuccess import java.math.RoundingMode.DOWN import java.math.RoundingMode.UP import java.util.Currency @@ -26,21 +29,14 @@ data class Auction( val winner: AuctionWinner?, val rules: AuctionRules ) { - fun placeBid(buyer: UserId, bid: Money): Auction { - if (buyer == seller) { - throw BadRequestException("shill bidding detected by $seller") + fun placeBid(buyer: UserId, bid: Money): Result4k { + return when { + buyer == seller -> BadRequestException("shill bidding detected by $seller").asFailure() + bid.currency != currency -> BadRequestException("bid in wrong currency, should be $currency").asFailure() + bid.amount == ZERO -> BadRequestException("zero bid").asFailure() + state != open -> WrongStateException("auction $id is closed").asFailure() + else -> copy(bids = bids + Bid(buyer, bid.amount)).asSuccess() } - if (bid.currency != currency) { - throw BadRequestException("bid in wrong currency, should be $currency") - } - if (bid.amount == ZERO) { - throw BadRequestException("zero bid") - } - if (state != open) { - throw WrongStateException("auction $id is closed") - } - - return copy(bids = bids + Bid(buyer, bid.amount)) } fun closed(): Auction = diff --git a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt index 15adde0..7a4996b 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt @@ -11,6 +11,7 @@ import com.example.auction.model.ReverseAuction import com.example.auction.model.VickreyAuction import com.example.auction.repository.AuctionRepository import com.example.pii.UserIdValidator +import dev.forkhandles.result4k.orThrow import org.springframework.dao.ConcurrencyFailureException import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable @@ -123,7 +124,7 @@ class AuctionServiceImpl( } loadAuction(auctionId) - .placeBid(bid.buyer, bid.amount) + .placeBid(bid.buyer, bid.amount).orThrow() .also { repository.updateAuction(it) } } diff --git a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt index 9afa37b..1b82f8d 100644 --- a/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt +++ b/src/test/kotlin/com/example/auction/repository/AuctionRepositoryContract.kt @@ -13,6 +13,8 @@ import com.example.auction.newReverseAuction import com.example.auction.newVickreyAuction import com.example.auction.predecessor import com.example.auction.streamPages +import com.example.pii.UserId +import dev.forkhandles.result4k.orThrow import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -49,8 +51,8 @@ interface AuctionRepositoryContract { .also { repository.addAuction(it) } load(auction.id) - .placeBid(alice, 1.EUR) - .placeBid(bob, 2.EUR) + .placeBid(alice, 1.EUR).orThrow() + .placeBid(bob, 2.EUR).orThrow() .also { repository.updateAuction(it) } val loaded = repository.getAuction(auction.id) @@ -71,8 +73,8 @@ interface AuctionRepositoryContract { val auctionId = auction.id load(auction.id) - .placeBid(alice, 1.EUR) - .placeBid(bob, 2.EUR) + .placeBid(alice, 1.EUR).orThrow() + .placeBid(bob, 2.EUR).orThrow() .also { repository.updateAuction(it) } load(auctionId) From 7dab18e562929a4e97e54584d02ea2bd1ce7a1cb Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Sun, 25 May 2025 08:28:02 +0100 Subject: [PATCH 09/12] Make AuctionService.placeBid return Result --- .../example/auction/service/AuctionService.kt | 4 ++- .../auction/service/AuctionServiceImpl.kt | 26 ++++++++++++------- .../example/auction/web/AuctionController.kt | 3 ++- .../acceptance/mem/DomainModelOnlyTesting.kt | 2 ++ .../auction/web/ControllerFuzzTests.kt | 7 ++++- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/example/auction/service/AuctionService.kt b/src/main/kotlin/com/example/auction/service/AuctionService.kt index 4533131..0c45dc7 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionService.kt @@ -1,10 +1,12 @@ package com.example.auction.service +import com.example.auction.model.Auction import com.example.auction.model.AuctionId +import dev.forkhandles.result4k.Result4k interface AuctionService { fun listAuctions(count: Int, after: AuctionId): List fun createAuction(rq: CreateAuctionRequest): AuctionId - fun placeBid(auctionId: AuctionId, bid: BidRequest) + fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k fun closeAuction(auctionId: AuctionId): AuctionResult } \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt index 7a4996b..a85dde0 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt @@ -11,7 +11,12 @@ import com.example.auction.model.ReverseAuction import com.example.auction.model.VickreyAuction import com.example.auction.repository.AuctionRepository import com.example.pii.UserIdValidator +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.asFailure +import dev.forkhandles.result4k.asSuccess +import dev.forkhandles.result4k.flatMap import dev.forkhandles.result4k.orThrow +import dev.forkhandles.result4k.peek import org.springframework.dao.ConcurrencyFailureException import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable @@ -118,15 +123,14 @@ class AuctionServiceImpl( } @ApiTransaction - override fun placeBid(auctionId: AuctionId, bid: BidRequest) { + override fun placeBid(auctionId: AuctionId, bid: BidRequest) = if (!piiVault.isValid(bid.buyer)) { - throw BadRequestException("invalid user id ${bid.buyer}") + BadRequestException("invalid user id ${bid.buyer}").asFailure() + } else { + loadAuctionToo(auctionId) + .flatMap { it.placeBid(bid.buyer, bid.amount) } + .peek { repository.updateAuction(it) } } - - loadAuction(auctionId) - .placeBid(bid.buyer, bid.amount).orThrow() - .also { repository.updateAuction(it) } - } @ApiTransaction override fun closeAuction(auctionId: AuctionId): AuctionResult { @@ -144,8 +148,12 @@ class AuctionServiceImpl( } private fun loadAuction(auctionId: AuctionId): Auction { - return repository.getAuction(auctionId) - ?: throw NotFoundException("no auction found with id $auctionId") + return loadAuctionToo(auctionId).orThrow() + } + + private fun loadAuctionToo(auctionId: AuctionId): Result4k { + return repository.getAuction(auctionId)?.asSuccess() + ?: NotFoundException("no auction found with id $auctionId").asFailure() } } diff --git a/src/main/kotlin/com/example/auction/web/AuctionController.kt b/src/main/kotlin/com/example/auction/web/AuctionController.kt index b3b9dfd..a6bb913 100644 --- a/src/main/kotlin/com/example/auction/web/AuctionController.kt +++ b/src/main/kotlin/com/example/auction/web/AuctionController.kt @@ -7,6 +7,7 @@ import com.example.auction.service.AuctionSummary import com.example.auction.service.BidRequest import com.example.auction.service.CreateAuctionRequest import com.example.auction.service.CreateAuctionResponse +import dev.forkhandles.result4k.orThrow import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.stereotype.Controller @@ -56,7 +57,7 @@ class AuctionController( ) @ResponseStatus(HttpStatus.NO_CONTENT) fun recordBid(@PathVariable auctionId: AuctionId, @RequestBody bid: BidRequest) { - auctionService.placeBid(auctionId, bid) + auctionService.placeBid(auctionId, bid).orThrow() } @PostMapping( diff --git a/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt b/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt index 961a7b7..423e4da 100644 --- a/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt +++ b/src/test/kotlin/com/example/auction/acceptance/mem/DomainModelOnlyTesting.kt @@ -15,6 +15,7 @@ import com.example.settlement.SettlementInstruction import com.example.simulators.pii_vault.PiiVaultSimulatorService import com.example.simulators.settlement.SettlementSimulatorService import com.example.simulators.settlement.get +import dev.forkhandles.result4k.orThrow abstract class DomainModelOnlyTesting : AuctionTesting { @@ -45,6 +46,7 @@ abstract class DomainModelOnlyTesting : AuctionTesting { override fun UserId.bid(auction: AuctionId, amount: Money) { service.placeBid(auction, BidRequest(this, amount)) + .orThrow() } override fun UserId.closeAuction(auction: AuctionId) = diff --git a/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt b/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt index 3d76341..8e37667 100644 --- a/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt +++ b/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt @@ -3,7 +3,9 @@ package com.example.auction.web import com.example.auction.AuctionApplication import com.example.auction.AuctionId import com.example.auction.acceptance.EUR +import com.example.auction.model.Auction import com.example.auction.model.AuctionId +import com.example.auction.newBlindAuction import com.example.auction.service.AuctionResult import com.example.auction.service.AuctionService import com.example.auction.service.AuctionSummary @@ -15,6 +17,8 @@ import com.example.pii.UserId import com.natpryce.snodge.json.defaultJsonMutagens import com.natpryce.snodge.json.forStrings import com.natpryce.snodge.mutants +import dev.forkhandles.result4k.Result4k +import dev.forkhandles.result4k.asSuccess import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -97,7 +101,8 @@ class DummyAuctionService : AuctionService { return AuctionId(1) } - override fun placeBid(auctionId: AuctionId, bid: BidRequest) { + override fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k { + return newBlindAuction(1).asSuccess() } override fun closeAuction(auctionId: AuctionId): AuctionResult { From 7cb1ed7862a9cb44f05d945b09a24477618f76ce Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Sun, 25 May 2025 08:29:23 +0100 Subject: [PATCH 10/12] Return ResponseEntity from AuctionController.recordBid --- .../example/auction/web/AuctionController.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/example/auction/web/AuctionController.kt b/src/main/kotlin/com/example/auction/web/AuctionController.kt index a6bb913..1c2e797 100644 --- a/src/main/kotlin/com/example/auction/web/AuctionController.kt +++ b/src/main/kotlin/com/example/auction/web/AuctionController.kt @@ -1,15 +1,21 @@ package com.example.auction.web import com.example.auction.model.AuctionId +import com.example.auction.model.BadRequestException +import com.example.auction.model.WrongStateException import com.example.auction.service.AuctionResult import com.example.auction.service.AuctionService import com.example.auction.service.AuctionSummary import com.example.auction.service.BidRequest import com.example.auction.service.CreateAuctionRequest import com.example.auction.service.CreateAuctionResponse -import dev.forkhandles.result4k.orThrow +import com.example.auction.service.NotFoundException +import dev.forkhandles.result4k.map +import dev.forkhandles.result4k.recover import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -53,11 +59,22 @@ class AuctionController( @PostMapping( "{auctionId}/bids", consumes = ["application/json"], - produces = [] + produces = ["application/json"] ) - @ResponseStatus(HttpStatus.NO_CONTENT) - fun recordBid(@PathVariable auctionId: AuctionId, @RequestBody bid: BidRequest) { - auctionService.placeBid(auctionId, bid).orThrow() + fun recordBid(@PathVariable auctionId: AuctionId, @RequestBody bid: BidRequest): ResponseEntity<*> { + return auctionService.placeBid(auctionId, bid) + .map { ResponseEntity.noContent().build() } + .recover { e -> + val status = when (e) { + is BadRequestException -> HttpStatus.BAD_REQUEST + is NotFoundException -> HttpStatus.NOT_FOUND + is WrongStateException -> HttpStatus.CONFLICT + else -> throw e + } + val problem = ProblemDetail.forStatusAndDetail(status, e.message) + + return ResponseEntity(problem, status) + } } @PostMapping( From 129f498ddc8e22d96c6ca65f6395972db3516dd0 Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Sun, 25 May 2025 08:53:32 +0100 Subject: [PATCH 11/12] Make the exceptions of placeBid implement a sealed class hieararchy so that we know what they are --- src/main/kotlin/com/example/auction/model/Auction.kt | 2 +- src/main/kotlin/com/example/auction/model/AuctionError.kt | 4 ++++ .../kotlin/com/example/auction/model/BadRequestException.kt | 3 ++- .../kotlin/com/example/auction/model/NotFoundException.kt | 4 ++++ .../kotlin/com/example/auction/model/WrongStateException.kt | 2 +- src/main/kotlin/com/example/auction/service/AuctionService.kt | 3 ++- .../kotlin/com/example/auction/service/AuctionServiceImpl.kt | 3 ++- .../kotlin/com/example/auction/service/NotFoundException.kt | 3 --- src/main/kotlin/com/example/auction/web/AuctionController.kt | 3 +-- .../com/example/auction/web/AuctionExceptionHandlers.kt | 2 +- .../com/example/auction/acceptance/http/HttpAuctionTesting.kt | 2 +- .../kotlin/com/example/auction/web/ControllerFuzzTests.kt | 3 ++- 12 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/com/example/auction/model/AuctionError.kt create mode 100644 src/main/kotlin/com/example/auction/model/NotFoundException.kt delete mode 100644 src/main/kotlin/com/example/auction/service/NotFoundException.kt diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index b75c3da..d9a19f6 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -29,7 +29,7 @@ data class Auction( val winner: AuctionWinner?, val rules: AuctionRules ) { - fun placeBid(buyer: UserId, bid: Money): Result4k { + fun placeBid(buyer: UserId, bid: Money): Result4k { return when { buyer == seller -> BadRequestException("shill bidding detected by $seller").asFailure() bid.currency != currency -> BadRequestException("bid in wrong currency, should be $currency").asFailure() diff --git a/src/main/kotlin/com/example/auction/model/AuctionError.kt b/src/main/kotlin/com/example/auction/model/AuctionError.kt new file mode 100644 index 0000000..dd61fd6 --- /dev/null +++ b/src/main/kotlin/com/example/auction/model/AuctionError.kt @@ -0,0 +1,4 @@ +package com.example.auction.model + +sealed class AuctionError(message: String, cause: Throwable? = null) + : RuntimeException(message, cause) diff --git a/src/main/kotlin/com/example/auction/model/BadRequestException.kt b/src/main/kotlin/com/example/auction/model/BadRequestException.kt index c2f400a..1e5ffc4 100644 --- a/src/main/kotlin/com/example/auction/model/BadRequestException.kt +++ b/src/main/kotlin/com/example/auction/model/BadRequestException.kt @@ -1,3 +1,4 @@ package com.example.auction.model -class BadRequestException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) \ No newline at end of file +class BadRequestException(message: String, cause: Throwable? = null) : + AuctionError(message, cause) diff --git a/src/main/kotlin/com/example/auction/model/NotFoundException.kt b/src/main/kotlin/com/example/auction/model/NotFoundException.kt new file mode 100644 index 0000000..bcdfec3 --- /dev/null +++ b/src/main/kotlin/com/example/auction/model/NotFoundException.kt @@ -0,0 +1,4 @@ +package com.example.auction.model + +class NotFoundException(message: String, cause: Throwable? = null) : + AuctionError(message, cause) diff --git a/src/main/kotlin/com/example/auction/model/WrongStateException.kt b/src/main/kotlin/com/example/auction/model/WrongStateException.kt index 8ebd99e..9584409 100644 --- a/src/main/kotlin/com/example/auction/model/WrongStateException.kt +++ b/src/main/kotlin/com/example/auction/model/WrongStateException.kt @@ -2,4 +2,4 @@ package com.example.auction.model class WrongStateException(message: String, cause: Throwable? = null) : - RuntimeException(message, cause) \ No newline at end of file + AuctionError(message, cause) diff --git a/src/main/kotlin/com/example/auction/service/AuctionService.kt b/src/main/kotlin/com/example/auction/service/AuctionService.kt index 0c45dc7..562aea7 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionService.kt @@ -1,12 +1,13 @@ package com.example.auction.service import com.example.auction.model.Auction +import com.example.auction.model.AuctionError import com.example.auction.model.AuctionId import dev.forkhandles.result4k.Result4k interface AuctionService { fun listAuctions(count: Int, after: AuctionId): List fun createAuction(rq: CreateAuctionRequest): AuctionId - fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k + fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k fun closeAuction(auctionId: AuctionId): AuctionResult } \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt index a85dde0..deee6f1 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionServiceImpl.kt @@ -7,6 +7,7 @@ import com.example.auction.model.BlindAuction import com.example.auction.model.MonetaryAmount import com.example.auction.model.MonetaryAmount.Companion.ZERO import com.example.auction.model.Money +import com.example.auction.model.NotFoundException import com.example.auction.model.ReverseAuction import com.example.auction.model.VickreyAuction import com.example.auction.repository.AuctionRepository @@ -151,7 +152,7 @@ class AuctionServiceImpl( return loadAuctionToo(auctionId).orThrow() } - private fun loadAuctionToo(auctionId: AuctionId): Result4k { + private fun loadAuctionToo(auctionId: AuctionId): Result4k { return repository.getAuction(auctionId)?.asSuccess() ?: NotFoundException("no auction found with id $auctionId").asFailure() } diff --git a/src/main/kotlin/com/example/auction/service/NotFoundException.kt b/src/main/kotlin/com/example/auction/service/NotFoundException.kt deleted file mode 100644 index 20f56ae..0000000 --- a/src/main/kotlin/com/example/auction/service/NotFoundException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.example.auction.service - -class NotFoundException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) \ No newline at end of file diff --git a/src/main/kotlin/com/example/auction/web/AuctionController.kt b/src/main/kotlin/com/example/auction/web/AuctionController.kt index 1c2e797..8ceee14 100644 --- a/src/main/kotlin/com/example/auction/web/AuctionController.kt +++ b/src/main/kotlin/com/example/auction/web/AuctionController.kt @@ -2,6 +2,7 @@ package com.example.auction.web import com.example.auction.model.AuctionId import com.example.auction.model.BadRequestException +import com.example.auction.model.NotFoundException import com.example.auction.model.WrongStateException import com.example.auction.service.AuctionResult import com.example.auction.service.AuctionService @@ -9,7 +10,6 @@ import com.example.auction.service.AuctionSummary import com.example.auction.service.BidRequest import com.example.auction.service.CreateAuctionRequest import com.example.auction.service.CreateAuctionResponse -import com.example.auction.service.NotFoundException import dev.forkhandles.result4k.map import dev.forkhandles.result4k.recover import org.slf4j.LoggerFactory @@ -69,7 +69,6 @@ class AuctionController( is BadRequestException -> HttpStatus.BAD_REQUEST is NotFoundException -> HttpStatus.NOT_FOUND is WrongStateException -> HttpStatus.CONFLICT - else -> throw e } val problem = ProblemDetail.forStatusAndDetail(status, e.message) diff --git a/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt b/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt index e61dfa7..1b0cc03 100644 --- a/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt +++ b/src/main/kotlin/com/example/auction/web/AuctionExceptionHandlers.kt @@ -1,8 +1,8 @@ package com.example.auction.web import com.example.auction.model.BadRequestException +import com.example.auction.model.NotFoundException import com.example.auction.model.WrongStateException -import com.example.auction.service.NotFoundException import org.springframework.http.HttpStatus.BAD_REQUEST import org.springframework.http.HttpStatus.CONFLICT import org.springframework.http.HttpStatus.NOT_FOUND diff --git a/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt b/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt index 46f4373..be6b5e2 100644 --- a/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt +++ b/src/test/kotlin/com/example/auction/acceptance/http/HttpAuctionTesting.kt @@ -4,13 +4,13 @@ import com.example.auction.acceptance.AuctionTesting import com.example.auction.model.AuctionId import com.example.auction.model.BadRequestException import com.example.auction.model.Money +import com.example.auction.model.NotFoundException import com.example.auction.model.WrongStateException import com.example.auction.service.AuctionResult import com.example.auction.service.AuctionSummary import com.example.auction.service.BidRequest import com.example.auction.service.CreateAuctionRequest import com.example.auction.service.CreateAuctionResponse -import com.example.auction.service.NotFoundException import com.example.pii.UserId import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue diff --git a/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt b/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt index 8e37667..35b4e32 100644 --- a/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt +++ b/src/test/kotlin/com/example/auction/web/ControllerFuzzTests.kt @@ -4,6 +4,7 @@ import com.example.auction.AuctionApplication import com.example.auction.AuctionId import com.example.auction.acceptance.EUR import com.example.auction.model.Auction +import com.example.auction.model.AuctionError import com.example.auction.model.AuctionId import com.example.auction.newBlindAuction import com.example.auction.service.AuctionResult @@ -101,7 +102,7 @@ class DummyAuctionService : AuctionService { return AuctionId(1) } - override fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k { + override fun placeBid(auctionId: AuctionId, bid: BidRequest): Result4k { return newBlindAuction(1).asSuccess() } From 55d1089f6fc97c16ba468f146e43e4751f822745 Mon Sep 17 00:00:00 2001 From: Nat Pryce Date: Mon, 26 May 2025 08:14:21 +0100 Subject: [PATCH 12/12] All Auction errors are reported as Results --- .../kotlin/com/example/auction/model/Auction.kt | 11 +++++------ .../auction/service/AuctionSettlementService.kt | 16 +++++++++++----- .../com/example/auction/AuctionErrorCasesTest.kt | 11 ++++++----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/example/auction/model/Auction.kt b/src/main/kotlin/com/example/auction/model/Auction.kt index d9a19f6..258b02f 100644 --- a/src/main/kotlin/com/example/auction/model/Auction.kt +++ b/src/main/kotlin/com/example/auction/model/Auction.kt @@ -29,7 +29,7 @@ data class Auction( val winner: AuctionWinner?, val rules: AuctionRules ) { - fun placeBid(buyer: UserId, bid: Money): Result4k { + fun placeBid(buyer: UserId, bid: Money): Result4k { return when { buyer == seller -> BadRequestException("shill bidding detected by $seller").asFailure() bid.currency != currency -> BadRequestException("bid in wrong currency, should be $currency").asFailure() @@ -42,13 +42,12 @@ data class Auction( fun closed(): Auction = copy(state = closed, winner = rules.decideWinner(this)) - fun settled(): Auction { + fun settled() = if (state == open) { - throw WrongStateException("auction $id not closed") + WrongStateException("auction $id not closed").asFailure() + } else { + copy(state = settled).asSuccess() } - - return copy(state = settled) - } fun settlement(): SettlementInstruction? { val winner = this.winner ?: return null diff --git a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt index cb95296..bf1ba6a 100644 --- a/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt +++ b/src/main/kotlin/com/example/auction/service/AuctionSettlementService.kt @@ -3,6 +3,7 @@ package com.example.auction.service import com.example.auction.model.AuctionId import com.example.auction.repository.AuctionRepository import com.example.settlement.Settlement +import dev.forkhandles.result4k.onFailure import org.springframework.beans.factory.annotation.Value import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @@ -26,14 +27,19 @@ class AuctionSettlementService( val batch = repository.listForSettlement(batchSize, after = after) for (id in batch) { - val auction = repository.getAuction(id) ?: continue - val instruction = auction.settlement() ?: continue - - settlement.settle(instruction) - repository.updateAuction(auction.settled()) + settleAuction(id) } return batch.lastOrNull() } + + private fun settleAuction(id: AuctionId) { + val auction = repository.getAuction(id) ?: return + val finalState = auction.settled().onFailure { return } + val instruction = auction.settlement() ?: return + + settlement.settle(instruction) + repository.updateAuction(finalState) + } } diff --git a/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt b/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt index ed96b24..39a24d3 100644 --- a/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt +++ b/src/test/kotlin/com/example/auction/AuctionErrorCasesTest.kt @@ -4,8 +4,10 @@ import com.example.auction.model.BlindAuction import com.example.auction.model.MonetaryAmount import com.example.auction.model.WrongStateException import com.example.pii.UserId +import dev.forkhandles.result4k.failureOrNull import kotlin.test.Test -import kotlin.test.assertFailsWith +import kotlin.test.assertIs + class AuctionErrorCasesTest { @Test @@ -18,8 +20,7 @@ class AuctionErrorCasesTest { reserve = MonetaryAmount("100.00") ) - assertFailsWith { - a.settled() - } + val failure = a.settled().failureOrNull() + assertIs(failure) } -} \ No newline at end of file +}