Featured image of post コード探偵ロックの事件簿【Service Locator】万能レジストリの共犯〜便利な仲介人が隠した依存の糸〜

コード探偵ロックの事件簿【Service Locator】万能レジストリの共犯〜便利な仲介人が隠した依存の糸〜

Service Locatorのグローバルレジストリがテストを壊し依存関係を隠蔽する問題を、コンストラクタインジェクションで解決するリファクタリング事例

呼ばれた探偵

金曜の午後、会議室で一人、CIの失敗ログを眺めていた。

夜間バッチのテスト結果。昨夜は18件中6件が赤い。月曜は4件、水曜は7件、今日は6件。テストコード自体を触った覚えはない。コードレビューでも致命的な変更は見当たらない。それなのに、毎晩ランダムに落ちる。

リーダーに就任して2週間。前任の田中さんから引き継いだのは、社内SaaS基盤の共通サービス群と、「便利だからそのまま使ってね」という一言だった。

社外の技術コミュニティのSlackチャンネルで、ある投稿が目に留まったのは3日前のことだ。

レガシーコードの設計問題なら LCI が確実。ちょっと変わった人だけど。

「レガシー・コード・インベスティゲーション」。検索すると簡素なウェブサイトが出てきた。連絡先にメールを送ると、翌日「現場を拝見したい。お伺いします」と返信があった。

会議室のドアが開いた。

コートを着た男が入ってきた。鞄から虫眼鏡型のUSB顕微鏡を取り出し、会議室のプロジェクタの接続口を覗き込み始めた。

「ほう、VGAポートがまだ生きている。レガシーインターフェースというのは、こういう場所にも棲んでいるのだね」

変わった人だとは聞いていた。聞いていた通りだった。

「ロックさん。お時間をいただいてありがとうございます。早速ですが、本題に入らせてください」

「せっかちなワトソン君だ」

私は聞こえなかったことにして、モニターに映したCIのログを指し示した。

ロックさんが少し物足りなそうな表情をした。

「……反応しないのか。最近の依頼人は堪え性がないか、あるいは情報を持ちすぎている。どちらだね」

「後者です。Slackで評判は拝見しました。呼び方の件は……もういいです、ワトソン君で」

「話が早いのは美徳だ。では見せてもらおう」

共犯者の名簿

私はノートPCで共通サービス基盤のリポジトリを開いた。

「このシステムでは、全サービスが ServiceLocator というモジュール経由で依存を取得しています」

ロックさんがモニターに目を向けた。私が ServiceLocator.pm を開く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package ServiceLocator;
use v5.36;

my %services;

sub register ($class, $name, $service) {
    $services{$name} = $service;
}

sub resolve ($class, $name) {
    return $services{$name} // die "Service '$name' not registered";
}

sub reset ($class) {
    %services = ();
}

1;

register でサービスを登録して、resolve で取り出す。全マイクロサービスがこのモジュールを使っています。前任者が構築した共通基盤で、便利だからとそのまま広まりました」

ロックさんが画面を見たまま、静かに言った。

「犯人はここにいる。……いや、正確に言おう。犯人はこの帳簿に載っている全員だ

「全員、ですか」

「このレジストリは便利な仲介人だ。誰でも預けられ、誰でも引き出せる。だが仲介人が便利すぎると、誰が何を預けたか分からなくなる。帳簿が共犯を隠蔽しているのだよ」

私は注文処理モジュールを開いた。

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

sub process ($self, $order) {
    my $validator = ServiceLocator->resolve('validator');
    my $pricer    = ServiceLocator->resolve('pricer');
    my $notifier  = ServiceLocator->resolve('notifier');

    $validator->validate($order);
    my $total = $pricer->calculate($order);
    $notifier->send_confirmation($order, $total);

    return { order_id => $order->{id}, total => $total, status => 'processed' };
}

1;

ロックさんが一行ずつ指でなぞった。

「このクラスは、何に依存しているか分かるかね?」

validatorpricernotifierですね。コードを読めば分かります」

コードを読めば。だが、コンストラクタは何も教えてくれない。OrderProcessor->new を見て、この3つが必要だと気づけるか?」

