Skip to main content

入力の検証

前の章の練習問題で、数量を 0 にした注文が 500 (Internal Server Error) になることを見ました。データベースの制約が最後に断ったので、矛盾した注文は残りませんでした。それでもクライアントには、自分の入力が誤っていたことが伝わりません。この章では、リクエストの中身を保存する前に確かめ、誤りを 400 (Bad Request) で断ります。

確かめるのは、リクエストを見るだけで判断できる 形 です。数量が 1 から 99 の範囲にあるか、連絡先メールアドレスが空でないか、などです。指した商品があるか、在庫が足りるかのように、データベースを読まないと判断できない決まりは、この章では扱いません。2 つの違いは章の後半で整理します。

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

Bean Validation を使う​

Java には、オブジェクトの値の決まりを注釈で書き、まとめて確かめる Bean Validation という仕様があります。Spring Boot では、検証の starter を足すと、その実装の Hibernate Validator が入ります。

app/build.gradle.kts (抜粋)
implementation(libs.spring.boot.starter.flyway)
// Flyway は PostgreSQL への対応を別のモジュールに分けている。starter だけでは PostgreSQL に当てられない。
implementation(libs.flyway.database.postgresql)
implementation(libs.spring.boot.starter.validation)

start.spring.io は、検証の starter と一緒に、テスト用の spring-boot-starter-validation-test も足します。サンプルリポジトリはこれを足していません。この starter の中身は、テストの基本の starter (spring-boot-starter-test) と本体の検証の starter の 2 つです。前者は Spring Data JDBC のテスト用の starter がすでに持ち、後者はすぐ上で本体に足しているので、足しても増えるものがありません。

7 章で Flyway のテスト用の starter を足さなかった理由とは違います。本体のコードが検証の注釈を使うので、本体の検証の starter を消すと、テストを動かす前にコンパイルで止まります。

app/build.gradle.kts (抜粋)
/*
* Flyway の test 用の starter (spring-boot-starter-flyway-test) は、start.spring.io の生成物と違って足さない。
* 本体の Flyway の starter を依存に含むので、足すと本体の starter が抜けても、テストの classpath に
* Flyway の自動設定 (spring-boot-flyway) が残り、起動確認のテスト (ShopApplicationTests) が通ってしまう。
* flyway-test が持つほかの依存 (starter-test と starter-jdbc-test) は、data-jdbc-test も持つ。
* 検証の test 用の starter (spring-boot-starter-validation-test) も足さない。中身は starter-test と本体の検証の
* starter で、starter-test は data-jdbc-test が持ち、本体の starter は上で足しているので、増えるものが無い。
*/

注釈で決まりを書く​

注文を作るリクエストのクラスは、前の章ではコントローラのファイルに置いていました。注釈が増えるので、この章で別のファイルに移しています。

app/src/main/kotlin/com/example/shop/order/CreateOrderRequest.kt (抜粋)
data class CreateOrderRequest(
@NotBlank(message = "連絡先メールアドレスを入れてください")
@Email(message = "連絡先メールアドレスの形が正しくありません")
@Size(max = 254, message = "連絡先メールアドレスは 254 文字以内にしてください")
val contactEmail: String,
@NotEmpty(message = "注文明細を 1 行以上入れてください")
@Size(max = 20, message = "注文明細は 20 行以内にしてください")
val lines: List<@Valid CreateOrderLineRequest>,
) {
/** 同じ商品の明細が 2 行あると、どちらの数量で注文するのかが決まらない。 */
@get:AssertTrue(message = "同じ商品の注文明細が重なっています")
val hasDistinctProducts: Boolean
get() = lines.distinctBy { it.productId }.size == lines.size
}

data class CreateOrderLineRequest(
val productId: UUID,
@Min(value = 1, message = "数量は 1 以上にしてください")
@Max(value = 99, message = "数量は 99 以下にしてください")
val quantity: Int,
)

注釈の意味は次のとおりです。どれも jakarta.validation.constraints のパッケージにあります。

注釈決まり
@NotBlank文字列が空でなく、空白だけでもない
@Emailメールアドレスの形をしている
@Size(max = ...)文字列の長さ、またはリストの要素の数が上限以下
@NotEmptyリストが 1 つ以上の要素を持つ
@Min と @Max数が範囲の中にある
@AssertTrue値が true である

