メインコンテンツまでスキップ

サービス層と DI

前の 2 つの章で、注文を作る処理はコントローラの中に書いてきました。この章では、その処理を サービス と呼ぶクラスに移し、まだ書いていなかった業務の処理を足します。注文の明細ごとに在庫を引き当てること、注文の合計と消費税額を出すこと、受付の注文を取り消して引き当てを戻すことです。

在庫を減らす処理が入ると、1 つの注文の処理で複数の行を書き換えます。途中で失敗したときに、書きかけの状態を残さない仕組み (トランザクション) も、この章で入れます。

この章のコードは tag api-12 にあります。

コントローラとサービスの役目​

この章のコントローラは、HTTP の言葉と、サービスの言葉を行き来するだけになります。

app/src/main/kotlin/com/example/shop/order/OrderController.kt (抜粋)
/** HTTP のリクエストを受け、サービスの結果をステータスコードに変える。業務の判断はサービスが持つ。 */
@RestController
@RequestMapping("/api/v1/orders")
class OrderController(private val orderService: OrderService) {

@PostMapping
fun create(@Valid @RequestBody request: CreateOrderRequest): ResponseEntity<Order> {
val command = CreateOrderCommand(
contactEmail = request.contactEmail,
lines = request.lines.map { CreateOrderCommand.Line(it.productId, it.quantity) },
)
return when (val result = orderService.create(command)) {
is CreateOrderResult.Created ->
ResponseEntity.created(URI.create("/api/v1/orders/${result.order.id}")).body(result.order)
is CreateOrderResult.ProductNotFound -> ResponseEntity.unprocessableContent().build()
is CreateOrderResult.InsufficientInventory -> ResponseEntity.status(HttpStatus.CONFLICT).build()
}
}

@GetMapping("/{id}")
fun find(@PathVariable id: UUID): ResponseEntity<Order> {
val order = orderService.find(id) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(order)
}

@PostMapping("/{id}/cancel")
fun cancel(@PathVariable id: UUID): ResponseEntity<Order> =
when (val result = orderService.cancel(id)) {
is CancelOrderResult.Cancelled -> ResponseEntity.ok(result.order)
CancelOrderResult.NotFound -> ResponseEntity.notFound().build()
CancelOrderResult.AlreadyCancelled -> ResponseEntity.status(HttpStatus.CONFLICT).build()
}
}

コントローラが受け持つのは 3 つです。リクエストの本文を検証すること (前の章)、リクエストの形をサービスに渡す値 (CreateOrderCommand) に変えること、サービスの結果をステータスコードと応答に変えることです。商品があるか、在庫が足りるか、どの順に書き込むかは、コントローラは知りません。

リクエストの CreateOrderRequest をそのままサービスに渡さず、CreateOrderCommand に写しているのは、サービスを Web の形から切り離すためです。CreateOrderRequest には、検証の注釈や、JSON を読むための形の都合が入っています。サービスを HTTP 以外の入り口から呼ぶことになっても、CreateOrderCommand を作れば同じ処理を使えます。

app/src/main/kotlin/com/example/shop/order/OrderService.kt (抜粋)
/** 注文を作るのに要る値。Web のリクエストの形 (CreateOrderRequest) から切り離して受け取る。 */
data class CreateOrderCommand(
val contactEmail: String,
val lines: List<Line>,
) {
data class Line(val productId: UUID, val quantity: Int)
}

コンストラクタ注入​

サービスは、使う部品をコンストラクタで受け取ります。

