Anticorruption Layer(腐敗防止層)とは、他のシステムを利用する下流が自分の責任で置く隔離層で、相手のモデルを自分のドメインモデルの語へ翻訳します。Evans の DDD Reference は「下流のクライアントとして、上流のシステムの機能を自分自身のドメインモデルの言葉で自分のシステムへ提供する隔離層を作れ」と述べ、ACL は相手の既存のインターフェース越しに通信するため、相手のシステムへの変更をほとんど、あるいは全く必要としないとしています。

ACL の内部では、2 つのモデルの間で必要に応じて一方向または双方向の翻訳を行います。Anti-Corruption Layer と綴る文献もあり、ACL と略します。

ここでの「語」とは、項目名だけでなく、その仕事の中で通じる言葉の集まりを指します。販売が使う語は「販売できる数」や「販売停止」で、在庫システムが使う語は QTY_ON_HANDSTS です。同じ在庫を指していても、名前も、数え方も、状態の表し方も違います。

上流と下流は、開発するチームの間の関係を表す語です。DDD Reference は、上流の行動が下流のプロジェクトの成否に影響する一方、下流の行動は上流のプロジェクトに大きくは影響しない関係だと定義しています。上流はモデルを提供する立場になる事が多く、ACL は下流が自分の判断で置きます。相手が Bounded Context(1 つのモデルとその語が一貫して通用する範囲)として設計されているとは限らず、その区切りを持たないレガシーシステムも上流になります。

ここで扱うのは、下流が自分のモデルを守る手段としての ACL です。上流が多数の相手へ向けてプロトコルを公開する Open-host Service は、末尾で比較だけします。

販売コンテキストが外部の在庫システムを利用する場面を例に、ACL の位置と語の切り替わりを以下に示します。

  flowchart LR
    subgraph DOWN["下流:販売コンテキスト"]
        M["注文の成立を判断するモデル<br/>語:販売できる数・販売停止"]
        A["Anticorruption Layer"]
    end
    subgraph UP["上流:外部の在庫システム"]
        L["語:QTY_ON_HAND<br/>QTY_ALLOC・STS"]
    end
    M -- "自分の語で問い合わせる" --> A
    A -- "相手の既存の API を呼ぶ" --> L
    L -- "相手の語で返す" --> A
    A -- "自分の語へ翻訳して返す" --> M

上図で相手の語が現れるのは、ACL と在庫システムを結ぶ 2 本の矢印だけです。販売コンテキストのモデルは自分の語だけで問い合わせと応答を受け取り、正常に終わった問い合わせで QTY_ON_HAND のような相手の項目名を目にする事はありません。ACL の中で語が切り替わる事がこのパターンの中心で、下流のモデルは上流のモデルから隔離されます。


なぜ Anticorruption Layer が必要なのか

外部のシステムを呼ぶだけなら、相手が配布しているクライアントをそのまま使う方法が最も短く済みます。翻訳を書く手間も、間に挟まる処理も増えません。問題は、相手の応答の形が下流のコードのどこまで広がるかです。

例に使う在庫システムの応答には、ITEM_CD(商品コード)、QTY_ON_HAND(手持ちの数量)、QTY_ALLOC(引き当て済みの数量)、STS(商品の状態コード)が並んでいます。引き当て済みというのは、既に別の注文へ割り当ててあり、手元にあっても他へは売れない数量です。この応答の型を下流のコードへそのまま渡した場合を以下に示します。

  flowchart TB
    U["ACL が無い場合<br/>在庫システムの応答をそのまま渡す<br/>ITEM_CD・QTY_ON_HAND<br/>QTY_ALLOC・STS"]
    U --> A["注文の受付<br/>手持ちから引き当て済みを引いて判定"]
    U --> B["商品の一覧<br/>手持ちの数量を残数として表示"]
    U --> C["再入荷の通知<br/>状態コードで対象を絞り込む"]

上図の 3 箇所は、どれも在庫システムの語で書かれています。「販売できる数」という販売の語はどこにも現れず、代わりに手持ちから引き当て済みを引く計算が 3 箇所へ複製されます。状態コードの解釈も同じで、どの値が販売停止を意味するのかを 3 箇所が個別に知っている状態になります。