連絡先メールアドレスの上限の 254 文字は、8 章で contact_email の列に決めた長さと同じです。検証で止めておかないと、長すぎる値はデータベースが断り、前の章の数量 0 と同じく 500 になります。注文明細の 20 行の上限は、このガイドで決めた値です。1 回のリクエストで送れる量に上限を置いておくと、極端に大きなリクエストが届いたときの処理の量を抑えられます。

message は、決まりに違反したときのメッセージです。書かなければ Hibernate Validator が用意したメッセージになりますが、その言語は JVM のロケールで変わります。サーバーの設定しだいでメッセージが英語になったり日本語になったりしないよう、すべての注釈に明示しています。

hasDistinctProducts は、同じ商品の明細が重なっていないことを表す、計算で値を出すプロパティです。@AssertTrue を付けると、この値が false のときに違反になります。1 つの項目でなく、明細のリスト全体を見ないと判断できない決まりを、この形で書けます。

Kotlin で注釈を付ける場所​

7 章の 注釈の付く場所 で見たとおり、Kotlin のコンストラクタの val に付けた注釈が、引数・フィールド・getter のどれに付くかは決まりで決まります。app/build.gradle.kts の -Xannotation-default-target=param-property の指定のもとでは、引数とフィールドの両方に付きます。Hibernate Validator が見るのはフィールドの側です。

この指定を外すと、この章の検証のテスト (後の節) で、メールアドレスと数量の違反が 1 つも出なくなります。注釈は引数にだけ付き、検証する側からは見えなくなります。コンパイラは This annotation is currently applied to the value parameter only, but in the future it will also be applied to field. という警告を出しますが、コンパイルは通ります。警告を見落とせば、テストが無いかぎり気づけません。

hasDistinctProducts のように、コンストラクタの外で getter だけを持つプロパティには、@get:AssertTrue と書いて、getter に付けることを明示します。@get: を書かないと、注釈を付ける先のフィールドが無いので、コンパイルが止まります。エラーの文面は This annotation is not applicable to target 'member property without backing field or delegate'. です。

明細のリストの List<@Valid CreateOrderLineRequest> の @Valid は、リストの要素も検証する指定です。この注釈は、リストの型ではなく、要素の型 (型引数) に付けます。リストそのものに @Valid を付ける書き方も動きますが、Hibernate Validator は、そのクラスを初めて検証するときに警告をログに出します。警告の文面は Using @Valid on a container (java.util.List) is deprecated. You should apply the annotation on the type argument(s). です。

型引数に付けた注釈を Hibernate Validator が読めるようにするには、もう 1 つ、コンパイラの指定が要ります。Kotlin は、型に付けた Java の注釈を、指定が無ければ bytecode に出しません1。

app/build.gradle.kts (抜粋)
* -Xemit-jvm-type-annotations: 型に付けた Java の注釈 (List<@Valid CreateOrderLineRequest> の @Valid) を
* bytecode に出す。付けないと Kotlin はこの注釈を出さず、Hibernate Validator がリストの要素を検証しない。
*/
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property",
"-Xemit-jvm-type-annotations",
)

この指定が無いと、@Valid は bytecode から消え、同じテストで明細の数量の違反が出なくなります。

コントローラで検証する​

検証は、コントローラの引数に @Valid を付けると、メソッドが呼ばれる前に行われます。

