Featured image of post コード探偵ロックの事件簿【Bounded Context】管轄争いの怪〜一つの名前に三つの真実〜

コード探偵ロックの事件簿【Bounded Context】管轄争いの怪〜一つの名前に三つの真実〜

営業・配送・経理で異なる意味を持つ「顧客」を単一モデルで表現して崩壊した事件。Bounded Contextで境界を定め、Perl/Mooの名前空間分離とShared Kernelで解決する。

管轄争いの怪

技術部長の城田さんに泣きついたのが先週のことだ。

「顧客マスタの統合プロジェクトが炎上してまして」と切り出すと、城田さんは苦笑いしてスマホの画面を見せてくれた。「レガシー・コード・インベスティゲーション」。聞いたことのない名前だった。

「変な男だが腕は確かだ。依頼者をワトソン君と呼ぶらしいが、気にするな」

探偵事務所みたいな名前のサービスに頼るほど追い詰められているのか、と自分で自分に問いかけて、答えは即座に出た。——追い詰められている。

都心近郊のコワーキングスペース。城田さんが指定した場所に着くと、受付で「お連れ様は奥のホワイトボードエリアに」と案内された。奥に進むと、大きなホワイトボードの前に一人の男が立っていた。丸と線と矢印。走り書きで「Sales」「Shipping」「Billing」と書かれている。

……コワーキングスペースのホワイトボードを私物化する人間は初めて見た。

「ワトソン君、3分遅れだ。まあいい」

振り向きもせずにそう言った男は、細身のシャツに紺のベストという出で立ちで、手にはホワイトボードマーカーが3色握られていた。赤、青、緑。色分けの規則は私にはわからない。

「城田の紹介かね」

「ええ。ロックさんですね。部長から聞いてます」

挨拶より先にワトソン君呼び。噂通りだ。ただ、城田さんから事前に聞いていたおかげで驚きはない。

「聞いているなら話が早い。統合プロジェクトの炎上だろう」ロックさんはようやくこちらを向いた。「コードを見せたまえ」

ノートPCを取り出し、統合した Customer クラスのコードを画面に映した。ロックさんが画面を一瞥し、すぐにホワイトボードの図に視線を戻す。

「……ふむ。城田が心配するわけだ」

一つの名前に三つの真実

経緯を説明した。経営層から「顧客マスタを一元化しろ」と号令がかかった。営業・配送・経理の3部門がそれぞれ持っていた顧客クラスをマージして、一つの Customer に統合した。正直に言えば、統合した時点では達成感すらあった。DRY原則——重複を避けよ。三つが一つになるのは、正しいことだと思った。

結果がこれだ。

 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
package Customer;
use Moo;
use v5.36;
use Types::Standard qw(Str Int Num);

# 共通
has id   => (is => 'ro', isa => Int, required => 1);
has name => (is => 'ro', isa => Str, required => 1);

# 営業用
has email      => (is => 'rw', isa => Str, default => sub { '' });
has phone      => (is => 'rw', isa => Str, default => sub { '' });
has lead_score => (is => 'rw', isa => Int, default => sub { 0 });

# 配送用
has shipping_address     => (is => 'rw', isa => Str, default => sub { '' });
has delivery_time_slot   => (is => 'rw', isa => Str, default => sub { '' });
has special_instructions => (is => 'rw', isa => Str, default => sub { '' });

# 経理用
has billing_address => (is => 'rw', isa => Str, default => sub { '' });
has payment_terms   => (is => 'rw', isa => Str, default => sub { '' });
has credit_limit    => (is => 'rw', isa => Num, default => sub { 0 });

1;

「属性はいくつある?」ロックさんが訊いた。

「ここに見えているだけで11個。本番コードにはさらに追加されていて、全部で23になります」

「23。——ワトソン君、法律の話をしよう」

法律。技術者に法律の話をする探偵。城田さんの「変な男」という評価は控えめだったかもしれない。

