テーブルに状態を持たせてはいけない(は本当か) ―― 3 つの設計を見比べる

退会フラグ、公開ステータス、論理削除フラグ —— テーブルに status カラムを置いて UPDATE で上書きする設計は、最も手軽で広く使われている選択肢です。 ただし経緯(履歴)が業務上の意味を持つ場面では、上書きで事実が消え、 後から「いつ・なぜそうなったか」に答えられなくなります。状態は 事実そのものではなく、事実から導かれた加工情報だからです。

この記事は、状態カラムを上書きする設計(方式 A)に加えて、 出来事を追記するイベント記録(方式 B)状態ごとにテーブルを分ける設計(方式 C)を、 同じ操作を流し込んで 3 方式並べて見比べられる教材です。 言いたいのは「状態を持つな」ではなく「状態を唯一の真実にして“事実”を失うな」。 状態を保持すること自体は問題ではなく、上書きで経緯が消えることが問題だ ―― というのが本記事の立ち位置です。

まずは下のデモで、同じ操作を 3 つの設計に同時に流し込んで、 データがどう残る/消えるかを見比べてください。

① 会員データを操作して 3 つの設計を見比べる

member#1 田中 太郎
いまの状態(最新の出来事から導出)未入会

入会 → 利用停止 → 復帰 → 退会 の順に、「次へ」で 1 ステップずつ確認できます (一気に見たいときは「通して再生」)。 停止・復帰という途中経過が、設計ごとにどう残るかに注目してください。

方式A

状態カラムを UPDATE で上書き

過去の値を上書きするので、いまの姿しか残らない。

member
idnamestatusupdated_at
(まだ行がありません)

⚠ 1 行を上書きし続けるだけ。停止や復帰の経緯は消える。

方式B

出来事を INSERT で記録

基本情報は不変。出来事を追記し、状態は最新行から導出する。

member
idname
(まだ行がありません)
membership_event
idmember_idtypeoccurred_at
(まだ出来事がありません)

✓ すべての出来事が残るので、過去も経緯もたどれる。

方式C

状態ごとにテーブルを分ける

基本情報は不変。レコードを状態テーブル間で移動し、居場所が状態を表す。

member
idname
(まだ行がありません)
member_active
member_idsince
member_suspended
member_idsince
member_withdrawn
member_idsince

○ 状態での絞り込みは不要。ただし移動の履歴は別途残す必要がある。

② 監査・トラブル対応で問われること

それぞれの設計が「自分の持っているデータだけ」で答えられるかを比べます。 いまの状態はどれも答えられますが、過去・経緯になると差が出ます。

質問方式A
状態カラム
方式B
イベント記録
方式C
テーブル分割
今の状態は?×××
最初に入会したのはいつ?×××移動だけでは履歴が残らない
過去に何回 利用停止された?×××移動だけでは履歴が残らない
一度退会してから復帰した経緯がある?×××移動だけでは履歴が残らない

「停止と復帰を経て退会」のストーリーを再生してから、この表を見てください。 方式 A と C は、いまが 退会済み という結果しか持たないため、 「いつ入会したか」「何回停止されたか」に答えられません

状態は「事実」ではなく「加工された情報」

「会員が退会した」という出来事は事実です。一方、「現在この会員は退会済みである」という 状態は、その事実から導き出された情報にすぎません。事実は過去を変えられないのと同じく、 更新できません。更新すべきでないものを UPDATE しているところに、ひずみが生まれます。

Clojure の作者 Rich Hickey は講演 “The Value of Values” で、 「事実とは起きた出来事であり、知識はそうした事実を比較・結合して導かれる」と述べています。 同じ考え方は 「年齢を保存せず、生年月日を保存する」という古典的な設計原則にも表れています。

✗ 年齢を保存→ 時間が経つと実態とズレる。導出できる値を冗長に持つ(第三正規形違反)。
✓ 生年月日を保存→ 不変の事実。年齢は「生年月日と現在日付」からいつでも導出できる。
✓ 退会した日を記録→ 「今は退会済み」は導出結果。保存すべきは出来事そのもの。

ドメインの言葉に「論理削除」はない

ソフトウェアエンジニアの和田卓人氏は、論理削除という概念は現実の業務には存在しないと指摘しています。 顧客が「論理削除してください」とは言いません。現場で使われるのは 「退会」「公開停止」「キャンセル」といった具体的な出来事の言葉です。