app/src/main/kotlin/com/example/shop/order/OrderController.kt (抜粋)
@PostMapping
fun create(@Valid @RequestBody request: CreateOrderRequest): ResponseEntity<Order> {

違反が 1 つでもあれば、Spring MVC は create を呼ばずに MethodArgumentNotValidException を投げ、400 で応答します。create の中のコードは、検証を通ったリクエストだけを受け取ります。

違反のメッセージを確かめる​

どの項目にどのメッセージの違反が出るかは、Spring を起動しないテストで確かめます。Bean Validation の Validator は、Spring が無くても作れます。

app/src/test/kotlin/com/example/shop/order/CreateOrderRequestValidationTest.kt (抜粋)
private val validator = Validation.buildDefaultValidatorFactory().validator
// ...
/** 違反を「項目: メッセージ」の形にして並べる。validate が返す集合は順を持たないので、並べてから比べる。 */
private fun violations(request: CreateOrderRequest): List<String> =
validator.validate(request).map { "${it.propertyPath}: ${it.message}" }.sorted()
// ...
@Test
fun `空のメールアドレスと数量 0 は、それぞれの項目の違反になる`() {
val request = CreateOrderRequest(
contactEmail = "",
lines = listOf(CreateOrderLineRequest(notebook, 0)),
)

assertEquals(
listOf(
"contactEmail: 連絡先メールアドレスを入れてください",
"lines[0].quantity: 数量は 1 以上にしてください",
),
violations(request),
)
}

@Test
fun `同じ商品の明細が重なると、リクエスト全体の違反になる`() {
val request = CreateOrderRequest(
contactEmail = "taro@example.com",
lines = listOf(CreateOrderLineRequest(notebook, 2), CreateOrderLineRequest(notebook, 1)),
)

assertEquals(listOf("hasDistinctProducts: 同じ商品の注文明細が重なっています"), violations(request))
}

validate は、違反を Set で返します。Set の要素の並びは決まっていないので、「項目: メッセージ」の文字列にしてから並べ替えて比べています。違反の項目は propertyPath で、明細の数量は lines[0].quantity のように、リストの何番目の要素の、どのプロパティかで表されます。

空のメールアドレスは @NotBlank に違反しますが、@Email には違反しません。@Email は空の文字列を正しい値として扱うためです。空かどうかは @NotBlank が受け持ち、2 つの注釈で役割を分けています。

検証の失敗の応答​

アプリを起動して、空のメールアドレスと数量 0 の注文を送ってみます。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 0}
]
}'
結果
{
"timestamp" : "2026-10-23T23:23:45.579Z",
"status" : 400,
"error" : "Bad Request",
"path" : "/api/v1/orders"
}
400

ステータスコードは 400 で、注文は作られません。ただし、応答の本文は 7 章で見た /error の既定の応答のままで、どの項目のどの決まりに違反したのかが書かれていません。Spring Boot は既定で、検証の違反の中身を応答に入れないためです (spring.web.error.include-binding-errors の既定が never)2。注釈に書いたメッセージは、いまはどこにも出ていません。

同じ商品の明細が重なる注文も、同じ形の 400 になります。

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-000000000001", "quantity": 1}
]
}'
結果
{
"timestamp" : "2026-10-23T23:23:45.609Z",
"status" : 400,
"error" : "Bad Request",
"path" : "/api/v1/orders"
}
400

この 2 つの結果は、応答の日時を固定できないので、記事の結果とテストの応答を 1 文字ずつは比べていません。記事の結果は手元で打ったときのもので、テストはステータスコードが 400 であることを確かめています。違反の項目とメッセージを本文に入れて返す形は、エラーレスポンスの章で作ります。そこで作る応答は日時を含まないので、記事の結果をテストで照合できます。

形の検証と業務ルールの境界​

注文が受け付けられるまでには、いくつもの決まりがあります。この章で注釈にしたのは、リクエストを見るだけで判断できるものだけです。

決まり判断に要るもの確かめる場所
数量が 1 から 99 / メールアドレスの形 / 同じ商品の明細が無いリクエストだけBean Validation (この章)
指した商品がある / 在庫が足りるデータベースの今の状態サービス層 (次の章)
数量が正 / 同じ注文に同じ商品が 1 行 / 在庫が負にならない書き込む行データベースの制約 (8 章)

在庫が足りるかを注釈で確かめないのは、判断にデータベースの今の状態が要るからです。検証の時点で足りていても、保存するまでのあいだにほかの注文が在庫を引き当てれば、足りなくなります。在庫の判断は、在庫を減らす処理と同じ場所 (サービス層) で、減らす直前に行います。

データベースの制約は、検証と同じ決まりを重ねて持っています (数量が正、同じ商品が 1 行)。検証を通らない書き込みの経路 (将来の別の API や、手で流す SQL) からも、矛盾した行が入らないようにするためです。検証はクライアントに誤りを伝えるため、制約は矛盾したデータを残さないため、と役割が違うので、片方を消して済ませることはしません。

