外部APIやレガシーシステムと連携していると、最初はただのAPI呼び出しだったはずのコードが、いつの間にか妙に込み入ってくる。たとえば次のようなコードだ。
const payment = await externalPaymentApi.getPayment(paymentId);
if (payment.status === 'AUTH' && payment.processor_code !== 'TEMP_ERROR') {
await externalPaymentApi.capture(payment.id);
}
一見すると、ごく普通の外部API連携である。ところがこのコードには、外部システムの都合がそっくりそのまま入り込んでいる。AUTH という外部サービス独自のステータス。processor_code という外部決済基盤のエラーコード。外部APIのレスポンス構造に、外部システムの状態遷移。これらがアプリケーション層やドメイン層に漏れはじめると、自分たちのモデルはじわじわと外部システムに引きずられていく。
この「外部システムの都合で、自分たちのドメインモデルが歪んでいく」問題を防ぐ設計パターンがある。それがアンチコラプションレイヤーだ。
隔離と変換の層
アンチコラプションレイヤーは英語でAnti-Corruption Layer、略してACLと呼ばれる。日本語では「腐敗防止層」と訳されることもある。一言でいえば、外部システムや別の境界づけられたコンテキストのモデルを、自分たちのドメインモデルに直接持ち込ませないための層だ。隔離と変換、つまり通訳を引き受けるレイヤーである。
Microsoft Azure Architecture Center では、ACLを、同じ意味体系を共有しないサブシステム間に置くFacadeまたはAdapterの層として説明している。目的は、外部サブシステムへの依存でアプリケーションの設計が縛られないようにすることだ。
AWS Prescriptive Guidance も、ACLを、あるシステムのドメインモデルの意味を別のシステムに適した意味へ変換する媒介層として説明している。とりわけ使われるのは、モノリスからマイクロサービスへ移行する場面だ。旧システムのモデルと新サービスのモデルがずれるからである。
つまり、ACLはただの「外部APIを叩くクラス」ではない。叩くだけなら、API Clientで事足りる。ACLの本質は、外部システムの語彙、データ形式、状態、例外、業務概念を、自分たちのモデルにそのまま侵入させないことにある。言葉の通じない相手と商談するのに、通訳を挟むようなものなのだ。
「腐敗」とはなにか
「腐敗防止層」という訳語だけを見ると、コードが汚くなるのを防ぐ層のように聞こえる。むろん、それも一面ではある。ただ、DDDにおける「腐敗」はもう少し根が深い。自分たちのドメインモデルが、外部システムの都合で歪められていくことを指すのだ。
たとえば、外部の決済サービスではAUTH が「オーソリ済み」を意味しているとする。一方の自社サービスでは、「支払い可能」「売上確定可能」「注文確定済み」はそれぞれ別の業務概念かもしれない。にも拘らず、内部のコードのあちこちで次のように書きはじめると危うい。
if (payment.status === 'AUTH') {
// 注文を確定する
}
この時点で、自社の「注文を確定できるか」という業務判断が、外部の AUTH という状態値に直接依存してしまっている。外部サービスの仕様が変われば、内部のユースケースまで影響を受ける。外部の命名が自社の変数名やクラス名に忍び込み、外部の状態遷移が、いつの間にか自社の業務ルールのような顔をしはじめる。これこそが、アンチコラプションレイヤーが防ぎたい腐敗である。
Martin Fowlerの記事も、EvansがACLに込めた意図を紹介している。クライアントが自分たちのドメインモデルの用語で機能を扱えるよう、隔離層を作る、というものだ。
同じ言葉で意味が違うという罠
外部システム連携で本当に危ないのは、言葉がまったく違うケースではない。同じ言葉を使っているのに、意味が違うケースだ。誤訳は、知らない単語より知っているつもりの単語で起きるのである。
たとえば予約システムとの連携で、双方にReservation という概念があるとする。外部システムの Reservation は仮押さえのことで、キャンセル済みも決済前も、等しく Reservation と呼ばれているかもしれない。一方の自社システムでは、Reservation は決済済みの確定予約を指す。仮押さえは ReservationRequest、キャンセル後は CancelledReservation と呼び分けているかもしれない。
このとき、外部APIの Reservation をそのまま自社の Reservation として扱えば、モデルは壊れる。名前が同じだからといって、意味まで同じとは限らない。そこでACLは、外部の ExternalReservation を内部の文脈に合わせて訳し分ける。行き先は ReservationRequest、ConfirmedReservation、CancelledReservation のいずれかだ。おかげで内部のコードは自分たちのユビキタス言語で業務を扱え、外部の概念は境界の外側に留め置かれる。
API Clientとの違い
アンチコラプションレイヤーは、API Client、Adapter、Facade、Gateway、Mapper、Translatorといった言葉と混同されがちだ。それぞれの役割を分けると、次のようになる。
| 名前 | 主な役割 |
|---|---|
| API Client | HTTP通信やSDK呼び出しを隠す |
| Gateway | 外部システムへのアクセスを、自分たちの語彙で包む |
| Adapter | 期待するインターフェースに合わせる |
| Facade | 複雑な外部APIを単純な入口にまとめる |
| Mapper | データ構造を変換する |
| Translator | 外部モデルと内部モデルの意味を変換する |
| ACL | これらを組み合わせて、自分たちのドメインモデルを守る境界を作る |
API Clientは通信の詳細を隠し、Mapperはフィールドを変換し、Gatewayは外部システムへの出入口を提供する。しかし、どれか一つがあるだけではACLとは呼べない。ACLは、それらの部品を組み合わせて作る設計上の境界である。外部モデルが内部モデルを汚染しないようにするための、だ。
FowlerのGatewayの記事では、Gatewayは外部システムやリソースへのアクセスをカプセル化するオブジェクトとして説明されている。そのうえで、Gatewayのインターフェースは自分たちのシステムの用語で設計し、外部APIへの変換はGateway側で行うべきだとしている。つまりGatewayは、ACLの重要な構成要素になりうる。
もっとも、ACL全体がGatewayだけで成り立つわけではない。FowlerのLegacy Mimicの記事でも、ACLの実装ではServices、Adapters、Translators、Facadesが典型的に使われると説明されている。
ACLの置き場所
クリーンアーキテクチャやヘキサゴナルアーキテクチャの観点では、ACLは基本的に外側に置く。典型的な構成では、domain 層にEntity、Value Object、Domain Serviceを置く。application 層にはUseCaseとPort(Interface)だ。そして infrastructure 層の external-service の下に、残りをまとめる。HttpClient、Gatewayの実装、外部DTO、Translator(Mapper)、Error Mapper、それに認証・リトライ・タイムアウトの処理である。
なにより大事な原則は、domain 層や application 層に外部APIのDTOを入れないことだ。Clean ArchitectureのDependency Ruleによれば、依存は内側に向かうべきである。内側のコードは、外側の名前やデータ形式を知るべきではない。
これをACLに当てはめると、domain 層は外部APIを知らない。application 層は外部DTOを知らず、Portだけを知っている。外部API Client、DTO、Translator、Error Mapperを抱えるのは infrastructure 層だ。外部APIのレスポンスは、内部のドメインモデルかApplication用のResult型に変換してから返す。application 層が見るのは、たとえば次のようなインターフェースだけである。
export interface PaymentGateway {
findById(id: PaymentId): Promise<Payment>;
capture(id: PaymentId): Promise<void>;
}
ここに BillingApiResponse や external_status のような、外部の都合を背負った型や名前を出さないことが肝心だ。
外部DTOが漏れたUseCase
まずは悪い例から見てみる。UseCaseが外部決済APIのレスポンス型をそのまま受け取り、ステータスを直接判定しているコードだ。
type BillingApiChargeResponse = {
id: string;
state: 'AUTH' | 'CAPTURED' | 'VOID' | 'ERR';
amount_minor: number;
currency_code: string;
processor_code?: string;
};
class CapturePaymentUseCase {
constructor(private readonly billingClient: BillingApiClient) {}
async execute(chargeId: string): Promise<void> {
const charge = await this.billingClient.getCharge(chargeId);
if (charge.state === 'AUTH') {
await this.billingClient.captureCharge(charge.id);
return;
}
if (charge.state === 'ERR' && charge.processor_code === 'TEMP_42') {
throw new Error('temporary failure');
}
throw new Error(`unexpected external state: ${charge.state}`);
}
}
このコードの問題は、UseCaseが外部決済APIの仕様を直に知っていることにある。AUTH、ERR、TEMP_42、amount_minor、currency_code。どれも外部システムの語彙だ。これでは外部の状態値やエラーコードが変わっただけで、UseCaseまで書き換える羽目になる。しかも内部の業務判断が、「この支払いはキャプチャできるか」から「外部ステータスが AUTH か」にすり替わっている。
ACLで内部モデルに変換する
では、ACLを置くとどうなるか。まずdomain 側には、自分たちの概念だけを置く。
export class PaymentId {
constructor(readonly value: string) {}
}
export type PaymentStatus =
| 'Authorized'
| 'Captured'
| 'Cancelled'
| 'Failed';
export class Payment {
constructor(
readonly id: PaymentId,
readonly status: PaymentStatus,
readonly amount: number,
readonly currency: string,
) {}
canBeCaptured(): boolean {
return this.status === 'Authorized';
}
}
次に、application 層にはPortを置く。
export interface PaymentGateway {
findById(id: PaymentId): Promise<Payment>;
capture(id: PaymentId): Promise<void>;
}
外部APIのDTOは infrastructure 側に閉じ込める。
type BillingApiChargeResponse = {
id: string;
state: 'AUTH' | 'CAPTURED' | 'VOID' | 'ERR';
amount_minor: number;
currency_code: string;
processor_code?: string;
};
そのうえで、Translatorが外部モデルを内部モデルに変換する。AUTH を Authorized に、VOID を Cancelled に。外部の語彙を自分たちの語彙へ訳すのが、この通訳の仕事だ。
class BillingTranslator {
toDomain(dto: BillingApiChargeResponse): Payment {
return new Payment(
new PaymentId(dto.id),
this.toStatus(dto.state),
dto.amount_minor,
dto.currency_code,
);
}
private toStatus(state: BillingApiChargeResponse['state']): PaymentStatus {
switch (state) {
case 'AUTH':
return 'Authorized';
case 'CAPTURED':
return 'Captured';
case 'VOID':
return 'Cancelled';
case 'ERR':
return 'Failed';
}
}
}
Gatewayの実装は、外部API ClientとTranslator、それに後述するError Mapperを組み合わせる。
class BillingPaymentGateway implements PaymentGateway {
constructor(
private readonly client: BillingApiClient,
private readonly translator: BillingTranslator,
private readonly errorMapper: BillingErrorMapper,
) {}
async findById(id: PaymentId): Promise<Payment> {
try {
const dto = await this.client.getCharge(id.value);
return this.translator.toDomain(dto);
} catch (error) {
throw this.errorMapper.map(error);
}
}
async capture(id: PaymentId): Promise<void> {
try {
await this.client.captureCharge(id.value);
} catch (error) {
throw this.errorMapper.map(error);
}
}
}
こうしておけば、UseCaseは自分たちのモデルだけを扱えばよい。
class CapturePaymentUseCase {
constructor(private readonly paymentGateway: PaymentGateway) {}
async execute(paymentId: string): Promise<void> {
const payment = await this.paymentGateway.findById(
new PaymentId(paymentId),
);
if (!payment.canBeCaptured()) {
throw new Error('PaymentIsNotCapturable');
}
await this.paymentGateway.capture(payment.id);
}
}
この形では、UseCaseは AUTH という外部ステータスも、processor_code も、外部APIのDTOも知らない。知っているのは、Payment と canBeCaptured() という自分たちのドメインの言葉だけだ。外部の決済サービスが明日、ステータス名を全部小文字に変えたとしよう。書き換えるのはTranslatorだけで済む。これがACLの効き目である。
Error Mapperも境界の一部
外部システムによる汚染は、レスポンスのDTOに限らない。エラーコードや例外もまた、汚染の源になる。たとえば、外部APIが次のようなエラーを返すとする。
{
"error_code": "BILLING_TEMP_42",
"message": "temporary processor failure"
}
これをUseCaseで直接見てしまうと、UseCaseが外部決済基盤のエラーコードに依存することになる。
if (error.code === 'BILLING_TEMP_42') {
throw new RetryablePaymentError();
}
これもまた外部仕様の漏れだ。そこでACLにはError Mapperを置き、外部のエラーを内部のエラーに訳させる。
class BillingErrorMapper {
map(error: unknown): Error {
if (isBillingTemporaryError(error)) {
return new PaymentGatewayTemporaryUnavailable();
}
if (isBillingAuthError(error)) {
return new PaymentGatewayUnauthorized();
}
return new PaymentGatewayUnknownError();
}
}
こうすると、内部では PaymentGatewayTemporaryUnavailable のような自分たちの意味でエラーを扱える。外部の BILLING_TEMP_42 は、Error Mapperの中に封じ込められ、境界の内側には届かない。
導入すべきとき
もっとも、外部APIと連携するなら必ずACLを入れるべき、というわけではない。便利ではあるが、タダではないからだ。導入を検討すべきなのは、外部システムと内部モデルの意味のずれが大きいときである。
具体的には、外部APIのDTOがapplication 層や domain 層に漏れているとき。外部の status、code、flag をUseCaseで直接判定しているとき。外部と自社で同じ言葉を違う意味で使っているときや、外部APIの仕様変更が内部の業務ロジックに波及しやすいときもそうだ。
ほかにも出番はある。レガシーシステムと新システムを共存させなければならないとき。モノリスからマイクロサービスへ段階的に移行しているとき。複数の外部サービスを同じ内部概念に統合したいとき。いずれもACLが真価を発揮する場面である。
AWSは、次の場合にACLを検討するよう説明している。二つのシステムの意味が異なり、片方をもう片方に合わせるのが現実的でない場合。あるいは、外部システムと連携する場合だ。Azureも、段階的な移行や、意味体系の異なるサブシステム間で通信が必要な場合にACLが有効だとしている。
過剰設計になるとき
一方で、ACLにはコストもある。実装量が増え、変換ロジックを保守し続けなければならない。レイテンシが増えるかもしれないし、障害点も増え、監視やログの設計も要る。挙げ句の果てには、変換層そのものが技術的負債と化すことさえある。
Azureの説明によれば、ACLは追加のレイヤーとして、レイテンシや運用負荷を増やしうる。意味的な差が大きくない場合には適さず、ビジネスルールやオーケストレーションを置くべきでもない、とのことだ。AWSも、ACLは運用負荷、単一障害点、レイテンシ、スケーリング上のボトルネック、技術的負債になりうると述べている。
つまり、ACLは「外部API連携だから、とりあえず作っておくもの」ではない。判断の基準は一点。意味のずれが、内部モデルを壊すかどうかだ。
よくある失敗
ありがちな失敗の筆頭は、ACLを一つの巨大なServiceクラスにしてしまうことだ。ExternalService というクラスが、HTTP通信も認証もDTO変換もエラー変換も、リトライもDB保存も業務判断もログ出力も、一切合切を引き受けている。こうなると、もはや腐敗を防ぐ層ではない。新たな腐敗の震源地である。
分けるなら、責務ごとに切り出した方が扱いやすい。通信を担う ExternalApiClient。application 向けの入口となる ExternalGateway。外部DTOを内部モデルに変換する ExternalTranslator と、外部エラーを内部エラーに変換する ExternalErrorMapper。そして認証を担う ExternalAuthProvider という具合だ。
次に多いのが、Translatorに業務ロジックを詰め込みすぎることだ。Translatorは外部の意味を内部の意味へ変換する場所であって、業務判断を下す場所ではない。通訳が勝手に商談をまとめはじめたら、誰だって困る。次のようなコードを書きはじめると、Translatorはじわじわと UseCase の顔をしはじめる。
if (dto.status === 'AUTH' && dto.risk_score < 80 && dto.country !== 'NG') {
return 'Capturable';
}
Translatorはできるだけ変換に専念させ、業務判断は domain や application に置く方がよい。
三つ目は、外部DTOを少しいじっただけで満足してしまうことである。フィールド名をいくつか付け替えただけでは、ACLとしてはいささか心許ない。
type PaymentResult = {
externalStatus: 'AUTH' | 'CAPTURED';
};
これでは名前が変わっただけで、外部の概念はそっくり残っている。内部で使うのなら、次のように自分たちの概念へ変換すべきだ。
type PaymentStatus = 'Authorized' | 'Captured' | 'Cancelled' | 'Failed';
ACLがやるべきなのは意味の変換であって、フィールド名の付け替えでは足りない。単語を置き換えるだけの直訳は、通訳とは呼ばないのである。
極めつけは、ACLを作ったのに、UseCaseが相変わらず外部仕様を知っているパターンだ。UseCaseに外部ステータスや外部エラーコードが顔を出しているなら、境界に穴が空いている。確かめ方は簡単である。
application 層に、external や api、provider、sdk といった名前の型が出てきていないか。domain 層に、外部サービスの status や code が紛れ込んでいるのも危ない。UseCaseが外部APIのレスポンス構造を直接覗いてはいまいか。外部APIの仕様変更で、domain 層まで修正が必要にならないか。どれかに当てはまるなら、ACLの境界を見直した方がよい。
テストの組み立て方
ACLは外部システムとの境界なので、テストも重要になる。まず、Translatorは単体テストがしやすい。
describe('BillingTranslator', () => {
it('AUTHをAuthorizedに変換する', () => {
const dto = {
id: 'pay_1',
state: 'AUTH',
amount_minor: 1000,
currency_code: 'JPY',
} as const;
const payment = new BillingTranslator().toDomain(dto);
expect(payment.status).toBe('Authorized');
});
});
殊にテストしておきたいのは、既知のステータスと未知のステータス、欠損値や null だ。外部APIの一時エラー、認証エラー、タイムアウト、外部のエラーコードも外せない。自社のモデルに対応しない外部の状態も要る。外部システムとは、こちらの想定しない値を返してくるのが仕事である。そう思っておいた方が、精神衛生上よい。
GatewayはHTTP通信を含むので、モックサーバーやテスト用のAPIを使った結合テストが有効だ。外部APIの仕様変更を検知したいなら、Contract Testも選択肢になる。FowlerはContract Testを、外部サービス呼び出しの契約を確かめるテストとして説明している。Pact も、Consumer Driven Contract Testingを、ConsumerとProviderがリクエストとレスポンスについて共通の理解を持っているかを確かめる仕組みとして説明している。
要するに、ACLのテストは三層で考えると整理しやすい。土台はTranslatorとError Mapperの単体テスト。その上にGatewayとモックサーバーの結合テスト。さらにその上に、Contract TestやSchema Validationを重ねる。
守るべきは自分たちのモデル
改めて言えば、アンチコラプションレイヤーはただのAPI Clientではない。API Clientは通信を隠し、Mapperはデータ構造を変換し、Gatewayは外部システムへの出入口を作る。しかしACLの本質は、外部システムのモデルが自分たちのドメインモデルを汚染しないようにすることにある。
外部APIのDTO、ステータスコード、エラーコード、命名規則、状態遷移、業務概念。これらをそのまま内部に持ち込めば、自分たちのモデルは少しずつ外部システムに引っ張られていく。だからACLは、外部の語彙を境界のところで自分たちのユビキタス言語に訳してから、内側へ通すのだ。
導入すべきかどうかは、「外部APIを使っているか」ではなく、「外部の意味体系が内部モデルを壊しはじめているか」で判断するとよい。外部DTOがUseCaseに漏れている。外部のstatus や code を domain 層で直接判定している。同じ言葉で意味が違うものを、同一視してしまう。外部APIの仕様変更で、内部の業務ロジックまで修正が要る。こうした兆候が見えたなら、ACLを検討する価値は十分にある。
以上、アンチコラプションレイヤーの考え方と実装、そしてその使いどころについて整理した。ACLは外部APIを叩くための層ではない。自分たちの言葉を守るために雇う、腕のいい通訳なのである。