Featured image of post コード探偵ロックの事件簿【Entity vs Value Object】二重人格の住所録〜同一性と等価性が交差する日〜

コード探偵ロックの事件簿【Entity vs Value Object】二重人格の住所録〜同一性と等価性が交差する日〜

同じ住所がeqでfalse、同じIDなのに属性更新でfalse——2つの比較バグの正体はEntityとValue Objectの混同だった。Perl/Mooでoverloadによる等価性・同一性の正しい実装法を解説する。

二重人格の住所録

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を持つ者と持たざる者

ロックさんが画面をスクロールし、問題のコードを表示した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package Address;
use Moo;
use Types::Standard qw(Int Str);

has address_id => (is => 'ro', isa => Int, required => 1);
has prefecture => (is => 'rw', isa => Str, required => 1);
has city       => (is => 'rw', isa => Str, required => 1);
has street     => (is => 'rw', isa => Str, required => 1);

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package Property;
use Moo;
use Types::Standard qw(Str InstanceOf);

has property_id => (is => 'ro', isa => Str, required => 1);
has name        => (is => 'rw', isa => Str, required => 1);
has owner       => (is => 'rw', isa => Str, required => 1);
has address     => (is => 'rw', isa => InstanceOf['Address'], required => 1);

1;

コードを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 から片付ける。住所に構造等価性を与える」

画面にコードが組み上がっていく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package Address;
use Moo;
use Types::Standard qw(Str);
use overload
    'eq'     => \&_eq,
    '=='     => \&_eq,
    '""'     => \&_stringify,
    fallback => 1;

has prefecture => (is => 'ro', isa => Str, required => 1);
has city       => (is => 'ro', isa => Str, required => 1);
has street     => (is => 'ro', isa => Str, required => 1);

sub _eq {
    my ($self, $other, $swap) = @_;
    return 0 unless defined $other && ref $other eq ref $self;
    return $self->prefecture eq $other->prefecture
        && $self->city       eq $other->city
        && $self->street     eq $other->street;
}

sub _stringify {
    my ($self) = @_;
    return join('', $self->prefecture, $self->city, $self->street);
}

sub with_street {
    my ($self, $new_street) = @_;
    return Address->new(
        prefecture => $self->prefecture,
        city       => $self->city,
        street     => $new_street,
    );
}

1;

私は変化に気づいた。

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 には識別子による同一性を与える」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package Property;
use Moo;
use Types::Standard qw(Str InstanceOf);
use overload
    'eq'     => \&_eq,
    '=='     => \&_eq,
    '""'     => \&_stringify,
    fallback => 1;

has property_id => (is => 'ro',  isa => Str, required => 1);
has name        => (is => 'rwp', isa => Str, required => 1);
has owner       => (is => 'rwp', isa => Str, required => 1);
has address     => (is => 'rwp', isa => InstanceOf['Address'], required => 1);

sub _eq {
    my ($self, $other, $swap) = @_;
    return 0 unless defined $other && ref $other eq ref $self;
    return $self->property_id eq $other->property_id;
}

sub _stringify {
    my ($self) = @_;
    return sprintf('Property(%s)', $self->property_id);
}

sub update_name {
    my ($self, $new_name) = @_;
    $self->_set_name($new_name);
    return $self;
}

sub update_owner {
    my ($self, $new_owner) = @_;
    $self->_set_owner($new_owner);
    return $self;
}

sub relocate {
    my ($self, $new_address) = @_;
    $self->_set_address($new_address);
    return $self;
}

1;

「これで、名前を更新しても eqtrue になるんですか?」

_eqproperty_id だけを比較している。名前が変わろうが、オーナーが変わろうが、住所が移ろうが、IDが同じなら同じ物件だ」

「なるほど……じゃあ Value Object の方は逆に、全属性が同じなら true になる」

「そうだ。Entity は名札で本人確認をする。Value Object は顔で本人確認をする。名札が同じなら、整形しても同一人物だ。顔が同じなら、名前が違っても同一人物だ」

整理好きの血が騒ぐ。分類の軸が見えてきた。

is => 'rwp' って……」

「外部からは読み取り専用だが、クラス内部の _set_name メソッドでは書き換えられる。Entity は属性が変わる。だが変更を誰にでも許すわけではない。メソッドを通じて、ビジネスルールの範囲内でだけ変更を認める」

「Value Object は全部 'ro' だから、誰が触っても変わらない」

