When you integrate with external APIs or legacy systems, code that started life as a simple API call has a way of getting oddly tangled. Take 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's perfectly ordinary external API integration. Yet the external system's circumstances have walked straight into this code. The external service's own status, AUTH. The external payment platform's error code, processor_code. The shape of the external API's response, and the external system's state transitions. Once these start leaking into the application and domain layers, your own model gets dragged, bit by bit, toward the external system's.

There is a design pattern for preventing exactly this, your domain model being bent out of shape by an external system's circumstances. It's called the Anti-Corruption Layer.

A Layer of Isolation and Translation

The Anti-Corruption Layer goes by ACL for short. In Japanese it's sometimes rendered as 腐敗防止層, a "corruption prevention layer." In a word, it's a layer that keeps the model of an external system or another bounded context from being carried directly into your own domain model. It handles isolation and translation; in other words, it's an interpreter.

The Microsoft Azure Architecture Center describes an ACL as a Facade or Adapter layer placed between subsystems that don't share the same semantics. The purpose is to keep the application's design from being shackled by its dependency on an external subsystem.

AWS Prescriptive Guidance likewise describes an ACL as a mediating layer that translates the meaning of one system's domain model into a meaning suited to another. It shows up especially when migrating from a monolith to microservices. That's where the old system's model and the new service's model drift apart.

In other words, an ACL is not just "a class that calls an external API." If calling is all you need, an API Client will do. The essence of an ACL lies in not letting the external system's vocabulary, data formats, states, exceptions, and business concepts barge into your own model. It's like bringing an interpreter to a negotiation with someone who doesn't speak your language.

What "Corruption" Means

Looking only at the phrase "corruption prevention layer," it sounds like a layer that keeps code from getting dirty. That's certainly one side of it. But "corruption" in DDD runs deeper. It means your own domain model being warped by an external system's circumstances.

Say that in an external payment service, AUTH means "authorized." In your own service, meanwhile, "payable," "capturable," and "order confirmed" may each be distinct business concepts. Even so, things turn precarious once you start writing this all over the internal code.
if (payment.status === 'AUTH') {
  // Confirm the order
}
At this point, your business decision of "can we confirm this order" depends directly on the external status value AUTH. Change the external service's specification, and your internal use cases feel it too. External naming sneaks into your variable and class names, and external state transitions start passing themselves off as your own business rules. This is precisely the corruption the Anti-Corruption Layer exists to prevent.

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

The Trap of Same Word, Different Meaning

What is truly dangerous in external system integration is not when the words are completely different. It's when the same word carries a different meaning. Mistranslations come not from words you don't know but from words you think you know.

Suppose, in an integration with a reservation system, both sides have a concept called Reservation. In the external system, a Reservation might be a tentative hold, with cancelled ones and unpaid ones all equally called Reservation. Your own system, meanwhile, might use Reservation to mean a confirmed, paid reservation. A tentative hold would be a ReservationRequest, and a cancelled one a CancelledReservation. Treat the external API's Reservation as your own Reservation as-is, and the model breaks. The same name doesn't guarantee the same meaning. So the ACL translates the external ExternalReservation according to the internal context. It lands as one of ReservationRequest, ConfirmedReservation, or CancelledReservation. Thanks to that, the internal code can handle the business in its own ubiquitous language, and the external concepts are kept outside the boundary.

How It Differs from an API Client

The Anti-Corruption Layer is easily confused with API Client, Adapter, Facade, Gateway, Mapper, and Translator. Separating their roles 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, and a Gateway provides an entry and exit point to the external system. But having any one of them on its own doesn't make an ACL. An ACL is a design boundary built by combining those parts. Its purpose is to keep the external model from contaminating the internal one.

Fowler's article on Gateway describes a Gateway as an object that encapsulates access to an external system or resource. It goes on to say the Gateway's interface should be designed in your own system's terms, with translation to the external API done on the Gateway side. So a Gateway can be an important component of an ACL.

That said, an ACL doesn't consist of a Gateway alone. Fowler's Legacy Mimic article also explains that ACL implementations typically use Services, Adapters, Translators, and Facades.