Evans は、上流のシステムとの大きなインターフェースが下流のモデルの意図をやがて完全に圧倒し、その場しのぎの形で相手のモデルに似せてモデルを変更させてしまうと書いています。同書は続けて、レガシーシステムのモデルは概して弱く、明確に設計されている例外的な場合でさえ今のプロジェクトの必要に合うとは限らないため、上流のモデルへ従う事が現実的でなくなるとしています。それでも統合は下流にとって価値を持つ事があり、必要とされる事すらあります。統合をやめられないなら、翻訳を置く選択が残ります。

翻訳が複雑になるかどうかは、相手との関係で決まります。DDD Reference は、設計の良い Bounded Context どうしを協力的なチームの間で繋ぐなら翻訳層は単純にも優雅にもなり得るとした上で、Shared Kernel・Partnership・Customer/Supplier を成立させるだけの統制や意思疎通が無い時に翻訳が複雑になり、より防御的な調子を帯びると書いています。統制や意思疎通が弱い関係では、ACL はより防御的な役割を持ちます。

翻訳を作らない選択も残っています。DDD Reference の Conformist は、上流チームのモデルへ盲目的に従う事で Bounded Context の間の翻訳の複雑さをなくす選択です。同書は、この選択が下流の設計者のやり方を窮屈にし、おそらく理想的なモデルにはならないとした上で、統合を著しく単純にするとしています。

同書が Conformist の前提に置いているのは、上流に下流の要求へ応える動機が無く、下流のチームが無力な状況です。窮屈さを受け入れられるなら Conformist で足り、自分のモデルと語を保ちたいなら ACL を置きます。


ACL の中に何を置くか

ACL の中身は、ここでは 3 つの責任に分けて見ます。下流が宣言したインターフェースを満たす Checker、2 つのモデルを翻訳する部分、上流の既存の API(Application Programming Interface)をそのまま呼ぶクライアントです。この 3 分割は説明のための整理で、原典が規定している構造ではありません。

Microsoft の Azure Architecture Center は、ACL を「同じ意味論を共有しない異なるサブシステムの間に facade または adapter の層を実装する」パターンとして説明しています。ここで言う意味論は、同じ名前や値が何を指すのかについて、それぞれのシステムが持っている取り決めです。

上に挙げた順で、3 つと下流のモデルの関係を以下に示します。図の矢印は、呼び出しの流れではなく、コードがどちらを参照するかの向きです。

  flowchart TB
    subgraph S["販売コンテキスト(下流)"]
        I["StockChecker<br/>自分の語で宣言したインターフェース"]
        M["Availability<br/>自分の語のモデル"]
    end
    subgraph ACL["Anticorruption Layer"]
        C["Checker<br/>インターフェースの実装"]
        T["翻訳<br/>項目名・型・単位・状態コード"]
        K["上流のクライアント<br/>既存の API をそのまま呼ぶ"]
    end
    U["外部の在庫システム(上流)"]
    C -. "満たす" .-> I
    C --> K --> U
    C --> T --> M

上図で在庫システムを知っているのは、ACL の枠の中にある 3 つのノードだけです。販売コンテキストから ACL を参照する矢印は 1 本も無く、CheckerStockChecker を満たす点線だけが 2 つを結んでいます。参照の向きが保てているかどうかは、下流のパッケージが上流の型を import しているかで、ある程度まで確認できます。ただし、import が 1 本も無くても語は入り込みます。次の Availability は在庫システムのパッケージを参照していません。

// Package sales の中に、在庫システムの語が残った例です。
package sales

// Availability は在庫の状態を持ちます。
type Availability struct {
	SKU    string
	OnHand int    // QTY_ON_HAND をそのまま持つ
	Status string // STS の値。"1" が通常、"9" が販売停止
}

Status に入っているのは在庫システムの状態コードで、"9" が販売停止だという知識は販売コンテキストの各所へ散ります。OnHand も同じで、引き当て済みを引く計算が呼び出す場所に残ります。侵入は型ではなく値と概念で起きるため、import の一覧だけでなくモデルの定義も読む事になります。


下流が語を決める

翻訳の行き先になるモデルは、上流を見ずに決めます。注文の成立を判断するために販売コンテキストが知りたいのは、今すぐ売れる数と、その商品の販売を止めているかどうかの 2 つです。