間を置いた。コンストラクタの定義を見返す。has 宣言はない。引数なしで new が呼べてしまう。

「……気づけません。new は引数なしで通ります」

「それがこの仲介人の罪だ。依存を隠す。事前条件を隠す。そして、実行してみるまで何が足りないか分からない

ロックさんが会議室のホワイトボードに向かった。マーカーを取り、テストコードを書き始めた。

「CIが30%落ちる理由を見せよう。ワトソン君、この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
# === テストA: 正常系 ===
subtest 'テストA: 正常な注文処理' => sub {
    ServiceLocator->register('validator', OrderValidator->new);
    ServiceLocator->register('pricer',    PricingService->new(tax_rate => 0.1));
    ServiceLocator->register('notifier',  NotificationService->new);

    my $processor = OrderProcessor->new;
    my $result = $processor->process($order);

    is $result->{total}, 2200, '税込金額が正しい';
    # reset を忘れている!
};

# === テストB: 税率変更 ===
subtest 'テストB: 税率8%で計算' => sub {
    # pricer だけ再登録
    ServiceLocator->register('pricer', PricingService->new(tax_rate => 0.08));

    my $processor = OrderProcessor->new;
    my $result = $processor->process($order);

    is $result->{total}, 2160, '税率8%で計算される';
    my $notifier = ServiceLocator->resolve('notifier');
    is scalar(@{$notifier->sent_messages}), 1, '今回のテストで1件だけ送信';
    # ↑ 実際は2件!
};

「テストAが notifier を登録し、reset を呼ばずに終わる。テストBは pricer だけ再登録するが、notifier はテストAのものが残っている。テストAの送信履歴がテストBに紛れ込む。送信件数のアサーションが期待1件、実際2件で失敗する」

私はCIのログを見直した。失敗しているテストの大半が、このパターンだった。テスト間で reset を呼ぶ規約はあるが、全員が守っているわけではない。

「テスト間で reset が必要……でも、reset を忘れたら?」

「前の事件の証拠品が次の事件に紛れ込む。証拠品保管庫が汚染されるのだよ。これがCIが30%落ちる理由だ。テストの実行順序が変われば、汚染のパターンも変わる。だからランダムに見える。だが原因は決定的だ——グローバルな状態の共有だ」

さらにもう一つ、ロックさんがホワイトボードに書き加えた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# === テストC: 依存の登録漏れ ===
subtest 'テストC: 依存の登録漏れ' => sub {
    ServiceLocator->reset;
    ServiceLocator->register('pricer', PricingService->new);
    # validator と notifier を登録し忘れ

    my $processor = OrderProcessor->new;  # ← new は成功してしまう!
    eval { $processor->process($order) };
    like $@, qr/not registered/, '実行時にやっとエラー';
};

new は何の不満もなく成功する。依存が3つ足りないのに。壊れるのは process を呼んだ瞬間だ。つまり本番のリクエストが来るまで気づかない

依存の糸を可視化する

ロックさんがマーカーのキャップを閉じた。

「解法は単純だ。隠された依存を、光の下に引きずり出す」

ロックさんが私のエディタに手を伸ばし——私が操作するのを待たずに——OrderProcessor.pm を書き換え始めた。

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

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

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

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

sub process ($self, $order) {
    $self->validator->validate($order);
    my $total = $self->pricer->calculate($order);
    $self->notifier->send_confirmation($order, $total);

    return { order_id => $order->{id}, total => $total, status => 'processed' };
}

1;

required にするだけですか。それだけで、テストの不安定さが消えるんですか」

「ワトソン君、required は手段だ。本質はそこではない。依存をコンストラクタに宣言することで、2つの変化が起きる」

ロックさんが指を立てた。

「第一に、このクラスが何に依存しているかが、コードを読み込まなくても分かるようになる。コンストラクタの引数リストが、依頼人名簿だ」

もう一本、指を立てた。

「第二に、各テストが自分専用の依存を持つ。グローバルな共有状態がなくなる。テストAの証拠品がテストBに紛れ込むことは、構造的に起こりえなくなる」

ロックさんがテストコードを書き換えた。

 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
