/note/tech

テーブルに状態を持たせてはいけない

要約:

■ 1. 状態を持たせる設計とその問題点

  • テーブルに退会フラグ、公開ステータス、論理削除フラグなどの「状態」をカラムで管理する設計が一般的に見られる
  • UPDATEによって状態を上書きすると、「いつ退会したか」「停止から復帰した経緯」といった事実が失われる
  • トラブル対応や監査の際に必要となる過去の経緯が消滅するリスクがある

■ 2. 状態は事実ではなく導出情報である

  • 「会員が退会した」という出来事は事実だが、「現在この会員は退会済みである」という状態は事実から導き出された情報にすぎない
  • Rich Hickey(Clojure作者)は「The Value of Values」において、事実は過去の出来事であり更新できないと指摘する
  • 年齢ではなく生年月日を保存するという設計原則と同様に、導出可能な状態を保存すると値の食い違いが生じる(第三正規形違反)
  • 保存すべきは「退会という出来事がいつ起きたか」であり、「今は退会済みである」という導出結果ではない

■ 3. ドメイン言語と論理削除

  • 和田卓人氏は、論理削除という概念は現実の業務には存在せず、顧客が使う言葉は「退会」「公開停止」「キャンセル」などの具体的な出来事であると指摘する
  • 業務で起きているのは状態の削除ではなく新しい出来事の発生であり、退会は「退会した」という事実の追加として捉える方が自然
  • 発生した事実に忠実にモデリングすることで、情報の消去・書き換えが不要になる

■ 4. 理論的背景: 履歴と集合のミスマッチ

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

■ 5. 解決策1: 出来事をレコードとして記録する設計

  • 状態を更新するのではなく、状態を変える出来事をそのつどINSERTで追加する
  • 会員そのものを表すテーブルと会員に起きた出来事を記録するテーブルを分けて設計する
  • 入会・停止・復帰・退会をすべてINSERTで記録し、現在の状態は最新の出来事から求める
  • この考え方はイミュータブルデータモデルやイベントソーシングと共通する
  • 利点:
    • 過去の経緯が失われないため監査やトラブル対応が可能
    • event_typeの値を増やすだけで拡張でき、テーブル構造の変更が不要
    • 上書きが発生しないためデータの食い違いが起こらない
  • 注意点:
    • 最新状態を求めるたびに並べ替えや絞り込みが必要
    • データ量が増えるとパフォーマンスが課題になる場合がある
    • すべてのテーブルに機械的に適用する必要はなく、状態の遷移や履歴が業務上意味を持つ場合に選択する

■ 6. 解決策2: 状態ごとにテーブルを分ける設計

  • 不変の情報を持つ親テーブルと、状態ごとの子テーブルに分けて設計する
  • 状態はどのテーブルにレコードが存在するかで表現し、基本的に親テーブルはINSERTのみ
  • 状態が変化する際はレコードを別テーブルへ移動(削除してINSERT)する
  • 利点:
    • 状態での絞り込みにWHEREを書かずに対応するテーブルを参照するだけで済む
    • 新しい状態の追加はテーブルの追加のみで対応でき、既存スキーマの変更が不要
    • NULLになりがちなカラムや状態で分岐する複雑なSQLを避けられる
  • 注意点:
    • レコードの移動だけでは遷移の経緯(いつどの状態へ遷移したか)が残らない
    • 1人の会員が複数の状態テーブルに存在しないという排他性はアプリケーション側で担保が必要

■ 7. 両アプローチの使い分け

  • 遷移の履歴そのものを残したい場合はイベントとして記録する設計を選ぶ
  • 状態によってデータのライフサイクルや属性が変わる場合はテーブルを分ける設計を選ぶ
  • 両方が必要な場合は組み合わせて使用する

■ 8. まとめ

  • テーブルに状態を持たせる設計は事実を上書きして経緯を失い、状態の増加とともに複雑さが積み重なる
  • 保存すべきは加工された状態ではなく、それを生み出した出来事という事実
  • 状態を更新するのではなく出来事を追加するという発想で、変更に強く事実に忠実なテーブル設計が実現できる