app/src/main/kotlin/com/example/shop/order/OrderService.kt (抜粋)
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
private val inventoryRepository: InventoryRepository,
private val idGenerator: IdGenerator,
private val clock: Clock,
) {

@Service は @Component をメタ注釈に持つ注釈で、7 章の @ComponentScan がこのクラスを見つけて bean にします。Spring はコンストラクタの引数の型を見て、合う bean を渡します。この形を コンストラクタ注入 と呼びます。

Spring には、@Autowired を付けた lateinit var のプロパティに bean を入れる書き方 (フィールド注入) もあります。このガイドがコンストラクタ注入を使うのは、次の 2 つの理由からです。

  • 依存がすべて val になり、インスタンスを作った後で差し替わりません。依存の無いインスタンスが作られることもありません
  • 依存がコンストラクタの引数に並ぶので、このクラスが何に頼っているかが 1 か所で分かります。引数が増えすぎたら、クラスの役目が多すぎるという合図になります

テストで Spring を使わずにサービスを作るときも、コンストラクタに部品を渡すだけで済みます。

interface を作るかどうか​

9 章の IdGenerator は interface にし、実装の UuidV7Generator を bean にしました。一方、OrderService は interface を持たないクラスです。

分けた基準は、実装が複数あるかどうかです。ID の採番には、本番の実装と、テストで決まった並びを返す実装の 2 つがあります。interface があれば、受け取る側を変えずに実装を差し替えられます。OrderService には、いまのところ実装が 1 つしかありません。interface を作っても、宣言と実装が同じ形で 2 つ並ぶだけです。実装が 2 つ要る場面が来たら、そのときに interface を切り出します。コンストラクタで OrderService の型として受け取る側は、後から interface を切り出しても書き直さずに済むので、先に作っておく必要はありません。

合計と消費税額​

注文の合計と消費税額は、注文明細から計算で出す値なので、注文の集約に持たせます。

app/src/main/kotlin/com/example/shop/order/Order.kt (抜粋)
/** 注文の合計。価格は消費税を含むので、合計も消費税を含む。 */
val totalAmount: Int
get() = lines.sumOf { it.subtotal }

/**
* 税率ごとの消費税額。税率ごとの合計から割り戻し、1 円未満を切り捨てる (合計 × 税率 ÷ (100 + 税率))。
* 明細ごとに割り戻して足すと、切り捨てが明細の数だけ重なり、合計の消費税額が変わる。
*/
val taxAmounts: List<TaxAmount>
get() = lines.groupBy { it.taxRate }
.map { (taxRate, lines) -> TaxAmount(taxRate, lines.sumOf { it.subtotal } * taxRate / (100 + taxRate)) }
.sortedByDescending { it.taxRate }

このガイドの価格は消費税を含んでいるので、消費税額は合計から割り戻して求めます。税率が 10% なら、合計 × 10 ÷ 110 です。Int どうしの割り算は小数を切り捨てるので、1 円未満の切り捨てがそのまま書けます。

割り戻しは、税率ごとの合計に対して 1 回だけ行います。明細ごとに割り戻して足すと、明細の数だけ切り捨てが重なり、結果が小さくなることがあります。どれだけ変わるかは、章末の練習問題で確かめます。

totalAmount と taxAmounts は、getter だけを持つプロパティです。Spring Data JDBC はこれを保存しません。orders の表にこの 2 つの列は無く、注文を保存して読み戻すテストはそのまま通ります。一方、Jackson は getter を JSON の項目として出すので、注文の応答に合計と消費税額が加わります (後の節の curl の結果)。

在庫を引き当てる​

引き当て は、注文明細の数量を、その商品の在庫から差し引くことです。在庫の集約に、引き当てと、引き当ての戻しのメソッドを足しました。6 章の例で reserve と呼んでいた操作が、ここでの allocate (引き当て) にあたります。

app/src/main/kotlin/com/example/shop/inventory/Inventory.kt (抜粋)
fun canAllocate(amount: Int): Boolean = quantity >= amount

/**
* [amount] 個を引き当てた在庫を返す。
*
* 足りるかは、呼ぶ側が [canAllocate] で先に確かめる。足りない在庫に呼ぶのはプログラムの誤りなので、check で止める。
*/
fun allocate(amount: Int): Inventory {
require(amount > 0) { "引き当てる数量は 1 以上にしてください: $amount" }
check(canAllocate(amount)) { "在庫が足りません: $productId は $quantity 個、引き当てる数量は $amount 個" }
return copy(quantity = quantity - amount)
}

6 章の Inventory.reserve は、在庫が足りないときに sealed の結果を返していました。ここでは、足りるかを先に canAllocate で確かめる前提にして、足りない在庫への allocate を check で止めています。足りないことを利用者に伝える sealed の結果は、在庫の集約でなくサービスが返します。その理由は、注文は複数の明細を持ち、どれか 1 つの在庫が足りなければ、どの明細も引き当ててはいけないからです。

app/src/main/kotlin/com/example/shop/order/OrderService.kt (抜粋)
@Transactional
fun create(command: CreateOrderCommand): CreateOrderResult {
val productIds = command.lines.map { it.productId }
val products = productRepository.findAllById(productIds).associateBy { it.id }
val missing = productIds.filterNot { it in products }
if (missing.isNotEmpty()) {
return CreateOrderResult.ProductNotFound(missing)
}

val inventories = inventoryRepository.findAllById(productIds).associateBy { it.productId }
val allocations = command.lines.map { line ->
// 商品には在庫の行を 1 つずつ持たせる決まりなので、在庫の行が無いのはデータの誤り。
val inventory = inventories[line.productId] ?: error("在庫の行がありません: ${line.productId}")
inventory to line.quantity
}
val shortages = allocations
.filterNot { (inventory, quantity) -> inventory.canAllocate(quantity) }
.map { (inventory, quantity) -> Shortage(inventory.productId, quantity, inventory.quantity) }
if (shortages.isNotEmpty()) {
return CreateOrderResult.InsufficientInventory(shortages)
}

allocations.forEach { (inventory, quantity) -> inventoryRepository.save(inventory.allocate(quantity)) }
val order = orderRepository.save(
Order(
id = idGenerator.newId(),
contactEmail = command.contactEmail,
status = OrderStatus.PENDING,
orderedAt = clock.instant(),
lines = command.lines.map { line ->
val product = products.getValue(line.productId)
OrderLine(
productId = product.id,
quantity = line.quantity,
unitPrice = product.price,
taxRate = product.taxRate,
)
},
),
)
return CreateOrderResult.Created(order)
}

create は、次の順に進みます。

  1. 明細が指す商品をまとめて読み、無い商品があれば ProductNotFound を返す
  2. 明細が指す在庫をまとめて読み、すべての明細について在庫が足りるかを確かめる。足りない明細が 1 つでもあれば InsufficientInventory を返す
  3. ここまで何も書き込んでいない。すべての明細の在庫を引き当てて保存し、注文を保存する。注文明細には、10 章のコントローラと同じく、その時点の商品の価格と税率を写す

確かめる段階と書き込む段階を分けているのには理由があります。次の節のトランザクションは、例外が出たときに書き込みを戻します。sealed の結果は例外でなく普通の戻り値なので、トランザクションはそのまま commit されます。1 件目の明細を引き当ててから 2 件目の在庫不足に気づいて InsufficientInventory を返すと、1 件目の引き当てだけが残ります。すべてを確かめてから書き込めば、失敗を返す時点では何も書いていません。

在庫の行が無いときの error は、6 章の表の「起こらないはずの場合で止める」にあたります。商品には在庫の行を 1 つずつ持たせる決まりなので、在庫の行が無いのはデータかプログラムの誤りです。

この引き当ては、在庫の行をロックしません。2 つの注文が同じ在庫を同時に読むと、どちらも「足りる」と判断します。その先で何が起きるかは、本番で効く注意点の節で扱います。

失敗を sealed の結果で返す​

注文の作成と取り消しは、失敗を sealed の結果で返します。

app/src/main/kotlin/com/example/shop/order/OrderService.kt (抜粋)
/** 注文を作った結果。失敗は、利用者の注文しだいで普通に起こるので、例外でなくこの型で返す。 */
sealed interface CreateOrderResult {
data class Created(val order: Order) : CreateOrderResult
data class ProductNotFound(val productIds: List<UUID>) : CreateOrderResult
data class InsufficientInventory(val shortages: List<Shortage>) : CreateOrderResult
}

/** 在庫が足りない明細。注文した数量と、その時点の在庫の数量を持つ。 */
data class Shortage(val productId: UUID, val requested: Int, val available: Int)

/** 注文を取り消した結果。 */
sealed interface CancelOrderResult {
data class Cancelled(val order: Order) : CancelOrderResult
data object NotFound : CancelOrderResult
data object AlreadyCancelled : CancelOrderResult
}

6 章の 失敗の表し方を選ぶ の表に当てはめると、次のようになります。

失敗性質表し方
指した商品が無い / 在庫が足りない (作成)利用者の注文しだいで普通に起こり、理由が複数あるCreateOrderResult
注文が無い / 取消の注文への依頼 (取り消し)利用者の依頼しだいで普通に起こり、理由が複数あるCancelOrderResult
注文が無い (取得)普通に起こり、理由が 1 つnull (find の戻り値)
足りない在庫への引き当て、取り消せない注文の取り消し呼ぶ側の誤りcheck の例外

取り消しは、6 章の練習問題の解答の最後の段落で見た「利用者の取り消しの依頼をそのまま渡す作り」にあたります。取消の注文をもう一度取り消す依頼は普通に起こるので、サービスは Order.cancel を呼ぶ前に確かめ、AlreadyCancelled を返します。

コントローラは、結果を when でステータスコードに変えます。when に else を書いていないので、結果の種類を足すと、コントローラの when がコンパイルエラーになり、ステータスコードを決め忘れることがありません (6 章の 失敗の種類を増やすとき)。

結果ステータスコード
Created201 Created
ProductNotFound422 Unprocessable Content
InsufficientInventory409 Conflict
Cancelled200 OK
NotFound404 Not Found
AlreadyCancelled409 Conflict

409 は、リクエストがサーバーの今の状態と食い違う、という意味のステータスコードです。在庫の数と取消の状態は、リクエストが正しくても、サーバーの状態しだいで受けられなくなります。この章では、本文の無い応答を返します。どの商品が何個足りないかを Shortage から本文に入れて返す形は、エラーレスポンスの章で作ります。

トランザクション​

注文の作成は、明細の数だけ在庫を保存し、最後に注文を保存します。9 章で見たとおり、repository の save は 1 回ずつトランザクションの中で動きます。create を何も付けずに書くと、save の 1 回ごとに commit されます。2 件目の在庫の保存で例外が出ると、1 件目の在庫は引き当てたまま残り、注文は作られません。

create と cancel に付けた @Transactional は、メソッド全体を 1 つのトランザクションにします。メソッドの中の save は、そのトランザクションに加わります。メソッドが例外を投げずに戻れば commit され、RuntimeException (とその派生) か Error を投げればロールバックされます。6 章で、このガイドの例外はすべて RuntimeException を継承すると決めたのは、この規則のためです1。

@Transactional は、Spring が OrderService を継承したプロキシを作り、メソッドの呼び出しの前後でトランザクションを始めて終えることで動きます。7 章の kotlin-spring プラグインが @Service のクラスを open にしているので、プロキシを作れます。

途中の例外で戻ることを確かめる​

ロールバックは、例外が出ないと動かない仕組みなので、普通に動かしているだけでは確かめられません。テストで、在庫の 2 回目の保存で例外が出る状況を作ります。

app/src/test/kotlin/com/example/shop/order/OrderServiceRollbackTest.kt (抜粋)
class FailOnSecondSaveInventoryRepository(
private val delegate: InventoryRepository,
) : InventoryRepository by delegate {
private val saves = AtomicInteger()

override fun <S : Inventory> save(entity: S): S {
if (saves.incrementAndGet() == 2) {
throw IllegalStateException("2 回目の在庫の保存で失敗させる")
}
return delegate.save(entity)
}
}

InventoryRepository by delegate は、4 章の 委譲 です。interface のメソッドをすべて delegate (本物の repository) に任せ、save だけを書き換えています。2 回目の save で例外を投げるので、2 つの明細を持つ注文を作ると、1 件目の在庫を保存した後で失敗します。

この repository を、サービスに渡す bean として差し替えます。

app/src/test/kotlin/com/example/shop/order/OrderServiceRollbackTest.kt (抜粋)
@TestConfiguration(proxyBeanMethods = false)
class FailingInventoryConfiguration {
/** @Primary なので、OrderService にはこちらが渡る。委譲先は、名前で指した本物の repository。 */
@Bean
@Primary
fun failOnSecondSaveInventoryRepository(
@Qualifier("inventoryRepository") delegate: InventoryRepository,
) = FailOnSecondSaveInventoryRepository(delegate)
}

InventoryRepository の型の bean は、Spring Data JDBC が作った本物 (名前は inventoryRepository) と、この差し替えの 2 つになります。@Primary を付けた側が、型で受け取る OrderService に渡ります。差し替えの側も InventoryRepository を受け取ります。どの bean に委譲するかを読み手に示すために、@Qualifier で本物の bean を名前で指しています。10 章の時計と ID の採番の差し替えも、同じ @Primary の仕組みです。

app/src/test/kotlin/com/example/shop/order/OrderServiceRollbackTest.kt (抜粋)
@Test
fun `2 件目の引き当てで例外が出ると、1 件目の引き当ても戻る`() {
insert(notebook, "ノート", 330, inventoryQuantity = 50)
insert(marker, "マーカー", 880, inventoryQuantity = 3)
val command = CreateOrderCommand(
contactEmail = "taro@example.com",
lines = listOf(CreateOrderCommand.Line(notebook, 2), CreateOrderCommand.Line(marker, 1)),
)

assertThrows<IllegalStateException> { orderService.create(command) }

assertEquals(50, inventoryQuantity(notebook))
assertEquals(3, inventoryQuantity(marker))
}

テストは、create が例外を投げた後に、1 件目のノートの在庫が準備したときのままであることを確かめます。OrderService.create から @Transactional を外すと、ノートの在庫は引き当てた後の数のまま残り、このテストは落ちます。

このテストのクラスには @Transactional を付けていません。9 章のテストのようにテストのメソッドを @Transactional にすると、サービスのトランザクションがテストのトランザクションに加わり、サービスの境界でのロールバックを確かめられないためです。準備の行は、差し替えた repository の save の回数に数えないよう、JdbcClient の SQL で入れています。

注文を取り消す​

取り消し は、注文を購入の依頼として無効にすることです。注文は消さずに、取消 (CANCELLED) の状態で残します。

取り消しは POST /api/v1/orders/{id}/cancel で受けます。10 章では、URL に操作の名前を入れず、操作は HTTP メソッドで表すと書きました。取り消しは、注文を消す操作 (DELETE) でも、送った形で置き換える操作 (PUT) でもありません。状態の項目を書き換える操作 (PATCH) にすると、クライアントが状態の値を送る形になり、サービスが明細ごとに引き当てを戻すことが、項目の書き換えの陰に隠れます。どのメソッドの意味にも当てはまらないので、注文の下に操作の名前 (cancel) を置き、POST で送ります。POST は、リクエストに入った内容を、送り先のリソースが自分の決まりで処理するよう求めるメソッドです2。取り消しのリクエストは、この内容が空です。

app/src/main/kotlin/com/example/shop/order/OrderService.kt (抜粋)
/** 受付の注文を取り消し、明細ごとに引き当てを戻す。 */
@Transactional
fun cancel(id: UUID): CancelOrderResult {
val order = orderRepository.findByIdOrNull(id) ?: return CancelOrderResult.NotFound
if (!order.canCancel()) {
return CancelOrderResult.AlreadyCancelled
}
order.lines.forEach { line ->
val inventory = inventoryRepository.findByIdOrNull(line.productId)
?: error("在庫の行がありません: ${line.productId}")
inventoryRepository.save(inventory.deallocate(line.quantity))
}
return CancelOrderResult.Cancelled(orderRepository.save(order.cancel()))
}

取り消せるのは受付 (PENDING) の注文だけです。取り消すと、明細ごとに 引き当てを戻し ます。作成と同じく、確かめてから書き込み、全体を @Transactional で包んでいます。2 件目の在庫の保存で失敗したときに、1 件目の引き当ての戻しだけが残って注文が受付のままだと、取り消しを送り直したときに 1 件目をもう一度戻すことになり、在庫が実際より多くなります。

このロールバックも、2 回目の在庫の保存で例外を投げる repository に差し替えて確かめています (OrderServiceCancelRollbackTest)。テストは、cancel が例外を投げた後に、どちらの在庫も取り消す前の数のままで、注文が受付のままであることを確かめます。cancel から @Transactional を外すと、このテストは落ちます。

app/src/main/kotlin/com/example/shop/order/Order.kt (抜粋)
fun canCancel(): Boolean = status == OrderStatus.PENDING

/**
* 取り消した注文を返す。
*
* 取り消せるかは、呼ぶ側が [canCancel] で先に確かめる。取り消せない注文に呼ぶのはプログラムの誤りなので、check で止める。
*/
fun cancel(): Order {
check(canCancel()) { "受付の注文だけを取り消せます: $id は $status" }
return copy(status = OrderStatus.CANCELLED)
}

canCancel を、プロパティでなく関数にしているのには理由があります。前の節の totalAmount のように getter のプロパティにすると、Jackson が注文の応答に canCancel の項目を足します。この値は、サービスが判断に使うためのもので、応答に出す約束をしたものではありません。

curl で試す​

アプリを起動して、在庫の引き当てと取り消しを試します。この節の例も、手元の DB が初期データだけの状態から順に打つことを前提にしています (手元の DB を確かめる、戻す)。例で使う商品は、ノートとマーカーです。

app/src/main/resources/db/seed/V3__insert_products.sql (抜粋)
insert into inventories (product_id, quantity, version) values
('0199f3a8-0000-7000-8000-000000000001', 50, 0),
-- ...
('0199f3a8-0000-7000-8000-000000000004', 3, 0),

ノートを 2 冊とマーカーを 1 本、注文します。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 2},
{"productId": "0199f3a8-0000-7000-8000-000000000004", "quantity": 1}
]
}'
結果
{
"id" : "019a1c00-0000-7000-8000-000000000001",
"contactEmail" : "taro@example.com",
"status" : "PENDING",
"orderedAt" : "2026-10-24T01:00:00Z",
"lines" : [ {
"productId" : "0199f3a8-0000-7000-8000-000000000001",
"quantity" : 2,
"unitPrice" : 330,
"taxRate" : 10,
"subtotal" : 660
}, {
"productId" : "0199f3a8-0000-7000-8000-000000000004",
"quantity" : 1,
"unitPrice" : 880,
"taxRate" : 10,
"subtotal" : 880
} ],
"version" : 0,
"taxAmounts" : [ {
"taxRate" : 10,
"amount" : 140
} ],
"totalAmount" : 1540
}
201

