DB制約で不正データを止める設計
「バリデーションは書いてあるのに、本番の DB に空の値が入っていた」。原因を追うと、たいていアプリのバリデーションを通らない経路が見つかります。この記事は設計フェーズの話です。アプリ側のバリデーションと DB 制約の責務を、実装に入る前に分けておく方法を紹介します。
なぜバリデーションだけでは足りないのか
アプリケーションのバリデーションが守れるのは「そのコードを通った入力」だけです。次の経路は素通りします。
- 並行リクエスト: 「無ければ作成」を同時に2本走らせると、両方の存在チェックが通って重複が入ります(check-then-insert の競合)
- スクリプト・コンソール経由: データ移行や
update_all、save(validate: false)はバリデーションを飛ばします - 別のアプリ・バッチ: 同じ DB を触る別サービスは、このバリデーションを知りません
責務分担表: アプリと DB で何を持つか
守りたいこと | アプリ側の役割 | DB 側の役割 |
|---|---|---|
必須項目 | 入力欄に「必須です」と表示 | NOT NULL |
一意性 | 「すでに使われています」と案内 | UNIQUE インデックス |
値の範囲・種類 | 選択肢の表示、エラー文言 | CHECK 制約 |
参照の整合性 | 存在しない選択肢を出さない | 外部キー |
原則は「アプリ側はユーザーに分かりやすく伝える」「DB は何があっても不正値を入れない」です。最後の砦は DB に置きます。
実例: アプリでは弾いたのに不正値が入った
クーポンは1ユーザー1回まで、という仕様を考えます。アプリで「使用済みか」を確認してから注文を作る実装は、ボタンの連打やリトライで2本のリクエストが同時に届くと、どちらも「未使用」と判定して二重に注文が作られます。
DB 側に制約を置くと、2本目は確実に弾かれます。
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','paid','shipped','canceled')),
quantity INTEGER NOT NULL CHECK (quantity > 0),
coupon_code TEXT
);
-- 同じユーザーが同じクーポンを2回使えない(NULL は対象外)
CREATE UNIQUE INDEX uniq_orders_user_coupon
ON orders (user_id, coupon_code)
WHERE coupon_code IS NOT NULL;アプリ側は違反エラーを受け取って、ユーザー向けの文言に変換します。Rails の例です。
def redeem!
Order.create!(user: user, coupon_code: code)
rescue ActiveRecord::RecordNotUnique
raise CouponAlreadyUsed, "このクーポンは使用済みです"
endポイントは、バリデーションを消すのではなく両方置くことです。バリデーションは親切なエラー表示のため、制約は取りこぼしを防ぐために働きます。
既存テーブルに制約を足す手順
すでに動いているテーブルに制約を足すときは、次の順で進めます。
- 違反データを数える:
SELECT count(*) FROM orders WHERE quantity <= 0; - データを直す: 補正するのか、削除するのか、業務側に確認して決める
- ロックを短くして追加する: PostgreSQL なら
NOT VALIDで追加してから検証します - エラー処理を先に入れる: 違反が 500 のまま返らないようにする
ALTER TABLE orders
ADD CONSTRAINT quantity_positive CHECK (quantity > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT quantity_positive;
-- UNIQUE は本番のロックを避けて作る
CREATE UNIQUE INDEX CONCURRENTLY uniq_orders_user_coupon ...;設計レビューのチェックリスト
- 「必須」の項目は NOT NULL になっているか(NULL と空文字を区別しているか)
- 一意であるべき組み合わせに UNIQUE があるか
- ステータス・種別の列に CHECK か enum があるか
- 数量・金額・期間に CHECK があるか(0 以上、開始日 <= 終了日など)
- 制約違反のときにユーザーへ返す文言と HTTP ステータスが決まっているか
Bugoon での実践
不正データ起因のバグは、報告者には「画面がおかしい」としか見えません。Bugoon のウィジェットをサイトに埋め込むと、報告者はスクリーンショットに注釈を書き込むだけで報告でき、操作ステップ、コンソールログ、ネットワークエラーが自動で添付されます。制約違反で API がエラーを返していれば、そのリクエストが証拠として残ります。
報告は GitHub Issue として起票され、カンバンボードで進捗を追えます。MCP サーバー経由で Claude Code や Cursor に渡せば、報告の内容から該当データの調査と、制約を足す修正案の作成まで進められます。不正データを見つけたら「直して終わり」にせず、どの制約があれば防げたかまで確認する習慣をつけると、同じバグの再発を設計で止められます。