// Package sales は販売コンテキストで、注文が成立するまでを扱います。
package sales

import "context"

// Availability は、在庫について販売コンテキストが知りたい事の全てです。
type Availability struct {
	SKU       string // Stock Keeping Unit。在庫を数える単位に付けた商品の識別子
	Sellable  int    // 今すぐ売れる数
	Suspended bool   // 販売を止めている商品かどうか
}

// StockChecker は在庫の問い合わせ先です。実装は販売コンテキストの外に置きます。
type StockChecker interface {
	Check(ctx context.Context, sku string) (Availability, error)
}

StockCheckersales が宣言し、実装を外へ置く形は依存性逆転です。呼び出す販売コンテキストがインターフェースを宣言し、呼び出される翻訳がそれを満たすため、参照は翻訳から販売コンテキストへ向きます。sales は在庫システムのパッケージを import せず、後から相手の型へ手が伸びる経路も作りにくくなります。言語が禁じるわけではないので、確実に止めたいなら import を制限する lint を入れる事になります。同じ向きの変え方は Repository でも使います。

Repository も外部との境界に見えるのではないか、と考える方がいるかもしれません。Repository が隠すのは自分の Aggregate(まとめて一貫性を保つオブジェクトの集まり)の永続化で、扱うモデルは最初から自分のものです。ACL は相手のモデルを入口で自分の語へ変える所に意味があり、守っている対象が違います。実装が似るのは、どちらも下流がインターフェースを宣言するためです。


翻訳で何をするか

翻訳は、項目名の写し替えだけでは終わりません。例に使った在庫システムの応答について、下流での扱いを以下に示します。

上流の項目型と値下流での扱い
ITEM_CD商品コードの文字列要求した SKU と突き合わせる。採番の体系が違う場合は対応付けも ACL で行う
QTY_ON_HAND数量を表す文字列数値へ変換し、引き当て済みを引いて Sellable を求める
QTY_ALLOC数量を表す文字列Sellable の計算にだけ使い、単独では下流へ渡さない
STS状態コードの文字列1 は通常、9Suspended。未知の値はエラーにする

翻訳を Go で書くと次のようになります。

// Package inventoryacl は、外部の在庫システムと販売コンテキストの間の翻訳だけを担当します。
package inventoryacl

import (
	"context"
	"fmt"
	"strconv"

	"example.com/shop/sales"
)

// StockLevelResponse は在庫システムが返す構造です。項目名も型も相手の都合で決まっています。
type StockLevelResponse struct {
	ItemCD    string
	QtyOnHand string
	QtyAlloc  string
	Sts       string
}

// legacyClient は在庫システムの既存の API を呼びます。実装は省略します。
type legacyClient interface {
	GetStockLevel(ctx context.Context, itemCode string) (StockLevelResponse, error)
}

// Checker は sales.StockChecker を満たします。
type Checker struct {
	client legacyClient
}

// NewChecker は Checker を作ります。
func NewChecker(c legacyClient) *Checker {
	return &Checker{client: c}
}

// Check は在庫システムへ問い合わせ、応答を販売コンテキストの語へ翻訳します。
// エラーを返す時の sales.Availability は意味を持ちません。
func (c *Checker) Check(ctx context.Context, sku string) (sales.Availability, error) {
	res, err := c.client.GetStockLevel(ctx, sku)
	if err != nil {
		return sales.Availability{}, fmt.Errorf("在庫システムへの問い合わせに失敗しました: %w", err)
	}
	if res.ItemCD != sku {
		return sales.Availability{}, fmt.Errorf("要求した商品と違う応答が返りました: %q", res.ItemCD)
	}

	switch res.Sts {
	case "1", "9":
		// 通常と販売停止のどちらも、数量は同じ形で翻訳します。
	default:
		return sales.Availability{}, fmt.Errorf("STS が未知の値です: %q", res.Sts)
	}

	onHand, err := strconv.Atoi(res.QtyOnHand)
	if err != nil {
		return sales.Availability{}, fmt.Errorf("QTY_ON_HAND を数値として読めません: %q: %w", res.QtyOnHand, err)
	}
	alloc, err := strconv.Atoi(res.QtyAlloc)
	if err != nil {
		return sales.Availability{}, fmt.Errorf("QTY_ALLOC を数値として読めません: %q: %w", res.QtyAlloc, err)
	}
	if onHand < 0 || alloc < 0 {
		return sales.Availability{}, fmt.Errorf("数量が負の値です: QTY_ON_HAND=%d QTY_ALLOC=%d", onHand, alloc)
	}

	sellable := onHand - alloc
	if sellable < 0 {
		// 引き当てが手持ちを超える状態は在庫システムでは起こり得ます。
		// 販売コンテキストの Sellable は売れる数なので、負の数を渡しません。
		sellable = 0
	}
	return sales.Availability{
		SKU:       sku,
		Sellable:  sellable,
		Suspended: res.Sts == "9",
	}, nil
}

