テーブル設計: statusカラムから生じる技術的負債とその解決策
概要
こういうテーブルを見たことはないだろうか。
statusごとに、nullable, Not Nullカラムが混在しているクレイジーなテーブルである。
どんなときに、値が入るねん!と不安にさせてくる。
どの現場にも存在して、もはや既視感すらある。
このテーブルの問題点とその解決案を説明していきたい。
対象読者
- テーブル設計を学んでいる方
- 技術的負債を解消したい方
いいね!してね
この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!
それでは以下が本編です。
結論
✅テーブルに状態を持たせない(statusカラムをつけない)、ロングタームイベントパターンでテーブルを分割する。
テーブルの数は多いけど、シンプルで可読性が高い
説明すること
- statusカラムの問題点
- 解決案
statusカラムの問題点
❌テーブルにnullableなカラムが混在してしまう
カラムの値を参照する前に、まずstatusを参照して条件を分岐する
その後に値のnullチェックをしながらロジックをつくる。
↓
そして、クソコードとの戦いが始まる、、!
エンジニアとして様々な案件にjoinしてきたが、アプリケーションの設計以前にテーブル設計がおかしいケースが多い。
ちなみに、statusカラムがあってそれに依存するカラムが別テーブルに切り出されている場合もほぼ同じ(status判定処理が残り続けるから)
同様の事例1: 削除フラグ
同様の事例2: クソコードUserクラス
❌履歴が残らない
「失敗から学ぶrdbの正しい歩き方」の「失われた事実」のように、状態を更新したら更新前のカラム値は消えてしまう。
※毎回、専用の履歴テーブルも作れるのは大変。できればやりたくない
解決案
ここから解決案を詳しく説明していく。
✅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クラス」で考える技術的負債解消の観点」:
曽根壮大さんの「失敗から学ぶrdbの正しい歩き方」:
kawasimaさんの「イミュータブルデータモデル」:
Discussion