概要
マルチテナントなSaaSで「テナント(プロジェクト・組織単位)ごとに独自の入力項目を追加したい」という要件はよくあります(例: QAチームの「対象環境」「OS/ブラウザ」、営業案件管理の「顧客名」「契約金額」など)。この記事では、RDBのスキーマを変更せずにこれを実現する設計パターンを、実装例つきで整理します。
要件
- テナントごとに、文字列・数値・ドロップダウン(選択式)の項目を自由に追加できる
- 項目の追加・削除・並び替えを、管理画面からその場で行える(DBマイグレーション不要)
- 必須項目を指定できる
- 既存レコードへの影響を最小限にする
テーブル設計
項目の「定義」と「値」を分けて考えるのがポイントです。
-- 項目の定義(テナントごとの設定)
CREATE TABLE custom_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
field_type TEXT NOT NULL DEFAULT 'text' CHECK (field_type IN ('text', 'number', 'select')),
options JSONB NOT NULL DEFAULT '[]', -- field_type = 'select' の場合の選択肢
is_required BOOLEAN NOT NULL DEFAULT false,
order_index INTEGER NOT NULL DEFAULT 0,
UNIQUE(tenant_id, name)
);
-- 対象レコード側に、値を1カラムだけ追加
ALTER TABLE records ADD COLUMN custom_field_values JSONB NOT NULL DEFAULT '{}';
custom_field_valuesは{ "<custom_fields.id>": "値" }という形の連想配列です。項目が何個追加されようとrecords側のスキーマは変わらないため、項目追加のたびにマイグレーションを打つ必要がありません。
フロントエンド側: 型ごとの入力コンポーネントを動的に出し分ける
{customFields.map((field) => (
<div key={field.id}>
<label>
{field.name}
{field.is_required && <span className="required">*</span>}
</label>
{field.field_type === 'select' ? (
<select
value={values[field.id] ?? ''}
onChange={(e) => setValues((prev) => ({ ...prev, [field.id]: e.target.value || null }))}
>
<option value="">選択してください</option>
{field.options.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : field.field_type === 'number' ? (
<input
type="number"
value={values[field.id] ?? ''}
onChange={(e) => setValues((prev) => ({ ...prev, [field.id]: e.target.value === '' ? null : Number(e.target.value) }))}
/>
) : (
<input
type="text"
value={values[field.id] ?? ''}
onChange={(e) => setValues((prev) => ({ ...prev, [field.id]: e.target.value || null }))}
/>
)}
</div>
))}
項目定義(customFields)をテナントごとに取得し、それをmapして入力欄を生成するだけなので、項目数が増えてもコンポーネント側の変更は不要です。
ハマりやすいポイント
1. 値の更新は「差分」ではなく「全体」を送る
custom_field_valuesはJSONB1カラムに全項目の値をまとめて持っているため、更新APIに一部の項目だけを送ってUPDATEすると、他の項目の値が消えます。
// NG: 1項目だけ送ると、他の項目の値がまるごと消える
await updateRecord(id, { custom_field_values: { [fieldId]: newValue } });
// OK: 既存値とマージした「完全な」オブジェクトを送る
await updateRecord(id, { custom_field_values: { ...record.custom_field_values, [fieldId]: newValue } });
フォーム側は必ず「既存の全項目値で初期化 → 変更箇所だけ差し替え → 送信時は全体を送る」という流れにする必要があります。複数のUI経路(一括編集フォーム、詳細画面のインライン編集、コメント欄からの更新など)が同じレコードを更新できる設計の場合、経路ごとにこの原則を徹底しないと、片方の経路で更新した値がもう片方の経路の更新で意図せず消える、という不具合を作り込みやすいので要注意です。
2. 更新用ホワイトリストへの追加漏れ
APIサーバー側で「更新を許可するカラムのホワイトリスト」を持っている設計の場合、新しく追加したJSONBカラム(この例ではcustom_field_values)をそのリストに加え忘れると、エラーにもならずに静かに保存がスキップされることがあります。フロントエンドの表示は楽観的更新で正しく見えているのに、リロードすると元に戻る、という気づきにくいバグになるため、新しいカラムを追加したら更新経路のホワイトリストを必ず確認することをお勧めします。
3. 型の削除・変更は既存データに影響する
field_typeを後からselectからtextに変更したり、選択肢(options)から既存レコードが参照している値を削除したりすると、表示側で「登録されているが選択肢に存在しない値」を描画する必要が出てきます。今回の実装では、select表示時に現在値がoptionsに含まれていなくても素の値として表示できるよう、<option>を動的に選ばせるのではなく現在値をそのまま表示する形でフォールバックしています。
まとめ
「テナントごとに項目を追加できる」機能は、テーブルを増やさずに「定義テーブル + JSONB値カラム」の2点構成で実現できます。実装コスト自体は小さいですが、値の更新を必ず「全体送信」にすることと、更新経路のホワイトリスト漏れの2点だけは事前に意識しておくと、後から発覚しづらいバグを避けられます。