前の節の subtotal・taxAmounts・totalAmount が、応答に加わっています。この時点で、マーカーの在庫は 1 本減っています。在庫の数は、この章の API では見られません (商品の一覧 API は 14 章で作ります)。在庫がどう変わるかは、次の節のテストで確かめています。

続けて、マーカーを在庫より多く注文すると、409 が返ります。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 2},
{"productId": "0199f3a8-0000-7000-8000-000000000004", "quantity": 5}
]
}'
結果
409

ノートの明細は在庫が足りていますが、マーカーが足りないので、ノートも引き当てません。

最初の注文を取り消します。URL の注文 ID は、手元で作った注文の id に置き換えてください。

curl -s -w '\n%{http_code}\n' -X POST http://localhost:8080/api/v1/orders/019a1c00-0000-7000-8000-000000000001/cancel
結果
{
"id" : "019a1c00-0000-7000-8000-000000000001",
"contactEmail" : "taro@example.com",
"status" : "CANCELLED",
"orderedAt" : "2026-10-24T01:00:00Z",
"lines" : [ {
"productId" : "0199f3a8-0000-7000-8000-000000000001",
"quantity" : 2,
"unitPrice" : 330,
"taxRate" : 10,
"subtotal" : 660
}, {
"productId" : "0199f3a8-0000-7000-8000-000000000004",
"quantity" : 1,
"unitPrice" : 880,
"taxRate" : 10,
"subtotal" : 880
} ],
"version" : 1,
"taxAmounts" : [ {
"taxRate" : 10,
"amount" : 140
} ],
"totalAmount" : 1540
}
200

