二重人格の住所録
Qiitaの記事を見つけたのは、先週の金曜日の深夜だった。
PerlでDDDを実装するという変わったテーマの記事。内容よりも、コメント欄の一行が目に留まった。「LCI、変な人だが技術は本物。検索すれば出てくる」。投稿者はそれ以上何も書いていなかった。
検索した。「レガシー・コード・インベスティゲーション」。雑居ビルの住所と、素っ気ない一文だけのウェブサイトが引っかかった。予約フォームはない。電話番号もない。メールアドレスすら見つからない。
——アポも取れないサービスって、それ自体がバグじゃないですか。
とはいえ、物件管理システムの比較処理が壊れていることへの苛立ちは、もう2週間分溜まっている。日曜日に休日出勤してデバッグしても原因がわからなかった。月曜日にチームリーダーに相談したら「Perl特有の問題じゃない?」と首をかしげられた。特有の問題なら、Perl特有の解決策があるはずだ。その解決策を知っている人間が、このビルの三階にいるらしい。
階段を上がる。三階。ドアの前に小さなプレートがある。「LCI — レガシー・コード・インベスティゲーション」。ノックする。応答がない。もう一度、少し強めに。応答がない。
ドアが2センチほど開いていることに気づいた。
恐る恐る押し開ける。デスクトップPCの排熱で妙に暖かい室内。飲みかけのエナジードリンクの缶が机の端に3本並んでいる。だが、私の目を引いたのは壁にかかったコルクボードだった。クラス図のようなものの切り抜きが何枚もピンで留められている。赤い糸で結ばれているものもある。ドラマの捜査本部みたいだ。
コルクボードの前に、一人の男が立っていた。私に背を向けたまま、新しい切り抜きをピンで留めている最中だ。
「アポなしの来客は、症状が急性だと相場が決まっている」
振り向かずに言った。声は落ち着いている。
「あの……Qiitaのコメント欄で見かけて——」
男がようやく振り向いた。視線が私のノートPCに一瞬だけ止まり、それから顔に移った。
「ロック。コードの探偵だ。——なるほど。不動産系か。オブジェクトの顔写真が2枚あるね——片方はIDが貼り付いている。もう片方はIDがない。見せたまえ、ワトソン君」
——ワトソン君?
私は一瞬眉を寄せた。訂正しかけたが、ロックさんはもうデスクに向かって手を差し出していて、会話が先に進んでしまっている。
名前より問題を解決してほしい。私はノートPCを渡した。
「2つ問題があるんです」
画面を開いて説明する。
「1つ目。同じ住所——東京都港区六本木1-1-1——を持つ2つの物件があります。でも住所オブジェクト同士を eq で比較すると false になるんです」
「2つ目。物件の名前を更新した後、更新前後の物件オブジェクトを eq で比較しても false です。同じ property_id なのに」
ロックさんはコードを読み進めている。私は付け加えた。
「つまり、比較が壊れてるんです。全部」
ロックさんは画面から目を上げた。静かに首を振った。
「壊れてはいない。何も宣言していないだけだ」
現場検証——IDを持つ者と持たざる者
ロックさんが画面をスクロールし、問題のコードを表示した。
| |
| |
コードを2周読んだ後、ロックさんは椅子から立ち上がってコルクボードの前に移動した。空いているスペースに指で2つの領域を区切る。
「ワトソン君。君は2つの事件を1つだと思っている」
「2つ? 比較が壊れてるっていう1つの問題では?」
「いいや。犯行動機がまったく違う」
ロックさんはコルクボードの左側を指さした。
「左側。住所。同じ文字列なのに等しくならない。これは身元を証明する手段がない事件だ。Perlはオブジェクト同士を eq で比較すると参照——メモリアドレスを比較する。同じ住所を2回 new すれば、中身は同じでも別の参照になる。住所は自分が何者かを語る言葉を持っていない」
右側を指さす。
「右側。物件。属性を変えたらIDが同じでも等しくならない。これは顔が変わったら別人と見なされる事件だ。IDという不変の身元があるのに、それを使っていない」
2つの事件。動機が逆。私は画面に映ったコードを見つめ直した。片方は「身元がない」。もう一方は「身元を使っていない」。
「つまり……Address と Property では、比較のやり方自体が違うべきだ、ということですか?」
「そこまでは近い。だがやり方が違うのではなく、存在の性質が違うのだ」
ロックさんはコルクボードの左側に太いマーカーで「Value Object」と書いた。右側に「Entity」。
「Value Object。属性がすべて。同じ属性なら同じ存在。交換可能。IDは不要」
「Entity。IDがすべて。属性が変わっても同じ存在。交換不可能。時間の中で追跡される」
「……住所は Value Object で、物件は Entity」
「そうだ。だが前任者は、両方にIDを振った。住所に偽のアイデンティティを与えてしまった」
私は Address クラスの address_id を見つめた。
「前任者がDBの都合でIDを振ったんだと思います。検索に便利だし」
「DBの都合でIDを振ること自体は罪ではない。だが、ドメインモデルにそのIDを持ち込むと、住所が自分のライフサイクルを持ち始める。親である物件を削除しても、住所レコードが孤児として残る。IDがあるということは『この行は唯一無二で、時間を超えて追跡される』という宣言だ」
一拍おいて、ロックさんは言った。
「——東京都港区六本木1-1-1は、追跡すべき唯一無二の存在かね?」
「……いいえ。住所は住所です。どの六本木1-1-1でも同じ六本木1-1-1です」
「その通り。交換可能だ。ならばIDは要らない」
推理披露——制服と名札
ロックさんがデスクに戻り、HHKBに手を置いた。
「まず Value Object から片付ける。住所に構造等価性を与える」
画面にコードが組み上がっていく。
| |
私は変化に気づいた。
「address_id が消えてる。それと、is => 'rw' が全部 'ro' になってます」
「2つの手術をした。1つ目、偽の身元証明——IDを除去した。2つ目、属性を不変にした」
「不変にする理由は?」
「Value Object は属性そのものが身元だ。属性を変えたら別人になる。別人にしたいなら、新しいインスタンスを作れ」
ロックさんはコルクボードを一瞥した。
「5円玉の刻印を削って10円に見せかけることはできないだろう? 10円が欲しければ10円玉を手に入れるんだ。with_street メソッドがそれだ。元のインスタンスは変えずに、新しい住所を作る」
「overload は……」
「eq と == を上書きしている。Perlのデフォルトでは、オブジェクト同士の eq は参照——メモリアドレスを比較する。これでは中身が同じでも false になる。Value Object では全属性で比較するのが正しい。_eq メソッドが、都道府県・市区町村・番地のすべてを照合している」
なるほど。でも、もっと根本的な疑問がある。
「Entity と Value Object を見分ける基準って、もっとはっきりしたものはないんですか?」
「簡単なテストがある。そのオブジェクトを、同じ属性の別インスタンスと交換できるか?」
ロックさんは指を立てた。
「東京都港区六本木1-1-1の住所を、同じ文字列の別オブジェクトに差し替えて問題があるか?」
「……ないです」
「ならば Value Object だ。では、property_id が P-001 の物件を、同じ名前・同じ住所の別オブジェクトに差し替えたら?」
「契約履歴が消えます。修繕記録も」
「それが Entity だ。交換不可能なら Entity、交換可能なら Value Object」
私はノートの端にメモを走らせた。交換テスト。これなら迷わない。
ロックさんが新しいファイルを開いた。
「次は物件だ。Entity には識別子による同一性を与える」
| |
「これで、名前を更新しても eq は true になるんですか?」
「_eq は property_id だけを比較している。名前が変わろうが、オーナーが変わろうが、住所が移ろうが、IDが同じなら同じ物件だ」
「なるほど……じゃあ Value Object の方は逆に、全属性が同じなら true になる」
「そうだ。Entity は名札で本人確認をする。Value Object は顔で本人確認をする。名札が同じなら、整形しても同一人物だ。顔が同じなら、名前が違っても同一人物だ」
整理好きの血が騒ぐ。分類の軸が見えてきた。
「is => 'rwp' って……」
「外部からは読み取り専用だが、クラス内部の _set_name メソッドでは書き換えられる。Entity は属性が変わる。だが変更を誰にでも許すわけではない。メソッドを通じて、ビジネスルールの範囲内でだけ変更を認める」
「Value Object は全部 'ro' だから、誰が触っても変わらない」
「硬貨の刻印は誰にも変えられない。だが不動産登記は所有者が変わる。それが両者の性質の違いだ」
私はコルクボードの「Value Object」と「Entity」の文字を見比べた。左側は属性で身元を証明する世界。右側はIDで身元を証明する世界。前任者のコードは、この2つの世界を区別していなかった。
事件の終わり——テストが語る真実
「論より証拠だ。テストを走らせよう」
ロックさんがターミナルにコマンドを打ち込んだ。私は画面を覗き込む。
| |
テスト結果が流れていく。
| |
「全部通ってる……」
思わず声が出た。2週間悩んだ2つのバグが、どちらも正しい結果を返している。
「当然だ。住所は顔で判定し、物件は名札で判定する。それぞれに正しい判定方法を与えれば、矛盾は消える」
つまり、壊れていたのは比較処理ではなく、分類の仕方だった。比較の前に、そもそも「何をもって同じとみなすか」を決めなければいけなかった。
「もう1つ聞いていいですか。既存の Address テーブルの address_id はどうすれば? DBから消すんですか?」
「DBテーブルにサロゲートキーを持つこと自体は技術上の必要性だ。問題は、そのIDをドメインモデルの属性として露出させるかだ」
ロックさんはコルクボードの Address の切り抜きを指さした。
「Address の Perl クラスからは address_id を消す。テーブルには残してもいい。ORM層でマッピングすればいい。——ドメインモデルは『この住所に固有のアイデンティティがある』と嘘をつく義務はない」
なるほど。DBとドメインモデルは別のレイヤーだ。テーブルにIDがあるからといって、クラスにIDが必要とは限らない。前任者はDBのスキーマをそのままクラスに写し取っていた。それが混乱の元だった。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| Value Object にIDを振る | Value Object は属性で等価性を判定する。IDは不要 | 同じ住所が eq で true になる。孤児データが発生しない |
| Entity を属性で比較する | Entity はIDで同一性を判定する | 属性更新後も eq が true。ライフサイクルを正しく追跡できる |
| Value Object を可変にする | Value Object は不変(is => 'ro')。変更は新インスタンスを生成 | 予期しない副作用(Aliasing Bug)が起きない |
| Entity の属性を外部から直接変更する | Entity は rwp + メソッド経由で変更を制御する | ビジネスルールの範囲内でのみ属性が変化する |
推理のステップ
- ドメインの概念を「同じ属性の別インスタンスと交換できるか?」で分類する
- Value Object(交換可能): IDを削除。全属性を
is => 'ro'。overloadで全属性を比較する_eqを実装。変更はwith_*メソッドで新しいインスタンスを返す - Entity(交換不可能): IDを
is => 'ro'、他属性をis => 'rwp'。overloadでIDのみを比較する_eqを実装。変更はupdate_*/_set_*メソッドで行う - DBのサロゲートキーとドメインモデルのIDを混同しない。テーブルのIDはORM層で吸収する
ロックより
帰り支度を始めると、ロックさんが棚から本を一冊取り出した。
「報酬だが——ISBNが同じ本を2冊持ってきたまえ。1冊は書架用、もう1冊は机上用」
「……それ、同じ本を2冊買えってことですよね」
「いやむしろ、ISBNが違うのに中身が同じ本を探してきたまえ。それが見つかったら、君はEntityとValue Objectの違いを完全に理解したことになる」
一瞬考えた。ISBNが同じなら、カバーが違っても刷りが違っても同じ本。つまり Entity だ。中身が同じなら、ISBNが違っても同じ内容。つまり Value Object だ。
「……わかりました。探してみます」
ロックさんは無言で微笑んで、コルクボードに向き直った。
事務所を出て、古い階段を降りる。外に出ると、曇り空が少しだけ明るくなっていた。
スマホを取り出して、Address クラスのソースコードを開いた。address_id のフィールドが目に入る。
この子にはIDは要らなかったんだ。住所は住所であって、追跡すべき誰かではない。
同じ問題だと思っていた。でも、2つの事件だった。片方は身元をどう証明するかの問題で、もう片方は身元をどう守るかの問題だった。「同じ」の意味が、Value Object と Entity では真逆だった。
振り返って、ビルの外壁に小さく掲げられたLCIの看板を見上げた。
——次来るときは、アポを取ろう。予約フォーム、ないけど。