つまり業務で起きているのは「状態の削除」ではなく「新しい出来事の発生」です。 退会は会員データの削除ではなく、「退会した」という事実の追加として捉えるのが自然です。 この「出来事を追加する」という捉え方をそのまま形にできるのが、出来事を INSERT で積む方式 Bです (方式 A は事実を上書きしてしまい、方式 C は行を移すだけでは「退会した」という出来事そのものが残りません)。

履歴(順序)と集合(無順序)のミスマッチ

理論面でも無理があります。リレーショナルモデルのリレーションは集合であり、要素間に順序はありません。 一方、状態の変化には「古い・新しい」という順序が必然的に伴います。 順序を持つ履歴を、順序のない集合の中の 1 カラムへ押し込もうとするところに、本質的なひずみが生じます。

設計1:状態を変える「出来事」を追加する

状態を UPDATE するのではなく、状態を変える出来事をそのつどレコードとして INSERT します(上のデモの方式B)。 イミュータブルデータモデルやイベントソーシングと共通の発想です。

CREATE TABLE member (
  id   INT PRIMARY KEY,
  name VARCHAR(255) NOT NULL
);

CREATE TABLE membership_event (
  id          INT PRIMARY KEY,
  member_id   INT NOT NULL,
  event_type  VARCHAR(50) NOT NULL,   -- join / suspend / reactivate / withdraw
  occurred_at DATETIME NOT NULL,
  FOREIGN KEY (member_id) REFERENCES member(id)
);
  • 過去の経緯が失われないので、監査・トラブル対応に強い
  • 新しい状態が増えても event_type の値を足すだけ。スキーマ変更が不要
  • 上書きが発生しないので、データの食い違いが起こらない
  • 最新状態を求めるたびに並べ替え・絞り込みが要る。データ量が増えると負荷になりうる

設計2:状態ごとにテーブルを分ける

よりリレーショナルらしい選択肢が、状態そのものをテーブルで分ける設計です(デモの方式C)。 そーだい氏が「ユーザ情報を保存する時のテーブル設計」などで紹介しています。 「どのテーブルに居るか」で状態を表すため、status カラムも絞り込みも要りません。

CREATE TABLE member (            -- 不変の情報。INSERT のみ
  id   INT PRIMARY KEY,
  name VARCHAR(255) NOT NULL
);

CREATE TABLE member_active (     -- 有効な会員はここに居る
  member_id    INT PRIMARY KEY,
  activated_at DATETIME NOT NULL,
  FOREIGN KEY (member_id) REFERENCES member(id)
);

CREATE TABLE member_withdrawn (  -- 退会したらここへ移す
  member_id    INT PRIMARY KEY,
  withdrawn_at DATETIME NOT NULL,
  FOREIGN KEY (member_id) REFERENCES member(id)
);
  • 有効な会員は WHERE status = 'active' ではなく member_active を見るだけ
  • 仮登録などが増えても member_tmp を足すだけ。既存スキーマは変えない
  • NULL になりがちなカラムや UPDATE が必要な行は、そこに状態が隠れているサイン
  • 移動だけでは遷移の履歴が残らない。経緯が要るならイベント記録を併用する
  • 「1 人はいずれか 1 テーブルにのみ」という排他性はアプリ側で担保する
履歴も残したいときは? ―― 状態テーブルは「現在地」、経緯はイベントで持つ

方式 C は「いま どのテーブルに居るか」を引くのは得意ですが、行を移すだけでは 「どんな順で移ってきたか」という経緯が残りません。経緯も必要なら、 方式 B(イベント記録)を併用して役割を分けるのが定石です。

  • 書き込みの正は membership_event ―― 起きた出来事を発生順に追記する(経緯はすべてここに残る)
  • 状態テーブルはそこから導出した「現在状態の投影」 ―― member_active / member_withdrawn は最新状態を高速に引くための読み取り用で、いつでもイベントから組み立て直せる

これは書き込み(イベント)と読み取り(状態テーブル)を分ける CQRS の発想そのものです。 会計に喩えるなら、仕訳帳(経緯の正)と、そこから集計した残高(試算表)の関係に当たります。 残高表だけでは取引の経緯を追えないので、仕訳帳を必ず残す ―― それと同じ理屈です。

