「下書き/公開」機能のデータ設計で考えた観点と選択肢、そして意思決定
はじめに
Dress Code Advent Calendar 2025の23日目の記事です。
今回、とある新機能を実装する中で、「編集中(Draft)と公開済み(Published)をどう管理するか」 という設計判断に直面しました。
この「下書き→公開」フローは、CMS(コンテンツ管理システム)や商品マスタ管理など、様々なシステムで見られる一般的なパターンです。
この記事では、3つの設計案を比較検討し、最終的にどう意思決定したかをADR(Architecture Decision Record)として残した過程を紹介します。
背景:実装する機能の要件
ビジネス要件
今回実装する機能には、以下のような要件がありました
- 申請ベースのワークフロー:ユーザーが何かを申請する際、具体的な実体ではなく「型」を指定する
- 承認プロセス:下書き状態で内容を準備し、レビュー後に公開
- 他機能との関連性:公開後のマスタは様々な機能から参照される
- 監査要件:いつ、誰が、何を公開したかの履歴を厳密に管理
例えば、ECサイトの商品マスタを想像してください。「iPhone 15 Pro」という商品マスタがあり、それをベースに在庫(実体)が紐づくイメージです。
実装する機能の特徴
- テンプレート的な存在:マスタをベースに実体データを作成
- 編集と公開の分離:下書き状態で編集し、問題なければ公開
- 不変性の要求:公開後のマスタは変更不可(監査性のため)
- 論理削除によるアーカイブ:公開後のマスタは他機能から参照されるため、物理削除はせず「アーカイブ」状態にして新規関連付けを防ぐ
設計課題:Draft/Publishedをどう管理するか
このマスタデータには以下の状態があります
- Draft(下書き):編集中、まだ使われていない
- Published(公開済み):実体データ作成に使える、編集不可
- Archived(アーカイブ):使用停止、過去データとして保持
この状態管理をデータベースでどう実現するか、3つの設計案を検討しました。
状態による要件の違い
設計を検討する前に、同じ「マスタデータ」という概念でも、Draft(下書き)とPublished(公開済み)では要件が全く異なる ことを整理しました。
| 操作 | Draft(下書き) | Published(公開済み) |
|---|---|---|
| 編集 | 管理者のみ自由に編集可能 | 公開後は不変とし、誰も編集できない |
| 参照 | 管理者のみ | 申請者や処理担当者など様々なユーザー |
| 作成・公開 | 管理者なら誰でも作成可能 | 運用によっては要承認で公開可能 |
| 削除・アーカイブ | 再利用・再参照しないので物理削除とする | 他機能から紐付きが発生するので削除せず、アーカイブとする |
この表から分かるように、見た目は同じ「マスタデータ」でも、編集可否、参照範囲、削除方法が全く異なります。
これらの異なる要件を1つのテーブルで管理しようとすると、状態によって異なるロジックをアプリケーション層で実装する必要があり、複雑になります。
この気づきが、「Draft と Published をテーブル分離する」という設計判断の重要な根拠になりました。
設計案の比較
案A:ステータスフラグ方式(1テーブルで管理)
最もシンプルな方法です。
master_data {
id String
organization_id String
status Enum -- DRAFT | PUBLISHED | ARCHIVED
name String
description String?
updated_at DateTime?
updated_by String?
...
}
メリット:
- テーブル数が少ない
- クエリがシンプル(1テーブルのみ)
- 一覧取得が容易
デメリット:
- 更新可否のルールがアプリケーション層依存:公開済みデータの編集防止はコード側でチェックが必要
-
updatedAt/Byの有無を状態で制御できない:DB構造として保証できない - イミュータブル性をDB構造で保証できない:誤ってUPDATEしてしまうリスク
- 状態によって異なるバリデーションルールを実装する必要
案B:Draft/Published テーブル分離(採用案)
Draft専用テーブルと、Published/Archivedを管理するテーブルに分離する方法です。
-- 下書き専用(編集可能)
master_data_draft {
id String
organization_id String
name String
description String?
updated_at DateTime -- 常に存在
updated_by String -- 常に存在
published_id String? -- 公開先への参照
...
}
-- 公開済み+アーカイブ(不変)
master_data {
id String
organization_id String
name String
description String?
created_at DateTime -- 公開日時
created_by String -- 公開者
archived_at DateTime? -- アーカイブ日時
archived_by String?
...
}
メリット:
- 監査性が高い:Draft と Published の責務が明確に分離
- 型安全:Prisma のリレーション機能をフル活用、TypeScript の型推論が効く
- 責務が明確:編集可能/不可が DB 構造で明示
- 整合性保証:Published のイミュータブル性を DB 構造で保証
デメリット:
- テーブル数が増加:Draft/Published で 2 テーブル
- 一覧取得が複雑:並列クエリ→マージが必要
検討したが採用しなかった案:ハブテーブル方式
案Bを検討する際、「一覧取得のマージを楽にできるのでは?」と考え、Draft と Published を紐づける中間テーブル(ハブテーブル)を置く方式も検討しました。
-- ハブテーブル(中間管理)
master_data {
id String
organization_id String
draft_id String? -- Draftへの参照
published_id String? -- Publishedへの参照
}
一見良さそうに見えましたが、詳細に検討した結果、以下の理由で断念しました
- ハブテーブルがほぼ空:実際のマスタデータを持たず、参照のみで存在価値が薄い
-
状態管理が複雑:
draft_idとpublished_idのNULL判定や、CHECK制約が必要になり、却って複雑化 - 公開処理が複雑化:Draft取得→Published作成→ハブ更新の3ステップになる
- クエリが常にJOIN経由:毎回ハブ経由でアクセスし、NULL安全でない
- 型安全性が低い:Prismaの型推論の恩恵を受けにくい
「一覧マージを楽にする」という目的に対して、得られるメリットよりもデメリットが大きいと判断し、素直に案Bでアプリケーション層マージを選択しました。
比較表(案A vs 案B)
| 観点 | 案A:ステータスフラグ | 案B:テーブル分離(採用) |
|---|---|---|
| テーブル数 | 1 テーブル | 2 テーブル |
| 状態管理 | アプリ層のみ |
published_id のみ |
| 公開処理 | 1 ステップ(UPDATE) | 2 ステップ(SELECT → INSERT) |
| クエリ | シンプル | 直接参照、型安全 |
| 一覧取得 | シンプル | 並列クエリ → マージ |
| イミュータブル性 | アプリ層で保証 | DB構造で保証 |
| 型安全性 | 低 | 高(Prisma の恩恵) |
| 不整合リスク | 高(更新処理依存) | 低 |
意思決定プロセス
重視した判断軸
- 監査性の確保:BtoB SaaSでは監査要件が厳しい
- 型安全性:TypeScript + Prisma の恩恵を最大化したい
- 整合性保証:不正なデータ状態をDB構造レベルで防ぎたい
- 保守性:チームメンバーが理解しやすい設計にしたい
最終判断:案B(テーブル分離)を採用
採用理由:
- 監査性の確保:Draft(編集可能)とPublished(不変)の責務が明確に分離され、データベース構造で保証される
- 型安全性:Prismaのリレーション機能をフル活用でき、TypeScriptの型推論が効く
- 整合性保証:Publishedのイミュータブル性をDB構造レベルで保証できる
- シンプルな公開処理:2ステップのトランザクションで完結(Draft取得→Published作成)
一覧取得の複雑化への対応:
一覧取得が複雑になるデメリットについては、アプリケーション層で並列クエリ→マージする実装で対応しました。
// 並列で取得してマージ
const [drafts, published] = await Promise.all([
prisma.masterDataDraft.findMany({ where: { organizationId, publishedId: null } }),
prisma.masterData.findMany({ where: { organizationId, archivedAt: null } }),
]);
// 型安全にマージ
const masterDataList = [
...drafts.map(d => ({ ...d, status: 'DRAFT' as const })),
...published.map(p => ({ ...p, status: 'PUBLISHED' as const })),
];
この程度の複雑さであれば、型安全性・監査性のメリットが上回ると判断しました。
まとめ
データベース設計において「シンプルさ」は重要な価値ですが、何をシンプルにするか の判断が大切だと学びました。
冒頭で示した表のとおり、同じ見た目の概念でも、状態によって「誰が編集できるか」「誰が参照できるか」「削除の扱い」が全く異なります。
これらの異なる要件を1つのテーブルで管理しようとすると、テーブル数はシンプルでも、アプリケーション層のロジックが複雑になり、不整合リスクも高まります。
- 案A(ステータスフラグ):テーブル数はシンプルだが、状態による分岐ロジックが複雑で、不整合リスクが高い
- 案B(テーブル分離):テーブル数は増えるが、責務が明確で型安全、DB構造でイミュータブル性を保証
今回は 「DB構造で保証できることはDB構造で保証する」 という方針で案Bを選択しました。
一覧取得のマージという多少の複雑さは許容し、型安全性と監査性を優先する判断です。
「テーブル構造のシンプルさ」よりも「責務の明確さとロジックのシンプルさ」を優先した と言えます。
この記事が参考になれば幸いです。質問やフィードバックがあればコメントください!
終わりに
公開時にDraftを削除する設計にすればpublished_idも不要になるし良いかなーと思ったのですが、「消すのは簡単、復活は無理ゲー」と思い、チキって残しました。。笑
(チキるってもう古いのかな)
Discussion