1ヶ月前、社内Slackの #tech-tips で見かけた投稿を思い出している。
「レガシーコードで困ってるならLCIってところが面白いらしい」。投稿者はそれ以上の詳細を書いていなかった。あのときは流し読みした。ポイント加算のバグが出るまでは。
先月、注文確定処理にポイント加算を追加した。テストは通った。本番に出した。翌朝、在庫引き当てが二重に走っていた。修正した。今度はメール送信のタイミングがずれて、注文確定前に確認メールが飛んだ。顧客からクレームが入った。
Slackの検索窓に「LCI」と打ち込んで、あの投稿を掘り起こしたのは、その夜だった。
ビデオ通話の接続ボタンを押す。画面が切り替わる。背景に本棚とモニターが雑然と並ぶ部屋が映った。飲みかけのエナジードリンクの缶が画面の端にちらりと見える。画面の中央に、手元の何かを見つめている男がいた。カメラのほうを見ていない。
「あの、接続できていますか?」
「……304ステンレスとチタンの熱伝導率の差は、コーヒーの冷め方に12%の影響を——ああ」男がようやく画面を見た。「ロックだ。コードの探偵。画面共有してくれたまえ、ワトソン君」
「……ワトソン君?」
一瞬、聞き間違いかと思った。
「私にはちゃんとした名前がありますけど」
「名前より症状だ。コードを見せたまえ」
(Slackの投稿には「面白い」と書いてあった。面白い、ね)
まあいい。コードの話さえできれば。画面共有を開始した。
事件現場——密結合の指紋
注文確定処理の OrderService クラスが画面に映し出される。
「注文確定の処理です。確認メールの送信、在庫の引き当て、ポイントの加算を、confirm_order メソッドの中でやっています」
| |
「先月ポイント加算を追加したら、在庫引き当てが二重に走って——」
「待ちたまえ」
ロックさんが画面のコードに目を細めた。数秒の沈黙。ビデオ通話の向こうで、缶を手に取る音が聞こえた。
容疑者の浮上——消えた目撃者
「ワトソン君、ひとつ聞きたい。この confirm_order メソッドの仕事は何だ?」
「注文を確定することです」
「では、メールを送ることは? 在庫を減らすことは? ポイントを加算することは?」
「……それは、注文を確定したら当然やるべきことで——」
「“当然やるべきこと”。その"当然"が3つ、4つ、5つと増えたとき、このメソッドはどうなる?」
少し考えた。実際、ポイント加算で4つ目になったばかりだ。
「……太ります」
「太るだけではない。ポイント加算を追加したとき、在庫引き当てが二重に走った。なぜだ?」
「条件分岐の位置を間違えて、return の前にポイント加算を入れたら、その下の在庫引き当てが——」
「つまり、副作用の順序がメソッドの行番号に依存している。副作用同士が暗黙に干渉し合う余地がある。これが密結合だ」
密結合。それはわかる。わかっているからこそ、次の言葉が口をついた。
「Observer パターンで解決できますよね? リスナーを登録して、confirm_order の最後で notify すれば——」
ロックさんが一拍置いた。
「では、なぜ君はそうしなかった?」
詰まった。Observer パターンは研修で学んだ。自分の別のコードでも使っている。なのに、この注文確定処理には適用しなかった。なぜだろう。
「……Observer だと、OrderService が各 Observer を add_observer で登録する必要がありますよね。結局、OrderService がリスナーの存在を知っている——」
「いい線だ。Observer は配線の問題を解く。Subject がリスナーに直接通知する代わりに、登録されたリスナーに順番に通知する。だが Subject はリスナーの顔を知っている。配線は変わったが、依存は残っている」
ロックさんは画面のコードを指さした。
「真犯人は"直接呼び出し"だけではない。より深い問題は、“何が起きたか"という情報が、どこにもオブジェクトとして存在していないことだ」
「どういう意味ですか?」
「メール送信も在庫引き当てもポイント加算も、“注文が確定した"という事実への反応だ。だが、“注文が確定した"という事実そのものが、このコードのどこにも記録されていない。事件が起きたのに、目撃証言がない。沈黙の目撃者——起きたことを誰も記録していないんだ」
推理披露——イベントという証言
第一の手がかり——「何が起きたか」をオブジェクトにする
「まず、“何が起きたか"をオブジェクトにする」
ロックさんが画面共有を切り替え、エディタに新しいコードを書き始めた。
| |
「全属性が ro。読み取り専用だ。“注文が確定した"という事実は、過去に起きたことだ。過去は変更できない」
「イベントをオブジェクトにする理由は何ですか? コールバックの引数にハッシュを渡せば十分では?」
「コールバックの引数は、呼び出し側が毎回決める。ハンドラが必要なデータが増えたら?」
「呼び出し側を変更する……あ」
「最初の密結合と同じ構造だ。イベントオブジェクトは"起きたこと"の完全な記録だ。作る側は記録を作るだけ、使う側は記録を読むだけ。間に依存はない」
(なるほど。引数の追加で呼び出し側が変わるなら、結局は密結合が形を変えただけだ)
「それと——order_updated じゃダメですか? イベント名」
「order_updated は"何かが変わった"としか言っていない。CRUDの名前は真犯人を隠す偽名だよ、ワトソン君。OrderConfirmed なら"注文が確定した"という業務上の意味がある。半年後にこのコードを読む人間が、名前だけで何が起きたか理解できる」
確かに order_updated では、確定なのかキャンセルなのか金額変更なのか区別がつかない。
第二の手がかり——仲介者を置く
「次に��伝言を届ける仲介者を置く」
| |
「subscribe でハンドラを登録し、dispatch でイベントを配る。それだけのシンプルな仲介者です」
「Observer なら Subject が直接 notify すれば済みますよね。わざわざ仲介者を置く意味は?」
「Subject が Observer を知っている限り、新しい Observer を追加するには Subject を変更する必要がある。ディスパッチャーはその参照を断ち切る。注文サービスは"誰が聞いているか"を知らなくていい。“何が起きたか"を叫ぶだけだ」
「叫ぶだけ……つまり、OrderService のコードは、ハンドラが何個追加されても変わらない?」
「その通り。開放閉鎖原則だ。新しい反応を追加するとき、既存のコードは一行も変えない」
ロックさんがMermaid図を画面に表示した。
classDiagram
class OrderConfirmed {
+order_id: Str
+customer_id: Str
+total_amount: Num
+item_count: Int
+confirmed_at: Str
}
class EventDispatcher {
-_subscribers: HashRef
+subscribe(event_class, handler)
+dispatch(event)
}
class OrderService {
+dispatcher: EventDispatcher
+confirm_order(%args)
}
class Handler_Email {
+handle(event)
}
class Handler_Inventory {
+handle(event)
}
class Handler_Points {
+handle(event)
}
OrderService --> EventDispatcher : dispatch
OrderService ..> OrderConfirmed : creates
EventDispatcher --> Handler_Email : notifies
EventDispatcher --> Handler_Inventory : notifies
EventDispatcher --> Handler_Points : notifies
Handler_Email ..> OrderConfirmed : reads
Handler_Inventory ..> OrderConfirmed : reads
Handler_Points ..> OrderConfirmed : reads
矢印の向きが Observer とは明らかに違う。Observer では Subject から Observer への矢印が直接伸びていた。ここでは OrderService から EventDispatcher への矢印が1本あるだけで、各ハンドラへの矢印は EventDispatcher から伸びている。OrderService とハンドラの間に、直接の線がない。
第三の手がかり——反応を独立させる
「最後に、各副作用を独立したハンドラとして分離する」
| |
| |
| |
「各ハンドラは $event からデータを読み取るだけ。OrderService を参照しない。ハンドラ同士も互いを知らない」
「これなら、たとえば Slack 通知を追加したいとき——」
「SlackHandler を1つ書いて、ディスパッチャーに登録する。OrderService には指一本触れない」
「Observer でもリスナーを追加するだけで拡張できますけど、違いは……あ。Observer だと $stock_manager->add_observer($slack_notifier) みたいに、Subject 側の初期化コードを変更しますよね」
「そうだ。Observer ではリスナーの登録が Subject の責務に含まれる。Domain Event ではディスパッチャーへの登録が独立しているから、Subject は登録の存在すら知らない」
リファクタリング後の OrderService
「そして OrderService はこうなる」
| |
「mailer も inventory_client も points_client も消えた。OrderService が知っているのは dispatcher だけ。注文を確定して、“確定した"というイベントを叫ぶ。それだけだ」
「……整理させてください」
私は画面を見ながら、頭の中で Observer との違いを並べた。
「Observer は"通知の仕組み"で、Domain Event は"何が起きたかの記録”。Observer は"どう伝えるか"を解決する。Domain Event は"何が起きたか"をドメインの言葉でモデル化する」
「正確だ」
「でも Observer も Domain Event の実装手段になり得ますよね。実際、ディスパッ��ャーの中身は Observer に似ている」
「その通り。だが、イベントをオブジェクトとして名前を与え、ディスパッチャーで参照を断ち切ったとき、Observer とは別の設計判断が入っている」
「別の設計判断……」
「3つある」ロックさんが指を立てた。「第一に、イベントは不変の Value Object だ。過去に起きたことは変更できない。第二に、イベントの名前はユビキタス言語——業務の言葉でつける。OrderConfirmed であって data_changed ではない。第三に、ディスパッチャーが仲介することで、発行側と購読側が互いの存在を知らない。Observer にはこの3つの制約がない」
Observer を知っていたつもりだった。通知の仕組みは理解していた。でもそれは"どう伝えるか"の話でしかなかった。“何が起きたか"をオブジェクトとして記録するという発想が、私にはなかった。
事件の終わり——テスト通過
ロックさんの指示に従って、テストを書いた。
OrderConfirmed の全属性が読み取り専用であること。EventDispatcher が登録されたハンドラにイベントを配ること。未登録のイベントクラスを dispatch してもエラーにならないこと。OrderService がイベントを発行すること。各ハンドラがイベントからデータを正しく読み取ること。ハンドラが未登録でも confirm_order が正常に完了すること。
そして、新しいハンドラの追加が OrderService のコードに一切影響しないこと。
全テスト通過。
「全部通りました。……ハンドラを全部外しても confirm_order 自体は動くんですね」
「注文の確定と、確定への反応は、別の関心事だからだ。反応がゼロでも、確定という事実は成立する」
「そうですね。確定は確定で、メールもポイントも在庫も、それぞれ"確定を聞いて動く"だけ」
ロックさんが小さくうなずいた。画面越しでも、それが肯定の合図だとわかった。
エピローグ
「今日はありがとうございました」
「礼には及ばないよ、ワトソン君。事件が解決しただけだ」
そう呼びたいならどうぞ。もう訂正する気はない。
ビデオ通話の終了ボタンを���した。画面が暗転する。
エディタを開いた。新しいファイル。カーソルが点滅している。
package OrderConfirmed;
打ち込んだ文字列を見つめる。たった1行。でも、1時間前の私はこの1行を書く発想がなかった。
Observer を知っていたつもりだった。通知の仕組みは理解していた。でもそれは"どう伝えるか"の話でしかなかった。
“何が起きたか"を記録する。その記録に業務の名前をつける。OrderConfirmed。注文が確定した。それだけのことが、コードの中に存在していなかった。
Slackの投稿には「面白い」と書いてあった。面白い、というのは少し違う。でも、忘れられない1時間だった。
#tech-tips に返信を書こうかと思った。やめた。代わりに、エディタに次の行を打ち込んだ。
has order_id => (is => 'ro', isa => Str, required => 1);
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| 副作用の直接呼び出し(メール・在庫・ポイントが1メソッドに密結合) | Domain Event(「何が起きたか」を不変オブジェクトとして記録) | 副作用の追加・削除が OrderService の変更なしに可能 |
| Subject が Observer を直接参照(Observer パターンの限界) | EventDispatcher による参照の断絶 | 発行側と購読側が互いの存在を知らない完全な疎結合 |
| イベント情報の不在(「何が起きたか」がコードに存在しない) | ユビキタス言語による命名(OrderConfirmed) | ビジネス意図がコードに記録され、半年後も読める |
推理のステップ
- 症状の確認:
confirm_orderに副作用が直書きされ、追加のたびに既存動作が壊れる - Observer の限界を確認: Observer では Subject がリスナーを知っている。依存は形を変えて残る
- イベントオブジェクトの切り出し: 「注文が確定した」を
OrderConfirmedとして不変の Value Object に - EventDispatcher の導入: 発行側と購読側の参照を断ち切る仲介者を置く
- ハンドラの分離: 各副作用を独立した���ラスに。イベントからデータを読み取るだけ
- テスト検証: ハンドラ追加時に OrderService が不変であること、ハンドラ未登録でも動作すること
ロックより
通知の仕組みを知っているだけでは足りない。「どう伝えるか」は配線の話だ。本当に必要なのは、「何が起きたか」にドメインの言葉で名前をつけ、不変の記録として残すことだ。記録がなければ、事件は闇に葬られる。記録があれば、誰がいつ聞きに来ても、同じ事実を伝えられる。——沈黙の目撃者に、声を与えたまえ。