// 静的な検査。シグネチャがずれた時点でコンパイルを止めます。
var _ sales.StockChecker = (*Checker)(nil)

未知の状態コードと負の数量は、下流へ渡さずにエラーとして返します。翻訳を通り抜けた Availability が既知の状態だけを表すのは、ACL が上流の異常をここで止めているためです。

Sellable を 0 にする処理には、意味の違う 2 つの考え方があります。上のコードが実装しているのは片方だけで、QTY_ON_HAND から QTY_ALLOC を引いた値が負になった時の丸めです。上流は引き当てが手持ちを超えた状態を負の数で表せる一方、下流の Sellable は今すぐ売れる数として非負に定義してあり、0 が表せる最小の値になります。上流と下流で値域と意味が違う所を吸収する翻訳なので、ACL の中に置けます。

もう片方は、Suspended が true だから Sellable も 0 にする、という書き換えです。上のコードは行っていません。停止中の商品を何個持っているかと、それを売ってよいかは別の事実で、2 つを結び付けるのは販売コンテキスト固有の業務判断です。停止中の商品でも数量をそのまま翻訳しているのは、この判断を ACL の外へ残すためです。

この例では商品コードの体系が上流と一致している前提で、sku をそのまま GetStockLevel へ渡しています。

1 回の問い合わせで語がどこで切り替わるのかを以下に示します。

  sequenceDiagram
    participant S as 販売コンテキスト
    participant A as Anticorruption Layer
    participant U as 在庫システム
    S->>A: Check(sku)
    Note over S,A: 販売の語だけ
    A->>U: GetStockLevel(ITEM_CD)
    U-->>A: QTY_ON_HAND<br/>QTY_ALLOC・STS
    Note over A,U: 在庫システムの語だけ
    A->>A: 状態コードを判定<br/>数量を数値へ変換
    A-->>S: Availability

上図の中央の 2 本の矢印だけが在庫システムの語で、その外側は販売の語です。ACL は両方の語を知っている唯一の場所で、翻訳に失敗した時に下流へエラーを返すのも ACL の責任です。相手の応答をモデルとしてそのまま下流へ渡す経路は作りません。翻訳に失敗した理由を書いたエラーには相手の項目名が残るので、そこを完全に断ちたいなら、下流で定義したエラーの型へ包み直す事になります。

ここまでの例は読み取りだけを扱いました。上流へ書き込む経路でも、同じ ACL を通します。注文の確定を在庫システムへ伝えるなら、販売の語で受け取った要求を ACL の中で ITEM_CD と数量へ翻訳してから相手の API を呼びます。DDD Reference が「一方向または双方向」と書いているのは、この 2 つの向きです。

注意点として、ACL には下流に固有の業務判断を置きません。意味を伴う変換までが翻訳で、ACL の仕事です。STS9 の商品を販売コンテキストでは Suspended と読む事も、QTY_ON_HAND から QTY_ALLOC を引いた値を Sellable と読む事も、2 つのモデルの間で意味を移しています。境界の外にあるのは、翻訳した結果を使った判断です。

Suspended が true なら注文を断る」「Sellable が 0 なら受け付けない」は販売コンテキストの規則で、ACL の仕事は Availability を渡す所で終わります。Microsoft のドキュメントも、ACL が適さない例として意味論の差が小さい状況を挙げた上で、その場合には ACL を翻訳の処理に集中させ、業務規則や、複数のシステムの呼び出し順序を組み立てるオーケストレーションを ACL の中へ置かないよう求めています。