# === テストA: 正常系 ===
subtest 'テストA: 正常な注文処理' => sub {
    my $notifier = NotificationService->new;
    my $processor = OrderProcessor->new(
        validator => OrderValidator->new,
        pricer    => PricingService->new(tax_rate => 0.1),
        notifier  => $notifier,
    );
    my $result = $processor->process($order);

    is $result->{status}, 'processed', '注文が処理された';
    is $result->{total}, 2200, '税込金額が正しい';
    is scalar(@{$notifier->sent_messages}), 1, '1件送信';
};

# === テストB: 税率変更 — 状態汚染なし ===
subtest 'テストB: テスト間の独立性(税率8%)' => sub {
    my $notifier = NotificationService->new;
    my $processor = OrderProcessor->new(
        validator => OrderValidator->new,
        pricer    => PricingService->new(tax_rate => 0.08),
        notifier  => $notifier,
    );
    my $result = $processor->process($order);

    is $result->{total}, 2160, '税率8%で計算される';
    is scalar(@{$notifier->sent_messages}), 1, '今回のテストで1件だけ送信';
};

subtest が自分の new で依存を組み立てている。ServiceLocator は呼ばれていない。reset も不要になった。

「要するに、依存の受け渡し経路を、グローバルな帳簿からコンストラクタの引数に変えるということですか」

「正確だ。よくまとめたね、ワトソン君」

テストCに相当する確認もした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# === テストC: 依存の不足はコンストラクタで即検知 ===
subtest 'テストC: 依存の不足はnew()で即検知' => sub {
    eval {
        OrderProcessor->new(
            pricer => PricingService->new,
            # validator と notifier を渡し忘れ
        );
    };
    like $@, qr/required/, 'new() の時点でエラー';
};

new が失敗する。process を呼ぶ前に、構築の時点で依存不足が判明する。

「Before では new が素通りして process で爆発していました。After では new の時点で止まる。検知のタイミングが実行時から構築時に前倒しされた」

「そうだ。Service Locator は、壊れるべきところで壊れないシステムだった。そして壊れるべきところで壊れないシステムを、人は安全だと錯覚する」

ここで一つ、引っかかることがあった。

「依存が5つ、10個と増えたら、コンストラクタが肥大化しませんか」

ロックさんが首を傾げた。

「そのときは別の事件だ。クラスの責務が多すぎるという。コンストラクタの肥大化は、責務過多の正直な告白だよ。Service Locator は、その告白を握りつぶしていたのだ」

「つまり、resolve なら何個追加してもインターフェースは変わらないから、責務の膨張に気づけない。コンストラクタなら、引数が増えるたびに『多すぎる』と目に見える形で教えてくれる、ということですか」

「その通り。仲介人が告白を握りつぶすか、コンストラクタが正直に告白するか。違いはそれだけだ」

「もう一つ質問があります。DIコンテナ——Bread::Board のようなものを使えば、配線の手間をもっとスマートに管理できるのでは?」

「コンテナ自体は無罪だ。ただし、使い方を誤れば有罪になる」

ロックさんがホワイトボードに図を描いた。

	graph LR
    subgraph "❌ Service Locator化"
        A1[クラスA] -->|"container->get()"| C1[DIコンテナ]
        B1[クラスB] -->|"container->get()"| C1
        D1[クラスC] -->|"container->get()"| C1
    end
    subgraph "✅ Composition Root"
        CR[エントリポイント] -->|"container->get()"| C2[DIコンテナ]
        CR -->|完成品を渡す| A2[クラスA]
        CR -->|完成品を渡す| B2[クラスB]
        CR -->|完成品を渡す| D2[クラスC]
    end

「コンテナの get() をアプリケーションコードの各所で呼べば、それは Service Locator の別名だ。コンテナを使うなら Composition Root ——アプリケーションの入口、1箇所だけ。そこで全ての配線を済ませ、あとは完成品を渡す」

1
2
3
4
5
6
7
8
9
# Composition Root(アプリケーションのエントリポイント)
my $processor = OrderProcessor->new(
    validator => OrderValidator->new,
    pricer    => PricingService->new(tax_rate => 0.1),
    notifier  => NotificationService->new,
);

# 以降は $processor を使うだけ。resolve 不要
$processor->process($order);