「硬貨の刻印は誰にも変えられない。だが不動産登記は所有者が変わる。それが両者の性質の違いだ」

私はコルクボードの「Value Object」と「Entity」の文字を見比べた。左側は属性で身元を証明する世界。右側はIDで身元を証明する世界。前任者のコードは、この2つの世界を区別していなかった。

事件の終わり——テストが語る真実

「論より証拠だ。テストを走らせよう」

ロックさんがターミナルにコマンドを打ち込んだ。私は画面を覗き込む。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
use v5.36;
use Test::More;

# --- Address (Value Object) のテスト ---

subtest 'Address: structural equality' => sub {
    my $addr1 = Address->new(
        prefecture => '東京都',
        city       => '港区',
        street     => '六本木1-1-1',
    );
    my $addr2 = Address->new(
        prefecture => '東京都',
        city       => '港区',
        street     => '六本木1-1-1',
    );
    ok($addr1 eq $addr2, 'same attributes → eq is true');
};

subtest 'Address: immutability' => sub {
    my $addr = Address->new(
        prefecture => '東京都',
        city       => '港区',
        street     => '六本木1-1-1',
    );
    eval { $addr->street('六本木2-2-2') };
    like($@, qr/read-only/, 'cannot mutate ro attribute');
};

subtest 'Address: with_street creates new instance' => sub {
    my $addr1 = Address->new(
        prefecture => '東京都',
        city       => '港区',
        street     => '六本木1-1-1',
    );
    my $addr2 = $addr1->with_street('六本木2-2-2');

    is($addr1->street, '六本木1-1-1', 'original unchanged');
    is($addr2->street, '六本木2-2-2', 'new instance has new street');
    ok(!($addr1 eq $addr2), 'original and new are not equal');
};

# --- Property (Entity) のテスト ---

subtest 'Property: identity equality by ID' => sub {
    my $addr = Address->new(
        prefecture => '東京都',
        city       => '港区',
        street     => '六本木1-1-1',
    );
    my $prop1 = Property->new(
        property_id => 'P-001',
        name        => 'ロック六本木ビル',
        owner       => '田中太郎',
        address     => $addr,
    );
    my $prop2 = Property->new(
        property_id => 'P-001',
        name        => 'ロック六本木タワー',
        owner       => '鈴木花子',
        address     => $addr,
    );
    ok($prop1 eq $prop2,
       'same property_id → eq is true despite different attributes');
};

subtest 'Property: identity preserved after update' => sub {
    my $addr = Address->new(
        prefecture => '東京都',
        city       => '港区',
        street     => '六本木1-1-1',
    );
    my $prop = Property->new(
        property_id => 'P-001',
        name        => 'ロック六本木ビル',
        owner       => '田中太郎',
        address     => $addr,
    );
    $prop->update_name('ロック六本木タワー');

    my $same_id = Property->new(
        property_id => 'P-001',
        name        => '全く別の名前',
        owner       => '全く別のオーナー',
        address     => $addr,
    );
    ok($prop eq $same_id, 'still equal by ID after attribute changes');
};

done_testing;

テスト結果が流れていく。

1
2
3
4
5
6
ok 1 - Address: structural equality
ok 2 - Address: immutability
ok 3 - Address: with_street creates new instance
ok 4 - Property: identity equality by ID
ok 5 - Property: identity preserved after update
1..5

「全部通ってる……」

思わず声が出た。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は不要同じ住所が eqtrue になる。孤児データが発生しない
Entity を属性で比較するEntity はIDで同一性を判定する属性更新後も eqtrue。ライフサイクルを正しく追跡できる
Value Object を可変にするValue Object は不変(is => 'ro')。変更は新インスタンスを生成予期しない副作用(Aliasing Bug)が起きない
Entity の属性を外部から直接変更するEntity は rwp + メソッド経由で変更を制御するビジネスルールの範囲内でのみ属性が変化する

推理のステップ

  1. ドメインの概念を「同じ属性の別インスタンスと交換できるか?」で分類する
  2. Value Object(交換可能): IDを削除。全属性を is => 'ro'overload で全属性を比較する _eq を実装。変更は with_* メソッドで新しいインスタンスを返す
  3. Entity(交換不可能): IDを is => 'ro'、他属性を is => 'rwp'overload でIDのみを比較する _eq を実装。変更は update_* / _set_* メソッドで行う
  4. 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の看板を見上げた。

——次来るときは、アポを取ろう。予約フォーム、ないけど。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。