DTO(Data Transfer Object、データを受け渡すためのオブジェクト)の変換と何が違うのか、と考える方がいるかもしれません。2 つは同じ種類の概念ではありません。DTO の変換はデータの表現を移す実装の手段で、ACL は上流のモデルが下流のドメインモデルへ入り込む事を防ぐ境界です。

上のコードも StockLevelResponse から Availability への変換に DTO の変換を使っており、手段として ACL の中に現れます。逆は成り立たず、変換のコードを書いた事が ACL を置いた事にはなりません。境界としてどこに置き、何を守っているのかで決まります。


上流が変わった時に止まる場所

ACL があると、上流の変更が下流へ届く前に 1 箇所で判定されます。在庫システムが状態コードへ新しい値を足した場合の流れを以下に示します。

  flowchart TB
    U["在庫システムの応答<br/>STS に未知の値が増えた"] --> A{"翻訳できるか"}
    A -- "できる" --> M["自分の語のモデルへ渡す"]
    A -- "できない" --> E["エラーとして扱い<br/>下流のモデルへ渡さない"]

上図の菱形が ACL の中の判定です。未知の値を既知の値として扱うと、下流は商品の状態を誤って解釈します。新しい値が販売停止を意味していれば、売れる商品として扱う誤りにもなり得ます。エラーで止めれば意味の確定していない状態は下流のモデルへ届かず、直す場所も翻訳の 1 箇所に限られます。ACL が無い場合、同じ判定は状態コードを読んでいる全ての箇所で必要になります。

ACL を置く経路は、同期の呼び出しに限りません。上流が発行したイベントを受け取る非同期の経路にも同じ ACL を置き、相手の形式のメッセージを自分の Domain Event(ドメインで既に起きた出来事を表すオブジェクト)へ翻訳してから下流のハンドラへ渡します。守る対象が下流のモデルである点は変わりません。


ACL を置く場所

Microsoft のドキュメントは、ACL をアプリケーションの中の部品として実装する方法と、独立したサービスとして実装する方法の 2 つを挙げています。どちらを選んでも翻訳の内容は変わらず、変わるのは運用の単位です。

同じプロセスの中のパッケージ独立したサービス
呼び出しの遅延関数呼び出しの分だけネットワークの往復が 1 段増える
配布下流と同時に更新する下流と別に更新できる
同じ翻訳を複数のプロセスで使う時各プロセスへ取り込み、更新のたびに配布し直す1 箇所を更新すれば全てのプロセスへ届く
運用下流に含まれる監視・リリース・設定の対象が 1 つ増える
言語下流と同じ上流のクライアントライブラリに合わせて選べる

下流のアプリケーションが 1 つで、上流のクライアントを同じ言語で書けるなら、パッケージとして置く方が扱いやすいと考えられます。同じ下流コンテキストに属する複数のプロセスが同じ翻訳を使う場合や、上流のクライアントが特定の言語でしか提供されておらず運用の境界を分けたい場合に、独立したサービスを選ぶ理由が出てきます。

注意点として、別の Bounded Context が同じ上流を使っていても、1 つの ACL を共用できるとは限りません。翻訳の行き先は下流ごとのモデルで、必要な語も、落としてよい情報も違います。2 つの下流の要求を 1 つの ACL へ集めると、どちらの語でもない共通のモデルがその中に育ち、下流を守る境界ではなく統合用の共有サービスに変わります。

段階的な移行のために ACL を置く場合は、移行が終わった後に残すのか撤去するのかを先に決めます。同じドキュメントも、移行戦略の一部として ACL を使う時に、恒久的なものか、レガシーの機能を全て移した後に廃止するものかを検討するよう挙げています。撤去する前提の ACL と、外部システムとの境界に置き続ける ACL では、掛けてよいコストが違います。


利点

  • 上流の変更を下流の既存の語へ写せる限り、直す箇所が翻訳の 1 箇所に収まる
  • 下流のモデルを自分の語だけで書け、相手の項目名や状態コードが混ざらない
  • 上流へ変更を求めずに統合でき、相手に手を入れられない関係でも使える
  • 上流を差し替える時、新しい相手が同じ語を埋められるなら翻訳だけを差し替えられる
  • 上流の異常値を ACL で止められ、下流のモデルが取り得る状態を絞れる

欠点

