🧩

テーブル設計: statusカラムから生じる技術的負債とその解決策

2024/04/25に公開

概要

こういうテーブルを見たことはないだろうか。

statusごとに、nullable, Not Nullカラムが混在しているクレイジーなテーブルである。

どんなときに、値が入るねん!と不安にさせてくる。
どの現場にも存在して、もはや既視感すらある。

このテーブルの問題点とその解決案を説明していきたい。

対象読者

  • テーブル設計を学んでいる方
  • 技術的負債を解消したい方

いいね!してね

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

それでは以下が本編です。

結論

✅テーブルに状態を持たせない(statusカラムをつけない)、ロングタームイベントパターンでテーブルを分割する。


テーブルの数は多いけど、シンプルで可読性が高い

説明すること

  • statusカラムの問題点
  • 解決案

statusカラムの問題点

❌テーブルにnullableなカラムが混在してしまう

カラムの値を参照する前に、まずstatusを参照して条件を分岐する
その後に値のnullチェックをしながらロジックをつくる。

そして、クソコードとの戦いが始まる、、!

エンジニアとして様々な案件にjoinしてきたが、アプリケーションの設計以前にテーブル設計がおかしいケースが多い。

ちなみに、statusカラムがあってそれに依存するカラムが別テーブルに切り出されている場合もほぼ同じ(status判定処理が残り続けるから)

同様の事例1: 削除フラグ
https://speakerdeck.com/soudai/delete-flag?slide=17

同様の事例2: クソコードUserクラス
https://speakerdeck.com/minodriven/kusokododong-hua-userkurasu-dekao-eruji-shu-de-fu-zhai-jie-xiao-falseguan-dian?slide=7

❌履歴が残らない

「失敗から学ぶrdbの正しい歩き方」の「失われた事実」のように、状態を更新したら更新前のカラム値は消えてしまう。
※毎回、専用の履歴テーブルも作れるのは大変。できればやりたくない
https://speakerdeck.com/soudai/learn-from-failure-1?slide=17

解決案

ここから解決案を詳しく説明していく。

✅statusごとにテーブルを分ける

まずテーブルに状態を持たせてはならない。

例: members(利用会員)の状態
「メール認証前」: member_pending_activation テーブル
「認証済」: member_active テーブル
「退会済」: member_resigned テーブル
「強制退会済」: member_banned テーブル
「復元した」: member_restored テーブル

※上から下に順番にイベントが発生するイメージでOK

のように、基本的にNot Nullな値しかもたない状態テーブルとして切り出す。

✅statusテーブルを集約する

イベントを集約するテーブル(ステータスアクティビティテーブル)を追加する。

関連

member_status_activities has_many
  member_pending_activation
  member_active
  member_resigned
  member_banned
  member_restored

をもち、ステータス変更があるたびにmember_status_activitiesと対応するステータステーブルに親子レコードをINSERTする

これを「ロングタームイベントパターン」と呼ぶ(らしい)
※引用元: https://scrapbox.io/kawasima/イミュータブルデータモデル

さらにmembersテーブルと関連付ける。

関連

members has_many member_status_activities


ドメイン駆動設計の集約っぽくなった!

✅updatedAtを捨てる

ロングタームイベントパターンを採用する場合、

  • DBへの操作はINSERT, DELETEのみになる(Immutable DB)
    ※ORマッパーで更新処理をしないこと!

  • レコードの更新しないはしないため、タイムスタンプはcreatedAtのみ
    ※ORマッパーがデフォルトで生成するupdatedAtをつけてはならない!

✅Readしやすいviewテーブルを作成する

ここまで説明した「ロングタームイベントパターン」を実践する場合、下記のユースケースでどうすべきか?と疑問が出てくるだろう。

代表的なユースケースとその解決案を挙げておく。

Q1 ログインユーザーを取得して判定したい。存在していれば表示させたい。
メンバーの最後のステータスアクティビティがActiveテーブルであるか?またその値を取得したい

Q2 メンバー一覧を表示したい。カラムの値を表示させたい。
全メンバーレコードをリストで取得したい

このような場合は、viewテーブルを作成する(or レポジトリ層でクエリを実装する)
※例: member_idでグルーピング&& 直近アクティビティレコード1件のみにした集合テーブル

1 「ログインユーザーを取得して判定したい。」の場合、
上記のviewテーブルとmember_activeテーブルをjoinしたのちmember_idで絞り込む。

2 「メンバー一覧を表示したい」の場合は、
上記のviewテーブルと各ステータステーブルをjoinする。

つまり、テーブルは基本に忠実に設計して、必要に応じてアプリケーション側でクエリを用意する。

どちらも、viewテーブル has_one member_active と status_activity_id(という主キー)でjoinなので負荷も少ない(と思う)

再度結論

「ロングタームイベントパターン」まとめ

✅テーブルに対応するルールや制約がシンプルになる
状態ごとにテーブルを分けることで、テーブルごとに(バリデーションなどの)ロジックを切り出せる。カラムには、(原則)Not Nullな値のみが存在する。
→ロジックがシンプルになる。

※ちなみに、個人的にNullを許すケースは「ユーザーが任意入力する項目」
例: 退会申請フォームの「退会理由のテキスト入力」項目など

✅テーブル運用がしやすい
テーブルのカラム数が少なくなる、原則Not Nullなのでindexが効きやすくなる
不要なレコードはバッチでDELETEする
分けすぎたらテーブル統合する(テーブル分離より圧倒的に楽)

✅履歴が残る
イベントを全てINSERTしてレコードを生成するため、履歴が残る。
「事実を残す」というDBの本来の目的を達成できる。

まとめ

「テーブルに状態を持たせるな!」と言うが、

「じゃあどうすればいいねん?」と思っていた。

その疑問に対する答えが出たので記事にした!

この記事が参考になった方は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

引用

ミノ駆動さんの「クソコード動画「Userクラス」で考える技術的負債解消の観点」:
https://speakerdeck.com/minodriven/kusokododong-hua-userkurasu-dekao-eruji-shu-de-fu-zhai-jie-xiao-falseguan-dian?slide=7

曽根壮大さんの「失敗から学ぶrdbの正しい歩き方」:
https://speakerdeck.com/soudai/learn-from-failure-1

kawasimaさんの「イミュータブルデータモデル」:
https://scrapbox.io/kawasima/イミュータブルデータモデル

Discussion