-X POST は、本文の無いリクエストを POST で送る指定です (-d が無いと curl は GET で送ります)。状態が取消になり、version が 1 つ進んでいます。同じ注文をもう一度取り消すと、409 が返ります。

curl -s -w '\n%{http_code}\n' -X POST http://localhost:8080/api/v1/orders/019a1c00-0000-7000-8000-000000000001/cancel
結果
409

無い注文を取り消すと、404 です。

curl -s -w '\n%{http_code}\n' -X POST http://localhost:8080/api/v1/orders/019a1c00-0000-7000-8000-000000000099/cancel
結果
404

在庫の変わり方をテストで確かめる​

在庫の数がどう変わったかは、サービスのテストで確かめています。

app/src/test/kotlin/com/example/shop/order/OrderServiceTest.kt (抜粋)
@BeforeEach
fun insertProducts() {
productRepository.save(Product(id = notebook, name = "ノート", price = 330, taxRate = 10))
productRepository.save(Product(id = marker, name = "マーカー", price = 880, taxRate = 10))
inventoryRepository.save(Inventory(productId = notebook, quantity = 50))
inventoryRepository.save(Inventory(productId = marker, quantity = 3))
}
// ...
@Test
fun `1 つでも在庫が足りない明細があれば、どの明細も引き当てない`() {
val result = orderService.create(command(notebook to 2, marker to 5))

assertEquals(
CreateOrderResult.InsufficientInventory(listOf(Shortage(productId = marker, requested = 5, available = 3))),
result,
)
assertEquals(50, inventoryQuantity(notebook))
assertEquals(3, inventoryQuantity(marker))
}

