Featured image of post コード探偵ロックの事件簿【Domain Event】沈黙の目撃者〜密結合が隠した「起きたこと」の記録〜

コード探偵ロックの事件簿【Domain Event】沈黙の目撃者〜密結合が隠した「起きたこと」の記録〜

注文確定時の副作用(メール・在庫・ポイント)が1メソッドに密結合した問題を、Domain Eventパターンで「何が起きたか」を不変オブジェクトとして記録しディスパッチャーで疎結合化するリファクタリング事例をPerl/Mooで解説

1ヶ月前、社内Slackの #tech-tips で見かけた投稿を思い出している。

「レガシーコードで困ってるならLCIってところが面白いらしい」。投稿者はそれ以上の詳細を書いていなかった。あのときは流し読みした。ポイント加算のバグが出るまでは。

先月、注文確定処理にポイント加算を追加した。テストは通った。本番に出した。翌朝、在庫引き当てが二重に走っていた。修正した。今度はメール送信のタイミングがずれて、注文確定前に確認メールが飛んだ。顧客からクレームが入った。

Slackの検索窓に「LCI」と打ち込んで、あの投稿を掘り起こしたのは、その夜だった。

ビデオ通話の接続ボタンを押す。画面が切り替わる。背景に本棚とモニターが雑然と並ぶ部屋が映った。飲みかけのエナジードリンクの缶が画面の端にちらりと見える。画面の中央に、手元の何かを見つめている男がいた。カメラのほうを見ていない。

「あの、接続できていますか?」

「……304ステンレスとチタンの熱伝導率の差は、コーヒーの冷め方に12%の影響を——ああ」男がようやく画面を見た。「ロックだ。コードの探偵。画面共有してくれたまえ、ワトソン君」

「……ワトソン君?」

一瞬、聞き間違いかと思った。

「私にはちゃんとした名前がありますけど」

「名前より症状だ。コードを見せたまえ」

(Slackの投稿には「面白い」と書いてあった。面白い、ね)

まあいい。コードの話さえできれば。画面共有を開始した。

事件現場——密結合の指紋

注文確定処理の OrderService クラスが画面に映し出される。

「注文確定の処理です。確認メールの送信、在庫の引き当て、ポイントの加算を、confirm_order メソッドの中でやっています」

 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
package OrderService;
use Moo;
use v5.36;

has mailer           => (is => 'ro', required => 1);
has inventory_client => (is => 'ro', required => 1);
has points_client    => (is => 'ro', required => 1);

sub confirm_order ($self, %args) {
    my $order_id    = $args{order_id};
    my $customer_id = $args{customer_id};
    my $items       = $args{items};

    # 注文確定ロジック
    my $total = 0;
    $total += $_->{unit_price} * $_->{quantity} for @$items;

    # 副作用: すべて直書き
    $self->mailer->send_confirmation(
        customer_id => $customer_id,
        order_id    => $order_id,
        total       => $total,
    );

    $self->inventory_client->reduce_stock(
        order_id => $order_id,
        items    => $items,
    );

    $self->points_client->add_points(
        customer_id => $customer_id,
        amount      => int($total * 0.01),
    );

    return { order_id => $order_id, total => $total };
}

1;

「先月ポイント加算を追加したら、在庫引き当てが二重に走って——」

「待ちたまえ」

ロックさんが画面のコードに目を細めた。数秒の沈黙。ビデオ通話の向こうで、缶を手に取る音が聞こえた。

容疑者の浮上——消えた目撃者

「ワトソン君、ひとつ聞きたい。この confirm_order メソッドの仕事は何だ?」

「注文を確定することです」

「では、メールを送ることは? 在庫を減らすことは? ポイントを加算することは?」

「……それは、注文を確定したら当然やるべきことで——」

「“当然やるべきこと”。その"当然"が3つ、4つ、5つと増えたとき、このメソッドはどうなる?」

少し考えた。実際、ポイント加算で4つ目になったばかりだ。

「……太ります」