Where the ACL Goes

From the standpoint of Clean Architecture and Hexagonal Architecture, the ACL basically goes on the outside. In a typical structure, the domain layer holds Entities, Value Objects, and Domain Services. The application layer holds UseCases and Ports (interfaces). Then, under external-service in the infrastructure layer, you gather the rest: the HttpClient, the Gateway implementation, external DTOs, the Translator (Mapper), the Error Mapper, and the handling for authentication, retries, and timeouts. The single most important principle is to keep external API DTOs out of the domain and application layers. According to Clean Architecture's Dependency Rule, dependencies should point inward. Inner code should not know the names or data formats of the outside. Applied to the ACL, that means the domain layer knows nothing of the external API. The application layer knows nothing of external DTOs, only Ports. It's the infrastructure layer that 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 before being returned. All the application layer sees is an interface like this.
export interface PaymentGateway {
  findById(id: PaymentId): Promise<Payment>;
  capture(id: PaymentId): Promise<void>;
}
The crux is not to expose types or names that carry external baggage here, such as BillingApiResponse or external_status.

A UseCase with Leaked External DTOs

Let's start with the bad example. Here is a UseCase that takes the external payment API's response type as-is and checks its status directly.

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 knows the external payment API's specification firsthand. AUTH, ERR, TEMP_42, amount_minor, currency_code — all of it is the external system's vocabulary. Now a mere change to external status values or error codes forces you to rewrite the UseCase too. Worse, the internal business decision has been swapped from "can this payment be captured" to "is the external status AUTH."

Translating to the Internal Model with an ACL

So what happens when you put an ACL in place? First, the domain side holds only our own concepts.
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';
  }
}
Next, the application layer gets a Port.
export interface PaymentGateway {
  findById(id: PaymentId): Promise<Payment>;
  capture(id: PaymentId): Promise<void>;
}
The external API's DTOs are confined to the infrastructure side.
type BillingApiChargeResponse = {
  id: string;
  state: 'AUTH' | 'CAPTURED' | 'VOID' | 'ERR';
  amount_minor: number;
  currency_code: string;
  processor_code?: string;
};
On top of that, a Translator converts the external model into the internal one. AUTH into Authorized, VOID into Cancelled. Turning the external vocabulary into ours is this interpreter's job.
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, the Translator, and the Error Mapper we'll come to shortly.

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

With that in place, the UseCase only has to deal 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);
  }
}
In this shape, the UseCase knows nothing of the external status AUTH, nothing of processor_code, and nothing of the external API's DTOs. All it knows are the words of our own domain: Payment and canBeCaptured(). Suppose the external payment service decided tomorrow to lowercase every status name. The Translator would be the only thing to rewrite. That is what an ACL buys you.

The Error Mapper Is Part of the Boundary

Contamination from the external system isn't limited to response DTOs. Error codes and exceptions are a source of it too. Suppose the external API returns an error like this.

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

Look at that directly in the UseCase, and the UseCase ends up depending on the external payment platform's error code.

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

This, too, is a leak of the external specification. So the ACL gets an Error Mapper that translates external errors into internal ones.

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 carry our own meanings, like PaymentGatewayTemporaryUnavailable. The external BILLING_TEMP_42 stays sealed inside the Error Mapper and never reaches the inside of the boundary.

When to Introduce One

That said, you don't have to add an ACL every time you integrate with an external API. Useful as it is, it isn't free. The time to consider one is when the semantic gap between the external system and the internal model is large.

Concretely, that's when external API DTOs are leaking into the application or domain layer. When external status, code, or flag values are checked directly in the UseCase. Also when the external system and yours use the same word with different meanings, or when changes to the external API's specification readily ripple into internal business logic.

There are other occasions, too. When a legacy system and a new one have to coexist. When you're migrating step by step from a monolith to microservices. When you want to unify several external services under one internal concept. In each case, the ACL earns its keep.

AWS explains that you should consider an ACL in the following cases. When two systems' semantics differ and making one match the other isn't realistic. Or when integrating with an external system. Azure likewise says an ACL is effective for gradual migration and when communication is needed between subsystems with different semantics.