別解として、状態テーブルとは別に member_status_history(member_id, from_state, to_state, occurred_at) のような遷移ログを 追記専用で持つ手もあります。ただし結局「出来事を追記する」ことに行き着くので、 経緯が主役なら最初から方式 B を土台にするのが素直です。

▶ 動かして確かめる:方式 B+C のハイブリッド
いまの状態: 未入会
member ―― 不変の基本情報(入会時に 1 度だけ作る)
idname
(入会するとここに 1 行できます)
membership_event ―― 経緯(追記され続ける)
idmember_idtypeoccurred_at
(まだ出来事がありません)
状態テーブル ―― 現在地(1 か所だけ)
member_active
member_idsince
member_suspended
member_idsince
member_withdrawn
member_idsince

「次へ」で進めると、左の イベントは積み上がり(経緯が残る)、 右の 状態テーブルは現在地だけが移動します。 両方を持つことで「いまの状態は速く引け、経緯も失われない」が両立します。

「イベントソーシングの一言で済む」のか?

この設計を紹介すると、しばしば「それはイベントソーシングと呼ぶもので、その一言で済む話だ」という指摘を受けます。 半分は当たっています。出来事を INSERT で追記し、現在の状態をそこから導出する方式 B の核は、 たしかにイベントソーシングの中心的なアイデアそのものだからです。

ただし「イベントソーシング」という名前のついたアーキテクチャは、もっと重い含意を背負っています。 イベントの列が唯一の正であり、現在の状態は永続化せず全イベントの再生(リプレイ)で都度組み立てるのが基本で、 実務では CQRS・スナップショット・結果整合性とセットで語られます。 イベントのスキーマ変更(バージョニング)、過去イベントの移行、リプレイ性能といった運用コストもそのまま付いてきます。

一方この記事が言っているのは、もっと軽くて広い原則 —— 「事実を上書きせず、出来事を追記する」だけです。 これは川島義隆氏のイミュータブルデータモデルに近く、イベントソーシングとは明確に別物です。 イミュータブルデータモデルは、現在の状態を普通のテーブルとして残し、SQL で素直に引けるようにしたうえで、 リソース(変わらない実体)とイベント(起きた事実)を分けて記録する設計の規律であって、 「イベントログだけを正にして全リプレイで状態を作る」という重い前提を取りません。

さらに、方式 C(状態テーブル分割)に至ってはイベントソーシングですらありません。 行の「居場所」で状態を表す、純粋にリレーショナルな正規化のテクニックです。 「年齢ではなく生年月日を保存する」「事実と知識を区別する」という話も、 イベントソーシングより一段抽象度の高い、データモデリング全般の原則です。

この記事の主張状態カラムを上書きしない(=事実を上書きしない)。素の SQL の範囲でできる、安価な設計の規律。
イベントソーシングその原則の一実装。イベントストアを唯一の正とし、リプレイで状態を組み立てる本格的なアーキテクチャ。相応に重い。

実は Event Sourcing を提唱した Martin Fowler 自身、原典で 「多くのアプリでは最新状態が要求されることが多く、その場合は現在の状態をそのまま保存するほうが、より高速な代替案になる。Event Sourcing ならではの機能が必要になれば、その上に追加で構築すればよい」と述べています。 状態保持+イベントログのハイブリッドは、Fowler 自身が中立に提示する有力な選択肢の一つです。 「状態カラムを持つこと」と「履歴を残すこと」は排他ではありません

実装の選択肢を整理すると、現実的には次のあたりに落ち着きます。

  • 状態カラム + 監査ログテーブル(最も一般的)
  • 状態テーブル分割
  • イベントテーブル + スナップショット
  • アプリ DB は状態を持ち、分析基盤側で SCD Type 2 として履歴化

どれにもトレードオフがあります。「状態を履歴から導出」する方式は最新状態の SELECT が複雑になりがちですし、 「状態テーブル分割」は親テーブルが肥大化しやすく、複数の状態テーブルに同時に存在しない排他性をアプリ側で担保する必要があります。 複雑さはどちらの方向にも生じうるもので、「状態カラムを持つこと自体が悪」というほどの話ではありません。

