共通カラム設計 — 全テーブルに入れる定番列
created_at / updated_at / deleted_at / version / tenant_id など全テーブル共通カラムの選び方。
共通カラムとは — 業務列ではない「運用のための列」
「共通カラム(common columns / audit columns)」は、業務上の意味はないがほぼ全テーブルに入れておく列のことです。ドメインモデリングの対象ではなく、運用・監査・不具合調査・マルチテナント・並行制御といった横断的な要求のために置きます。
代表的な 7 列を先に一覧で示します。どれも「あとから追加する」のはできても「あとから値を埋める」ことはできないので、最初から入れておくのが定石です。
| 列 | 型の例 | 用途 | 必須度 |
|---|---|---|---|
id | BIGINT / UUID | 主キー(代理キー) | ★★★ |
created_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | 作成日時 | ★★★ |
updated_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | 最終更新日時 | ★★★ |
deleted_at | TIMESTAMPTZ NULL | 論理削除時刻(使うなら) | ★★ 条件付き |
created_by | BIGINT / VARCHAR | 作成者 ID | ★★ |
updated_by | BIGINT / VARCHAR | 最終更新者 ID | ★★ |
version / lock_version | INTEGER NOT NULL DEFAULT 0 | 楽観ロック用カウンタ | ★ 必要に応じて |
tenant_id | BIGINT / UUID | マルチテナント分離 | ★ SaaS 系のみ |
主キー <code>id</code> — 原則は代理キー
ほぼすべての業務テーブルに代理キー id を置くのが実務の定石です。自然キー(メールアドレス・社員番号)はビジネス変更で必ず変わり、変わると FK 参照や外部システムとの連携がすべて書き換わります。
型の選択肢:
- BIGINT(連番) — 最もシンプル、インデックスもコンパクト。
BIGSERIAL(PostgreSQL) /AUTO_INCREMENT(MySQL) で十分 - UUID v7 — 分散発行可。時刻ベースで順序性もある(v4 は並び替えで苦しむ)。URL に露出しても実数が漏れない
- 複合キー — 中間テーブル(N:M の交差表)だけに限定して使う。通常のエンティティには使わない
詳細と選び方のトレードオフは 代理キー vs 自然キー を参照。
時刻系 — <code>created_at</code> / <code>updated_at</code>
この 2 列は理由がない限り全テーブルに入れるのが実務のデフォルトです。新旧判断・キャッシュ制御・不具合調査・データ同期など、後から必ず必要になります。
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
- 型は TIMESTAMPTZ(PostgreSQL)/TIMESTAMP(MySQL、UTC 保存)/DATETIMEOFFSET(SQL Server)。TZ 付きを選ぶ
updated_atの自動更新は RDBMS 依存。MySQL のみON UPDATE CURRENT_TIMESTAMPで素のままで済む。PostgreSQL 系はトリガまたは ORM フック- DB 側で時刻を決めるか、アプリ側で決めるかで「時計ずれ」「テスト容易性」のトレードオフがある
詳細は created_at / updated_at パターンと時刻型の選び方 を参照。
削除系 — <code>deleted_at</code>(使うなら)
論理削除を採用する場合のみ、deleted_at TIMESTAMPTZ NULL を追加します。値が入っていれば削除済み、NULL なら生存中、と読みます。全クエリに WHERE deleted_at IS NULL が必要になる重い選択なので、「全テーブル自動で付ける」運用にはしない方が安全です。
論理削除の主な罠:
- UNIQUE 制約が機能しなくなる — 削除済み行がキーを占有し、再登録できない
- 外部キー制約は論理削除を認識しない — 親が論理削除されても子から参照できてしまう
- GDPR 等の削除権と相性が悪い — 「本当に消してほしい」に応えられない
部分インデックスでの UNIQUE 回避、MySQL の生成列による代替、物理削除を選ぶべき場面など、論理削除固有の扱いは独立ページにまとめています。
監査系 — <code>created_by</code> / <code>updated_by</code>
「誰が作成・更新したか」を残したい業務では created_by / updated_by を入れます。型は users.id への FK か、ログインユーザー名の文字列(外部 IdP 連携で users テーブルを持たない場合)のいずれか。
created_by BIGINT REFERENCES users(id),
updated_by BIGINT REFERENCES users(id)
設計のコツ:
- NULL 可にしておく — バッチ処理・マイグレーション・外部 API 由来の行では「人」が存在しないケースがある
- システム実行用の擬似ユーザーを 1 つ作っておくと、
created_byを一貫して埋められる - 本格的な監査が必要なら履歴テーブル — いつ・誰が・何をどう変えたかを完全に追うには別テーブルが必要(履歴・監査ログ設計)
created_by / updated_by は「最終更新者 1 人」しか覚えられないため、完全な監査ログには力不足です。法務・コンプライアンス要件があるなら、最初から shadow history テーブルや temporal table を検討してください。
並行制御 — <code>version</code>(楽観ロック)
同じ行を複数ユーザーが同時に編集する業務(管理画面・CRUD フォーム系)では、楽観ロック用の version 列が有効です。更新のたびに +1 し、UPDATE の WHERE に「読み込み時の version」を付けて、一致しなければエラーにするパターンです。
-- 読み込み時に version = 3 を取得した場合
UPDATE articles
SET title = $1, body = $2, version = version + 1, updated_at = NOW()
WHERE id = $3 AND version = 3;
-- 影響行数 0 → 他の誰かが先に更新済み(stale write)
- 型は
INTEGER NOT NULL DEFAULT 0(あるいはBIGINT) - ORM では
@Version(Hibernate / JPA、TypeORM の@VersionColumn)で自動化されることが多い - 必要な業務だけに付ければよい — 完全に追記型のログテーブルや、バッチで一括更新する集計テーブルには不要
マルチテナント — <code>tenant_id</code>
SaaS のように「顧客企業ごとにデータを分離」する必要がある場合、テナント ID を全業務テーブルに持たせる方式(行レベル分離)が現実的です。スキーマ分離(顧客ごとに PostgreSQL schema を作る)は 100 社を超えたあたりからメンテが辛くなるため、行レベルのテナント分離 + 全クエリに WHERE tenant_id = ? が標準になりました。
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
-- 複合インデックスの先頭は tenant_id
CREATE INDEX orders_tenant_created ON orders(tenant_id, created_at);
-- PostgreSQL なら RLS(Row Level Security)で漏れを防げる
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
- インデックスはほぼすべて
tenant_idから始まる複合にする。単独検索より常にテナント絞り込みが先 - 抜け漏れチェックは DB 側でかける(PostgreSQL: Row Level Security、アプリ層だけでやると漏れやすい)
- 小規模ならスキーマ分離・DB 分離もあり得るが、運用オーバーヘッドと引き換え
テンプレート — ほぼ全テーブルの出発点
判断に迷ったら、次のテンプレートを出発点にしてください。不要な列は削り、必要な業務列を足すだけです。
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
-- ↓ 業務列
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
-- ↓ 共通カラム
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL, -- 論理削除する場合のみ
created_by BIGINT NULL REFERENCES users(id), -- 監査必要なら
updated_by BIGINT NULL REFERENCES users(id), -- 監査必要なら
version INTEGER NOT NULL DEFAULT 0 -- 楽観ロック必要なら
);
チームで統一するポイント:
- 命名規約をまず決める(
created_atかcreatedAtか、*_idか*Idか — 命名規約) - マイグレーションのひな形に共通カラムを最初から含める(忘れを減らす)
- ビュー・集計結果テーブル・一時テーブルには全部付けない — 意味がない列まで機械的に付けると逆にノイズ
