Featured image of post コード探偵ロックの事件簿【Anti-Corruption Layer】越境伝票の怪〜外様の略語が捜査本部を汚す夜〜

コード探偵ロックの事件簿【Anti-Corruption Layer】越境伝票の怪〜外様の略語が捜査本部を汚す夜〜

外部倉庫 API の略語と状態コードが内部ドメインを汚染する問題を、ACL と Translator で隔離する Perl/Moo 実装です

共同結合試験室の青い付箋

「小さな仕様変更です。sts の値が一つ増えるだけですので」

ベンダー担当のその説明を、私は今週だけで三度聞きました。小さい変更で済むなら、社内の pull request が七本同時に立つはずがありません。受注確認、在庫引当、結合試験の fixture、障害通知文面。どこを見ても sts という三文字が出てきました。

それが問題だと、最初は言葉にできませんでした。壊れているのは API でも、Perl のコードでもなく、もっと曖昧な何かに見えたからです。

共同結合試験室に入ったとき、ロックさんはホワイトボードの前に立っていました。壁には外部倉庫 API の仕様書、項目対応表、社内の設計資料が並んでいます。その仕様書の略語の上へ、半透明の付箋が何枚も貼られていました。

qty_avlbl の上には quantity

lst_upd の上には updated_at

sts の上には status

「ワトソン君。借り物の名前を、そのまま本庁の台帳へ貼ったね」

「その呼び名まで付いてくるんですね」

私は一度だけ眉を上げてから、壁の資料へ視線を戻しました。

「ですが、言いたいことは分かります。いま社内のコードレビューで、誰もがベンダーの略語を前提に話しています」

ロックさんは付箋を一枚ずつまっすぐに貼り直してから、ようやくこちらを見ました。

「結構。今日はバグを一つ捕まえるのではない。捜査本部に紛れ込んだ外様の言葉を追い出す」

その時点で、今日の相談が単なる修正依頼ではないことだけは分かりました。

現場検証 - 借り物の略語が内部で通用している

最初に見せたのは、いま本番系に近い検証環境で動いている在庫確認のコードでした。

Beforeコード: OrderService が外部 payload を直に読む

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

has _stock_data => (is => 'ro', isa => HashRef, default => sub { {} });

sub get_stock ($self, $product_id) {
    return $self->_stock_data->{$product_id} // {
        qty_avlbl => 0,
        lst_upd   => '20261501',
        sts       => 0,
    };
}

sub reduce_stock ($self, $params) {
    return { result => 'ok' };
}
 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 v5.36;
use Moo;
use Types::Standard qw(Object);

has warehouse_api => (is => 'ro', isa => Object, required => 1);

sub check_availability ($self, $product_id) {
    my $raw = $self->warehouse_api->get_stock($product_id);

    my $quantity  = $raw->{qty_avlbl};
    my $raw_date  = $raw->{lst_upd};
    my $available = $raw->{sts} == 1;

    my ($year, $day, $month) = $raw_date =~ /^(\d{4})(\d{2})(\d{2})$/;

    return {
        quantity   => $quantity,
        available  => $available,
        updated_at => "$year-$month-$day",
    };
}

sub place_order ($self, $product_id, $amount) {
    my $stock = $self->check_availability($product_id);
    die 'Out of stock' unless $stock->{available};
    die 'Insufficient stock' unless $stock->{quantity} >= $amount;

    return $self->warehouse_api->reduce_stock({
        prd_id  => $product_id,
        qty_rdc => $amount,
        sts     => 1,
    });
}

このコードは一見すると、ちゃんと内部形式へ変換しているように見えます。quantity という名前へ変えていますし、日付も YYYY-MM-DD に直しています。

けれども、変換の責任を持っている場所が OrderService そのものです。つまり、受注を扱うアプリケーションサービスが、外部倉庫 API の略語と日付ルールと状態コードを理解してしまっているわけです。

私はホワイトボードの前で腕を組んだまま、先に反論を出しました。

「最初は integration の近道でした。Translator を作る時間が惜しかったんです。ですが今は、ベンダー側の minor version 変更で order も inventory も test も全部が揺れています」