@Test
fun `取り消すと、明細ごとに引き当てを戻す`() {
val created = orderService.create(command(notebook to 2, marker to 1)) as CreateOrderResult.Created

val result = orderService.cancel(created.order.id)

assertEquals(OrderStatus.CANCELLED, (result as CancelOrderResult.Cancelled).order.status)
assertEquals(50, inventoryQuantity(notebook))
assertEquals(3, inventoryQuantity(marker))
}

@BeforeEach は、クラスの中のテストを 1 つ動かす前に毎回呼ばれるメソッドです。テストのクラスは @Transactional なので、テストごとに準備の行もロールバックされ、どのテストも同じ在庫から始まります。1 つ目のテストは、足りない明細の結果に、注文した数量と在庫の数量が入ること、ノートの在庫も減らないことを確かめます。2 つ目は、作って取り消した後に、どちらの在庫も準備したときの数に戻ることを確かめます。

本番で効く注意点​

同時の注文は、片方が失敗する​

この章の引き当ては、在庫の行をロックせずに読み、足りるかを確かめてから書きます。2 つの注文が同じ在庫を同時に読むと、どちらも「足りる」と判断して引き当てに進みます。在庫の集約は @Version を持つので、後から保存した側は、読んだときの version が古くなっていて、9 章で見た OptimisticLockingFailureException で失敗します。売り越し (在庫より多く引き当てること) にはなりませんが、後の側の注文は例外で失敗し、いまのアプリでは 500 が返ります。

