外部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を叩くこと自体は、API Clientでもできる。

ACLの本質は、外部システムの語彙、データ形式、状態、例外、業務概念を、自分たちのモデルにそのまま侵入させないことにある。

「腐敗」とは何か

「腐敗防止層」という訳語だけを見ると、コードが汚くなることを防ぐ層のように聞こえる。

もちろん、それも一部ではある。

ただし、DDDにおける「腐敗」はもう少し深い意味だと思う。

ここでいう腐敗とは、自分たちのドメインモデルが、外部システムの都合によって歪んでいくことだ。

たとえば、外部の決済サービスでは AUTH が「オーソリ済み」を意味しているとする。

一方、自社サービスでは「支払い可能」「売上確定可能」「注文確定済み」は、それぞれ別の業務概念かもしれない。

それにもかかわらず、内部コードのあちこちでこう書き始めると危険だ。

if (payment.status === 'AUTH') {
  // 注文を確定する
}
この時点で、自社サービスの「注文を確定できるか」という業務判断が、外部決済サービスの AUTH という状態値に直接依存している。

外部サービスの仕様が変わると、内部のユースケースまで影響を受ける。

外部サービスの命名が、自社の変数名やクラス名に入り込む。

外部サービスの状態遷移が、自社の業務ルールのように扱われ始める。

これが、アンチコラプションレイヤーが防ぎたい「腐敗」だ。

Martin Fowlerの記事でも、EvansのACLの意図として、クライアントが自分たちのドメインモデルの用語で機能を扱えるように隔離層を作る、という説明が紹介されている。

同じ言葉なのに意味が違う、が一番危ない

外部システム連携で本当に危ないのは、言葉がまったく違うケースよりも、同じ言葉を使っているのに意味が違うケースだ。

たとえば、予約システム連携で両方に Reservation という概念があるとする。

しかし、外部システムではこうかもしれない。

  • Reservation = 仮押さえ
  • キャンセル済みも Reservation
  • 決済前でも Reservation

一方、自社システムではこう扱っているかもしれない。

  • Reservation = 決済済みの確定予約
  • 仮押さえは ReservationRequest
  • キャンセル後は CancelledReservation
このとき、外部APIの Reservation をそのまま自社の Reservation として扱うと、モデルが壊れる。

名前が同じでも、意味が同じとは限らない。

ACLはここで、外部の Reservation を内部の文脈に合わせて変換する。
ExternalReservation
  ↓
ReservationRequest / ConfirmedReservation / CancelledReservation

この変換によって、内部のコードは外部システムの概念ではなく、自分たちのユビキタス言語で業務を扱えるようになる。

API ClientとACLは何が違うのか

アンチコラプションレイヤーは、API Client、Adapter、Facade、Gateway、Mapper、Translator と混同されがちだ。

それぞれの役割を分けると、次のようになる。

名前主な役割
API ClientHTTP通信や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 implementation
      ├─ External DTO
      ├─ Translator / Mapper
      ├─ Error Mapper
      └─ Auth / Retry / Timeout
重要なのは、外部APIのDTOを domain 層や application 層に入れないことだ。

Clean ArchitectureのDependency Ruleでは、依存は内側に向かうべきであり、内側のコードは外側の名前やデータ形式を知るべきではないと説明されている。

これをACLに当てはめると、次のようになる。

  • domain 層は外部APIを知らない
  • application 層は外部DTOを知らない
  • application 層は Port / Interface だけを知る
  • infrastructure 層が外部API Client、DTO、Translator、Error Mapperを持つ
  • 外部APIのレスポンスは、内部のドメインモデルまたはApplication用Result型に変換して返す
たとえば、application 層ではこういうインターフェースだけを見る。
export interface PaymentGateway {
  findById(id: PaymentId): Promise<Payment>;
  capture(id: PaymentId): Promise<void>;
}
ここに BillingApiResponseexternal_status のような外部都合の型や名前を出さないことが大事だ。

悪い例:外部DTOがUseCaseに漏れている

まず悪い例から見る。

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の仕様を直接知っていることだ。

AUTHERRTEMP_42amount_minorcurrency_code

これらは外部システムの語彙だ。

この状態では、外部APIの状態値やエラーコードが変わっただけで、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で外部モデルを内部モデルに変換する。

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を組み合わせる。

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も知らない。

UseCaseが知っているのは、PaymentcanBeCaptured() という自分たちのドメインの言葉だけだ。

これがACLの効果だと思う。

Error MapperもACLの一部になる

外部システムの汚染は、レスポンス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();
  }
}
こうすると、内部では外部の BILLING_TEMP_42 ではなく、PaymentGatewayTemporaryUnavailable のような内部の意味で扱える。

ACLを導入すべきケース

ACLは便利だが、外部API連携なら必ず入れるべき、というものではない。

導入を検討すべきなのは、外部システムと内部モデルの意味のズレが大きいときだ。

具体的には、次のようなケースがある。

  • 外部APIのDTOが application 層や domain 層に漏れている
  • 外部の statuscodeflag をUseCaseで直接判定している
  • 外部システムと自社システムで同じ言葉を使っているが意味が違う
  • 外部APIの仕様変更が内部の業務ロジックに波及しやすい
  • レガシーシステムと新システムを共存させる必要がある
  • モノリスからマイクロサービスへ段階的に移行している
  • 複数の外部サービスを、同じ内部概念に統合したい

