はじめに
RDBを使っていると「CHECK制約ってどこまで書くべきか」で迷うことがあります。特に、ビジネスロジックまでDBに寄せるべきかは意見が分かれやすいポイントです。
結論から言うと、CHECK制約にビジネスロジックは書くべきではありません。
この記事では、実務で使いやすい判断基準と、CHECK制約が得意なこと・苦手なことを整理します。
結論
- DBが壊れると困るものだけCHECKに書く
- 業務ロジックはアプリケーションに寄せる
CHECK制約は「ロジックを書く場所」ではなく、データを壊さないための最後の砦として使うのがちょうどよいです。
CHECK制約に書くべきもの
データの絶対条件(壊れると詰む)
どの処理から入ってきても守られるべきルールです。バッチ・API・手動更新、すべての経路に対して効きます。
age INT CHECK (age >= 0)
price DECIMAL(10,2) CHECK (price >= 0)
status VARCHAR(20) NOT NULL CHECK (status IN ('draft', 'published', 'archived'))
なお、statusのような区分値は使い分けの目安があります。
- 値が固定で変わらない → CHECK
- 値が増える可能性がある → マスタテーブル + FOREIGN KEY
- アプリ内だけで閉じる → Enum
CHECKが向くのは、値が将来にわたって変わらないと確信できる場合に限ります。statusのような区分値は多くの場合「増えることがある」ため、実務ではマスタテーブルかEnumで管理する方が変更に強くなります。
1行で完結する制約
他テーブルや集計に依存せず、DB単体で判断できるものです。
終了日が必ず存在するテーブルの場合は、両方 NOT NULL にした上でCHECKを書きます。
start_date DATE NOT NULL,
end_date DATE NOT NULL,
CHECK (start_date <= end_date)
NULL を許可すると評価結果が UNKNOWN になり、CHECKを素通りします。終了日が必須のテーブルであれば NOT NULL と併記するのが安全です。
一方、「終了日未定」を表したい場合は end_date を NULL 許容にします。その場合はCHECKの条件に IS NULL の考慮を入れます。
start_date DATE NOT NULL,
end_date DATE,
CHECK (end_date IS NULL OR start_date <= end_date)
end_date がNULLのときは「期間未定」として素通りさせ、値が入っているときだけ順序を検証します。
変更されにくいルール
頻繁に変わるとマイグレーションのたびにコストがかかります。「この制約は今後も変わらない」と言い切れるものに絞るのが現実的です。
CHECK制約の数には注意する
CHECK制約が多くなると、テストデータを手で入れたいときに制約に引っかかって煩わしく感じる場面が出てきます。バルクINSERTや検証用データを流したいだけなのに、制約違反で詰まることもあります。そのたびに一時的に制約を無効化する運用が発生し、手間とリスクが増えます。
必要な制約だけを絞って入れることが、開発体験の面でも重要です。
CHECKに書かない方がいいもの
ビジネスロジック
一見正しそうに見えても、CHECKに書くべきではありません。
-- 未成年は喫煙不可
CHECK (age >= 20 OR smoker = 'N')
SQLとしては書けますし、パズル的な面白さもあります。しかし業務アプリでこれをDB層に書くのは避けた方がよいです。
そもそも「何歳を成人とみなすか」はシステムによって異なります。設定テーブルで管理したい場合もあるでしょう。しかしCHECK制約から他テーブルを参照することはできないため、その時点でDB層での対応は詰まります。結果としてアプリ側で処理するしかなく、であれば最初からアプリ側に書いておく方が素直です。
問題になる理由は次のとおりです。
- ルールが変わる可能性がある
- 例外対応が増えたときにDB変更が必要になる
- アプリとDBでロジックが分散して責務が曖昧になる
ビジネスロジックはアプリケーション側に寄せるべきです。
CHECKが苦手なこと
CHECK制約は「その行だけを見て真偽判定できる条件」に向いています。逆に、次のことは苦手か、基本的にできません。
| やりたいこと | 例 | 代替手段 |
|---|---|---|
| 他の行との比較 | 同一 user_id で有効レコードは1件まで | UNIQUE制約・設計で対応 |
| 他テーブルを参照した条件 | 親テーブルの status が active のときだけ登録可 | FOREIGN KEY・アプリ側・トリガー |
| 集計結果を使う条件 | 1日の入金合計が100万以下 | アプリ側で制御 |
| 行をまたぐ順序性 | 前回履歴より valid_from は後であること | アプリ側・トリガー |
| 複雑な業務ルール | 法人なら担当者必須、個人なら不要、特定国だけ別条件 | アプリ側 |
| 副作用を伴う処理 | 条件を満たさなければ別テーブルに記録する | トリガー・アプリ側 |
そもそも、INSERT・UPDATEのたびに他テーブルを参照したり集計を走らせたりすれば、書き込みのたびに余分なクエリが発生してパフォーマンスが落ちます。できないというより、やらなくて正解という仕様です。
使い分けの目安は次のとおりです。
- 値の範囲・形式 → CHECK
- 一意性 → UNIQUE
- 親子関係 → FOREIGN KEY
- 複雑な業務ルール → アプリ側またはトリガー
CHECKでやろうとしがちだがUNIQUEで解決できる例
「同一ユーザーで有効レコードは1件まで」という制約は、CHECKで書こうとすると他の行を参照する必要があり実現できません。こういうケースはUNIQUEで対応します。
-- NG: CHECKでは他の行を参照できないため書けない
-- CHECK (count_active_records(user_id) <= 1) ← 不可
論理削除を使っている場合、有効レコードだけを対象にした一意性を担保したい場面があります。これはPostgreSQLであれば部分インデックスで実現できます。
-- PostgreSQL の場合
CREATE UNIQUE INDEX uq_user_active
ON subscriptions (user_id)
WHERE deleted_at IS NULL;
ただしMySQLの CREATE INDEX には WHERE 句がないため、この書き方はそのまま使えません。MySQLで同等のことをしたい場合は、別途設計で対応が必要になります。
実務で使える判断基準
迷ったときはこの3つで判断します。
- 壊れたらDB単体で防げないと困るか
- 1行で完結するか
- 頻繁に変わらないか
3つすべてYESならCHECKに入れます。1つでもNOがあればアプリ側で対処する方が安全です。
補足:DBに寄せる設計が有効なケースもある
この記事ではアプリ側にロジックを寄せることを推奨していますが、複数のアプリケーションから同一DBを利用する構成では、DB側にある程度ルールを寄せた方が整合性を保ちやすいケースもあります。
また、代替手段として何度か挙げたトリガーは強力ですが、テーブル定義を見ただけでは動作が見えないため可視性が低くなります。乱用すると原因調査が難しくなるため、使い所は絞るのが無難です。
なぜビジネスロジックを入れると辛くなるのか
変更コストが高い
ルールが変わるたびにマイグレーションが必要になります。アプリ側なら関数を修正するだけで済む変更でも、DB制約の場合は本番環境への適用手順が必要です。ロールバックも重くなります。
責務が分散する
アプリとDBの両方にロジックが存在する状態になると、どちらを見れば正しいルールがわかるかが曖昧になります。バグの原因調査もしにくくなります。
制約が増えて読みにくくなる
テーブル定義にCHECKが増えていくと、何を守っているのかが一目でわからなくなります。本来守るべきデータの整合性が、業務ルールの記述に埋もれていきます。
まとめ
CHECK制約は強力ですが、使いすぎると開発・運用の両方が重くなります。
- 「データが壊れないライン」だけCHECKで守る
- ビジネスロジックはアプリケーションに寄せる
- 他行・他テーブル・集計が絡む制約はCHECKでは対処しない
CHECK制約は「ビジネスルールを書く場所」ではなく、「データの最低限の整合性を守る最後の砦」です。その役割に絞って使うのが、実務では一番うまくいきます。