コードやテストより先に、NOT NULL/CHECK/UNIQUE/FOREIGN KEY といった PostgreSQL の制約に頼ることで、AI生成アプリをより安全にデプロイしましょう。

AI が生成したコードはハッピーパスを扱えているように見えることが多いです。でも本番アプリは「中間の混乱」で壊れます:フォームが null の代わりに空文字を送る、バックグラウンドジョブが再試行で同じレコードを二重に作る、あるいは親行が削除されて子が孤立する。こうしたバグは珍しくありません。必須フィールドが空になる、ユニークなはずの値が重複する、参照先が存在しない孤立行が残る、といった形で現れます。
これらはコードレビューや基本的なテストをすり抜けがちです。理由は単純で、レビュアーは意図を見るので細かなエッジケースまでは追い切れません。テストも典型例をいくつかカバーすることが多く、数週間分の実ユーザーの振る舞いや CSV インポート、ネットワークの再試行、並行リクエストまでは検証しません。アシスタントが生成したコードなら、空白のトリムや範囲チェック、レースコンディションの防止といった小さな重要チェックを見落とすことがあります。
「まず制約、次にコード」とは、データベースに非交渉のルールを置いておき、どの経路から書き込まれても悪いデータが保存されないようにする考え方です。アプリ側ではユーザー向けのわかりやすいエラーメッセージを出すために入力検証を残すべきですが、真実を担保するのはデータベースです。そこに PostgreSQL の制約が威力を発揮します。広範なミスからあなたを守ってくれます。
簡単な例を挙げます。小さな CRM を想像してください。AI が生成したインポートスクリプトが連絡先を作ります。ある行はメールが ""(空文字)で、別の二行は大文字小文字だけ違う同じメールが重複し、さらに一件は参照する account_id が別プロセスで削除されたため存在しない。制約がなければ、これらは本番にそのまま入り込み、後でレポートを壊します。
適切なデータベースルールがあれば、そうした書き込みはすぐに失敗します。発生源に近いところで止められるのです。必須フィールドが欠けていれば保存できず、再試行で重複が混入することを防ぎ、参照が削除されたり存在しない行を指すことを許さず、値が許容範囲外であれば弾きます。
制約で全てのバグが防げるわけではありません。UI が分かりにくい、割引計算が間違っている、クエリが遅い、といった問題は解決できません。しかし、悪いデータが静かに蓄積されるのを止められるため、「AI生成が引き起こすエッジケースのバグ」が高コストになるのを防げます。
アプリはたいてい一つのコードベースと一人のユーザーだけの世界ではありません。典型的なプロダクトにはウェブ UI、モバイル、管理画面、バックグラウンドジョブ、CSV インポート、サードパーティ統合など、複数の経路があります。それぞれの経路がデータを作成・変更します。すべての経路が同じルールを覚えている必要があると、一つは忘れます。
データベースはそれらが共有する唯一の場所です。最終的な門番として扱えば、ルールは自動的にすべてに適用されます。PostgreSQL の制約は「常にそうだと仮定する」から「必ずそうでなければならない、さもなければ書き込みは失敗する」へと変えてくれます。
AI が生成したコードがあると、これはさらに重要になります。モデルは React のフォームバリデーションを追加しても、バックグラウンドジョブの角ケースを見落とすかもしれません。あるいはハッピーパスはうまくいっても、本物のユーザーが予期しない入力をすると壊れます。制約は悪いデータが入ろうとした瞬間に検出し、数週間後の奇妙なレポートで原因を探す必要をなくします。
制約をスキップすると、悪いデータは静かに入り込みます。保存は成功し、アプリは先に進み、問題はサポートチケット、請求のずれ、信用できないダッシュボードとして後で現れます。履歴を直すのは高コストです。過去のデータを修正しなければならないからです。
悪いデータは日常の状況から忍び込みます:新しいクライアントアプリのバージョンがフィールドを "空" にする、再試行で重複が生まれる、管理画面の編集が UI チェックを迂回する、インポートファイルのフォーマットが不揃い、あるいは二人のユーザーが同時に関連レコードを更新する、などです。
実践的な考え方はこうです:境界でしか有効なデータを受け入れない。その境界にはデータベースを含めるべきです。データベースはすべての書き込みを見ているからです。
NOT NULL は最も単純な PostgreSQL の制約で、意外に多くのバグを防ぎます。行の意味を成すために値が必須なら、データベースにその enforcement を任せましょう。
NOT NULL は識別子や必須の名前、タイムスタンプに対して妥当なことが多いです。有効なレコードを作るのに値が必要なら、空にさせないでください。小さな CRM でオーナーや作成時刻がないリードは「部分的なリード」ではなく、後で奇妙な挙動を引き起こす壊れたデータです。
NULL は AI が生成したコードで侵入しやすいです。なぜなら「オプショナル」な経路を気づかず作ってしまうことがあるからです。フォームが UI 上は任意、API がキー不在を受け入れる、あるいは作成関数のある分岐が値の割り当てをスキップする。すべてはコンパイルし、ハッピーパステストは通ります。しかし実ユーザーが CSV をインポートして空セルが入ると、あるいはモバイルクライアントが異なるペイロードを送ると NULL が入り込みます。
よいパターンは、システムが管理するフィールドに対して NOT NULL と妥当なデフォルトを組み合わせることです:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueただしデフォルトが常に正解というわけではありません。email や company_name のようなユーザー提供のフィールドを NOT NULL を満たすためだけにデフォルト化しないでください。空文字は NULL より「より正しい」わけではなく、問題を隠すだけです。
迷ったら、その値が本当に「未知」なのか、それとも別の状態を表すのかを判断してください。「まだ提供されていない」が意味を持つなら、値を nullable にして別途状態カラムを追加するほうがいい場合があります。例えば phone を nullable にして phone_status を missing、requested、verified のようにしておけば意味が一貫します。
CHECK 制約はテーブルが約束するものです:すべての行は常にそのルールを満たさなければなりません。行内の値だけに依存するルール(数値の範囲、許容値、列間の単純な関係)に対して導入すると効果的で、コード上は問題なさそうに見えて実際には意味のないレコードを防げます。
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents >= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date >= start_date);
良い CHECK は一目で読めます。データのドキュメントとして扱ってください。式は短く、制約名はわかりやすく、パターンは予測可能にするのが良いでしょう。
CHECK が向かない場合もあります。ルールが他の行を参照したり集計したり、テーブルを横断して比較する必要がある場合(例:「アカウントがプランの上限を超えていない」)は、アプリロジック、トリガ、あるいは制御されたバックグラウンドジョブに任せてください。
UNIQUE 制約は単純です:定義した列(または列の組み合わせ)に同じ値を持つ二つ目の行をデータベースが許しません。これで「作成」経路が二度走った、再試行が起きた、二人が同時に同じものを送った、というクラスのバグを防げます。
UNIQUE は定義した正確な値に対して重複を許さないだけで、値が存在するか(NOT NULL)、形式に従うか(CHECK)、あるいは等価性の定義(大文字小文字や空白)まで保証するわけではないことに注意してください。
典型的にユニークにしたい場所は、ユーザテーブルのメール、外部システムから来る external_id、あるいは (account_id, name) のようにアカウント内で一意である必要がある名前などです。
注意点:NULL と UNIQUE の関係。PostgreSQL では NULL は「不明」と扱われるため、UNIQUE 制約のもとでは複数の NULL が許されます。「値が存在し、かつ一意であること」を意味するなら、UNIQUE と NOT NULL を組み合わせてください。
ユーザー向け識別子では大文字小文字を区別しない一意性が実用的です。人は “[email protected]” と書き、後で “[email protected]” と入力して同じだと期待します。
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
「重複」をユーザーにとって何を意味するか(大文字小文字、空白、アカウント単位かグローバルか)を定義し、それを一回だけスキーマに記述して全ての経路が従うようにしましょう。
FOREIGN KEY は「この行はあちらの本物の行を指していなければならない」と言います。これがないと、孤立したレコードが静かに作られてアプリを後で壊します。例えば、削除された顧客を参照するノートや、存在しないユーザー ID を指す請求書などです。
外部キーは、削除と作成が近接して起きる場合、タイムアウト後の再試行、あるいは古いデータでバックグラウンドジョブが動く場合に特に重要です。データベースは一貫性を強制するのが得意で、すべてのアプリ経路がチェックを覚えておくより確実です。
ON DELETE のオプションは現実の関係性に合うように選んでください。「親が消えたら子はどうすべきか?」を考えます。
RESTRICT(または NO ACTION):子が存在する場合は親の削除をブロックする。CASCADE:親を削除すると子も削除される。SET NULL:子は残るが参照を外す。CASCADE は便利ですが、間違えると予想以上に多くのデータを消してしまうので注意してください。
マルチテナントアプリでは外部キーは正しさだけでなくアカウント間の漏えい防止にも役立ちます。一般的なパターンは、テナント所有テーブルに account_id を含め、関係を通じて所有権を結びつけることです。
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
これによりスキーマで「誰が何を所有しているか」が強制されます:ノートは別アカウントのコンタクトを指せません。アプリコードや LLM が誤ったクエリを出しても防げます。
まず不変条件の短いリストを書き出してください:常に成り立たなければならない事実を平易に。例えば「すべてのコンタクトにはメールが必要」「ステータスは許可された値の一つである」「請求書は実在する顧客に属する」といった具合です。これがデータベースに強制させたいルールです。
変更は小さなマイグレーションで展開し、本番を驚かせないようにします:
NOT NULL、UNIQUE、CHECK、FOREIGN KEY)。厄介なのは既存の悪いデータです。計画を立ててください。重複には勝者を選び、残りをマージして小さな監査ノートを残す。必須フィールドが欠けている場合は、本当に安全なら安全なデフォルトを選ぶか、そうでなければ隔離します。壊れた参照は子を正しい親に再割り当てするか、悪い行を削除します。
各マイグレーション後には、失敗すべき書き込みで検証してください:必須値が欠けた行を挿入する、重複キーを挿入する、範囲外の値を挿入する、存在しない親を参照する行を挿入する。書き込みの失敗は有益なシグナルです。どの経路が「ベストエフォート」に頼っていたかを示してくれます。
小さな CRM を想像してください:アカウント(SaaS のお客様)、取り引き先の会社、会社の担当者、会社に結びつく商談。
チャットツールで素早く生成されるアプリはこうした構成が多く、デモでは問題がないように見えますが実データはすぐに混乱します。初期に見つかるバグは大抵二つ:連絡先の重複(同じメールが少し違う形で複数登録される)と、company_id を設定し忘れて会社なしで商談が作られること。リファクタやパースのミスで商談金額が負になるケースもよくあります。
対処は if 文を増やすことではなく、悪いデータを保存不可能にする適切な制約をいくつか置くことです。
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
これはただ厳しくするためのものではありません。あいまいな期待を、どの経路から書き込まれてもデータベースが強制するルールに変えるのです。
これらの制約が入るとアプリは単純になります。事後に重複を検出する防御的チェックの多くを減らせます。失敗は明確で対応しやすくなります(例:「このアカウントですでにメールが存在します」など)。生成された API ルートがフィールドを忘れても、書き込みが即座に失敗してデータベースが破損するのを防ぎます。
制約はビジネスの実態に合うとき最も効果的です。多くの痛みは、その場しのぎで「安全そう」に見えるルールを入れてしまい、後で驚きになる場合に起きます。
よくある落とし穴は ON DELETE CASCADE を至る所で使うことです。便利に見えますが、誰かが親を削除するとシステムの半分が消えてしまうことがあります。草稿の行項目など本当に単独で存在してはいけないデータには適切ですが、顧客や請求書のように重要なデータにはリスクがあります。確信がないなら RESTRICT を選び、削除を意図的に扱いましょう。
別の問題は CHECK を狭く書きすぎることです。「status は 'new', 'won', 'lost' のいずれか」だと仮定すると、後で 'paused' や 'archived' が必要になったときに困ります。良い CHECK は安定的な真実を記述するべきで、一時的な UI 選択肢ではありません。例えば amount >= 0 は長持ちしますが、country in (...) はしばしば変わります。
生成されたコードが既に出ているチームが後から制約を入れるときに繰り返し見る問題:
CASCADE をクリーニング用途と誤用して意図以上にデータを消す。パフォーマンス面では:PostgreSQL は UNIQUE のために自動的にインデックスを作りますが、外部キーの参照側の列には自動でインデックスは作りません。参照列にインデックスがないと、親の更新や削除時に子テーブルを全走査して参照をチェックする必要が出て遅くなります。
ルールを厳しくする前に、そのルールに違反する既存行を探し、修正するか隔離する手順を決め、段階的に展開してください。
出荷前に各テーブルについて5分程度で「常に正しいこと」を書き出してください。英語で簡潔に言えれば、その多くは制約で強制できます。
各テーブルに対して自問してください:
チャット駆動のビルドツールを使うなら、これら不変条件をデータに関する受け入れ基準として扱ってください。例えば:「商談金額は非負であること」「コンタクトのメールはワークスペースごとに一意であること」「タスクは実在するコンタクトを参照すること」など。ルールを具体化するほど、偶発的なエッジケースが減ります。
Koder.ai (koder.ai) は planning mode、スナップショットとロールバック、ソースコードのエクスポートなどの機能を提供しており、制約を段階的に強化しながら安全にスキーマ変更を繰り返すのを助けます。
現実的に機能する単純なロールアウトパターン:一つの価値の高いテーブル(users、orders、invoices、contacts)を選び、最も致命的な失敗を防ぐ 1~2 個の制約(多くは NOT NULL と UNIQUE)を追加し、失敗する書き込みを修正し、これを繰り返す。時間をかけてルールを厳しくしていく方が一度に大きなリスクを取るより安全です。