When you integrate with external APIs or legacy systems, code that started as a simple API call can grow complicated before you know it.

For example, code like this.

const payment = await externalPaymentApi.getPayment(paymentId);

if (payment.status === 'AUTH' && payment.processor_code !== 'TEMP_ERROR') {
  await externalPaymentApi.capture(payment.id);
}

At first glance it looks like ordinary external API integration.

But this code has the external system's circumstances baked right in.

  • The external service's own status AUTH
  • The external payment platform's error code processor_code
  • The structure of the external API's response
  • The external system's state transitions

Once these start leaking into the application layer and the domain layer, your own service's model gets gradually pulled toward the external system's model.

The design pattern for preventing this problem of "your own domain model being distorted by the circumstances of an external system" is the Anti-Corruption Layer.

What Is an Anti-Corruption Layer

The Anti-Corruption Layer, abbreviated ACL, is sometimes translated in Japanese as 腐敗防止層 ("corruption prevention layer").

In a nutshell, I think an Anti-Corruption Layer is the following.

An isolation and translation layer that keeps the model of an external
system or another bounded context from being brought directly into
your own domain model

The Microsoft Azure Architecture Center describes an ACL as a Facade or Adapter layer placed between subsystems that do not share the same semantics.

The purpose is to keep the application's design from being constrained by its dependency on an external subsystem.

AWS Prescriptive Guidance also describes an ACL as a mediating layer that translates the meaning of one system's domain model into a meaning suited to another system. It is used especially when migrating from a monolith to microservices, in situations where the old system's model and the new service's model diverge.

In other words, an ACL is not merely "a class that calls an external API."

Calling an external API can be done by an API Client, too.

The essence of an ACL is not letting the external system's vocabulary, data formats, states, exceptions, and business concepts intrude directly into your own model.

What Is "Corruption"

Looking only at the translation "corruption prevention layer," it sounds like a layer that prevents code from getting dirty.

Of course, that is part of it.

But I think "corruption" in DDD has a somewhat deeper meaning.

Corruption here means your own domain model being distorted by the circumstances of an external system.

For example, suppose in an external payment service AUTH means "authorized."

Meanwhile, in your own service, "payable," "capturable," and "order confirmed" may each be separate business concepts.

Even so, it becomes dangerous if you start writing this all over the internal code.

if (payment.status === 'AUTH') {
  // Confirm the order
}
At this point, your own service's business decision of "can we confirm the order" directly depends on the external payment service's status value AUTH.

If the external service's specification changes, even the internal use cases are affected.

The external service's naming creeps into your own variable and class names.

The external service's state transitions start being treated as your own business rules.

This is the "corruption" that the Anti-Corruption Layer aims to prevent.

Martin Fowler's article also presents Evans's intent for the ACL as creating an isolating layer so that clients can work with functionality in terms of their own domain model.

Same Word, Different Meaning Is the Most Dangerous

What is really dangerous in external system integration is not the case where the words are completely different, but the case where the same word is used with a different meaning.

For example, suppose both sides have a concept called Reservation in a reservation system integration.

But in the external system it may be like this.

  • Reservation = a tentative hold
  • Cancelled ones are also Reservation
  • Even before payment, it is a Reservation

Meanwhile, your own system may treat it like this.

  • Reservation = a confirmed, paid reservation
  • A tentative hold is a ReservationRequest
  • After cancellation, it is a CancelledReservation
If you treat the external API's Reservation as your own Reservation as-is, the model breaks.

Even if the names are the same, the meanings are not necessarily the same.

Here the ACL translates the external Reservation to fit the internal context.
ExternalReservation
  ↓
ReservationRequest / ConfirmedReservation / CancelledReservation

With this translation, the internal code can handle the business in its own ubiquitous language. The external system's concepts stay outside the boundary.

How Does an ACL Differ from an API Client

The Anti-Corruption Layer is easily confused with API Client, Adapter, Facade, Gateway, Mapper, and Translator.

Separating their roles, it looks like this.

NameMain role
API ClientHides HTTP communication and SDK calls
GatewayWraps access to an external system in your own vocabulary
AdapterAdapts to the expected interface
FacadeConsolidates a complex external API into a simple entry point
MapperConverts data structures
TranslatorConverts the meaning between the external model and the internal model
ACLCombines these to form a boundary that protects your own domain model

An API Client hides the details of communication.

A Mapper converts fields.

A Gateway provides an entry and exit point to the external system.

But that alone is not an ACL.

An ACL is a design boundary that combines those parts so that the external model does not contaminate the internal model.

