データモデル設計ガイド

共通カラム設計 — 全テーブルに入れる定番列

created_at / updated_at / deleted_at / version / tenant_id など全テーブル共通カラムの選び方。

共通カラム設計 — 全テーブルに入れる定番列 diagram

共通カラムとは — 業務列ではない「運用のための列」

「共通カラム(common columns / audit columns)」は、業務上の意味はないがほぼ全テーブルに入れておく列のことです。ドメインモデリングの対象ではなく、運用・監査・不具合調査・マルチテナント・並行制御といった横断的な要求のために置きます。

代表的な 7 列を先に一覧で示します。どれも「あとから追加する」のはできても「あとから値を埋める」ことはできないので、最初から入れておくのが定石です。

型の例用途必須度
idBIGINT / UUID主キー(代理キー)★★★
created_atTIMESTAMPTZ NOT NULL DEFAULT NOW()作成日時★★★
updated_atTIMESTAMPTZ NOT NULL DEFAULT NOW()最終更新日時★★★
deleted_atTIMESTAMPTZ NULL論理削除時刻(使うなら)★★ 条件付き
created_byBIGINT / VARCHAR作成者 ID★★
updated_byBIGINT / VARCHAR最終更新者 ID★★
version / lock_versionINTEGER NOT NULL DEFAULT 0楽観ロック用カウンタ★ 必要に応じて
tenant_idBIGINT / 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 の生成列による代替、物理削除を選ぶべき場面など、論理削除固有の扱いは独立ページにまとめています。

→ 論理削除 vs 物理削除 — deleted_at パターンの落とし穴

監査系 — <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_atcreatedAt か、*_id*Id か — 命名規約
  • マイグレーションのひな形に共通カラムを最初から含める(忘れを減らす)
  • ビュー・集計結果テーブル・一時テーブルには全部付けない — 意味がない列まで機械的に付けると逆にノイズ