「太るだけではない。ポイント加算を追加したとき、在庫引き当てが二重に走った。なぜだ?」

「条件分岐の位置を間違えて、return の前にポイント加算を入れたら、その下の在庫引き当てが——」

「つまり、副作用の順序がメソッドの行番号に依存している。副作用同士が暗黙に干渉し合う余地がある。これが密結合だ」

密結合。それはわかる。わかっているからこそ、次の言葉が口をついた。

「Observer パターンで解決できますよね? リスナーを登録して、confirm_order の最後で notify すれば——」

ロックさんが一拍置いた。

「では、なぜ君はそうしなかった?」

詰まった。Observer パターンは研修で学んだ。自分の別のコードでも使っている。なのに、この注文確定処理には適用しなかった。なぜだろう。

「……Observer だと、OrderService が各 Observer を add_observer で登録する必要がありますよね。結局、OrderService がリスナーの存在を知っている——」

「いい線だ。Observer は配線の問題を解く。Subject がリスナーに直接通知する代わりに、登録されたリスナーに順番に通知する。だが Subject はリスナーの顔を知っている。配線は変わったが、依存は残っている」

ロックさんは画面のコードを指さした。

「真犯人は"直接呼び出し"だけではない。より深い問題は、“何が起きたか"という情報が、どこにもオブジェクトとして存在していないことだ」

「どういう意味ですか?」

「メール送信も在庫引き当てもポイント加算も、“注文が確定した"という事実への反応だ。だが、“注文が確定した"という事実そのものが、このコードのどこにも記録されていない。事件が起きたのに、目撃証言がない。沈黙の目撃者——起きたことを誰も記録していないんだ」

推理披露——イベントという証言

第一の手がかり——「何が起きたか」をオブジェクトにする

「まず、“何が起きたか"をオブジェクトにする」

ロックさんが画面共有を切り替え、エディタに新しいコードを書き始めた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package OrderConfirmed;
use Moo;
use v5.36;
use Types::Standard qw(Str Num Int);

has order_id     => (is => 'ro', isa => Str, required => 1);
has customer_id  => (is => 'ro', isa => Str, required => 1);
has total_amount => (is => 'ro', isa => Num, required => 1);
has item_count   => (is => 'ro', isa => Int, required => 1);
has confirmed_at => (is => 'ro', isa => Str, required => 1);

1;

「全属性が ro。読み取り専用だ。“注文が確定した"という事実は、過去に起きたことだ。過去は変更できない」

「イベントをオブジェクトにする理由は何ですか? コールバックの引数にハッシュを渡せば十分では?」

「コールバックの引数は、呼び出し側が毎回決める。ハンドラが必要なデータが増えたら?」

「呼び出し側を変更する……あ」

「最初の密結合と同じ構造だ。イベントオブジェクトは"起きたこと"の完全な記録だ。作る側は記録を作るだけ、使う側は記録を読むだけ。間に依存はない」

(なるほど。引数の追加で呼び出し側が変わるなら、結局は密結合が形を変えただけだ)

「それと——order_updated じゃダメですか? イベント名」

order_updated は"何かが変わった"としか言っていない。CRUDの名前は真犯人を隠す偽名だよ、ワトソン君。OrderConfirmed なら"注文が確定した"という業務上の意味がある。半年後にこのコードを読む人間が、名前だけで何が起きたか理解できる」

確かに order_updated では、確定なのかキャンセルなのか金額変更なのか区別がつかない。

第二の手がかり——仲介者を置く

「次に��伝言を届ける仲介者を置く」

 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
package EventDispatcher;
use Moo;
use v5.36;
use Types::Standard qw(HashRef ArrayRef CodeRef);
use Scalar::Util qw(blessed);

has _subscribers => (
    is       => 'ro',
    isa      => HashRef[ArrayRef[CodeRef]],
    default  => sub { {} },
    init_arg => undef,
);