Fowler's article on Gateway describes a Gateway as an object that encapsulates access to an external system or resource. It also says the Gateway's interface should be designed in your own system's terms, and the translation to the external API should be done on the Gateway side.

In other words, a Gateway can be an important component of an ACL.

But the ACL as a whole is not just the Gateway.

Fowler's Legacy Mimic article also explains that implementations of an ACL typically use Services, Adapters, Translators, and Facades.

Where Should the ACL Go

From the viewpoint of Clean Architecture and Hexagonal Architecture, the ACL is basically placed on the outside.

Typically, the structure looks like this.

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
The top principle is not to put external API DTOs in the domain or application layers.

Clean Architecture's Dependency Rule says that dependencies should point inward, and inner code should not know about the names or data formats of the outside.

Applying this to the ACL gives the following.

  • The domain layer does not know about the external API
  • The application layer does not know about external DTOs
  • The application layer knows only the Port / Interface
  • The infrastructure layer holds the external API Client, DTOs, Translator, and Error Mapper
  • The external API's response is converted into an internal domain model or an application Result type and returned
For example, the application layer only sees an interface like this.
export interface PaymentGateway {
  findById(id: PaymentId): Promise<Payment>;
  capture(id: PaymentId): Promise<void>;
}
It is important not to expose externally driven types or names such as BillingApiResponse or external_status here.

Bad Example: External DTOs Leak into the UseCase