「ある街に警察署、消防署、税務署がある。三つとも市民を相手にする組織だ。では、三つの組織の住民台帳を一つに統合したらどうなる?」

「……管轄が混乱する、ということですか」

「警察が把握すべき情報と、税務署が把握すべき情報は違う。前科情報が税務署から丸見えになったり、年収が警察のデータベースに入ったりする」ロックさんがディスプレイを指した。「君のコードで起きているのはまさにそれだ

心当たりがある。先月のインシデントだ。

「営業チームが email を更新したら、配送チームの通知先も変わったことがあるだろう?」

「……あります。営業が商談用の担当者メールに変更したら、配送通知が担当者宛に飛んで、お客さんの倉庫で誰も荷物を受け取らなかった」

あのときの配送チームリーダーの顔を思い出したくない。

犯人はDRYだ

予想外の名前が出た。「……DRY? 重複を避けよ、のDRYですか」

「正確には、DRYを誤解した者が犯人だ」ロックさんがホワイトボードに向き直り、赤いマーカーで Customer と書いた。その周りに email (Sales) email (Shipping) と書き添える。「DRYは『知識の重複を避けよ』という原則であって、『同じ綴りの属性を一つにまとめよ』という原則ではない。営業の email と配送の通知先メールは同姓同名の別人だ。別人を同じ部屋に住まわせたら、郵便物が混ざる。当然だろう」

同姓同名の別人。考えたこともなかった。emailemail だと、疑わなかった。

「犯人はコードのバグではない」ロックさんが赤いマーカーで Customer の周囲に大きく×を書いた。「犯人は管轄の不在——Bounded Contextのない統合モデルだ」

「Bounded Context……」

「DDDでは、モデルが正しく意味を持つ範囲を**境界付きコンテキスト(Bounded Context)**と呼ぶ。営業にとっての顧客と、配送にとっての顧客と、経理にとっての顧客は、同じ名前を持つ別の概念だ。無理に一つにしようとしたから、三人の別人が一つの身体を共有しているような状態になった」

ホワイトボードに描かれた図を見る。「Sales」「Shipping」「Billing」——最初からそう書いてあった。

「最初から見えていたんですか」

「城田から聞いた時点で、問題の構造は見えていた。三つの部門が一つの名前を巡って争っている。管轄争いだよ、ワトソン君」

境界線を引け

三人の別人に別の住所を

「まず、三人の別人にそれぞれ別の住所を与える」

ロックさんがホワイトボードの新しい区画に、3つの箱を青いマーカーで描いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package Sales::Customer;
use Moo;
use v5.36;
use Types::Standard qw(Str Int);

with 'Shared::CustomerIdentity';

has email      => (is => 'rw', isa => Str, required => 1);
has phone      => (is => 'rw', isa => Str, default => sub { '' });
has lead_score => (is => 'rw', isa => Int, default => sub { 0 });