When It's Over-Engineering

On the other hand, an ACL has costs. There's more to implement, and translation logic to maintain indefinitely. Latency may go up, points of failure multiply, and monitoring and logging need designing. To top it off, the translation layer itself can turn into technical debt.

According to Azure, an ACL, as an extra layer, can add latency and operational burden. It isn't suitable when the semantic differences are small, and business rules and orchestration don't belong in it either. AWS similarly says an ACL can become an operational burden, a single point of failure, a source of latency, a scaling bottleneck, and technical debt.

In other words, an ACL is not "something you build just in case because it's external API integration." There is one criterion. Would the semantic gap break the internal model?

Common Failures

The most common failure is turning the ACL into a single giant Service class. A class called ExternalService takes on HTTP communication, authentication, DTO conversion, error conversion, retries, DB persistence, business decisions, logging — the whole lot. At that point it's no longer a layer that prevents corruption. It's the new epicenter of it. If you split it, carving it up by responsibility makes it easier to work with. ExternalApiClient for communication. ExternalGateway as the entry point for the application. ExternalTranslator for converting external DTOs into the internal model, and ExternalErrorMapper for converting external errors into internal ones. And ExternalAuthProvider for authentication.

The next most common is stuffing too much business logic into the Translator. The Translator is the place to convert external meaning into internal meaning, not the place to make business decisions. An interpreter who starts closing the deal on their own would worry anyone. Start writing code like this, and the Translator gradually begins to look like a UseCase.

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

Third is being satisfied after tweaking the external DTO a little. Renaming a few fields leaves you with a rather flimsy ACL.

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

Only the name has changed here; the external concept remains fully intact. If it's going to be used internally, it should be converted into our own concepts like this.

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

What an ACL should do is translate meaning. Relabeling fields is not enough. A word-for-word swap is not what anyone means by interpreting.

The crowning failure is building an ACL only to find the UseCase still knows the external specification. If external statuses or external error codes are showing up in the UseCase, the boundary has a hole in it. Checking is easy.

Do types with names like external, api, provider, or sdk appear in the application layer? External service status or code values creeping into the domain layer are a warning sign too. Is the UseCase peeking directly at the external API's response structure? Would a change to the external API's specification require fixes all the way down to the domain layer? If any of these apply, it's time to revisit the ACL's boundary.

Putting Tests Together

Since the ACL is the boundary with external systems, testing matters too. 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');
  });
});
What you especially want to test are known and unknown statuses, and missing values and null. Temporary errors from the external API, authentication errors, timeouts, and external error codes can't be skipped either. Nor can external states that have no counterpart in your own model. Returning values you never anticipated is part of an external system's job description. For your peace of mind, it's best to assume as much.

The Gateway involves HTTP communication, so integration tests with a mock server or a test API are effective. If you want to catch changes in the external API's specification, Contract Testing is 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.

In short, ACL testing is easiest to organize in three layers. The foundation is unit tests of the Translator and Error Mapper. On top of that, integration tests of the Gateway against a mock server. And on top of that, Contract Tests or Schema Validation.

Your Own Model Is What You're Guarding

To say it once more, an Anti-Corruption Layer is not just an API Client. An API Client hides communication, a Mapper converts data structures, and 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.

The external API's DTOs, status codes, error codes, naming conventions, state transitions, business concepts. Bring these inside as they are, and your model will gradually be pulled toward the external system. That's why the ACL translates the external vocabulary into your own ubiquitous language at the boundary before letting it through.

Whether to introduce one is best judged not by "are we using an external API" but by "have the external semantics started to break our internal model." External DTOs are leaking into the UseCase. External status or code values are checked directly in the domain layer. Things that share a word but not a meaning get treated as identical. Changes to the external API's specification require fixes even to internal business logic. If you see signs like these, an ACL is well worth considering.

That's my rundown of the Anti-Corruption Layer: the idea, the implementation, and when to reach for it. An ACL is not a layer for calling external APIs. It's a skilled interpreter you hire to protect your own language.