つまり「イベントソーシングの一言で済む」とまとめてしまうと、 素の SQL でできる安価な設計の規律と、イベントストアやリプレイを抱える重いアーキテクチャを混同させ、 必要もないのに大掛かりなインフラへ人を押しやるミスリードになりかねません。 ここで言いたいのは「状態カラムを上書きするな」という一段上の原則で、 イベントソーシングはそれを実現する選択肢のひとつにすぎない、というのが正確なところです。

現実的な境界線 ―― 「常に残せ」は理想論

ここまでを「では何でもイベントで残すべきだ」と受け取ると、それは理想論になりがちです。 変更履歴は、残せるなら残したいに決まっています。問われるのはそのコストに現実が見合うか。 会員数(データ量)・更新頻度・保持期間を数字で見積もったうえで、どこまで残すかを ケースバイケースで決めるしかありません。

おおまかな当たりは次の 2 つの問いで付けられます。ただしこれは出発点で、最後は規模の見積もりがものを言います。

ケース経緯(履歴)は
業務要件?
現在値の参照は
高頻度・低レイテンシ?
素直な設計
体育館の入退室+在館数
プリペイド課金あり
YesYesハイブリッド:入退室はイベントで厳格に残し、在館数は導出カウンタで持つ(CQRS)
今の人数を数えるだけNoYes状態(カウンタ)のみで十分
監査重視・更新は稀YesNoイベントのみ。現在値は都度導出
小規模・どちらも軽いNoNo状態カラムでも実害は出にくい

体育館の例がわかりやすい縮図です。入退室という事実は課金の根拠なので、上書きや削除は許されません。 一方「今何人いて、満員か」は高頻度に問われるので、毎回イベントを数え直すより 在館数のカウンタを持つほうが実務的です。ただしそのカウンタは「唯一の真実」ではなく、 入退室イベントから導出した投影にすぎない ―― この役割分担さえ崩さなければ、状態を持つことは何も悪くありません。

体育館で動かす:カウンタ/入退室イベント/ハイブリッド

定員 3 人 / 在館0 人 空きあり
在室: (誰もいません)

定員 3 人の体育館。入室 → 満員 → 退室 → 別の人が入室 を順に流します。 満員のあいだ新規入室はお断りです。プリペイド課金があるので入退室は正確に残す必要があります。

方式A

在館数カウンタを上書き

現在人数だけを持ち、入退室のたびに上書きする。

gym
gym_idoccupancyupdated_at
(まだ行がありません)

⚠ 今の人数は一発。でも誰がいつ来たかが残らず、プリペイド課金ができない。

方式B

入退室イベントを記録

起きた入退室を追記。現在人数は集計して導出する。

entry_event
idpersontypeat
(まだ出来事がありません)

○ 課金も延べ人数もたどれる。ただし満員判定のたびに集計が要る。

方式C

ハイブリッド(両方持つ)

イベントを正とし、現在人数のカウンタを導出して併せ持つ。

gym(導出カウンタ)
occupancyupdated_at
entry_event(経緯)
idpersontype

✓ 即答も経緯も両立。体育館はこれ。

体育館で問われること方式A
カウンタ
方式B
イベント
方式C
ハイブリッド
今 何人いる?満員か?×××
誰が何時に入退室した?(課金の根拠)×××
延べ何人 入館した?×××

方式A は「誰がいつ入退室したか」を持たないので、プリペイド課金の根拠にできません(体育館では単独だと無理)。 方式B はすべて答えられますが、満員判定のたびに集計が要るので高頻度の参照に弱い。 だから体育館は両方を持つ方式C(ハイブリッド)に落ち着きます。

さらに会員数が増え、更新が頻繁になり、保持期間が長くなるほど、イベントを全件抱える負荷は効いてきます。 スナップショット(ある時点の状態を保存し、以降の差分だけ追う)、古いイベントのアーカイブ、集計テーブルの併用 ―― このあたりを規模に応じて足し引きするのが現実の設計です。

Microsoft Azure Architecture Center も、スナップショットを「イベントストリームの代替ではなく最適化」と位置づけ、 N イベントごとに状態を保存し、読み込み時はそこからリプレイする方式を紹介しています。 同じ資料は 「ほとんどのシステム、あるいはその大部分には、従来のデータ管理で十分」とも明記しており、 Event Sourcing は全方位に推奨される銀の弾丸ではありません。

