Integrating another system often starts with a small amount of mapping code. Then its field names appear in business logic. Its status values enter conditionals. Its error codes shape application decisions. Months later, changing providers or even upgrading the integration requires edits across the codebase.
The problem is not simply that the application has an external dependency. The deeper problem is that the external system’s model has become part of the application’s own model.
An anti-corruption layer is a boundary that translates between those models. Code on your side speaks in concepts that make sense for your application. The integration layer handles the foreign vocabulary, structures, and conventions.
This article explains how to design that boundary, where translation should happen, what belongs inside it, and when the extra layer is not worth the cost.
Think in models, not data formats
Suppose an order application asks a shipping provider for a delivery estimate. The provider returns this simplified response:
{
"service_code": "EXP_1",
"eta_days": 2,
"eligible": "Y"
}A direct integration might pass that object through the application:
if quote.eligible == "Y" and quote.service_code == "EXP_1":
promiseDeliveryIn(quote.eta_days)This works, but the business logic now knows three provider-specific decisions:
- eligibility is encoded as
"Y"rather than a boolean; - express delivery is identified by
"EXP_1"; - the estimate is expressed as a number of days.
If these details appear in one adapter, they are implementation details. If they appear throughout the application, they become accidental parts of the application’s design.
The useful mental model is:
foreign representation
|
v
translation boundary
|
v
local conceptThe boundary does more than rename fields. It decides what the external information means locally.
Start with the smallest useful translation
Assume the application only needs to know whether express delivery is available and, if so, the estimated number of days.
Instead of exposing the provider response, define a local result:
DeliveryEstimate:
available
daysThe integration code can translate the provider response:
toDeliveryEstimate(response):
isExpress = response.service_code == "EXP_1"
isEligible = response.eligible == "Y"
if not isExpress or not isEligible:
return DeliveryEstimate(available=false)
return DeliveryEstimate(
available=true,
days=response.eta_days
)Now the application can make its decision in its own vocabulary:
estimate = shipping.estimateExpressDelivery(order)
if estimate.available:
promiseDeliveryIn(estimate.days)This is deliberately small. A production design might use a richer type to make an unavailable estimate impossible to combine with a meaningless days value. The important point here is the boundary: provider codes are interpreted once, and downstream code receives a local concept.
Translation should absorb semantic differences
A weak adapter changes syntax while preserving foreign meaning. For example:
ProviderQuote -> ShippingQuote
service_code -> serviceCode
eta_days -> etaDays
eligible -> eligibleThe names look more familiar, but callers still need to understand the provider’s concepts. The dependency has merely been repackaged.
A stronger boundary translates semantics. Suppose your application distinguishes three outcomes:
Available(days)
Unavailable
TemporarilyUnknownThe provider might represent those outcomes through a mixture of status codes, missing fields, and errors. The anti-corruption layer interprets those combinations and produces one of the local outcomes.
That changes where knowledge lives. Without translation, every caller needs rules such as “status 42 with no estimate means try again later.” With translation, that rule belongs to the integration boundary, and callers reason about TemporarilyUnknown.
This is the main design test: could application code use the local type correctly without learning the external system’s documentation? If not, too much foreign meaning is probably leaking through.
Put the boundary at the ownership edge
The anti-corruption layer should sit where your application crosses into a model it does not control.
A common shape is:
application code
|
v
local shipping interface
|
v
provider adapter + translation
|
v
external shipping APIThe application depends on an interface defined around its own needs, such as:
ShippingService:
estimateExpressDelivery(order) -> DeliveryEstimateThe adapter implements that interface using the provider’s API.
Dependency direction matters. If the local interface is copied from the provider’s endpoints, request objects, and error model, the external design still controls the application. Define the local contract from what the application needs to accomplish, then make the adapter perform the translation required to satisfy it.
This does not mean hiding every technical fact. Timeouts, authentication failures, or unavailable dependencies may still matter operationally. The boundary should translate facts whose business meaning differs while preserving failures that callers genuinely need to handle.
Translate in both directions when necessary
Integration is often bidirectional. Requests can leak foreign concepts just as easily as responses.
Imagine your application has this local command:
ShipmentRequest:
orderId
destination
priorityThe provider expects:
{
"reference": "...",
"zone": "...",
"product": "EXP_1"
}The adapter can translate priority into the provider’s product code and convert the local destination into the provider’s zone representation.
The same principle applies: application code should not choose "EXP_1" because that is not an application concept. It should choose a local priority or service level whose meaning the application owns.
Translation in both directions also gives one place to enforce integration assumptions. If a destination cannot be represented by the provider, the adapter can reject or translate that condition at the boundary rather than allowing a malformed provider request to emerge from arbitrary business code.
Treat errors as part of the foreign model
External systems usually have their own failure vocabulary. Passing it straight through can couple callers as strongly as passing through response objects.
Suppose the provider reports:
P104 = destination not served
P221 = service temporarily suspended
P900 = internal provider errorYour application may care about different categories:
UnsupportedDestination
ServiceTemporarilyUnavailable
ShippingDependencyFailedMapping P104 to UnsupportedDestination lets application logic make a stable decision without knowing which provider invented the code.
Do not flatten every error into one generic failure, though. Translation should preserve distinctions that affect local behavior. If an unsupported destination should stop checkout while a temporary outage should allow retry later, those outcomes need to remain distinct.
Also keep diagnostic context somewhere appropriate for operations. A local error can carry or log the provider’s request identifier and original error code without forcing business logic to branch on them.
Do not mirror the entire external system
An anti-corruption layer can become wasteful if it tries to recreate every external object locally.
If the provider exposes fifty fields and your application needs four, translating all fifty creates code with no current purpose. It also makes provider changes look important even when they do not affect your application.
Translate the subset required by your local use cases. This keeps the boundary narrow and makes its contract easier to understand.
The same restraint applies to abstractions. If you have one shipping provider and one clear use case, you do not need a universal framework for all possible carriers. A small interface and one adapter may be enough.
The goal is not provider independence as an abstract ideal. The goal is to stop externally owned concepts from spreading farther than their usefulness requires.
Test the translation rules directly
The boundary contains decisions that are easy to get subtly wrong, so test those decisions at the boundary.
For the shipping example, useful cases include:
EXP_1 + eligible Y + eta 2 -> Available(2)
EXP_1 + eligible N -> Unavailable
unknown service code -> defined local outcome
provider temporary error -> ServiceTemporarilyUnavailableThese tests are valuable because they describe the mapping contract. If the provider changes a code or response shape, failures point to the translation layer rather than appearing as unrelated business failures elsewhere.
A separate integration or contract test can verify that the adapter still understands representative real provider responses. Unit tests of the translation alone cannot prove that the external system still sends what you expect.
Keep those two concerns distinct: translation tests verify your mapping rules; boundary integration tests verify your assumptions about the external contract.
Watch for leakage during code review
Once a boundary exists, new features can gradually bypass it. A provider adds a useful field, and a developer passes it directly to a caller “just for now.” Several changes later, the local model depends on that field everywhere.
Useful leakage signals include:
- provider-specific status or product codes outside the adapter;
- external request or response types in application interfaces;
- business rules branching on provider error codes;
- local names copied from foreign concepts that have different local meaning;
- callers needing provider documentation to use a supposedly local API.
These are not automatic defects. Sometimes an external concept genuinely becomes part of your domain. The review question is whether the application intentionally owns that concept or merely inherited it from the integration.
Understand the trade-offs
An anti-corruption layer adds code. Every translated field, error, and operation creates mapping logic that must be maintained and tested. When the external and local models are already nearly identical, translation may add ceremony without reducing meaningful coupling.
The layer is most useful when at least one of these conditions is true:
- the external model uses concepts that do not fit your application’s language;
- provider-specific details are starting to appear in many modules;
- the external contract changes on a different schedule from your application;
- multiple external systems need to satisfy the same local capability;
- external errors or workflows require interpretation before the application can act on them.
A simpler direct adapter may be better for a small, stable integration whose concepts are intentionally part of the application. The decision should depend on the cost of semantic coupling, not on a rule that every dependency needs an elaborate boundary.
There is also a risk of creating a misleading abstraction. Two providers that both “ship packages” may have fundamentally different guarantees, service levels, or workflows. Forcing them behind one lowest-common-denominator interface can hide differences the application actually needs to know. A good local model preserves meaningful distinctions rather than pretending they do not exist.
Evolve the local model deliberately
The anti-corruption layer is not a permanent freeze on your application’s design. Sometimes an external capability reveals a concept that truly belongs in your model.
Suppose several providers introduce scheduled delivery windows and customers clearly benefit from choosing them. The application may decide that DeliveryWindow is now a first-class local concept.
That change should happen deliberately:
external capability
|
v
engineering decision: does this belong to us?
|
v
local model evolves
|
v
adapters translate each provider into that modelThis is different from letting the first provider’s slot_code spread through the codebase. The application owns DeliveryWindow because the business now needs the concept, not because a vendor happened to expose a field.
Conclusion
An anti-corruption layer protects an application from accidental semantic coupling to systems it does not control. Its job is not merely to convert JSON into objects. Its job is to translate foreign concepts into local ones at a clear boundary.
Start small. Define the capability your application needs in its own language. Translate requests, responses, and meaningful errors at the integration edge. Keep provider-specific codes and structures inside that boundary, and test the mapping rules directly.
Use the pattern when the cost of foreign concepts spreading through the codebase is greater than the cost of maintaining the translation. When the models are already aligned and stable, a simpler adapter can be enough.
The practical test is simple: application code should be able to make correct decisions using the local model without needing to understand how the external system happens to represent the same problem.