以下は、下流のモデルを相手の語から守る事を優先した結果として現れる制約です。

  • 独立したサービスとして置くと、呼び出しが 1 段増え、往復のたびに遅延が加わる
  • 統合する相手の数だけ、上流と下流の両方の語を知る翻訳のコードを保守する事になる
  • 翻訳で落とした情報が後から必要になった時、ACL とモデルの定義の両方を見直す
  • 2 つのモデルがずれている箇所の扱いを下流が決め、ACL の中の規則として書く事になる
  • 独立したサービスとして置くと、監視・リリース・設定の対象が 1 つ増える

3 番目は、例に使った QTY_ALLOC がそのまま当てはまります。引き当て済みの数量を販売コンテキストが直接見たくなった時点で、Availability に何を持たせるのかを決め直す事になります。4 番目については、翻訳のコードが業務の判断を持ち始める徴候でもあるので、その規則が本当に翻訳なのかを確かめる価値があります。


適さないケース

  • 上流と下流でモデルの語がほぼ一致していて、翻訳が項目名の写し替えだけになる統合
  • モデルの違いが業務上の必要から来ておらず、上流の設計の歪みが原因で、しかも上流を直せる関係
  • 1 回限りの移行スクリプトや、次の作り直しまでの繋ぎとして書くコード
  • 許容できる遅延が小さく、ACL を独立したサービスとして挟む余裕が無い経路

1 番目は Microsoft のドキュメントが挙げている条件で、意味論の差が小さいなら ACL は翻訳の分だけコストを増やします。2 番目は、上流と下流でモデルが違う事そのものを指してはいません。Bounded Context が違えばモデルが違うのは正常で、同じ組織や同じチームが両方を管理しているというだけの理由でモデルを 1 つへ統合する必要はありません。意味の違いを保ったまま統合する必要がある時に、境界で翻訳する選択肢が出てきます。

2 番目に挙げているのは、モデルの違いが業務から来ておらず、上流の設計の歪みが原因になっている場合です。ACL を足しても歪みは上流に残るので、直せるなら直す方が根本的な解決になります。ただし、上流に別のチームが居て変更の合意に時間が掛かるなら、下流が自分の判断だけで置ける ACL の方が早く進むと考えられます。


Conformist・Open-host Service との使い分け

DDD Reference は、Bounded Context どうしの関係を表すパターンを並べています。その中で、上流と下流の境界でモデルをどう扱うかを決めるものが 3 つあります。

Anticorruption LayerConformistOpen-host Service
選ぶ立場下流下流上流
何をするか下流が自分のモデルへ翻訳する下流が上流のモデルへ従う上流が統合用の公開プロトコルを提供する
下流のモデル自分の語を保つ上流のモデルに合わせるプロトコルへ従うか、さらに翻訳するかを下流が選ぶ
掛かるコスト翻訳の実装と保守設計の自由とモデルの質プロトコルの設計と維持
選ぶ理由下流が自分のモデルと語を保ったまま統合したい下流が上流のモデルへ従う事を受け入れられる上流が複数の利用者へ安定した統合用のプロトコルを維持する価値がある

3 つは排他ではありません。DDD Reference は Open-host Service の説明で、公開したプロトコルの利用者のうち、ある者は Conformist になり、ある者は ACL を作るだろうと書いています。上流がプロトコルを整えても、下流が自分の語を守る必要があるかどうかは下流ごとに違います。

名前が似た別の概念もあります。書籍『オブジェクト指向における再利用のためのデザインパターン』の Adapter は、既存のクラスのインターフェースを呼び出す方が期待する形へ合わせるパターンで、対象はインターフェースの形です。Microsoft が ACL を「facade または adapter の層」と呼ぶ時の adapter は、この意味です。

AdapterAmbassador はコンテナのパターンで、アプリケーションと同じ実行単位へ補助コンテナを置き、出力の形式や外部との通信を引き受けます。どれも変換を行います。ただし、ACL が守るのはドメインモデルの語で、翻訳の行き先が下流のユビキタス言語(境界の中の全員が会話・図・コードで同じ意味に使う語)である点が違います。ACL を独立したサービスとして置いた構成では、配置だけを見れば Ambassador と重なる事も起こります。判定は、翻訳の結果が誰の語になるのかで行います。