明細の順が逆の注文が重なると、楽観ロックの失敗の前に デッドロック になることがあります。在庫の UPDATE は、更新した行のロックを commit まで持ちます。ノート、マーカーの順の注文と、マーカー、ノートの順の注文が、それぞれ 1 件目の在庫を更新してから 2 件目に進むと、互いに相手が持つロックを待ちます。PostgreSQL はこの待ち合いを見つけ、片方のトランザクションを止めます (SQLState は 40P01)3。Spring は、既定の変換でこれを PessimisticLockingFailureException にします。止められなかった側の注文はそのまま通り、止められた側には、いまのアプリでは 500 が返ります。PostgreSQL の文書は、複数の行をいつも同じ順でロックすれば、このデッドロックは起きないと書いています。

どちらの例外も RuntimeException なので、@Transactional が失敗した側の注文の書き込みをすべて戻します。在庫が残り 1 個のときに 2 人が同時に注文する場面は、本番では普通に起こります。応答の形はエラーレスポンスの章で整えますが、同時の注文をどちらも正しく処理する方法は、後の部で扱います。

同じクラスの中から呼んだメソッドには、@Transactional が効かない​

@Transactional はプロキシが受け持つので、トランザクションが始まるのは、外からプロキシを通して呼ばれたときだけです。OrderService の中で、@Transactional の無いメソッドから、同じクラスの @Transactional のメソッドを呼んでも、トランザクションは始まりません4。Spring の文書は、この形はロールバックを試すまで動いているように見える、と注意しています。トランザクションの境界にするメソッドは、コントローラなど、クラスの外から呼ばれるメソッドにします。

