Old-School Backend / 2026 ・ No.06

いにしえのバックエンド入門

第6回 入力チェック ── 何を、どこで、どこまで確かめるか

公開

端末の画面。python3 send.py c1 で @Valid を付け忘れた窓口に送ると、品目が空、品目が全角空白だけ、金額マイナス、金額 0 は 200 で DB に入り、日付なしは DB の not-null 違反で 500。send.py c2 で @Valid の窓口に送ると、5件とも 400 で、item: must not be blank、amount: must be greater than 0、date: must not be null
同じ5件の申請を、@Valid を付け忘れた窓口(上)と、付けた窓口(下)に送ったところ。付け忘れると、空の品目もマイナスの金額も DB に入りました(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)

前回、Jackson は知らない項目を黙って捨てました。では、空の品目やマイナスの金額は、どこで止まるのでしょう。フロントエンド編の第9回では画面で入力をチェックしましたが、サーバーは画面を通らない申請も受け取ります。
今回は、申請が DB に入るまでの関所を1つずつ試します。@Valid と Bean Validation、印だけでは足りないところ、JSON の段階、「今日」はどこの今日か、そして最後の砦の DB の制約です。

一

入口で確かめる

新井入力チェックなら、フロントエンド編で画面に入れました。金額が空だと赤くなるやつ。

古井あれは利用者への親切だ。curl で送られたら、画面は通らない。

新井そんな人、いますか。

古井いる。ほかのシステムも、古いアプリも、いたずらも。サーバーは、だれから来たかに関係なく確かめる。

全15回の路線図(いまここ:第6回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
  • @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回の印と、きびしい印(実測)

申請 第1回の印(v1) きびしい印(v2) 前後に空白 「 交通費 」のまま保存 strip して保存 1280.125 円 1280.13 に丸めて保存 400 @Digits 品目 101 文字 500(DB が断る) 400 @Size(max=100) 1 兆円 500(DB が断る) 400 @Digits 未来の日付 そのまま保存 400 @PastOrPresent 6 万円・理由なし そのまま保存 400 @AssertTrue 印が足りないと、黙って保存されるか、DB まで行って 500 になる DB の型(varchar(100)、numeric(12,2))と、印の数字をそろえておく 0.005 円の丸めは、DB が黙ってやった。お金の端数は、第2回のとおり自分で決める 空白は、確かめる前に落とす(正規化)。確かめてから直すと、直した値は確かめていない
  • 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 分に、その日の日付で試しました。

端末の画面。TZ=Asia/Tokyo の date は 2026-10-31 00:55:07 JST、TZ=UTC の date は 2026-10-30 15:55:07 UTC。申請日 2026-10-31 で送ると 400、date: must be a date in the past or in the present
東京では 10 月 31 日の 0 時台。その「今日」の日付で申請すると、時差の設定がない(UTC の)アプリは「未来の日付」として断りました(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 東京は 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 の時計の今日(実測)

@PastOrPresent の「今日」は、JVM の時計の今日 東京(利用者) 00:55 2026-10-31 JVM(設定なし=UTC) 15:55 2026-10-30 申請日 2026-10-31 未来 → 400 利用者にとっては今日。JVM にとっては、まだ明日 同じ申請が、-Duser.timezone=Asia/Tokyo で起動したアプリでは 201 で通った(実測) 毎日 0 時から 9 時まで、東京の「今日」の申請が断られる 第2回の宿題と同じ根。「今日」を使う決まりは、どこの今日かを先に決める 時差を起動時に決める(-Duser.timezone)か、会社の土地の時計を決まりとして持つ

アプリの JVM は時差の設定がなく、UTC で動いています。UTC ではまだ 10 月 30 日なので、東京の今日の申請は「未来の日付」として断られました。毎日 0 時から 9 時まで、東京の利用者は今日の申請を出せません。第2回の宿題と同じ根っこです。-Duser.timezone=Asia/Tokyo で起動すると、同じ申請が 201 で通りました。「今日」を使う決まりは、どこの今日かを先に決めておきます。

新井夜中に申請を出す人なんて……。

古井出張から終電で帰って、家で出す人がいる。そういう人ほど、断られると怒る。

八

最後の砦:DB の制約

アプリの印は、アプリを通った申請にしか効きません。移行のスクリプト、別のシステム、psql での手作業は、アプリを通らずに DB へ書きます。そこで、DB 自身にも決まりを持たせます。

端末の画面。psql の \d expense に Check constraints: amount_positive CHECK (amount > 0::numeric)。@Valid のない窓口からマイナスの金額を送ると 500 で violates check constraint amount_positive。psql で直接 insert しても ERROR: new row for relation expense violates check constraint amount_positive
DB に「金額は 0 より大きい」という CHECK 制約を足すと、@Valid のない窓口からも、psql で直接書いても、マイナスの金額は入らなくなりました(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 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 に入るまでの関所

画面 フロント編 required min・max (すり抜け可) JSON Jackson 型が合うか 2月30日 知らない項目 形 Bean Validation 空・null 範囲・桁・長さ 未来の日付 きまり 担当者 項目をまたぐ DB を見て判断 (第7・10回) DB PostgreSQL NOT NULL varchar(100) CHECK 申請が DB に入るまでの関所 画面のチェックは利用者のため。サーバーを守るのは、うしろの4つ 手前ほど早く、具体的に断れる。うしろほど確実だが、断り方が雑になる(500) JSON:型が合わなければ 400。知らない項目は、既定では黙って捨てる 形:@Valid を付けた窓口だけで働く。付け忘れると、何も確かめない DB:アプリを通らない書きこみも止める最後の砦。ただし返事は 500
POINT

画面のチェックは利用者のため、サーバーのチェックはデータのため。同じ決まりを両方に書くのは無駄ではありません。サーバーでは、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 なので英語でした。利用者に見せる文言を印の既定のメッセージに頼らず、自分で決める話も、次回の返事の作り方で扱います。

結 — 一枚まとめと、次回

  1. 画面のチェックは利用者のため。サーバーは、だれから来ても確かめる。
  2. Bean Validation の印は、@Valid を付けた窓口でだけ働く。付け忘れると、空もマイナスも DB に入った。
  3. 印は DB の型とそろえる。足りないと、黙って丸められるか、DB まで行って 500 になる。
  4. 確かめる前に掃除(strip)。項目をまたぐ決まりは @AssertTrue、DB を見る決まりは担当者。
  5. JSON の段階:文字の "1280" は通り、全角は 400。知らない項目を断るかどうかは、決めて書く。
  6. 「今日」は JVM の今日。UTC のアプリは、毎日 0〜9 時に東京の今日の申請を断った。
  7. DB の制約は最後の砦。アプリを通らない書きこみも止める。ただし返事は 500。

新井断れるようになったのに、返事が「Bad Request」と「500」だけなのが、なんだか不親切です。

古井次回はそこだ。例外をどこで受け止め、どの状態コードで、何を書いて返すか。いにしえの「システムエラーが発生しました」から卒業しよう。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