Let's look at a bad example first.

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}`);
  }
}

The problem with this code is that the UseCase directly knows the external payment API's specification.

AUTH, ERR, TEMP_42, amount_minor, currency_code.

These are the external system's vocabulary.

In this state, merely changing the external API's status values or error codes requires modifying the UseCase, too.

Also, the internal business decision has become "is the external status AUTH" instead of "can the payment be captured."

Good Example: Convert to the Internal Model with an ACL

Next, let's look at an example with an ACL.

First, put only our own concepts on the domain side.
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';
  }
}
Put the Port in the application layer.
export interface PaymentGateway {
  findById(id: PaymentId): Promise<Payment>;
  capture(id: PaymentId): Promise<void>;
}
Confine the external API's DTOs to the infrastructure side.
type BillingApiChargeResponse = {
  id: string;
  state: 'AUTH' | 'CAPTURED' | 'VOID' | 'ERR';
  amount_minor: number;
  currency_code: string;
  processor_code?: string;
};

A Translator converts the external model into the internal model.

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';
    }
  }
}

The Gateway implementation combines the external API Client and the 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);
    }
  }
}

The UseCase deals only with our own model.

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);
  }
}
With this shape, the UseCase does not know the external status AUTH. It does not know processor_code, either.

It does not know the external API's DTOs.

All the UseCase knows are the words of our own domain: Payment and canBeCaptured().

I think this is the effect of an ACL.

The Error Mapper Is Also Part of the ACL

Contamination from the external system is not limited to response DTOs.

Error codes and exceptions can also be a source of contamination.

For example, suppose the external API returns an error like the following.

{
  "error_code": "BILLING_TEMP_42",
  "message": "temporary processor failure"
}

If the UseCase looks at this directly, the UseCase comes to depend on the external payment platform's error code.

if (error.code === 'BILLING_TEMP_42') {
  throw new RetryablePaymentError();
}

This is also a leak of the external specification.

That is why an Error Mapper is placed in the ACL.

class BillingErrorMapper {
  map(error: unknown): Error {
    if (isBillingTemporaryError(error)) {
      return new PaymentGatewayTemporaryUnavailable();
    }

    if (isBillingAuthError(error)) {
      return new PaymentGatewayUnauthorized();
    }

    return new PaymentGatewayUnknownError();
  }
}
This way, internally, errors can be handled with our own meanings, such as PaymentGatewayTemporaryUnavailable. The external BILLING_TEMP_42 stays confined inside the Error Mapper.

Cases Where You Should Introduce an ACL

An ACL is useful, but it is not something you must always add whenever you integrate with an external API.

You should consider introducing one when the semantic gap between the external system and the internal model is large.

Specifically, there are cases like the following.

  • External API DTOs are leaking into the application or domain layer
  • External status, code, or flag values are checked directly in the UseCase
  • The external system and your own system use the same word with different meanings
  • Changes to the external API's specification easily ripple into the internal business logic
  • You need to keep a legacy system and a new system coexisting
  • You are migrating step by step from a monolith to microservices
  • You want to unify multiple external services under the same internal concept

AWS explains that you should consider an ACL when the semantics of two systems differ and it is not realistic to make one match the other, or when integrating with an external system.

Azure likewise says an ACL is effective for gradual migration and when communication is needed between multiple subsystems with different semantics.

Cases Where an ACL Is Over-Engineering

On the other hand, an ACL has costs.

  • The amount of implementation increases
  • Translation logic has to be maintained
  • Latency may increase
  • Points of failure increase
  • Monitoring and logging design becomes necessary
  • The translation layer itself can become technical debt

Azure explains that an ACL, as an extra layer, may increase latency and operational burden. It also states that it is not suitable when the semantic differences are not large, and that business rules and orchestration should not be placed in the ACL.

AWS also explains that an ACL can become an operational burden, a single point of failure, a latency source, a scaling bottleneck, and technical debt.

In other words, an ACL is not "something you build for now just because it's external API integration."

The criterion is whether the semantic gap would break the internal model.

Common Failure Patterns

1. The ACL Becomes a Giant Service Class

A common failure is making the ACL a single giant Service.

ExternalService
  ├─ HTTP communication
  ├─ Authentication
  ├─ DTO conversion
  ├─ Error conversion
  ├─ Retry
  ├─ DB persistence
  ├─ Business decisions
  └─ Logging

At that point, it is no longer a layer that prevents corruption but the new center of corruption.

If you split it, it is easier to handle by separating responsibilities like this.

ExternalApiClient      // communication
ExternalGateway        // entry point for the application
ExternalTranslator     // external DTO -> internal model
ExternalErrorMapper    // external error -> internal error
ExternalAuthProvider   // authentication

2. Too Much Business Logic in the Translator

The Translator is the place to "convert external meaning into internal meaning."

But putting too many business decisions there is dangerous.

For example, once you start writing code like this, the Translator begins to turn into a UseCase.

if (dto.status === 'AUTH' && dto.risk_score < 80 && dto.country !== 'NG') {
  return 'Capturable';
}
It is better for the Translator to focus on conversion as much as possible, and to put business decisions in domain or application.

3. Being Satisfied After Only Slightly Converting the External DTO

Merely renaming a few fields of the external DTO is weak as an ACL.

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

This only changes the name, and the external concept remains.

If used internally, it should be converted into our own concepts like this.

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

What an ACL should do is convert meaning. Merely renaming fields is not enough.

4. You Built an ACL but the UseCase Still Knows the External Specification

Even if you built an ACL, if external statuses or external error codes appear in the UseCase, there is a hole in the boundary.

It is easy to check.

  • Do types with names like external / api / provider / sdk appear in the application layer?
  • Do external service status or code values appear in the domain layer?
  • Does the UseCase look directly at the external API's response structure?
  • Does a change in the external API's specification require fixes all the way to the domain layer?

If any of these apply, you should revisit the ACL boundary.

Testing Strategy

Because the ACL is the boundary with external systems, testing is also important.

First, the Translator is easy to unit test.

describe('BillingTranslator', () => {
  it('converts AUTH to 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');
  });
});

The cases you especially want to test are the following.

  • Known statuses
  • Unknown statuses
  • Missing values
  • null
  • Temporary errors from the external API
  • Authentication errors
  • Timeouts
  • External error codes
  • External states that do not correspond to your own model

The Gateway involves HTTP communication, so integration tests using a mock server or a test API are effective.

Furthermore, if you want to detect specification changes in the external API, Contract Testing is also an option. Fowler describes Contract Tests as tests that verify the contract of external service calls. Pact also describes Consumer Driven Contract Testing as a mechanism for checking that Consumer and Provider share a common understanding of requests and responses.

It is easier to organize ACL testing in roughly the following three layers.

Unit tests of Translator / Error Mapper
        ↓
Integration tests of Gateway + Mock Server
        ↓
Contract Test / Schema Validation

Summary

An Anti-Corruption Layer is not merely an API Client.

An API Client hides communication.

A Mapper converts data structures.

A Gateway creates an entry and exit point to the external system.

But the essence of an ACL is keeping the external system's model from contaminating your own domain model.

If you bring the external API's DTOs, status codes, error codes, naming conventions, state transitions, and business concepts into the internal code as they are, your own model will gradually be pulled toward the external system.

That is why the ACL converts the external representation to fit your own ubiquitous language.

External system's vocabulary
  ↓
Anti-Corruption Layer
  ↓
Your own domain model

Whether to introduce one is best judged not by "are you using an external API" but by "has the external system's semantics started to break the internal model."

External DTOs are leaking into the UseCase.

External status or code values are checked directly in the domain layer.

Things with the same word but different meanings are treated as identical.

Changes to the external API's specification require fixes even to internal business logic.

If you see signs like these, an Anti-Corruption Layer is worth considering.

An ACL is not "a layer for calling external APIs."

It is a boundary for protecting your own domain model.