sub qualify_lead ($self) {
    return $self->lead_score >= 50 ? 'qualified' : 'unqualified';
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package Shipping::Recipient;
use Moo;
use v5.36;
use Types::Standard qw(Str Int);

with 'Shared::CustomerIdentity';

has address      => (is => 'ro', isa => Str, required => 1);
has time_slot    => (is => 'ro', isa => Str, default => sub { '' });
has instructions => (is => 'ro', isa => Str, default => sub { '' });

sub schedule_delivery ($self) {
    return $self->time_slot
        ? sprintf('Deliver to %s during %s', $self->address, $self->time_slot)
        : sprintf('Deliver to %s (any time)', $self->address);
}

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

with 'Shared::CustomerIdentity';

has billing_address => (is => 'ro', isa => Str, required => 1);
has payment_terms   => (is => 'ro', isa => Str, default => sub { 'net30' });
has credit_limit    => (is => 'ro', isa => Num, default => sub { 0 });

sub within_credit_limit ($self, $amount) {
    return $amount <= $self->credit_limit;
}

1;

3つのクラスが画面に並んだ。Sales::CustomerShipping::RecipientBilling::Account

「配送の方は Customer ではなく Recipient なんですね」

「配送部門にとって重要なのは『誰が買ったか』ではなく『誰が受け取るか』だ。概念が違えば名前も変わる。名前が正確になることが、境界が正しく引けた証拠だ

たしかに、配送チームのSlackでは「受取人」という表現が多かった。彼らは最初から Customer とは思っていなかったのかもしれない。私が統合と称して、彼らの言葉を奪ってしまったのだ。

「分けたら重複コードだらけになりませんか? 名前やIDは全部門で持っているのに」

「名前が同じだから同じものだと思い込む。それが今回の罠だ」ロックさんが青いマーカーの蓋を閉じた。「営業の顧客と経理の顧客のどこが重複しているかね? IDと名前だけだろう。残りの20属性はまったく別の情報だ。——共通部分は後で扱う。まず境界を引くことが先だ」

共有カーネルという最小限の合意

「共通部分は本当にゼロでいいんですか? IDの引き回しだけでも面倒ですが」

「完全にゼロとは言っていない」

ロックさんが緑のマーカーを手に取った。3つの箱の中央に、小さな丸を描く。

1
2
3
4
5
6
7
8
9
package Shared::CustomerIdentity;
use Moo::Role;
use v5.36;
use Types::Standard qw(Int Str);

has customer_id => (is => 'ro', isa => Int, required => 1);
has name        => (is => 'ro', isa => Str, required => 1);

1;

「これをDDDでは**Shared Kernel(共有カーネル)**と呼ぶ。全コンテキストが共有する最小限のモデルだ。customer_idname だけ。Perlでは Moo::Role で実装すると、各クラスが with で取り込める」

各クラスの先頭にある with 'Shared::CustomerIdentity' の一行を見返す。なるほど、これが共有部分か。

「共有する範囲は小さければ小さいほどいい」ロックさんが緑の丸を指した。「ここを変更するには全チームの合意がいる。共有が増えるほど、合意のコストが増え、結局は巨大モデルの二の舞になる」

「つまり、共有部分を最小化することで、各チームの自律性を最大化する、ということですか」

「その通り。管轄を分けたのだから、管轄内のことはそれぞれの署で決めればいい。ただし——」

コンテキストマップ:管轄の境界図

ロックさんがホワイトボードの図を完成させた。

最初からあの箱と矢印を描いていたのかと思うと、城田さんが問題を伝えた時点で構造が見えていたという話は大げさではなかったのだろう。

	graph LR
    SK["Shared Kernel<br/>(CustomerIdentity:<br/>customer_id + name)"]

    subgraph Sales Context
        SC["Sales::Customer<br/>email, phone, lead_score<br/>qualify_lead()"]
    end

    subgraph Shipping Context
        SR["Shipping::Recipient<br/>address, time_slot, instructions<br/>schedule_delivery()"]
    end

    subgraph Billing Context
        BA["Billing::Account<br/>billing_address, payment_terms,<br/>credit_limit<br/>within_credit_limit()"]
    end

    SC -- "with Role" --> SK
    SR -- "with Role" --> SK
    BA -- "with Role" --> SK

「この図を**Context Map(コンテキストマップ)**と呼ぶ。管轄の境界と、管轄同士の関係を一枚の地図にしたものだ」

「同じ customer_id で紐付いている。でもオブジェクト参照はしていない」

「IDで参照する。オブジェクト参照を渡せば、また管轄を超えて手を出す誘惑が生まれる」

なるほど。考え方は理解した。技術的には筋が通っている。問題は——

「3チームをどう説得すればいいですか。技術的には理解しましたが、『統合しろ』と言った経営層と、既に統合コードを書いた3チームがいます」

「コードの問題は解決した。だが——」

「あとは私の仕事、ですか」

ロックさんが少しだけ口の端を上げた。笑った、のだと思う。

「推理は探偵の仕事だが、判決を下すのは裁判官の仕事だ。君はPLだろう。各チームに『自分たちの言葉で自分たちのモデルを持てる』と伝えたまえ。それは権限の委譲であって、分裂ではない」

「境界線の引き方を間違えたら?」

「このContext Mapだけは全員で合意すること。管轄の境界線は独断で引くものではない。だからこそ地図が要る」

Aggregateとの関係——門番と管轄

ふと、以前勉強した概念を思い出した。

「Aggregateという概念を勉強したことがあります。門番を立てるという話でした。それとの関係は?」

「いい質問だ」ロックさんがホワイトボードの余白に小さく図を描き足した。「Aggregateは管轄内の秩序だ。帳簿係が帳簿の整合性を守る仕組み。Bounded Contextは管轄そのもの——どの署がどの事件を担当するかを決める枠組みだ」

「つまり、スケールが違う」

「帳簿係を雇っても、管轄が定まっていなければ、三つの署が同じ帳簿に手を出す。それが今回の事件だよ」

事件の終わり

リファクタリングしたコードでテストを走らせた。

  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
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
use v5.36;
use Test2::V0;

use Sales::Customer;
use Shipping::Recipient;
use Billing::Account;

subtest '各コンテキストが独立したモデルを持つ' => sub {
    my $sales = Sales::Customer->new(
        customer_id => 1,
        name        => 'Tanaka Corp',
        email       => 'sales@tanaka.com',
        lead_score  => 80,
    );

    my $shipping = Shipping::Recipient->new(
        customer_id => 1,
        name        => 'Tanaka Corp',
        address     => 'Tokyo, Shibuya 1-2-3',
        time_slot   => 'AM',
    );

    my $billing = Billing::Account->new(
        customer_id     => 1,
        name            => 'Tanaka Corp',
        billing_address => 'Tokyo, Chiyoda 4-5-6',
        credit_limit    => 1_000_000,
    );

    # 同じ customer_id で参照
    is $sales->customer_id,    1, 'Sales: customer_id';
    is $shipping->customer_id, 1, 'Shipping: customer_id';
    is $billing->customer_id,  1, 'Billing: customer_id';
};

subtest '営業の操作が配送に影響しない' => sub {
    my $sales = Sales::Customer->new(
        customer_id => 1,
        name        => 'Tanaka Corp',
        email       => 'sales@tanaka.com',
    );

    my $shipping = Shipping::Recipient->new(
        customer_id => 1,
        name        => 'Tanaka Corp',
        address     => 'Tokyo, Shibuya 1-2-3',
    );

    $sales->email('new-contact@tanaka.com');
    is $sales->email, 'new-contact@tanaka.com', '営業のemail変更';
    ok !$shipping->can('email'), '配送モデルにはemail属性がない';
};

subtest '各コンテキスト固有の属性は他から見えない' => sub {
    my $sales = Sales::Customer->new(
        customer_id => 1,
        name        => 'Test',
        email       => 'test@example.com',
    );

    ok !$sales->can('address'),         'Sales には address がない';
    ok !$sales->can('billing_address'), 'Sales には billing_address がない';
    ok !$sales->can('credit_limit'),    'Sales には credit_limit がない';
};

subtest '各コンテキスト固有のメソッドが動作する' => sub {
    my $sales = Sales::Customer->new(
        customer_id => 1,
        name        => 'Test',
        email       => 'test@example.com',
        lead_score  => 80,
    );
    is $sales->qualify_lead, 'qualified', 'リードスコア80 → qualified';

    my $shipping = Shipping::Recipient->new(
        customer_id => 1,
        name        => 'Test',
        address     => 'Tokyo',
        time_slot   => 'AM',
    );
    is $shipping->schedule_delivery,
        'Deliver to Tokyo during AM',
        '配送スケジュール';

    my $billing = Billing::Account->new(
        customer_id     => 1,
        name            => 'Test',
        billing_address => 'Tokyo',
        credit_limit    => 500_000,
    );
    ok $billing->within_credit_limit(400_000),  '与信枠内';
    ok !$billing->within_credit_limit(600_000), '与信枠超過';
};

subtest 'Shared::CustomerIdentity Role の検証' => sub {
    ok Sales::Customer->does('Shared::CustomerIdentity'),
        'Sales::Customer は Shared::CustomerIdentity を消費';
    ok Shipping::Recipient->does('Shared::CustomerIdentity'),
        'Shipping::Recipient は Shared::CustomerIdentity を消費';
    ok Billing::Account->does('Shared::CustomerIdentity'),
        'Billing::Account は Shared::CustomerIdentity を消費';
};

done_testing;

全テスト通過。営業が email を変更しても配送の通知先に影響しない。先月のインシデントは、このコードでは再現しない。

「管轄が分かれたのだから、隣の署の書類を勝手に書き換えられないのは当然だよ」

ロックさんがホワイトボードマーカーのキャップを閉じた。三色のマーカーを並べて置く仕草が、事件の幕引きのように見えた。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
全部門共有の巨大CustomerモデルBounded Context(境界付きコンテキスト)各部門が独立したモデルを持ち、属性の衝突・副作用を排除
DRYの過剰適用(同名属性の無条件統合)Shared Kernel(共有カーネル)共有部分を最小限(ID+名前)に絞り、各チームの自律性を確保
コンテキスト間の境界不在Context Map(コンテキストマップ)コンテキスト間の関係と境界を図示し、チーム間の合意を可視化

推理のステップ

  1. 巨大モデルの属性を洗い出し、部門ごとに「本当に必要な属性」を特定する
  2. 「同じ名前だが別の概念」を見つける(営業の email ≠ 配送の通知先メール)
  3. 部門ごとの名前空間(Sales::, Shipping::, Billing::)を切り、独立したモデルクラスを定義する
  4. 全コンテキストが必要とする最小限の共通部分を Moo::Role(Shared Kernel)として切り出す
  5. Context Mapを描き、コンテキスト間の関係と境界を全チームで合意する
  6. 各コンテキストの操作が他に影響しないことをテストで検証する

ロックより

統合は正義だと信じる者は多い。一つにまとめれば効率的だと。だが、意味の異なるものを一つに束ねることは統合ではない。混同だ。

分けることを恐れたまえな。正しく分けることで、それぞれの言葉が初めて正確に通じるようになる。管轄争いを仲裁するのは、どちらかを吸収することではなく、境界線を引くことだ。

ワトソン君、君のプロジェクトに必要なのは統合ではない。地図だよ。


コワーキングスペースを出ると、春の夕方の風が頬に当たった。

ロックさんは先に出て行った。ホワイトボードの図を消さずに。スタッフに怒られるのは私だろうか、と余計な心配が頭をよぎった。

帰りの電車の中で、窓に映る自分の顔を見ながら考える。

統合は正義だと思っていた。一つにまとめれば効率的だと。経営層もそう言った。私もそう信じた。

でも、三人の顧客はそれぞれ別の人間だった。名前が同じだっただけだ。営業にとっての顧客は商談相手。配送にとっての顧客は荷物の受取人。経理にとっての顧客は請求先。同じ「顧客」という言葉の裏に、まったく違う概念が隠れていた。

分けることは後退ではない。分けることで初めて、それぞれの部門が自分たちの言葉で正確に仕事ができるようになる。

スマホを取り出した。Slackを開く。3チームのリーダーが集まるチャンネルに、打ち始める。

「来週、Context Mapの共有ミーティングをやりませんか」

ロックさんが言った通りだ。管轄の境界線は独断では引けない。でも、境界線があることを知っていれば、議論はできる。

ホワイトボードの図を写真に撮っておけばよかった、と気づいたのは駅に着いてからだった。

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