ロックさんは、sts に貼った付箋を指で軽く叩きました。

「minor version が犯人ではない。犯人は、外様の書式をそのまま本庁へ通したことだよ」

「境界がない、と」

「そうだ。借り物の伝票を、借り物の名前のまま台帳へ綴じた。だから伝票の書式が変わるたび、本庁の各部署まで巻き添えになる」

ここで大事なのは、field 名だけの話ではありません。sts が 1 なら在庫あり、0 なら在庫なし、という外部都合の意味まで、内部コードが前提にしています。これでは sts=2 が追加された瞬間、社内の判断ロジックまで一斉に揺れます。

Facade を一枚足しただけでは足りない理由

私はそこで、もう一つ疑問を口にしました。

「メソッド名を fetch_stock にして、窓口を一つにすれば済む話ではないですか」

ロックさんは首を横に振りました。

「窓口を一つにしても、その窓口から qty_avlbl が出てくるなら汚染は残る。Facade は入り口を整える。だが、何をどの名前で本庁へ通すかは別問題だ」

これが Anti-Corruption Layer を Adapter や Facade と言い切れない理由です。Adapter は接続の形を合わせます。Facade は複雑な subsystem への窓口を一つにします。ACL はその窓口で、外部の意味を内部の言葉へ翻訳してから通すという設計意図そのものです。

推理披露 - 関所を置き、借り物の言葉を一室で止める

ロックさんはホワイトボードの中央に一本、太い縦線を引きました。

「こちらが外。こちらが本庁だ。まず、本庁が使う台帳を先に決める」

内部モデルを先に定義する

After では、外部 API の略語から考え始めません。内部で何を扱いたいかを先に決めます。

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

has product_id => (is => 'ro', isa => Int, required => 1);
has quantity   => (is => 'ro', isa => Int, required => 1);
has status     => (is => 'ro', isa => Enum[qw(in_stock out_of_stock hold)], required => 1);
has updated_at => (is => 'ro', isa => Str, required => 1);

sub available ($self) {
    return $self->status eq 'in_stock';
}

Stockqty_avlblsts も知りません。あるのは quantitystatusupdated_at だけです。

私はそこでまた止まりました。

sts をただの数値で持ち回さないのは、意味ごと翻訳し直すためですか」

「その通り。名前だけ隠しても、意味が foreign code のままなら次も同じ場所が燃える」

この返答が、今回いちばん重要でした。ACL は field 名の付け替えではありません。意味論の翻訳です。

Translator role に inbound / outbound を閉じ込める

次に、翻訳責務を一室へ集めます。

1
2
3
4
5
package Role::WarehouseTranslation;
use v5.36;
use Moo::Role;

requires qw(to_domain_stock to_external_reduce_request);
 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
package WarehouseTranslator;
use v5.36;
use Moo;

with 'Role::WarehouseTranslation';

sub to_domain_stock ($self, $product_id, $raw) {
    my ($year, $day, $month) = $raw->{lst_upd} =~ /^(\d{4})(\d{2})(\d{2})$/;
    my %status_map = (
        1 => 'in_stock',
        0 => 'out_of_stock',
        2 => 'hold',
    );

    return Stock->new(
        product_id => $product_id,
        quantity   => $raw->{qty_avlbl},
        status     => $status_map{$raw->{sts}} // die('Unknown status'),
        updated_at => "$year-$month-$day",
    );
}

sub to_external_reduce_request ($self, $product_id, $amount) {
    return {
        prd_id  => $product_id,
        qty_rdc => $amount,
        sts     => 1,
    };
}

to_domain_stock が inbound translation、to_external_reduce_request が outbound translation です。外部から入ってくるときだけではなく、外へ出すときの payload も同じ境界で管理します。

私はまだ完全には納得していませんでした。

「Translator を置いても、依存が一段ずれるだけではないですか」

ロックさんは、さっき引いた縦線の上に小さな四角を描きました。

「その通りだ。依存は消えない。だが、今は庁舎全体が外国語で揺れている。Translator 一室だけが揺れるなら、事件の規模は変わる」

