DRESS CODE TECH BLOG
🐔

「下書き/公開」機能のデータ設計で考えた観点と選択肢、そして意思決定

に公開

はじめに

Dress Code Advent Calendar 2025の23日目の記事です。

今回、とある新機能を実装する中で、「編集中(Draft)と公開済み(Published)をどう管理するか」 という設計判断に直面しました。
この「下書き→公開」フローは、CMS(コンテンツ管理システム)や商品マスタ管理など、様々なシステムで見られる一般的なパターンです。

この記事では、3つの設計案を比較検討し、最終的にどう意思決定したかをADR(Architecture Decision Record)として残した過程を紹介します。

背景:実装する機能の要件

ビジネス要件

今回実装する機能には、以下のような要件がありました

  1. 申請ベースのワークフロー:ユーザーが何かを申請する際、具体的な実体ではなく「型」を指定する
  2. 承認プロセス:下書き状態で内容を準備し、レビュー後に公開
  3. 他機能との関連性:公開後のマスタは様々な機能から参照される
  4. 監査要件:いつ、誰が、何を公開したかの履歴を厳密に管理

例えば、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 の恩恵)
不整合リスク 高(更新処理依存) 低

意思決定プロセス

重視した判断軸

  1. 監査性の確保:BtoB SaaSでは監査要件が厳しい
  2. 型安全性:TypeScript + Prisma の恩恵を最大化したい
  3. 整合性保証:不正なデータ状態をDB構造レベルで防ぎたい
  4. 保守性:チームメンバーが理解しやすい設計にしたい

最終判断:案B(テーブル分離)を採用

採用理由:

  1. 監査性の確保:Draft(編集可能)とPublished(不変)の責務が明確に分離され、データベース構造で保証される
  2. 型安全性:Prismaのリレーション機能をフル活用でき、TypeScriptの型推論が効く
  3. 整合性保証:Publishedのイミュータブル性をDB構造レベルで保証できる
  4. シンプルな公開処理: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も不要になるし良いかなーと思ったのですが、「消すのは簡単、復活は無理ゲー」と思い、チキって残しました。。笑
(チキるってもう古いのかな)

DRESS CODE TECH BLOG
DRESS CODE TECH BLOG

Discussion