本番で効く注意点​

検証の上限をデータベースの列と合わせる​

文字列の長さの検証が無いと、列の長さを超える値はデータベースが断り、応答は 500 になります。クライアントの誤りを、サーバーの誤りとして返すことになります。列に長さを決めたら、同じ上限を検証にも書きます。列の長さを変えるマイグレーションを書くときは、検証の上限も一緒に直します。

違反の中身をそのまま返さない​

spring.web.error.include-binding-errors を always にすると、既定の応答の errors に違反の中身が入ります3。手早く中身を見られますが、その形は Spring Boot が決めたもので、このアプリの API としてクライアントと約束した形ではありません。クライアントに返す違反の形は、エラーレスポンスの章で、このアプリが決めた形にします。

まとめ​

  • Bean Validation の注釈で、リクエストの形の決まりを書きます。メッセージは JVM のロケールに頼らず、注釈に明示します
  • テスト用の検証の starter は、中身がすでに入っているので足しません
  • コンストラクタのプロパティの注釈は、-Xannotation-default-target=param-property でフィールドにも付きます。getter だけのプロパティは @get: で付けます
  • リストの要素の検証は、型引数に @Valid を付けます。Kotlin では -Xemit-jvm-type-annotations が無いと、この注釈が bytecode に出ません
  • コントローラの引数の @Valid で、メソッドが呼ばれる前に検証し、違反があれば 400 で応答します。既定の応答は違反の中身を含みません
  • データベースの状態が要る決まりはサービス層で、矛盾したデータを残さないための決まりはデータベースの制約で受け持ちます

次に読む​

前の章は ルーティングと最初のエンドポイント です。

次章 サービス層と DI では、注文を作る処理をコントローラからサービスに移し、在庫の引き当てと、注文の取り消しを足します。

練習問題​

注文明細の productId を送り忘れると、400 が返ります。CreateOrderLineRequest の productId には検証の注釈を付けていないのに、なぜ 400 になるのですか

解答例

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"quantity": 1}
]
}'
結果
{
"timestamp" : "2026-10-23T23:23:45.621Z",
"status" : 400,
"error" : "Bad Request",
"path" : "/api/v1/orders"
}
400

productId の型が、null を許さない UUID だからです。Jackson は JSON を CreateOrderLineRequest に変えるとき、コンストラクタの引数に productId の値を渡す必要があります。JSON に値が無く、引数に既定値も無いので、インスタンスを作れずに失敗します。Spring MVC は、本文を読めなかったとして 400 で応答します。検証は、インスタンスができた後に行うものなので、ここまで進みません。

Kotlin の null を許さない型は、@NotNull の役目を、検証より前の段階で果たしています。そのため、このガイドのリクエストのクラスには @NotNull を書いていません。違いは、失敗の種類です。本文を読めない失敗と検証の違反は、Spring の中では別の例外になります。エラーレスポンスの章で 2 つを同じ形の応答にするときは、両方を扱います。

在庫が注文の数量に足りるかを、Bean Validation の注釈 (自分で作った注釈を含む) で確かめないのはなぜですか

解答例

判断に、データベースの今の在庫の数が要るからです。

注釈の検証は、コントローラのメソッドが呼ばれる前に、リクエストのオブジェクトだけを見て行います。在庫の数を読む検証を作ることはできますが、検証の時点で足りていても、在庫を減らすまでのあいだにほかの注文が同じ在庫を引き当てれば、足りなくなります。検証を通ったことが、在庫を減らせることを保証しません。

在庫が足りるかは、在庫を減らす処理の中で、減らす直前に判断します。足りないときは、利用者の注文しだいで普通に起こる失敗として、6 章で決めたとおり sealed の結果で返します。その形は次の章で作ります。


Footnotes​

  1. 出典: What's new in Kotlin 1.4.0: Type annotations in the JVM bytecode (Kotlin のドキュメント)。 ↩

  2. 出典: Spring Boot 4.1.2 の ErrorProperties のソース。include-message・include-binding-errors・include-stacktrace の既定は never です。 ↩

  3. 出典: Spring Boot 4.1.2 の DefaultErrorAttributes のソース。 ↩