これは ACL の説明で見落としやすい点です。ACL は依存をゼロにする魔法ではありません。依存の存在を否認せず、その影響範囲を局所化します。問題が消えるのではなく、問題の性質が「庁舎全体の汚染」から「関所一室の修理」へ変わります。

Facade と Adapter を ACL の意図で束ねる

Translator だけでは、まだ外部との窓口が散らばる可能性があります。そこで WarehouseAcl を置きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package WarehouseAcl;
use v5.36;
use Moo;
use Types::Standard qw(ConsumerOf Object);

has api => (is => 'ro', isa => Object, required => 1);
has translator => (is => 'ro', isa => ConsumerOf['Role::WarehouseTranslation'], required => 1);

sub fetch_stock ($self, $product_id) {
    my $raw = $self->api->get_stock($product_id);
    return $self->translator->to_domain_stock($product_id, $raw);
}

sub reduce_stock ($self, $product_id, $amount) {
    my $payload = $self->translator->to_external_reduce_request($product_id, $amount);
    return $self->api->reduce_stock($payload);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package OrderService;
use v5.36;
use Moo;
use Types::Standard qw(Object);

has warehouse => (is => 'ro', isa => Object, required => 1);

sub check_availability ($self, $product_id) {
    return $self->warehouse->fetch_stock($product_id);
}

sub place_order ($self, $product_id, $amount) {
    my $stock = $self->check_availability($product_id);

    die 'Stock on hold' if $stock->status eq 'hold';
    die 'Out of stock' unless $stock->available;
    die 'Insufficient stock' unless $stock->quantity >= $amount;

    return $self->warehouse->reduce_stock($product_id, $amount);
}

この時点で OrderService は外部略語を一つも知りません。知っているのは StockWarehouseAcl だけです。

	flowchart LR
    API[External Warehouse API\nqty_avlbl / lst_upd / sts] --> TR[WarehouseTranslator\ninbound / outbound]
    TR --> ACL[WarehouseAcl\ninternal facade]
    ACL --> APP[OrderService\nquantity / status / updated_at]

私はようやく、Facade と Adapter と ACL の関係を言い換えられるようになりました。

「Facade は窓口、Adapter は接続具、ACL はその窓口で何をどう翻訳して通すかという設計意図なんですね」

「結構。ようやく事件簿の題名が見えてきた」

複雑さは消えない。ただし、広がらなくなる

それでも、まだ一つ引っかかりが残りました。

「双方向マッピングを増やすと、複雑になるだけではありませんか」

ロックさんは付箋を剥がして、同じ位置にもう一枚だけ重ねました。

「複雑さは増えるのではない。今もあるものを、見える場所へ集めるのだ。広がった複雑さは追跡できない。隔離された複雑さは監査できる」

この一言で、私の中の抵抗がかなり減りました。たしかに、いまの実装にも複雑さはあります。ただ、それが OrderService、テスト、運用手順、レビュー会話へ薄く広がっていて、どこが境界なのか誰にも見えていませんでした。

ACL は複雑さをなかったことにしません。複雑さを honest に局所化します。だから契約テストも書けますし、仕様変更時に「直すべき部屋」が特定できます。

解決 - テストで被害半径を測る

今回のコード例では、before と after をそれぞれテストしています。ポイントは、ACL を入れた後に「コードが動く」だけでなく、「どこまでが揺れて、どこから先は揺れないか」を確認することです。

Beforeテスト: 外部 schema 変更が application service まで漏れる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
subtest 'Before: 外部スキーマ変更が application service まで漏れる' => sub {
    package WarehouseApiV2 {
        use Moo;
        extends 'WarehouseApi';

        sub get_stock ($self, $product_id) {
            my $raw = $self->SUPER::get_stock($product_id);
            return {
                quantity_available => $raw->{qty_avlbl},
                last_updated       => $raw->{lst_upd},
                status_code        => $raw->{sts},
            };
        }
    }

    my $service = OrderService->new(warehouse_api => WarehouseApiV2->new);
    my $stock   = $service->check_availability(1);

    ok(!defined $stock->{quantity}, 'quantity が取得できない');
};

このテストが示しているのは、before の実装が外部 field 名そのものへ依存していることです。qty_avlblquantity_available に変わった瞬間、壊れるのは translator ではなく OrderService です。つまり、影響範囲が application service まで漏れています。

ここでは説明を絞るために field rename を例にしていますが、sts=2 の追加で起きていたことも本質は同じです。外部側の意味差分を application service が直接背負っているため、変更の種類を問わず内部まで揺れます。

Afterテスト: translator 差し替えで内部契約は維持できる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
subtest 'After: translator 差し替えで外部 schema 変更を吸収できる' => sub {
    my $acl = WarehouseAcl->new(
        api        => $api,
        translator => WarehouseTranslatorV2->new,
    );
    my $service = OrderService->new(warehouse => $acl);
    my $stock   = $service->check_availability(1);
    my $result  = $service->place_order(1, 2);

    is($stock->quantity, 20, 'internal quantity を維持する');
    is($stock->status, 'in_stock', 'internal status を維持する');
    is($result->{echoed}{product_identifier}, 1, 'outbound も translator が差し替える');
};

ここでは外部 API 側の schema を変えていますが、OrderService は無改修です。差し替えたのは translator だけで、inbound だけでなく outbound の payload も translator 側で吸収しています。

これが ACL の効き目です。依存そのものは残っています。しかし、その依存は translator の部屋で止まります。OrderService まで foreign vocabulary が漏れないから、内部の判断軸は変わりません。

何が「解決した」のか

私は最初、ACL を「変換コードが増える設計」として見ていました。今はそうは見えません。

解決したのは field 名ではありません。

解決したのは、外部都合が内部の会話を支配していた状態です。

qty_avlbl がどこで quantity になるのか。

sts がどこで in_stockhold になるのか。

その責任の所在が、ようやく一室へ定まりました。だから、次に仕様変更が来ても、被害半径を測れます。

ホワイトボードに残った社内語

話が終わったあと、私は試験室の壁ではなく、自分の設計資料を開きました。

Warehouse Sync Helper

その見出しを、WarehouseAcl に書き換えました。次に、チケットを一枚切ります。

「外部略語の除去。order / inventory / tests / docs から順に」

ロックさんは、もうこちらを見ていませんでした。ホワイトボードの外側、つまり外部 API 側の欄だけを残して、本庁側の欄をきれいにしていました。

「それで、どこまで直せばいいですか」

「本庁が借り物の言葉を使わなくなるところまでだよ、ワトソン君」

それは珍しく、標語というより実務指示に聞こえました。

私は壁の仕様書をそのまま残して、社内用の欄だけを写真に撮りました。必要なのは相手の仕様書を捨てることではありません。必要なのは、社内の会話まで借り物である必要はないと認めることでした。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
外部 DTO の直流しAnti-Corruption Layer外部 schema 変更の被害半径が translator に閉じる
field 名だけを application service 側で変換する設計Translator role + internal model外部語彙と意味論が domain に漏れなくなる
inbound だけ整えて outbound を放置する実装WarehouseAcl による双方向 translation入出力の境界責務が一箇所に集まる
Facade だけで十分だと思い込むことFacade + Adapter を ACL の意図で束ねる窓口の統一と semantic protection を両立できる

推理のステップ

  1. 外部 payload のどの語彙が内部へ漏れているかを洗い出す
  2. 内部で使いたいモデルと語彙を先に定義する
  3. inbound / outbound の翻訳責務を translator に集約する
  4. 外部接続の窓口を ACL facade にまとめる
  5. status、date、ID、request payload を境界で翻訳する
  6. translator 差し替えで内部契約が壊れないことをテストで確認する

ロックより

借り物の帳票を持ち込むなとは言わない。だが、そのまま本庁の台帳へ綴じてはならない。名前を確かめ、意味を確かめ、自分たちの言葉へ直してから中へ入れたまえ。

境界とは壁ではない。言葉を検査する関所だ。関所があるからこそ、次の仕様変更は庁舎全体ではなく、一室だけを揺らす事件になる。ワトソン君、事件の規模を縮めることも、立派な推理なのだよ。

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