AWSは、2つのシステムの意味が異なり、片方をもう片方に合わせるのが現実的でない場合や、外部システムと連携する場合にACLを検討すると説明している。

Azureも、段階的な移行や、意味体系が異なる複数サブシステム間の通信が必要な場合にACLが有効だとしている。

ACLが過剰設計になるケース

一方で、ACLはコストもある。

  • 実装量が増える
  • 変換ロジックを保守する必要がある
  • レイテンシが増える可能性がある
  • 障害点が増える
  • 監視やログ設計が必要になる
  • 変換層そのものが技術的負債になることがある

Azureは、ACLは追加のレイヤーとしてレイテンシや運用負荷を増やす可能性があると説明している。また、意味的な差が大きくない場合には適さず、ACLにビジネスルールやオーケストレーションを置くべきではないとも述べている。

AWSも、ACLは運用負荷、単一障害点、レイテンシ、スケーリング上のボトルネック、技術的負債になり得ると説明している。

つまり、ACLは「外部API連携だからとりあえず作るもの」ではない。

判断基準は、外部APIの有無ではなく、意味のズレが内部モデルを壊すかどうかだ。

よくある失敗パターン

1. ACLが巨大なServiceクラスになる

ありがちな失敗は、ACLを1つの巨大なServiceにしてしまうことだ。

ExternalService
  ├─ HTTP通信
  ├─ 認証
  ├─ DTO変換
  ├─ エラー変換
  ├─ リトライ
  ├─ DB保存
  ├─ 業務判断
  └─ ログ出力

こうなると、もはや腐敗を防ぐ層ではなく、新しい腐敗の中心になる。

分けるなら、次のように責務を分ける方が扱いやすい。

ExternalApiClient      // 通信
ExternalGateway        // application向けの入口
ExternalTranslator     // 外部DTO → 内部モデル
ExternalErrorMapper    // 外部エラー → 内部エラー
ExternalAuthProvider   // 認証

2. Translatorに業務ロジックが入りすぎる

Translatorは「外部の意味を内部の意味へ変換する」場所だ。

しかし、そこに業務判断を入れすぎると危険だ。

たとえば、こういうコードを書き始めると、TranslatorがUseCaseのようになっていく。

if (dto.status === 'AUTH' && dto.risk_score < 80 && dto.country !== 'NG') {
  return 'Capturable';
}
Translatorはできるだけ変換に集中し、業務判断は domainapplication に置く方がよい。

3. 外部DTOを少し変換しただけで満足する

外部DTOのフィールド名を少し変えただけでは、ACLとしては弱い。

type PaymentResult = {
  externalStatus: 'AUTH' | 'CAPTURED';
};

これでは、名前を変えただけで外部の概念が残っている。

内部で使うなら、次のように自分たちの概念へ変換するべきだ。

type PaymentStatus = 'Authorized' | 'Captured' | 'Cancelled' | 'Failed';

重要なのは、フィールド名を変えることではなく、意味を変換することだ。

4. ACLを作ったのにUseCaseが外部仕様を知っている

ACLを作っても、UseCaseに外部ステータスや外部エラーコードが出てくるなら、境界に穴が空いている。

チェックポイントは簡単だ。

  • application 層に external / api / provider / sdk の型が出ていないか
  • domain 層に外部サービスの statuscode が出ていないか
  • 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とProviderがリクエスト・レスポンスについて共通理解を持っているか確認するための仕組みとして Consumer Driven Contract Testing を説明している。

ACLのテストは、ざっくり次の3層で考えると整理しやすい。

Translator / Error Mapper の単体テスト
        ↓
Gateway + Mock Server の結合テスト
        ↓
Contract Test / Schema Validation

まとめ

アンチコラプションレイヤーは、単なるAPI Clientではない。

API Clientは通信を隠す。

Mapperはデータ構造を変換する。

Gatewayは外部システムへの出入口を作る。

しかしACLの本質は、外部システムのモデルが、自分たちのドメインモデルを汚染しないようにすることだ。

外部APIのDTO、ステータスコード、エラーコード、命名規則、状態遷移、業務概念をそのまま内部に持ち込むと、自分たちのモデルは少しずつ外部システムに引っ張られていく。

だからACLでは、外部の表現を、自分たちのユビキタス言語に合わせて変換する。

外部システムの語彙
  ↓
Anti-Corruption Layer
  ↓
自分たちのドメインモデル

導入すべきかどうかは、「外部APIを使っているか」ではなく、「外部システムの意味体系が内部モデルを壊し始めているか」で判断するとよい。

外部DTOがUseCaseに漏れている。

外部の statuscodedomain 層で直接判定している。

同じ言葉なのに意味が違うものを同一視している。

外部APIの仕様変更で内部の業務ロジックまで修正が必要になる。

こうした兆候があるなら、アンチコラプションレイヤーを検討する価値がある。

ACLは「外部APIを叩くための層」ではない。

自分たちのドメインモデルを守るための境界だ。