「この形なら、DIコンテナを使っても使わなくても同じ構造になる。依存は new に明示され、グローバル状態はない。テストでは各テストが自分の依存を組み立てる。それだけだ」

静かなグリーン

After のテストを実行した。

1
2
3
4
ok 1 - After: テストA - 正常な注文処理
ok 2 - After: テストB - テスト間の独立性(税率8%)
ok 3 - After: テストC - 依存の不足はnew()で即検知
1..3

全件パス。赤いログが一つもない。

「CIで100回実行しても同じ結果になる、ということですか」

「そうだ。各テストが自分の依存を保持している限り、実行順序は関係ない。決定的なテストだ」

新しい依存を追加した場合の影響を考えた。コンストラクタに引数を追加すれば、既存の呼び出し元で required のエラーが出る。変更の影響が、構築時に分かる。

「Service Locator だと、新しい依存を resolve() で追加しても呼び出し元は何も変わらないんですよね。便利に見えますが——」

「便利に見えて、変更の影響を隠している。壊れるべきところが壊れない。それは安全ではない。沈黙する警報器だよ」

「全サービスを一度にリファクタリングするのは現実的ではないのですが」

「一つずつだ。最も不安定なテストを持つクラスから始めろ。CIのログが優先順位を教えてくれる」

一人の会議室

ロックさんが帰り支度を始めた。コートを羽織り、鞄にUSB顕微鏡を戻す。会議室を出る前に、プロジェクタのVGAポートに名残惜しそうな視線を送っていた。

「報酬の件ですが——」

「金は要らない。CIの安定率を90%にしたら教えてくれ。それが報酬だ」

「……変わったことを言う人ですね」

「変わっているのは私ではない。便利だという理由で依存を隠すシステムの方が、よほど変わっている」

ロックさんが会議室を出て行った。ドアが閉まり、空調の音だけが残った。

私はCIのログを閉じた。エディタを開いた。

失敗率の高いテストファイルのリストは、さっき作ったばかりだ。一番上のファイルを開く。ServiceLocator->resolve が3箇所。まずここからだ。

has を3行書き、required => 1 を付ける。process メソッドの中の resolve$self-> に置き換える。テストファイルを開き、ServiceLocator->register を消して、new の引数に差し替える。

小さな変更だ。ただし、変更の意味は小さくない。

依存は、見えているうちは管理できる。見えなくなった瞬間に、管理しているつもりの幻想が始まる。ちょうど、あのレジストリのように。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
Service Locator(グローバルレジストリ)コンストラクタインジェクション依存関係がコンストラクタで明示される
暗黙的依存(resolve で動的取得)has + required => 1依存不足が構築時に検知される
テスト間の状態汚染(共有 %services各テストが独自の依存を保持テスト実行順序に依存しない決定的テスト
沈黙する警報器(依存追加が非破壊的)コンストラクタ引数の増加変更の影響が構築時に明示される

推理のステップ

  1. Service Locator の特定: グローバルな my %services を持つモジュールが、全サービスの依存解決に使われていないか確認する
  2. 暗黙的依存の洗い出し: resolve() を呼んでいるクラスのメソッドを調べ、本当に必要な依存のリストを作る
  3. コンストラクタへの移行: 各依存を has 属性として宣言し、required => 1 を付ける。isa による型制約も追加する
  4. resolve の置き換え: メソッド内の ServiceLocator->resolve('xxx')$self->xxx に置き換える
  5. テストの書き換え: ServiceLocator->registerreset を削除し、各テストで new に依存を直接渡す
  6. Composition Root の設定: アプリケーションのエントリポイント1箇所で、全依存を組み立てて渡す

ロックより

便利な仲介人は、最初は歓迎される。誰でも預けられ、誰でも引き出せる。チーム全員がそれを「共通基盤」と呼び、設計の一部だと信じている。

だがその利便性の裏側で、依存関係は帳簿の奥に沈んでいく。テストは夜ごとに不安定になり、新しい機能を追加するたびに、どこかで見覚えのないエラーが顔を出す。

便利な仲介人を追放せよ。コンストラクタに真実を語らせるのだ。コードが正直であること——それが最も確実な安定性である。

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