Old-School Backend / 2026 ・ No.06
いにしえのバックエンド入門
第6回 入力チェック ── 何を、どこで、どこまで確かめるか
公開
前回、Jackson は知らない項目を黙って捨てました。では、空の品目やマイナスの金額は、どこで止まるのでしょう。フロントエンド編の第9回では画面で入力をチェックしましたが、サーバーは画面を通らない申請も受け取ります。
今回は、申請が DB に入るまでの関所を1つずつ試します。@Valid と Bean Validation、印だけでは足りないところ、JSON の段階、「今日」はどこの今日か、そして最後の砦の DB の制約です。
入口で確かめる
新井入力チェックなら、フロントエンド編で画面に入れました。金額が空だと赤くなるやつ。
古井あれは利用者への親切だ。curl で送られたら、画面は通らない。
新井そんな人、いますか。
古井いる。ほかのシステムも、古いアプリも、いたずらも。サーバーは、だれから来たかに関係なく確かめる。
全15回の路線図(いまここ:第6回)
- @Valid ── 付け忘れた窓口では、空の品目もマイナスの金額も DB に入った(実測)
- 印の足りなさ ── 1280.125 円は黙って 1280.13 円に、101 文字の品目は 500 に(実測)
- JSON の段階 ── 文字の "1280" は通り、全角の "1280" は 400。知らない項目は設定1つで断れる(実測)
- 今日 ── 東京の今日の申請を、UTC のアプリが「未来」と断った(実測)
- DB の制約 ── CHECK を足すと、アプリを通らない書きこみも止まった(実測)
舞台はこれまでと同じ3台です。アプリは前回の Spring Boot 4.1.2(Hibernate Validator 9、Jackson 3)に、チェックの強さを変えた窓口を3つ足しました。/api/novalid(@Valid なし)、/api/v1(第1回の印)、/api/v2(きびしい印)です。申請は PC から送り、受け付けたものは DB に何が入ったかまで確かめました。
@Valid を付け忘れると
第1回の申請の形(ExpenseRequest)には、すでに印が付いています。ところが印は、窓口の引数に @Valid を付けたときにしか働きません。
@PostMapping("/api/novalid/expenses") // @Valid を付け忘れた窓口
public Map<String, String> noValid(@RequestBody ExpenseRequest req) {
return Map.of("id", service.submit(req));
}
@PostMapping("/api/v1/expenses") // 第1回の印(@NotNull、@NotBlank、@Positive)
public Map<String, String> v1(@Valid @RequestBody ExpenseRequest req) {
return Map.of("id", service.submit(req));
}
# @Valid を付け忘れた窓口(/api/novalid/expenses) a 品目が空 200 DB に入った: 2026-10-28||1280.00 a2 品目が全角空白だけ 200 DB に入った: 2026-10-28| |1280.00 b 金額がマイナス 200 DB に入った: 2026-10-28|交通費|-1280.00 c 日付がない 500 ERROR: null value in column "applied_on" of relation "expense" violates not-null constraint d 金額が 0 200 DB に入った: 2026-10-28|交通費|0.00 # @Valid と第1回の印(/api/v1/expenses) a 品目が空 400 item: must not be blank a2 品目が全角空白だけ 400 item: must not be blank b 金額がマイナス 400 amount: must be greater than 0 c 日付がない 400 date: must not be null d 金額が 0 400 amount: must be greater than 0
@Valid がない窓口は、空の品目も、全角空白だけの品目も、マイナスの金額も、0 円も、そのまま DB に入れました。日付のない申請だけは 500 になりましたが、これはアプリではなく DB の NOT NULL が断ったものです。@Valid を付けた窓口は、5件とも 400 で断り、ログにはどの項目がなぜだめかが残りました。たった1語の付け忘れで、印は全部ただの飾りになります。
Bean Validation の印
public record ExpenseRequest(
@NotNull LocalDate date,
@NotBlank String item,
@NotNull @Positive BigDecimal amount) {
}
項目に付けた @NotNull(null でない)、@NotBlank(空白だけでない文字)、@Positive(0 より大きい)が、Bean Validation(Jakarta Validation)の印です。Spring Boot では Hibernate Validator がこれを読み、@Valid の付いた引数を、窓口に渡す前に確かめます。全角空白だけの品目も @NotBlank が断りました。
印のよいところは、「金額は正の数」という決まりが、申請の形のすぐ横に書かれることです。ただし、400 の返事は前回と同じく「Bad Request」だけで、どの項目が悪いのかは利用者に届きません。届くように返事を作るのは、次回の仕事です。
印は通るが、困る申請
第1回の印は、最低限です。印をすり抜けて困ったことになる申請を、6つ送りました。
# 第1回の印は通るが、困る申請(/api/v1/expenses) e 品目の前後に空白 200 DB に入った: 2026-10-28| 交通費 |1280.00 f 金額 1280.125 200 DB に入った: 2026-10-28|交通費|1280.13 g 品目が 101 文字 500 ERROR: value too long for type character varying(100) h 金額 1 兆円 500 ERROR: numeric field overflow i 未来の日付 200 DB に入った: 2027-04-01|交通費|1280.00 j 6 万円で理由なし 200 DB に入った: 2026-10-28|接待費|60000.00
- 前後の空白 ── 「 交通費 」が空白ごと保存された。集計で「交通費」と別の品目になる
- 1280.125 円 ── DB の numeric(12,2) が、黙って 1280.13 円に丸めた。だれも丸め方を決めていない(第2回)
- 101 文字の品目、1 兆円 ── アプリは通し、DB の型(varchar(100)、numeric(12,2))が断って 500。利用者には「サーバーの故障」に見える
- 未来の日付、理由のない 6 万円 ── 業務では断りたいが、印がないので保存された
きびしい印と、確かめる前の掃除
印を足した申請の形(StrictExpenseRequest)を作りました。
public record StrictExpenseRequest(
@NotNull @PastOrPresent LocalDate date, // 未来の日付は不可
@NotBlank @Size(max = 100) String item, // DB の varchar(100) と合わせる
@NotNull @Positive @Digits(integer = 9, fraction = 0) BigDecimal amount, // 円。小数なし、9桁まで
@Size(max = 200) String reason) {
public StrictExpenseRequest {
if (item != null) item = item.strip(); // 前後の空白(全角も)を落としてから確かめる
if (reason != null) reason = reason.strip();
}
@AssertTrue(message = "5万円を超える申請には理由が必要です")
public boolean isReasonGivenForLargeAmount() { // 項目をまたぐ決まり
return amount == null || amount.compareTo(new BigDecimal("50000")) <= 0 || (reason != null && !reason.isEmpty());
}
# 同じ申請を、きびしい印の窓口へ(/api/v2/expenses) e 品目の前後に空白 200 DB に入った: 2026-10-28|交通費|1280.00 f 金額 1280.125 400 amount: numeric value out of bounds (<9 digits>.<0 digits> expected) g 品目が 101 文字 400 item: size must be between 0 and 100 h 金額 1 兆円 400 amount: numeric value out of bounds (<9 digits>.<0 digits> expected); reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です i 未来の日付 400 date: must be a date in the past or in the present j 6 万円で理由なし 400 reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です k 6 万円で理由あり 200 DB に入った: 2026-10-28|接待費|60000.00
第1回の印と、きびしい印(実測)
- DB の型とそろえる ── @Size(max = 100) と @Digits(integer = 9, fraction = 0)。DB で 500 になっていたものが、400 になった
- 確かめる前に掃除する ── record のコンストラクターで strip()(全角空白も落とす)。確かめたあとに直すと、直した値は確かめていないことになる
- 項目をまたぐ決まり ── 「5万円を超えたら理由が必要」は、金額と理由の両方を見る。@AssertTrue を付けたメソッドで書けた
ただし、印で書けるのは「この申請だけを見て決まること」までです。「同じ日に同じ金額の交通費がもうある」「上長の承認済みの申請は直せない」のように、DB を見ないと分からない決まりは、担当者(Service)の仕事です(第10回)。
JSON の段階で起きること
Bean Validation より前に、Jackson が JSON を Java の値に変えます。ここでも断られるものと、意外に通るものがあります。
# JSON の段階で起きること(/api/v2/expenses) l 金額が文字列 "1280" 200 DB に入った: 2026-10-28|交通費|1280.00 m 金額が全角 "1280" 400 Cannot deserialize value of type `java.math.BigDecimal` from String "1280": not a valid representation n 金額が "1,280" 400 Cannot deserialize value of type `java.math.BigDecimal` from String "1,280": not a valid representation o 品目が数 123 200 DB に入った: 2026-10-28|123|1280.00 p 金額が true 400 Cannot deserialize value of type `java.math.BigDecimal` from Boolean value (token `JsonToken.VALUE_TRUE`) q 2 月 30 日 400 Cannot deserialize value of type `java.time.LocalDate` from String "2026-02-30": Failed to deserialize `java.time.LocalDate` r 知らない項目 receipts 200 DB に入った: 2026-10-28|交通費|1280.00 # spring.jackson.deserialization.fail-on-unknown-properties=true で起動すると r 知らない項目 receipts 400 Unrecognized property "receipts" (class example.expenses.StrictExpenseRequest), not marked as ignorable
文字の "1280" は数に、数の 123 は品目の文字「123」に、Jackson が気を利かせて変えました。一方、全角の "1280" は 400。第2回では new BigDecimal("1280") が 1280 として読めたので、道具によって答えが違います。"1,280"、true、2 月 30 日も 400 でした。
前回黙って捨てられた receipts は、spring.jackson.deserialization.fail-on-unknown-properties=true を付けて起動すると 400 で断れました。画面と API を同じチームで作るなら、断る設定にして送り間違いに早く気づくのが安全です。逆に、外の会社に公開する API で項目を足していくなら、知らない項目を受け流すほうが古い利用者を壊しません。どちらにするかは、決めて書いておく決まりです。
「今日」はどこの今日か
きびしい印の @PastOrPresent(今日か過去の日付)を、東京の 10 月 31 日 0 時 55 分に、その日の日付で試しました。
# 東京は 10 月 31 日 0 時台、UTC は 10 月 30 日 15 時台。JVM の時差の設定なし(UTC) s 今日(東京)2026-10-31 400 date: must be a date in the past or in the present # 同じ申請を、-Duser.timezone=Asia/Tokyo で起動したアプリへ s 今日(東京)2026-10-31 200 DB に入った: 2026-10-31|交通費|1280.00
「今日」は、JVM の時計の今日(実測)
アプリの JVM は時差の設定がなく、UTC で動いています。UTC ではまだ 10 月 30 日なので、東京の今日の申請は「未来の日付」として断られました。毎日 0 時から 9 時まで、東京の利用者は今日の申請を出せません。第2回の宿題と同じ根っこです。-Duser.timezone=Asia/Tokyo で起動すると、同じ申請が 201 で通りました。「今日」を使う決まりは、どこの今日かを先に決めておきます。
新井夜中に申請を出す人なんて……。
古井出張から終電で帰って、家で出す人がいる。そういう人ほど、断られると怒る。
最後の砦:DB の制約
アプリの印は、アプリを通った申請にしか効きません。移行のスクリプト、別のシステム、psql での手作業は、アプリを通らずに DB へ書きます。そこで、DB 自身にも決まりを持たせます。
# DB に CHECK 制約を足す alter table expense add constraint amount_positive check (amount > 0); # @Valid のない窓口から、マイナスの金額 b 金額がマイナス 500 ERROR: new row for relation "expense" violates check constraint "amount_positive" # psql で直接書いても ERROR: new row for relation "expense" violates check constraint "amount_positive" DETAIL: Failing row contains (EXP-2026-9999, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 00:53:08.807753+09, 新井).
CHECK (amount > 0) を足すと、@Valid のない窓口からも、psql で直接書いても、マイナスの金額は入らなくなりました。第二章で日付のない申請を止めたのも、DB の NOT NULL でした。ただし DB が断ると、アプリには例外が飛び、利用者への返事は 500 になります。DB の制約は「最後の砦」で、利用者への丁寧な説明は手前の関所の仕事です。
何を、どこで確かめるか
申請が DB に入るまでの関所
画面のチェックは利用者のため、サーバーのチェックはデータのため。同じ決まりを両方に書くのは無駄ではありません。サーバーでは、JSON の型(Jackson)、項目の形(Bean Validation と @Valid)、DB を見て決める業務のきまり(担当者)、そして DB の制約。手前ほど早く分かりやすく断れ、うしろほど確実です。
自分の PC で確かめる
| 試したいこと | 書き方 |
|---|---|
| 窓口で印を働かせる | @Valid @RequestBody StrictExpenseRequest req |
| DB の長さと桁にそろえる | @Size(max = 100)、@Digits(integer = 9, fraction = 0) |
| 項目をまたぐ決まり | @AssertTrue public boolean isReasonGiven…() |
| 知らない項目を断る | spring.jackson.deserialization.fail-on-unknown-properties=true |
| JVM の今日を東京にする | java -Duser.timezone=Asia/Tokyo -jar app.jar |
| DB にも決まりを持たせる | alter table expense add check (amount > 0); |
印のメッセージは、JVM の言語の設定で変わります。この実験では LANG=C.UTF-8 なので英語でした。利用者に見せる文言を印の既定のメッセージに頼らず、自分で決める話も、次回の返事の作り方で扱います。
結 — 一枚まとめと、次回
- 画面のチェックは利用者のため。サーバーは、だれから来ても確かめる。
- Bean Validation の印は、@Valid を付けた窓口でだけ働く。付け忘れると、空もマイナスも DB に入った。
- 印は DB の型とそろえる。足りないと、黙って丸められるか、DB まで行って 500 になる。
- 確かめる前に掃除(strip)。項目をまたぐ決まりは @AssertTrue、DB を見る決まりは担当者。
- JSON の段階:文字の "1280" は通り、全角は 400。知らない項目を断るかどうかは、決めて書く。
- 「今日」は JVM の今日。UTC のアプリは、毎日 0〜9 時に東京の今日の申請を断った。
- DB の制約は最後の砦。アプリを通らない書きこみも止める。ただし返事は 500。
新井断れるようになったのに、返事が「Bad Request」と「500」だけなのが、なんだか不親切です。
古井次回はそこだ。例外をどこで受け止め、どの状態コードで、何を書いて返すか。いにしえの「システムエラーが発生しました」から卒業しよう。
参考
- Jakarta Validation 3.1
- Hibernate Validator — Documentation
- Spring Framework — Java Bean Validation
- Spring Boot — Validation
- PostgreSQL 16 — Constraints(CHECK、NOT NULL)
- OWASP — Input Validation Cheat Sheet
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、Spring Boot 4.1.2(Hibernate Validator 9、Jackson 3)、OpenJDK 25.0.4、PostgreSQL 16.15 を使って 2026年10月31日(東京)に取ったものです。PC・申請サーバー・DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)です。「今日」の実験は、東京の 0 時台(UTC の 15 時台)に行いました。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第6回 — 入力チェック / 2026.10