取り消しの送り直し​

取り消しの応答を受け取る前に通信が切れ、クライアントが送り直すと、2 回目は 409 が返ります。取り消しは 1 回目で済んでいるのに、クライアントからは失敗に見えます。送り直しを成功として扱う方法は、後の部で扱います。

まとめ​

  • コントローラは HTTP とサービスのあいだの変換だけを受け持ち、業務の判断とトランザクションはサービスが持ちます
  • 依存はコンストラクタで受け取ります。interface は、実装が複数ある部品 (ID の採番など) にだけ作ります
  • 合計と消費税額は注文の集約が計算します。消費税額は税率ごとの合計から割り戻し、1 円未満を切り捨てます
  • 引き当ては、すべての明細を確かめてから書き込みます。sealed の失敗はトランザクションを戻さないので、書き込んだ後に失敗を返さないためです
  • 作成と取り消しの失敗は sealed の結果で返し、コントローラが else の無い when でステータスコードに変えます
  • @Transactional は、例外が出たときにメソッドの中の書き込みをすべて戻します。ロールバックは、委譲で例外を投げる repository に差し替えたテストで確かめます
  • テストでの bean の差し替えは、@Primary を付けた bean で行います

次に読む​

前の章は 入力の検証 です。

次章 レスポンスの形 では、注文の応答を集約から切り離し、日時・金額・enum・消費税額の表し方を決めます。

