あかね
イベント「そのバリデーション、本当にデータを守れてる?」
2026年11月06日
要約
- フロント、API、DBの各バリデーションを実演し、複数の入力経路からデータを守る方法を確認した。
- 画面やAPIの検証だけでは不十分で、DBのCHECK・UNIQUE制約が直接SQLによる不正登録も防ぐ。
- 利用者向けのフロント検証、受信時のAPI検証、保存時のDB制約を組み合わせる重要性を解説した。
そのバリデーション、本当に守れてる?
フロント・API・DBの役割を実演で確かめました。
画面でバリデーションを入れているから、不正なデータは登録されない。そう思っていたのに、別の入り口から送ってみると、あっさり保存できてしまう。
2026年11月6日の勉強会では、備品管理のデモアプリを使って、そんな場面を実際に作ってみました。
テーマは、フロントエンド・API・データベース、それぞれのバリデーションの役割です。同じデータを画面から、Postmanから、直接SQLから登録して、どこで止まり、どこを通り抜けるのかを順番に確認しました。
題材は、東京と大阪の備品管理
用意したのは、東京と大阪に拠点がある会社の備品管理アプリです。
テーブルは「拠点」と「備品」の2つ。備品は必ず1つの拠点に所属します。
最初に入っている備品は、東京の「A001」という管理番号のモニター、数量3の1件だけ。今回確かめたい業務ルールも、次の2つに絞りました。
数量は0以上。マイナスの数量は登録できない。
同じ拠点では管理番号を重複させない。拠点が違えば、同じ管理番号でもよい。
たとえば、大阪の「A001」は登録してよいけれど、東京の「A001」をもう1件増やすのはNGです。
数量0はOK、数量−5はNG。この条件を、入り口を変えても守れるかを見ていきます。
アプリはNext.js、バリデーションはZod、DBアクセスはPrisma、データベースはPostgreSQLという構成です。
STEP 1:画面で止められた。でも、APIからは入ってしまう
最初は、画面側だけで業務ルールを検証する状態です。
デモ画面で、大阪のキーボードを数量「−5」で登録しようとすると、「数量は0以上にしてください」と表示されました。APIリクエストも送信されず、一覧のデータも増えません。ここまでは期待どおりです。
では、同じ内容をPostmanからAPIへ直接送るとどうなるでしょうか。
返ってきたのは 201 Created。画面の一覧を更新すると、数量がマイナスの備品が増えていました。続けて、東京の「A001」と重複するデータも送ってみると、こちらも保存できてしまいました。
「バリデーションしているのに入っちゃった」という場面です。実演中はやり取りしながら笑っていましたが、笑っていられるのもデモアプリだから。本番で不整合なデータができたら、原因を調べるのも、影響範囲を探すのも、直すのも大変です。
ここで確認できたのは、フロントの検証は、フロントを通るときにしか動かないということでした。
APIは、こちらが用意したフォーム以外からも呼び出せます。画面で止められたことだけでは、保存されるデータの保証にはなりません。
※この教材では、STEP 1でもDBの型・NOT NULL・外部キーなどは存在します。意図的に省いているのは、APIでの業務ルールの検証と、数量・管理番号に関するDBの追加制約です。
STEP 2:APIで止められた。でも、SQLからは入ってしまう
次に、サーバー側でも入力を検証する状態に切り替えます。
Postmanから、先ほどと同じ負数のデータを送ると、今度は 422。東京の「A001」と重複するデータは 409 で拒否されました。一覧を更新しても、不正なデータは増えていません。
これで安心……と言いたいところですが、もう1つ入り口があります。APIを通らない、DBへの直接の書き込みです。
実演では、同梱のSQLを実行するコマンドを使いました。
make sql-negative
make sql-duplicateこの処理は、画面もAPIも通らずにDBへ直接INSERTします。すると、APIでは止められたはずの負数も重複も、保存できてしまいました。
APIの検証が壊れたわけではありません。そもそも、今回の書き込みはAPIを通っていないからです。
管理用のスクリプトやバッチ、DBの管理画面など、データを書き込む経路はフォームとAPIだけとは限りません。勉強会では、SupabaseのようにGUIからテーブルを操作できる環境も、アプリのAPIを通らない経路として考えよう、という話をしました。
同じZodスキーマでも、検証する場所は別
教材では、フォームとAPIで同じZodスキーマを共有しています。
// フロント:フォームで検証する
zodResolver(equipmentItemSchema)
// API:受信したJSONを検証する
equipmentItemSchema.safeParse(body)定義を共有すれば、入力ルールをそろえやすくなります。ただし、共有しただけでAPIへの入力まで検証されるわけではありません。受け取った側でも、そのスキーマを実行する必要があります。
なお、管理番号の重複や拠点の存在確認は、Zodの入力チェックとは別に、API側でDBを照会しています。
STEP 3:保存する場所にも制約を置く
最後に、DBへ次の2つの制約を追加しました。
CHECK (quantity >= 0)
UNIQUE (location_id, management_number)CHECK制約で数量を0以上にし、複合UNIQUE制約で「拠点IDと管理番号の組み合わせ」を一意にします。
実演では make status を使い、切り替えの前後で実際のDB制約が「なし」から「あり」に変わったことも確認しました。
その状態で、STEP 2と同じSQLをもう一度実行します。
負数の登録はCHECK制約違反。重複する管理番号の登録はUNIQUE制約違反。どちらもエラーになり、データは増えませんでした。今回は、エラーが出るのが期待どおりの結果です。
当日は英語のエラーメッセージも翻訳しながら、「何の制約に違反したのか」を確認しました。ただエラーになっただけでなく、意図した条件で拒否されたと分かると安心できます。
「正しいデータは通る」まで確かめる
不正なデータを拒否できたところで終わらず、正しいデータが登録できることも確認しました。
大阪の「A001」は、東京の「A001」と拠点が異なるので登録できます。東京の「A002」、数量0も、今回の業務ルールでは有効です。Postmanから送ると、どちらも 201 Created になりました。
実演中にも「何でも入らなくなっているわけじゃない」というやり取りがありました。止めたいものを止めるのと同じくらい、通したいものが通る確認も大切です。
今回、数量が負数のデータについて比べた結果を整理すると、こうなります。
入力経路 | STEP 1:画面で検証 | STEP 2:APIでも検証 | STEP 3:DB制約も追加 |
|---|---|---|---|
画面から | 拒否 | 拒否 | 拒否 |
APIへ直接 | 保存される | 拒否 | 拒否 |
SQLで直接 | 保存される | 保存される | 拒否 |
DB制約は、その制約が有効なテーブルへの書き込みに、入り口を問わず適用されます。ただし、保証するのはあくまで定義した条件です。今回のCHECKとUNIQUEだけで、すべての業務ルールを保証できるわけではありません。
それでも、フロントのバリデーションは必要
最後に、フロント側の検証の大切さについて、自分の経験も話しました。
以前、API側ではきちんとエラーを返せている一方で、フロントの入力チェックが足りず、使う人からすると「なぜ登録できないのか分からない」状態になっていたことがありました。
データが壊れないことと、使いやすいことは、両方必要です。
入力したその場で「数量は0以上にしてください」と分かれば、すぐ修正できます。
送信してからエラーになるまで待たされたり、何を直せばよいか分からなかったりすると、使う人は困ります。
フロントのバリデーションは、利用者が正しく入力できるように助けるもの。APIの検証は、受け取った値を確認してから処理へ進めるためのもの。DB制約は、定義した整合性を保存時に保証するもの。それぞれに役割があります。
「APIで守っているから画面は不要」「DB制約があるからAPIの検証は不要」とは考えず、それぞれの場所で必要な確認をする。その違いを、今回の実演で伝えたかったです。
自分のアプリでも、画面以外にどんな書き込み経路があるのか、APIで何を検証しているのか、DBでは何を保証しているのか。一度、実際にデータを送って確かめてみると、コードを読むだけでは気づかなかった抜けが見つかるかもしれません。
参加してくださった皆さん、実演にお付き合いいただきありがとうございました!





