Creating a good domain model is hard. We need to talk with domain experts, refine Ubiquitous Language, split Bounded Contexts, and think about Aggregate boundaries. But even a model created with difficulty can easily break down.
One of the most common reasons is the intrusion of external models.
External APIs, legacy systems, services from other teams, payment gateways, ERP, and CRM systems all have their own models and languages. The problem is that their language differs from the language of our domain. Accepting an external model as it is feels convenient at first, but over time our model is reshaped to match the terms and structure of the external system.
An Anti-Corruption Layer is a defensive line that prevents this problem.
External models are not neutral
A DTO from an external system looks like a simple bundle of data. But inside it is the perspective of that external system.
Suppose a payment gateway response looks like this:
{
"resultCode": "0000",
"transactionId": "tx_12345",
"approvalNo": "A-9999",
"cancelable": true,
"amount": 9900,
"paidAt": "2026-07-05T10:00:00"
}
In the payment context, this data is natural. Approval numbers, transaction identifiers, response codes, and whether cancellation is possible are important concepts. But the subscription context may want to know something different.
Did the payment required for subscription renewal succeed?
If payment failed, should a grace period start?
Can an already expired subscription be reactivated?
Can access entitlements be granted based on this payment result?
The payment gateway’s resultCode and the subscription domain’s SubscriptionRenewed are not language at the same level. The former is a technical result from an external system, and the latter is a meaningful change in our domain.
External models are not neutral. Using them as they are means accepting the external system’s perspective into our code.
Corruption starts from very small convenience
At first, it looks like simple convenience.
PgPaymentResponse response = pgClient.pay(request);
if (response.isSuccess()) {
subscription.setStatus(SubscriptionStatus.ACTIVE);
}
This much may look harmless. But conditions increase over time.
if (response.getResultCode().equals("0000")) {
subscription.renew();
} else if (response.getResultCode().equals("E101")) {
subscription.startGracePeriod();
} else if (response.getResultCode().equals("E302")) {
subscription.expire();
}
Now subscription domain policy is directly tied to the response codes of the payment gateway. If we change the payment system, subscription policy code also shakes. The bigger problem is the language of the code. Inside the subscription domain, external codes such as E101 and E302 gain stronger influence than words like “renewal failure,” “grace period started,” and “expiration.”
This is model corruption.
Corruption does not arrive all at once in a large form. It starts from small convenience, fast implementation, and skipping simple mappings. Over time, the language of the external system replaces the language of our domain model.
ACL is not just DTO conversion
It is not enough to understand Anti-Corruption Layer as a layer that simply converts DTOs. Conversion is an important role of an ACL. But the core is not conversion itself. The core is protecting language.
An ACL translates an external model into a model our domain can understand.
PgPaymentResponse response = pgClient.pay(request);
PaymentResult result = paymentTranslator.translate(response);
subscription.handlePaymentResult(result, now);
Here, PaymentResult should not be the response model of the payment gateway. It should be a concept our domain can understand.
public sealed interface PaymentResult {
record Succeeded(Money amount, Instant paidAt) implements PaymentResult {}
record Failed(PaymentFailureReason reason) implements PaymentResult {}
}
Now the subscription domain does not need to know resultCode. It does not need to know what approvalNo is. The subscription domain only needs to know whether the payment succeeded, and if it failed, what the domain-level reason was.
An ACL is an interpreter that translates the language of an external system into the language of the internal domain.
Translation is design, not loss
We do not need to bring every field from an external response into the internal model. In many cases, it is important not to bring them in.
A payment gateway response may contain many pieces of information, such as approval number, merchant ID, card company code, raw response message, whether cancellation is possible, and installment months. But subscription renewal policy may need only some of them.
Did the payment succeed?
Does the paid amount match the expected amount?
Is the failure temporary or permanent?
When was the payment actually completed?
Some external information disappearing during translation may look like loss. But this is modeling. It is the process of leaving only the information meaningful to our domain.
Of course, we may store the raw response for auditing or troubleshooting. But it should not become the central language of the domain model.
Where should ACL be placed?
An ACL is usually placed between an external system and the domain model. The structure can differ by project, but generally the following roles are needed:
External client
- Calls the external API
- Handles technical details such as authentication, HTTP, and retry
Translator or Mapper
- Converts the external response model into an internal model
- Translates external error codes into domain meaning
Internal model
- A result or command the domain can understand
For example, we can create a flow like this:
PgPaymentResponse response = pgPaymentClient.requestPayment(pgRequest);
PaymentResult paymentResult = paymentAcl.toPaymentResult(response);
subscription.applyPayment(paymentResult, now);
What matters is that the domain layer does not know PgPaymentResponse directly. The domain layer does not need to know what JSON the external system returns, which HTTP status codes it uses, or which string codes express failure.
Legacy systems and ACL
ACL is important not only for external APIs, but also when connecting to legacy systems.
In legacy systems, a single table or code value often has several meanings at the same time. For example, status = 9 may mean cancellation in one case, expiration in another, and refund completion only when combined with a specific flag.
If we bring this legacy state value directly while creating a new subscription domain model, the new model quickly becomes stained by the ambiguity of the legacy model.
if (legacyStatus == 9 && refundFlag == true) {
// ...
}
An ACL should turn this ambiguous combination into a concept the new domain can understand.
SubscriptionState state = legacySubscriptionTranslator.translate(legacyRecord);
Translation may not be simple. Sometimes we need to talk with operators to interpret the meaning of the legacy model. Questions like, “What exactly does it mean when this flag is on and the status value is 9?” are necessary. This process is also part of DDD.
Too much ACL also becomes a cost
This does not mean we should build a huge ACL for every external integration. If an integration does not greatly affect the domain model, such as a simple email delivery API, a simple adapter may be enough.
ACL is especially important in these situations:
The external model directly affects the Core Domain.
The language of the external system differs from the language of the internal domain.
The external system may be changed or replaced.
The legacy model is ambiguous or corrupted.
External response codes are used to judge internal policy.
Conversely, creating an excessive ACL where data is only passed through creates unnecessary complexity. ACL also needs a purpose. The purpose is always the same: protecting my domain model.
Closing
An Anti-Corruption Layer is not just a mapper. It is a defensive line that translates language between external and internal models and prevents the domain model from being dragged by the perspective of an external system.
A good ACL does not merely hide the external system. It changes the result of the external system into words our domain can understand. Instead of passing resultCode = E101 as it is, it translates it into the domain meaning: “a grace period must begin because of a temporary payment failure.”
What matters in DDD is not sacredly preserving model purity. What matters is preventing the language and rules of the Core Domain from being damaged by the accidental structure of external systems.
External systems keep changing. Legacy systems do not disappear easily. Models from other teams do not move as we wish. That is why we need a boundary that protects our model. ACL is an interpreter standing on that boundary.