sub subscribe ($self, $event_class, $handler) {
    push @{ $self->_subscribers->{$event_class} //= [] }, $handler;
    return;
}

sub dispatch ($self, $event) {
    my $class = blessed($event) // return;
    my $handlers = $self->_subscribers->{$class} // return;
    $_->($event) for @$handlers;
    return;
}

1;

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 とハンドラの間に、直接の線がない。

第三の手がかり——反応を独立させる

「最後に、各副作用を独立したハンドラとして分離する」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package Handler::SendConfirmationEmail;
use Moo;
use v5.36;

has mailer => (is => 'ro', required => 1);

sub handle ($self, $event) {
    $self->mailer->send_confirmation(
        customer_id => $event->customer_id,
        order_id    => $event->order_id,
        total       => $event->total_amount,
    );
    return;
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package Handler::ReduceInventory;
use Moo;
use v5.36;

has inventory_client => (is => 'ro', required => 1);

sub handle ($self, $event) {
    $self->inventory_client->reduce_stock(
        order_id => $event->order_id,
    );
    return;
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package Handler::AddPoints;
use Moo;
use v5.36;

has points_client => (is => 'ro', required => 1);

sub handle ($self, $event) {
    $self->points_client->add_points(
        customer_id => $event->customer_id,
        amount      => int($event->total_amount * 0.01),
    );
    return;
}

1;

「各ハンドラは $event からデータを読み取るだけ。OrderService を参照しない。ハンドラ同士も互いを知らない」

「これなら、たとえば Slack 通知を追加したいとき——」

SlackHandler を1つ書いて、ディスパッチャーに登録する。OrderService には指一本触れない」

「Observer でもリスナーを追加するだけで拡張できますけど、違いは……あ。Observer だと $stock_manager->add_observer($slack_notifier) みたいに、Subject 側の初期化コードを変更しますよね」

「そうだ。Observer ではリスナーの登録が Subject の責務に含まれる。Domain Event ではディスパッチャーへの登録が独立しているから、Subject は登録の存在すら知らない」

リファクタリング後の OrderService

「そして OrderService はこうなる」

 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
package OrderService;
use Moo;
use v5.36;
use Types::Standard qw(InstanceOf);

has dispatcher => (
    is       => 'ro',
    isa      => InstanceOf['EventDispatcher'],
    required => 1,
);

sub confirm_order ($self, %args) {
    my $order_id    = $args{order_id};
    my $customer_id = $args{customer_id};
    my $items       = $args{items};

    # 注文確定ロジック
    my $total = 0;
    $total += $_->{unit_price} * $_->{quantity} for @$items;

    # イベントを発行するだけ
    my $event = OrderConfirmed->new(
        order_id     => $order_id,
        customer_id  => $customer_id,
        total_amount => $total,
        item_count   => scalar @$items,
        confirmed_at => '2026-04-21T07:07:05+09:00',
    );
    $self->dispatcher->dispatch($event);

    return { order_id => $order_id, total => $total };
}

1;

mailerinventory_clientpoints_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)ビジネス意図がコードに記録され、半年後も読める

推理のステップ

  1. 症状の確認: confirm_order に副作用が直書きされ、追加のたびに既存動作が壊れる
  2. Observer の限界を確認: Observer では Subject がリスナーを知っている。依存は形を変えて残る
  3. イベントオブジェクトの切り出し: 「注文が確定した」を OrderConfirmed として不変の Value Object に
  4. EventDispatcher の導入: 発行側と購読側の参照を断ち切る仲介者を置く
  5. ハンドラの分離: 各副作用を独立した���ラスに。イベントからデータを読み取るだけ
  6. テスト検証: ハンドラ追加時に OrderService が不変であること、ハンドラ未登録でも動作すること

ロックより

通知の仕組みを知っているだけでは足りない。「どう伝えるか」は配線の話だ。本当に必要なのは、「何が起きたか」にドメインの言葉で名前をつけ、不変の記録として残すことだ。記録がなければ、事件は闇に葬られる。記録があれば、誰がいつ聞きに来ても、同じ事実を伝えられる。——沈黙の目撃者に、声を与えたまえ。

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