Enumには"状態"ではなく"種類"を書こう

に公開

はじめに

この記事は、あの時「もっと◯◯を勉強しておけば!」と思った話 by エルピーアイジャパン Advent Calendar 2025 のエントリーです。

数年前に書いた自分のコード、見返したんですよね。

そしたら「あぁこれ設計良くなかったな...」っていうコードを発見しました。

Enumの使い方自体は間違ってないんです。でも、仕様変更が来たときに対応ができないような設計になっていました。

ってことで もっとデータモデリングの基礎を勉強しておけば! って話を書いていきます。

この記事で話すこと:

  • Enumで「状態」を管理すると、なぜ将来困るのか
  • 「いつその状態になったか」を知りたくなったときに耐えられない
    • 対策のアンチパターン
  • 状態の変化をイベントとして記録する設計パターン

初期はEnumでの管理でよかった

数年前に作ったアプリケーション、こんな感じでした。

記事管理システムで:

  • 記事を 下書き / レビュー中 / 公開 に変更できる
  • 記事の種類は ニュース / コラム / キャンペーン の3種類

よくある要件ですよね。

で、こんなテーブルを作りました。

CREATE TABLE articles (
  id BIGINT PRIMARY KEY,
  status INTEGER NOT NULL,   -- draft:1, reviewing:2, published:3
  type INTEGER NOT NULL,     -- news:1, column:2, campaign:3
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

記事の状態(下書き/レビュー中/公開)と種類(ニュース/コラム/キャンペーン)を管理するシステム
記事の状態と種類を管理するシステムの概要

こういうテーブル、一度は作ったことある人多いと思います。
が、このテーブルの形にして困ったという話です。

Railsだと

class Article < ApplicationRecord
  enum status: {
    draft: 1,        # 下書き
    reviewing: 2,    # レビュー中
    published: 3,    # 公開
  }
  enum type: {
    news: 1,         # ニュース
    column: 2,       # コラム
    campaign: 3,      # キャンペーン
  }
end

こんな感じでEnumを使ってました。

Enum は便利です。列挙した値しか取らないから、バリデーションも楽です。

  • statusは「下書き」「レビュー中」「公開」のどれか
  • typeは「ニュース」「コラム」「キャンペーン」のどれか

と決まっているので、変な値が入ることもありません。

当時の自分はそれで良かったし、実際これで問題ないアプリケーションも多いと思います。

新たな要件が降ってきて対応できなくなった

新たな要件が降ってきた様子
ここまでは特に困っていませんでした。

でもあるとき、こんな要件が降ってきました。

記事の公開日時やレビュー中になった日時を知りたい」と。

例えば管理画面で:

  • 先月公開された記事一覧を出したい
  • 3日以上レビュー中になっている記事一覧を出したい

みたいなことをやりたいとのことです。

つまり:

  • 下書きになった日時
  • レビュー中になった日時
  • 公開された日時

が欲しいとなりました。

ここで気づくEnumの設計の問題

Enumで状態を上書きすると履歴が失われる問題を示す図
Enumの値を上書きすると過去の状態情報が失われてしまう
ここで初めて、Enumでの設計の問題に気づきました。

現在のテーブルを見てみましょう。

CREATE TABLE articles (
  id BIGINT PRIMARY KEY,
  status INTEGER NOT NULL,   -- いまの状態しか保持していない
  type INTEGER NOT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

statusカラムには「いまの状態」しか入っていません。

  • status = 3(公開中)なら「いま公開されている」ことは分かります
  • しかし「いつ公開されたのか」は分かりません
  • 記事が下書き→レビュー→公開と遷移した履歴も残っていません

Enum は「いまどの状態か」は表せますが「いつその状態になったか」「どういう経路で状態が変わったか」は表せないのです。

これは、Enumを「状態」の管理に使ったことによる設計上の制約です。

状態は時間とともに変化しますが、Enumの値を上書きすると過去の情報は失われてしまいます

じゃあどうする?

さて、どう対応しましょう。

ここで多くの人がやりがちな対応があります。

ありがちな対応(でも良くない)

この要件に対応するために、以下のようなテーブル設計にしたことがある人もいるのではないでしょうか。

drafted_atreviewing_atpublished_at 各状態の日時カラムを追加しました。

CREATE TABLE articles (
  id BIGINT PRIMARY KEY,
  type INTEGER NOT NULL,     -- news:1, column:2, campaign:3
  drafted_at TIMESTAMP NOT NULL, -- 追加: 下書きになった日時
  reviewing_at TIMESTAMP,        -- 追加: レビュー中になった日時
  published_at TIMESTAMP,        -- 追加: 公開した日時
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

これで:

  • 「先月公開された記事」→ published_at が先月のものを取ればよい
  • 「3日以上レビュー中」→ reviewing_at <= 3.days.ago で取ればよい

実現できている……ように見えます。

しかし、実はイベント系のテーブルに複数の日時属性を持ってしまっている、良くないパターンです。

イベント系のエンティティには日時属性は1つにする
イベント系のエンティティには日時属性は1つにする(出典: WEB+DB PRESS Vol.130 特集1「イミュータブルデータモデルで始める実践データモデリング」

なぜ良くないのか①:更なるステータス追加に耐えられない

例えばこの仕様に加えて:

  • レビューが終わったステータスとその日時も欲しい
  • アーカイブステータスとその日時も欲しい

といった要望が出てきたらどうでしょうか。

CREATE TABLE articles (
  id BIGINT PRIMARY KEY,
  type INTEGER NOT NULL,     -- news:1, column:2, campaign:3
  drafted_at TIMESTAMP NOT NULL, -- 下書きになった日時
  reviewing_at TIMESTAMP,        -- レビュー中になった日時
  published_at TIMESTAMP,        -- 公開した日時
  reviewed_at TIMESTAMP,          -- 追加: レビューした日時
  archived_at TIMESTAMP,          -- 追加: アーカイブした日時
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

nullable なカラムがどんどん増えてきて流石に不吉な臭いがします。

なぜ良くないのか②:状態が戻ると破綻する

もう一つ問題があります。

下書き → レビュー → 公開 という直線的なステータス遷移であればこの設計でも問題ないかもしれません。

操作 drafted_at reviewing_at published_at
draft! 2025-12-24 12:00 nil nil
reviewing! 2025-12-24 12:00(維持) 2025-12-24 17:00 nil
publish! 2025-12-24 12:00(維持) 2025-12-24 17:00(維持) 2025-12-25 09:00

こういうデータになるイメージです。

ですが、「レビューしたが、差し戻しで下書きに戻る」場合が発生したときはどうでしょうか。

操作 drafted_at reviewing_at published_at
draft! 2025-12-24 12:00 nil nil
reviewing! 2025-12-24 12:00(維持) 2025-12-24 17:00 nil
draft! 2025-12-25 09:00(上書き) 2025-12-24 17:00(維持) nil

drafted_atreviewing_at より後に来ている違和感が出てきました...。

状態が進んだり戻ったりするだけで、不整合が発生し始めます。

改善案:状態の変化をイベントとして記録する

では、どうすれば良いのでしょうか。

drafted_atreviewing_atpublished_at...と状態ごとに日時カラムを増やしていくのではなく、状態の変化そのものを Event として記録することです。

つまり、「いま公開中である」という状態や「公開された日時」をカラムに持つのではなく、「2025-12-25 09:00に公開した」という**出来事(イベント)**をレコードとして蓄積していきます。
現在の状態は、記録されたイベントの履歴から導き出すことができます。

例えばこんなモデルです。

class ArticleEvent < ApplicationRecord
  belongs_to :article

  enum kind: {
    drafted: 1,
    reviewing: 2,
    published: 3,
  }
end

上記のモデルで出てくるenumはイベントの "種類" です。
テーブルはこんな形です。

CREATE TABLE articles (
  id BIGINT PRIMARY KEY,
  type INTEGER NOT NULL,     -- news:1, column:2, campaign:3
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

CREATE TABLE article_events (
  id BIGINT PRIMARY KEY,
  article_id BIGINT NOT NULL,
  kind INTEGER NOT NULL,     -- drafted:1, reviewing:2, published:3
  created_at TIMESTAMP NOT NULL
);

状態が変わるたびに、article_eventsテーブルに新しいレコードが追加されていきます。
例えば、ある記事の状態遷移を見てみましょう。

id | article_id | kind       | created_at
---+------------+------------+---------------------
 1 |        123 | drafted    | 2025-12-24 12:00
 2 |        123 | reviewing  | 2025-12-24 17:00
 3 |        123 | drafted    | 2025-12-25 09:00   -- 差し戻し
 4 |        123 | reviewing  | 2025-12-25 11:00
 5 |        123 | published  | 2025-12-25 15:00

イベントテーブルで状態変化を時系列で記録する様子を示す図
状態の変化をイベントとして記録することで履歴が保存される

この設計のメリット

1. 元の要件を満たせる

  • 「先月公開された記事」→ WHERE kind = 'published' AND created_at BETWEEN ... で取得可能
  • 「3日以上レビュー中の記事」→ 最新イベントがreviewingで、そのcreated_atが3日以上前のものを抽出

2. 状態の種類が増えても柔軟
reviewed(レビュー完了)やarchived(アーカイブ)などの新しい状態が増えても、kindの値を追加するだけです。
テーブル構造を変更する必要がありません。

3. 状態が戻っても問題ない
差し戻しで下書きに戻った場合も、id=3のように新しいイベントとして記録されます。
drafted_atを上書きする必要がなく、「いつ差し戻されたか」という情報も残ります。

4. 状態遷移の履歴が残る
「この記事は何回レビューに出されたか」「公開までに何日かかったか」といった分析も可能になります。

このように、イベントとして記録することで、先ほどの悪い例で発生していた問題をすべて解決できます。

さらに要望が増えたとき:イベントごとに異なる情報を持たせたい

イベントテーブルで履歴を管理できるようになりましたが、運用していると次のような要望が出てくることがあります...。

  • レビューに入れたのは誰かを知りたい(reviewer_id)
  • レビューコメントも記録したい(review_comment)
  • 公開操作をしたユーザーも記録したい(publisher_id)

イベントの種類ごとに必要な情報が異なる

前回の要望と異なるのは、イベントの種類によって必要な情報が違うということです。

  • draftedイベント → 特別な情報は不要
  • reviewingイベント → レビュアー、レビューコメントが必要
  • publishedイベント → 公開者、通知処理が必要

これを1つのテーブルで表現しようとすると、こうなります。

CREATE TABLE article_events (
  id BIGINT PRIMARY KEY,
  article_id BIGINT NOT NULL,
  kind INTEGER NOT NULL,
  created_at TIMESTAMP NOT NULL,
  reviewer_id BIGINT,           -- reviewingのときだけ使う
  review_comment TEXT,          -- reviewingのときだけ使う
  publisher_id BIGINT           -- publishedのときだけ使う
);

また nullable なカラムが増えてきましたね...。
これでは最初の悪い例と同じ問題が再発してしまうかもしれません。

解決策:ポリモーフィズムでイベントをサブクラス化

こういう場合は、STI (Single Table Inheritance)Delegated Type を使って、イベントの種類ごとにサブクラスに分けるのが良いアプローチです。
イベントをサブクラス化してポリモーフィズムで管理する設計図
STI/Delegated Typeによるイベントのサブクラス化

まず、テーブル構造はこうなります。

CREATE TABLE article_events (
  id BIGINT PRIMARY KEY,
  article_id BIGINT NOT NULL,
  type VARCHAR NOT NULL,        -- クラス名を格納 (STI/Delegated Type用)
  created_at TIMESTAMP NOT NULL,
  reviewer_id BIGINT,           -- Reviewingイベント専用
  review_comment TEXT,          -- Reviewingイベント専用
  publisher_id BIGINT           -- Publishedイベント専用
);

そして、Railsのモデルではこのように実装します。

# 基底クラス
class ArticleEvent < ApplicationRecord
  belongs_to :article

  # サブクラスごとに種類を判定
  # STIを使う場合は自動的に type カラムでサブクラスを判別
end

# 下書きイベント
class ArticleEvent::Drafted < ArticleEvent
  # 特有の処理があればここに書く
end

# レビュー中イベント
class ArticleEvent::Reviewing < ArticleEvent
  belongs_to :reviewer, class_name: 'User'

  # このイベントでだけ必要な制約をここに書ける
  validates :reviewer_id, presence: true
  validates :review_comment, presence: true
end

# 公開イベント
class ArticleEvent::Published < ArticleEvent
  belongs_to :publisher, class_name: 'User'

  # 公開時だけの処理をここに書ける
  after_create :notify_subscribers

  def notify_subscribers
    # メール送信やプッシュ通知など...
  end
end

この設計手法のメリット

1. イベントごとの制約を型安全に表現できる
Reviewingイベントではreviewer_idreview_commentが必須、という制約をそのクラスにだけ書けます。
他のイベントでは不要なので、バリデーションが混在しません。

2. イベントごとの振る舞いを分離できる
Publishedイベントだけ通知処理を行う、といった処理を該当クラスに閉じ込められます。
条件分岐(if kind == 'published' then ...)が不要になります。

3. コードの見通しが良くなる
「レビューイベントに関するロジック」がArticleEvent::Reviewingクラスに集約されるため、保守しやすくなります。

4. 拡張しやすい
新しいイベント(例:Archived)を追加する場合、新しいサブクラスを作るだけで済みます。
既存のイベントクラスに影響を与えません。

まとめ

数年前の自分は、取る値が限定的であれば、「種類」でも「状態」でも Enum で管理していました。
が、「状態」の変化を記録したくなったときに辛くなったりします。

当時の自分に伝えるなら、以下の3つかなと。
まとめ:データモデリングで学んだ3つの教訓を示す図
過去の自分に伝えたい3つのポイント

1. Enumには「種類」と「状態」がある。混同してはいけない。

type(記事の種類)は変わらないのでEnumでOKです。一方、status(記事の状態)は時間で変化するので要注意です。

2. drafted_at, reviewing_at, published_at... と1テーブルに複数の日時カラムを持たない。

複数の〜_atカラムを持つテーブルは破綻します。これはWEB+DB PRESS Vol.130でも解説されているアンチパターンです。

3. 状態の変化はイベントとして記録する。

「いま公開中」ではなく「2025-12-25 09:00に公開した」を記録します。そうすれば履歴も残るし、状態が戻っても困りません。分析もできます。

DB設計する時、特に「状態」を扱う時は、

  • この状態、将来変わる?
  • その履歴、あとで欲しくなる?
  • 状態が増えたり戻ったりする?

を一度立ち止まって考えると良さそうですね。

Discussion