そしてすべてのテーブルを機械的にこの形にする必要もありません。 履歴が業務上の意味を持つドメイン(契約・申込・承認・金銭授受など)でこの設計が活きます。 UI 設定値や表示順のように履歴を業務的に問わない属性、監査要件のない小規模 PJ では、 状態カラムを素直に UPDATE するほうがコスト・可読性で勝ちます。

📒 会計の人への補足 ―― 「特定時点の残高」は繰越=スナップショットで速くする

「4 月 1 日時点の会員数は?」のような特定時点の集計は、イベント列からでも導出できます (各会員の、その日以前の最新イベントで状態を判定する)。ただしデータが増えると、毎回 全期間を走査するのは重い。 ここで効くのが、会計でおなじみの繰越です。

会計期末で締めて残高を確定 → 翌期首に「前期繰越」を開始仕訳で計上 → 当期は「開始残高 + 当期仕訳」で残高を出す
イベント設計ある時点の状態をスナップショットとして保存 → 以降は「スナップショット + それ以降のイベント」だけ集計する

どちらも「最初の取引からぜんぶ足し直さない」ための同じ工夫です。期首残高を持てば全仕訳をさかのぼらずに済むのと同様、 スナップショットを持てば全イベントの再走査を避けられます。会計の繰越は、イベント設計でいうスナップショットそのものです。

時点・期間の検索がさらに頻繁なら、区間モデルvalid_from / valid_to のペア)を導出ビューとして併せ持つ手もあります。 WHERE valid_from <= '2024-04-01' AND (valid_to IS NULL OR valid_to > '2024-04-01') で時点クエリが速い一方、 状態が変わるたびに前区間の valid_to を閉じる UPDATE が要る、という代償があります。 いずれにせよ真実はイベントに残し、集計や区間は導出ビューとして持つ(=方式 C の発想)のが筋です。

⚠ 履歴を全部残す設計ほど、テストを手厚く

出来事を積んで状態を毎回導出する方式は、 集計・並べ替え・「退会後に再入会したか」のような時系列をまたぐ判定でバグを作りやすいところです。 境界値(0 件・1 件・大量)、状態をまたぐ遷移、同時更新の競合を、ユニットテストで必ず押さえてください。 (このページの導出ロジックも、空・単発・連続遷移・脱線ケースをテストで固めています。)

まとめ:状態は悪ではない、唯一の真実にするのが悪

保存すべきは加工された状態ではなく、それを生み出した出来事という事実です。 経緯が業務上の意味を持つドメイン(契約・申込・承認・金銭授受など)では、状態カラムを上書きで潰す前に、 その出来事を別テーブルに記録して事実を失わない設計を選びます。

状態によってデータのライフサイクルや属性が大きく変わるなら、状態ごとにテーブルを分け、 高頻度に現在値が要るならイベント+導出カウンタ(ハイブリッド)でも構いません。 逆に履歴を業務的に問わない属性や、監査要件のない小規模案件では、状態カラムを素直に UPDATE する設計のほうが コスト・可読性で勝ります。あらゆるケースに効く単一の正解はほぼ存在しません

つまり、言いたいのは 「状態を持つな」ではなく「状態を唯一の真実にして、その背後の事実を失うな」。 状態を保持すること自体は問題ではなく、状態を上書きで潰して経緯が消えることが問題なのです。

出典・参考

  • farstep(@farstep_)「テーブルに状態を持たせてはいけない」
  • Rich Hickey “The Value of Values”(事実と知識の区別)
  • 和田卓人「論理削除はドメインに存在しない」
  • そーだい「ユーザ情報を保存する時のテーブル設計」(状態テーブル分割)
  • 川島義隆「イミュータブルデータモデル」(リソース/イベントの分離、イベントソーシングとの違い)
  • Martin Fowler “Event Sourcing”(martinfowler.com/eaaDev/EventSourcing.html。状態保持+イベントログのハイブリッドを中立に提示)
  • Microsoft Azure Architecture Center “Event Sourcing pattern”(スナップショットは代替でなく最適化/多くのシステムは従来のデータ管理で十分)

本ページは上記の議論を、操作しながら学べる教材として再構成したものです。