farstep(@farstep_)さんの「テーブルに状態を持たせてはいけない」という記事を読んで、頭では理解できるのに手が動かない感覚が残った。退会フラグを UPDATE で上書きすると履歴が消える——その一文を読んでうなずいたあと、では自分なら何を選ぶのか、と問われると言葉に詰まる。図と文章を何度往復しても、設計の善し悪しが「絵」として浮かんでこない。読んで分かった気になるだけで終わらせたくなかったので、同じ会員データに同じ操作を流し込んで、3つの設計が並んで動くところを目で見られる教材を作ることにした。ボタンを押すと、片方のテーブルは1行が黙って書き換わり、もう片方は出来事が下に積み上がっていく。その差を一画面で見せたかった。
何を作ったか
会員(member)に対して「入会 → 利用停止 → 復帰 → 退会」という出来事を1つずつ流し込むと、3つの設計が同時に更新されるページを作った。設計の核は、3方式とも「会員に起きた出来事の列」という1つの入力から導出している点にある。
- 方式A: 状態カラムを UPDATE で上書き —
memberテーブルのstatusを書き換え続ける。1行を上書きするだけなので、停止・復帰という途中経過が消える。 - 方式B: 出来事を INSERT で記録 —
membership_eventに出来事を積むだけ。すべての出来事が残り、現在の状態は最新行から導出する。 - 方式C: 状態ごとにテーブルを分割 —
member_active/member_suspended/member_withdrawnの間でレコードを移動させ、「どのテーブルに居るか」で状態を表す。
ボタンを押すと、方式Bのテーブルだけが行を1本ずつ下に伸ばし、方式Aは status のピルだけが入れ替わる。同じ操作で動きの違いがそのまま見えるようにした。
操作UIは3つ用意した。「次へ」で入会→利用停止→復帰→退会を1ステップずつ、「通して再生」で自動再生、「リセット」で初期化。状態に応じて押せるボタンだけを残す(停止中なら「復帰」と「退会」だけ)ようにして、業務上ありえない遷移はそもそも押せないようにした。
監査で問われたとき、どの設計が自力で答えられるか
設計の違いを一番はっきり見せられるのが「監査・トラブル対応で問われること」だと考えて、4つの質問を表にした。
| 質問 | 方式A 状態カラム | 方式B イベント記録 | 方式C テーブル分割 |
|---|---|---|---|
| 今の状態は? | ○ | ○ | ○ |
| 最初に入会したのはいつ? | × | ○ | × |
| 過去に何回 利用停止された? | × | ○ | × |
| 一度退会してから復帰した経緯がある? | × | ○ | × |
各方式には「その方式が物理的に持っているデータだけ」を渡して答えさせた。いまの状態はどれも答えられる。だが「いつ入会したか」「何回停止されたか」のような過去・経緯になると、方式Aは上書きで消えていて、方式Cは移動しただけで履歴が残らず、どちらも答えられない。表のセルが○と×でくっきり割れるので、「履歴を残す」という言葉の重みが見た目で伝わる。
ロジックは純粋関数に隔離し、Vueは副作用シェルに徹する
ここが今回いちばんこだわった設計判断だ。グローバルの方針どおり、計算と副作用を混ぜないことにした。
3方式への投影も、監査クイズの回答も、すべてを app/utils/membershipState.ts の純粋関数として切り出した。どれも引数を受け取って値を返すだけで、ref も Date.now() も DOM も触らない。AIに実装させたシグネチャは、たとえばこうなっている。
function deriveCurrentState(events: MembershipEvent[]): MemberState
function appendEvent(events: MembershipEvent[], type: EventType): MembershipEvent[]
function projectToStatusColumn(events: MembershipEvent[]): StatusColumnRow | null
function answerByEventTable(events: MembershipEvent[], questionId: string): AuditResult
appendEvent は元の配列を変えず、新しい配列を返す。日付も computeOccurredAt(index) で決定的に決めて、同じ index なら常に同じ値が返るようにした(Date.now() を呼ばないので、テストが時刻に揺さぶられない)。
各方式の関数には「その方式が保持しているデータだけ」を渡す設計にしたのも意図的だ。answerByStatusColumn にはスナップショット1行しか渡さないので、履歴系の問いに型のうえで答えようがない。設計の制約を関数の引数で表現した。
一方、Vueページ(app/pages/blog/state-vs-events.vue)は「唯一の真実はイベント列」と決めて、events という1本の ref だけを持たせた。3方式の表示はすべて computed でこのイベント列から導出する。ページに残す副作用は、イベント列の更新と「通して再生」のタイマー管理だけ。setTimeout のハンドルを配列に溜めて onUnmounted で必ず片付けるようにして、計算ロジックはVue側に1行も書かなかった。題材が「状態を上書きせず、出来事を追記する」だったので、実装の構造もそれに合わせて「真実はイベント列1つ、残りは導出」と揃えた。
参考までに、方式Bが対応するテーブルはこの程度の素直さで足りる。
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)
);
検証
純粋関数に切り出したぶん、テストはそのまま素直に書けた。AIに tests/membership-state.test.ts を書かせて、ユニットテスト19件がすべてパスした。deriveCurrentState の状態遷移、appendEvent のイミュータビリティ、3方式の投影、監査クイズの○×、それに「イベントなし」「未知の質問ID」のような失敗ケースまでカバーした。app/utils/ を触ったのでカバレッジも計測した。
dev サーバーでは HTTP 200 が返り、SSRで正しく描画され、import名のミスや型エラーがないことを確認した。blog一覧にも載せたかったので、useBlogArticles の vuePageArticles に登録させた。
記事の技術内容のレビューと、「イベントソーシングの一言で済む」への反論
教材を作るだけでなく、元記事の技術的な中身もレビューさせた。自分の知っているデータベース設計の知識——イミュータブルデータモデルとイベントソーシングの違い、「年齢ではなく生年月日を保存する」、論理削除はドメインに存在しない、リレーションは集合だから順序を持てない——と突き合わせて、遜色ない内容だと判断した。
この手の話には「それはイベントソーシングと呼ぶもので、その一言で済む」という指摘がつきものなので、その反論もページに軽く差し込ませた。方式Bの核(出来事を INSERT し、状態を導出する)はたしかにイベントソーシングの中心的なアイデアそのものだ。だが「イベントソーシング」という名前のついたアーキテクチャは、イベント列を唯一の正とし、状態を永続化せず全リプレイで組み立て、CQRSやスナップショットや結果整合性を抱える重い前提を背負っている。この記事が言っているのはもっと軽い「事実を上書きせず、出来事を追記する」だけの原則で、川島義隆氏のイミュータブルデータモデルに近い。両者を混同して「一言で済む」とまとめると、素のSQLでできる安価な設計の規律と、イベントストアやリプレイを抱える重いインフラを取り違えさせるミスリードになる、という整理にした。
出典として farstep / Rich Hickey "The Value of Values" / 和田卓人「論理削除はドメインに存在しない」/ そーだい「ユーザ情報を保存する時のテーブル設計」/ 川島義隆「イミュータブルデータモデル」をページに明記した。
この日のハマり
終盤、ツール呼び出しが崩れる「The model's tool call could not be parsed」エラーが繰り返し出て、ブラウザでの最終動作チェックは途中までしか進められなかった。dev での HTTP 200 と主要テキストの描画までは確認できているので、ボタン操作やトランジションの目視確認は翌日に持ち越した。
学びメモ
- 読むだけの教材より、同じ操作で自分の手で動かせる教材の方が腹落ちする。退会フラグの上書きで履歴が消える話は、文章で読んだときは流したのに、ボタンを押して1行が黙って書き換わる様子を見たら一発で腑に落ちた。
- 3設計を同じ操作で同時に動かすと、違いが一目で出る。方式Bのテーブルだけが下に伸び、方式Aのピルだけが入れ替わる——その対比が、言葉で並べた長所短所より雄弁だった。
- ロジックを純粋関数に出すとテストが楽になる。引数→戻り値に閉じていればモックがいらないので、19件がすんなり通った。
computeOccurredAtを時刻非依存にしたおかげで、日付のテストも揺れずに固定できた。 - 「真実はイベント列1つ、残りは導出」という構造を実装にも持ち込むと、画面に出る数字が必ずイベント列と一致する。状態を別に持たない設計は、UI のバグも減らしてくれる。