練習問題​

消費税額を、税率ごとの合計からでなく、明細ごとに割り戻して足すと、結果が変わることがあります。価格 100 円の軽減税率の商品を 3 種類 1 個ずつ注文したときで確かめてください

解答例

TaxRoundingTest.kt (抜粋)
/** 第 12 章の練習問題 1 の解答例: 明細ごとに割り戻して足した消費税額。 */
fun taxAmountByLines(order: Order, taxRate: Int): Int =
order.lines
.filter { it.taxRate == taxRate }
.sumOf { it.subtotal * taxRate / (100 + taxRate) }
TaxRoundingTest.kt (抜粋)
@Test
fun `税率ごとに割り戻すと、合計 300 円の消費税額は 22 円`() {
assertEquals(listOf(TaxAmount(taxRate = 8, amount = 22)), order.taxAmounts)
}

@Test
fun `明細ごとに割り戻して足すと、切り捨てが 3 回重なって 21 円になる`() {
assertEquals(21, taxAmountByLines(order, taxRate = 8))
}

明細ごとに割り戻すと、100 × 8 ÷ 108 の 1 円未満を 3 回切り捨ててから足します。税率ごとの合計 300 円から割り戻すと、切り捨ては 1 回だけです。切り捨てた端数の分だけ、明細ごとの計算のほうが小さくなります。

どちらの計算にするかは、計算の決まりとして先に決めておく必要があります。同じ注文の消費税額が、画面、領収書、会計の記録で違うと、どれが正しいのかを後から説明できなくなります。このガイドは税率ごとの合計から割り戻す決まりにし、Order.taxAmounts の 1 か所で計算しています。

コントローラが OrderService を通さず、repository を直接呼んで在庫の引き当てを書いたとします。何が困りますか

解答例

主に 3 つのことが困ります。

1 つ目は、トランザクションの境界がコントローラに入ることです。在庫と注文の保存をまとめて戻すには、コントローラのメソッドに @Transactional を付けることになります。トランザクションの範囲に、リクエストの検証や応答への変換まで入り、データベースの接続を握る時間が長くなります。

2 つ目は、同じ処理を別の入り口から使えないことです。注文の作成の手順 (確かめてから書き込む順) が HTTP のコントローラの中にあると、HTTP 以外の入り口から注文を作るときに、同じ手順を書き直すことになります。

3 つ目は、テストの手間です。手順を確かめるたびに、HTTP のリクエストを組み立てて送る必要があります。サービスに分けてあれば、この章の OrderServiceTest のように、CreateOrderCommand を渡してメソッドを呼ぶだけで、在庫の変わり方を確かめられます。


Footnotes​

  1. 出典: Rolling Back a Declarative Transaction (Spring Framework)。 ↩

  2. 出典: RFC 9110 (HTTP Semantics) の 9.3.3 節。 ↩

  3. 出典: Deadlocks と PostgreSQL Error Codes (PostgreSQL 18)。 ↩

  4. 出典: Using @Transactional (Spring Framework)。 ↩