DEVBLUEPRINTS

Blog

  • Sobre o blog
  • Arquivo
  • Newsletter
  • RSS

Legal

  • Termos e Privacidade
  • Contato
  • Sobre mim

Inscreva-se na Newsletter

Autorizo o envio de comunicações por e-mail ou qualquer outro meio e concordo com os Termos e Política de Privacidade

 

Blog
  • Sobre o blog
  • Arquivo
  • Newsletter
  • RSS
Legal
  • Termos e Privacidade
  • Contato
  • Sobre mim
© 2026 Todos os direitos reservados — Desenhado e construido comFooter Heartpor Ednaldo Luiz
Blog/System Design

Anti-Corruption Layer: isolate your application from external models

Third-party contracts, statuses, and errors spread through the codebase little by little. Learn how an Anti-Corruption Layer isolates this coupling and when it is worth the cost.

software architecture
integration patterns
best practices
ddd
Anti-Corruption Layer: isolate your application from external models
Ednaldo Luiz
Ednaldo Luiz
Level: Intermediate
Level:
Published: 02 de agosto de 2026
Last updated: 02 de agosto de 2026
16 min read
views: - views

Introduction

You know that integration that starts with an HTTP call, a response DTO, and a status check?

The application needs to collect payment for a subscription. The gateway returns SETTLED, the code activates the plan, and everything seems solved.

But the integration grows. Pix, boleto payments, recurring billing, cancellations, refunds, and webhooks enter the picture. Little by little, provider responses begin to appear inside use cases, provider-specific exceptions are handled in several places, and statuses such as AWAITING_PAYMENT, OVERDUE, and REFUNDED, which are not even part of the application's language, start driving important domain decisions.

The problem is not depending on an external system. Using third-party APIs is natural and often necessary.

The problem begins when the application stops merely talking to that system and starts to think like it.

A change to the external contract now requires changes across different parts of the codebase. Replacing the provider is no longer just a matter of swapping an integration. You need to find every place that has learned its names, formats, codes, and rules.

An Anti-Corruption Layer creates a boundary between these models. Instead of allowing external contracts to cross into the application, it translates statuses, data, errors, and operations into concepts that make sense internally and align with the language the application already uses.

The pattern was originally described in the context of Domain-Driven Design, but the problem it addresses appears in any system that integrates with gateways, third-party APIs, legacy systems, SDKs, or services owned by other teams.

In this article, we will understand how this boundary works, why it is more than a simple mapper, and when its cost is actually justified.

TL;DR

An Anti-Corruption Layer belongs to the consuming context that wants to preserve its own model.

It translates external contracts, statuses, errors, and operations into internal concepts, preventing provider, legacy-system, or other-service details from spreading throughout the application.

Use an ACL when there is a meaningful semantic difference. When both sides already speak almost the same language, a simple client or adapter may be enough.

An ACL concentrates coupling, but does not eliminate it. In exchange for that protection, it requires translations, tests, and boundary maintenance.


What is an Anti-Corruption Layer?

An Anti-Corruption Layer, or ACL, is a translation boundary between the application and an external model.

It allows two systems to communicate without forcing either side to adopt the other side's contracts, names, and rules.

ACL can also mean Access Control List, the permission list commonly found in security and networking. Here, however, it always means Anti-Corruption Layer.

Diagram: an external system, with its own model and language, communicates with the application domain through an ACL that translates, adapts, and protects.

A gateway may return SETTLED, while the application works with PAID. The ACL understands both meanings and translates between them.

The external model does not need to be wrong or poorly designed. It may be perfectly suitable for the context in which it was created and still fail to represent the problem in the way that makes sense inside our application.

An ACL does not try to create a universal model. It lets each side keep speaking its own language while the boundary translates what is necessary for them to communicate.

What does "corruption" mean?

In this context, corruption has nothing to do with security, fraud, or damaged data.

It happens when a concept that makes sense on the other side begins to determine how our own model must be structured.

In practice, it starts small:

Notice the detail: SETTLED is a provider status, not a concept from our application. It may make sense to the gateway, but not necessarily to the subscription domain.

if ("SETTLED".equals(payment.status())) {
	subscription.activate();
}
if ("SETTLED".equals(payment.status())) {
	subscription.activate();
}

The subscription activation rule now depends directly on the provider's vocabulary.

With translation:

if (payment.isPaid()) {
	subscription.activate();
}
if (payment.isPaid()) {
	subscription.activate();
}

At this point, it is fair to be skeptical: isPaid() did not appear out of nowhere, and this is not merely hiding the same comparison inside another method.

The difference lies in where the knowledge that SETTLED means a completed payment for this context remains.

The external vocabulary stays inside the translation performed by the ACL:

private PaymentStatus translateStatus(String externalStatus) {
	return switch (externalStatus) {
			case "SETTLED", "RECEIVED" -> PaymentStatus.PAID;
			case "AWAITING_PAYMENT" -> PaymentStatus.PENDING;
			case "OVERDUE" -> PaymentStatus.PAST_DUE;
			case "REFUNDED" -> PaymentStatus.REFUNDED;
			default -> throw new UnsupportedProviderStatusException(
					externalStatus
			);
	};
}
private PaymentStatus translateStatus(String externalStatus) {
	return switch (externalStatus) {
			case "SETTLED", "RECEIVED" -> PaymentStatus.PAID;
			case "AWAITING_PAYMENT" -> PaymentStatus.PENDING;
			case "OVERDUE" -> PaymentStatus.PAST_DUE;
			case "REFUNDED" -> PaymentStatus.REFUNDED;
			default -> throw new UnsupportedProviderStatusException(
					externalStatus
			);
	};
}

This switch belongs to the ACL translator. It converts the provider's vocabulary into the internal model; the use case does not need to know these external statuses.

The isPaid() behavior belongs to the internal PaymentResult model. It checks the PaymentStatus that has already been translated by the ACL:

PaymentResult.java
public boolean isPaid() {
	return status == PaymentStatus.PAID;
}
PaymentResult.java
public boolean isPaid() {
	return status == PaymentStatus.PAID;
}

Notice that isPaid() does not hide a comparison with SETTLED: it does not even know that this string exists.

The ACL interprets the external vocabulary. The internal model decides the behavior associated with PAID.

If another provider calls the same outcome SUCCEEDED, it gets its own translation. The rest of the application continues working with PaymentStatus.PAID.

Two providers, two vocabularies, and one internal concept.

The corruption is not in the external model.

It happens when a concept created for another context begins to determine the language and decisions of our application.

The ACL belongs to the consumer

An ACL is built from the perspective of the context that wants to preserve its model.

In an integration with a gateway:

Consumer context(downstream)Provider system(upstream)Subscription applicationPayment gateway
The subscription application consumes the contract offered by the gateway; upstream and downstream describe the relative position of each system in the integration.

The upstream system defines the contract it offers. The downstream system consumes that contract, but it does not need to copy it into its own model.

The ACL belongs to the consuming side:

Gateway model
ACL
Subscription model
upstream
→
translation
→
downstream
Gateway model
upstream
→
ACL
translation
→
Subscription model
downstream

Although it may translate information in both directions, it is not a neutral boundary. Its purpose is to protect the downstream model.

Translation happens every time the boundary is crossed:

FlowACL receivesACL returns
Application → GatewayInternal PaymentRequestThe request expected by the gateway
Gateway → ApplicationExternal responseInternal PaymentResult
Gateway → ApplicationProvider errorA failure the application can understand
Gateway → ApplicationExternal webhookInternal event

After each crossing, the rest of the application continues working only with its own language.

Imagine that the application needs only the identifier and status of a charge:

PaymentResult.java
public record PaymentResult(
	PaymentId id,
	PaymentStatus status
) {}
PaymentResult.java
public record PaymentResult(
	PaymentId id,
	PaymentStatus status
) {}

The gateway, however, may return a much larger contract:

ProviderAPaymentResponse.java
public record ProviderAPaymentResponse(
	String id,
	String status,
	String billingType,
	String invoiceUrl,
	String receiptUrl,
	String estimatedCreditDate,
	String providerAccount
) {}
ProviderAPaymentResponse.java
public record ProviderAPaymentResponse(
	String id,
	String status,
	String billingType,
	String invoiceUrl,
	String receiptUrl,
	String estimatedCreditDate,
	String providerAccount
) {}

The application does not need to reproduce all of that.

The ACL selects what is relevant and prevents the internal model from becoming a copy of the external API. That is why, in addition to translating, it also acts as a filter.

How the ACL protects the model

Translation may be technical or semantic, and each type solves a different problem:

TypeExampleWhat changes
TechnicalJSON → XMLThe message format.
TechnicalREST → SOAPThe protocol or communication style.
TechnicalHTTP → messagingHow information is transported.
SemanticSETTLED → PAIDThe meaning used inside the application.
Semanticcustomer → subscriberThe language used to represent the concept.
Semanticcode B → blocked accountThe interpretation of an external value.

An ACL may perform both.

It may receive XML over SOAP, convert the payload into Java objects, and then interpret code A as CustomerStatus.ACTIVE.

But technical translation alone does not necessarily characterize an ACL. A can also convert protocols.

The central role of the ACL is to protect the meaning used by the internal model.

In practice, this boundary may be composed of:

  • a contract defined by the application;
  • a client that knows the external system;
  • an adapter that connects both sides;
  • translators or mappers;
  • provider-specific DTOs;
  • translation of errors, events, and references.

These components are not mandatory and do not need to exist as separate classes.

In a small integration, the adapter may also perform the translation. In a larger integration, separating the client, adapter, and translator may improve clarity.

The complexity of the structure should follow the real complexity of the problem.

Port, adapter, and ACL are not synonyms. In the example below, PaymentGateway defines the internal contract; ProviderAPaymentAdapter implements it against the provider; and the ACL is the architectural boundary formed by these pieces and the semantic translation.

Element in the exampleRoleRelationship to the ACL
PaymentGatewayPortDefines the contract the application wants to consume.
ProviderAPaymentAdapterAdapterImplements the port using the provider's contract.
ProviderAPaymentTranslatorSemantic translatorConverts external contracts, statuses, and errors into internal concepts.
Anti-Corruption LayerArchitectural boundaryBrings these pieces together to isolate the external contract.

Creating an interface and an adapter does not automatically mean that you have created an ACL.

An adapter that returns ProviderAPaymentResponse to the use case still allows the external contract to cross into the application.

The difference lies in the architectural intent and in semantic protection.

Some signs show that the boundary is leaking:

// Provider type leaking through the method signature
public ProviderAPaymentResponse createSubscription(...) {
	// ...
}

// External exception handled inside the use case
catch (ProviderAException exception) {
	// ...
}

// External code driving an internal rule
if ("PAYMENT_SETTLED".equals(event.status())) {
	// ...
}
// Provider type leaking through the method signature
public ProviderAPaymentResponse createSubscription(...) {
	// ...
}

// External exception handled inside the use case
catch (ProviderAException exception) {
	// ...
}

// External code driving an internal rule
if ("PAYMENT_SETTLED".equals(event.status())) {
	// ...
}

Provider types, enums, codes, and exceptions should not appear in controllers, entities, or internal use cases.

After all, they represent rules and details of the external provider, not concepts that our application should know.

This does not eliminate coupling. The ACL still knows the external contract.

The gain comes from concentrating that coupling inside a controlled boundary instead of allowing it to spread.

As with the Law of Demeter, the goal is to limit how much one part needs to know about another. Here, however, the boundary is between models and systems rather than between objects.

An ACL does not remove the external dependency. It prevents that dependency from determining how the rest of the application must think.


Use cases

ACLs do not appear only in legacy systems or projects that formally adopt DDD.

They can be useful whenever two systems need to collaborate but represent the problem in different ways.

Examples of Anti-Corruption Layers for gateways, legacy migrations, and communication between services.

Gateways and providers

Payment gateways are a common scenario.

Each provider may have different contracts, statuses, exceptions, webhooks, and capabilities. The ACL translates these differences before they reach the application.

The statuses below are fictional and simplified. Real gateways have their own lifecycles and should not be forced into equivalences that the business does not recognize.

Our modelGateway AGateway B
PAIDSETTLED, RECEIVEDSUCCEEDED, COMPLETED
PENDINGAWAITING_PAYMENTPROCESSING
PAST_DUEOVERDUEPAST_DUE
REFUNDEDREFUNDEDREVERSED

In this subscription context, PAID means that the payment has reached the state required to grant access.

A financial reconciliation context might preserve more detailed distinctions between confirmation, capture, and settlement.

This shows an important point: the ACL does not translate data universally. It translates according to the question the consuming context needs to answer.

A new provider can be added without changing the use case as long as the internal contract continues to represent the capabilities required by the business correctly.

The ACL concentrates the differences, but it does not turn semantically different providers into perfectly interchangeable alternatives.

If one supports partial refunds, separate authorization and capture, or payment splitting while another does not, the internal contract may need to reflect those differences.

The same principle also applies to email services, cloud storage, shipping providers, identity providers, and AI providers with their SDKs.

Gradual legacy migration

During modernization, an ACL allows the new system to work with its own model while it still queries data and executes operations in the legacy system.

The legacy system may represent customers like this:

{
	"cod_cli": "1029",
	"sit": "A",
	"tp_cli": "02",
	"fl_pend": "S"
}
{
	"cod_cli": "1029",
	"sit": "A",
	"tp_cli": "02",
	"fl_pend": "S"
}

Opaque strings and codes are not pleasant to work with. The modern system would rather use:

Customer.java
public record Customer(
	CustomerId id,
	CustomerStatus status,
	CustomerType type,
	boolean hasPendingIssues
) {}
Customer.java
public record Customer(
	CustomerId id,
	CustomerStatus status,
	CustomerType type,
	boolean hasPendingIssues
) {}

This is where the ACL shines by interpreting those values:

Legacy valueInternal modelWhat the translation solves
cod_cli: "1029"CustomerIdA loose string gains a type and meaning.
sit: "A"CustomerStatus.ACTIVEAn opaque code becomes a named status.
tp_cli: "02"CustomerType.BUSINESSA magic number becomes a domain concept.
fl_pend: "S"hasPendingIssues = trueA textual flag becomes a boolean.

The new system continues using its own model without copying historical names, codes, and limitations.

The ACL also allows the migration to happen incrementally. Some operations remain in the legacy system while others have already moved to the modern system.

In migrations, this boundary may be created with an explicit removal criterion. Once the legacy system has been fully replaced, the layer no longer serves a purpose.

Communication between services

Two internal services may also represent the same process in different ways.

Imagine an inventory service publishing:

{
	"sku": "ABC-123",
	"availabilityCode": 2,
	"warehouse": "REC-01"
}
{
	"sku": "ABC-123",
	"availabilityCode": 2,
	"warehouse": "REC-01"
}

The ordering context may only need to know whether the item can be reserved. At the boundary, the external code gains an internal meaning:

availabilityCode
ACL
Availability Status
2
→
translates
→
AVAILABLE
availabilityCode
2
→
ACL
translates
→
Availability Status
AVAILABLE

The ACL translates the message before delivering it to the ordering model.

This communication may happen through REST, gRPC, events, or queues. Protocol translation may be part of the boundary, but it does not replace semantic translation.

Another context may interpret the same data differently:

Orders contextReplenishment contextInventory serviceInventory ACLfor OrdersProductAvailabilityInventory ACLfor ReplenishmentReplenishmentPriorityavailabilityCodeavailabilityCode+ warehouse
Two contexts consume the same inventory service, but each ACL preserves its own interpretation of the data.

Technical infrastructure can be shared: authentication, HTTP clients, rate limiting, and telemetry.

Semantic translation, however, usually stays close to the consuming context because different contexts may interpret the same external data in different ways.

A central and shared ACL may exist, but it needs its own language and clear .

Otherwise, it risks turning into a generic corporate model that imposes the same meaning on every consumer.

It is also important not to hide the real consequences of the communication model.

If work enters a queue, , , retries, and delayed failures still exist.

The ACL translates the flow. It does not turn an asynchronous message into a synchronous call.

Before creating an ACL between internal services, verify that there is a real model mismatch.

Sometimes the problem is only an unstable contract, a lack of backward compatibility, or a poorly defined relationship between teams.


When is an ACL actually worth it?

An ACL should protect a concrete difference, not a hypothetical one.

Creating one merely because an integration exists can add code without reducing the system's complexity.

The most important question is:

What exactly are we trying to protect?

If the team cannot answer that question, the layer may be getting created simply because it looks “architecturally elegant.” That is where becomes dangerous: adding structure to solve a problem that does not exist yet.

An ACL tends to make sense when:

  • the models use different languages;
  • external concepts do not represent the internal business well;
  • the provider changes outside the team's control;
  • multiple providers need to be normalized;
  • a migration requires the legacy and modern systems to coexist;
  • external types and exceptions are already spreading;
  • the internal model matters to the product's evolution.

The greater the semantic difference, the more valuable the boundary becomes:

  • Small difference: a simple client or adapter.
  • Localized difference: a focused translation.
  • Deep difference: an explicit ACL.

The existence of an external API alone is not enough justification.

When accepting the external model is enough

We do not always need to maintain our own model.

If the system offers a stable, backward-compatible contract and both sides use almost the same concepts, accepting that model can be a deliberate choice.

In DDD , this relationship is known as Conformist.

  • Anti-Corruption Layer: preserves its own model and translates the upstream model.
  • Conformist: deliberately accepts the upstream model.
  • Simple adapter: adjusts the interface or communication without maintaining a meaningful semantic separation.

Consider two contracts in which id, name, email, active, and createdAt have exactly the same meaning.

Creating an external , an internal one, a mapper, an interface, and tests for identical transformations may only duplicate structures.

Preserving your own model has value when there is something meaningful to preserve.

Accepting an external contract does not mean there is no architecture.

In some scenarios, it simply means recognizing that the cost of translation would be greater than the benefit.

Costs and signs of excess

An ACL offers protection, but that protection is not free.

AspectWhat we may gainWhat we must pay
Internal modelClearer and more independent language.Additional types and translations.
External changesImpact concentrated at the boundary.Maintenance whenever either side changes.
TestsUse cases isolated from the provider.Translation and contract tests.
Multiple providersStable internal contracts.Provider-specific adapters and capability limits.
Legacy migrationIncremental modernization.Coexistence and synchronization between systems.
Remote ACLIndependent operation or scaling.Network, deployment, and another failure point.

There is also a risk of losing information during translation.

If the provider has ten statuses and the application reduces them to three, that simplification may be useful. But it may also hide distinctions that will matter later.

Another warning sign appears when multiple internal services need ACLs to protect themselves from the same system.

There may be:

  • an unstable contract;
  • no backward compatibility;
  • unclear ownership;
  • uncoordinated changes;
  • poorly defined service boundaries;
  • low trust between teams.

In this scenario, additional adapters and DTOs reduce the local impact but do not solve the underlying cause.

The architecture may be hiding another problem

Sometimes an ACL protects a real model mismatch.

In other cases, it merely compensates for unstable contracts or a poorly defined relationship between teams.

Before adding another layer, consider whether the upstream contract should instead be stabilized, simplified, or redesigned.

The practical question is:

Does the boundary remove more complexity than it adds?

When the answer is no, a simple client, a localized translation, or a Conformist relationship may be better choices.


Where does the ACL live, and for how long?

An ACL may live inside the application itself or as a separate service. The most common starting point is to keep it internal: this reduces latency and infrastructure and keeps ownership simpler.

Moving it across the network usually makes sense only when multiple consumers share the same translation, a dedicated team owns it, or there is a concrete operational need for isolation and independent scaling.

Even then, sharing the boundary requires sharing meaning. If subscriptions, billing, and reconciliation interpret the provider differently, a central ACL may impose yet another external model instead of protecting each context.

It is also worth deciding how long the layer should exist. During an incremental migration, it may be created with a planned removal date. In permanent integrations with gateways, shipping providers, or other vendors, it tends to remain as a stable boundary.


Before creating an ACL

If the boundary is justified, these points deserve an explicit decision:

  1. Define which context and model need protection.
  2. Identify clearly who is upstream and who is downstream.
  3. Compare the meanings on both sides, not only field names.
  4. Start from the contract the application would like to consume.
  5. Translate meaning, errors, references, and events—not only structures.
  6. Keep external DTOs, enums, codes, and exceptions inside the integration.
  7. Do not place core business rules inside the boundary.
  8. Handle unknown values and possible information loss explicitly.
  9. Do not promise interchangeability where providers have different capabilities.
  10. Verify whether the protection removes more complexity than it adds.

Do not use an ACL merely because an integration exists.

Use one when accepting the external model would harm the application's language, rules, or ability to evolve.


Before we finish, here are five quick questions about the boundary and when it is worth the cost:

Question1/5

In the context of an ACL, what does "corruption" mean?


Conclusion

External contracts, codes, and exceptions rarely invade an application all at once. They enter one if at a time, each one too small to justify an entire layer on its own.

An Anti-Corruption Layer concentrates that knowledge inside a controlled boundary so that each side can continue speaking its own language.

It charges a price for that protection: additional types, adapters, translations, and tests.

When the models already say the same thing, the layer may only duplicate structure. And when providers offer genuinely different capabilities, it does not make them interchangeable by magic.

A good integration allows two systems to communicate.

A good Anti-Corruption Layer allows them to communicate without forcing them to think the same way.

Now open the oldest integration in your project and look for a provider status, DTO, or exception outside the integration layer.

If you find one, you have found a boundary worth reviewing.

On this page

Share

References

Guides and readings on Anti-Corruption Layer, DDD, and integration boundaries.

Ednaldo Luiz
GitHubLinkedInPortfólio

Ednaldo Luiz

Software Architect and Engineer | Java & AI

Software Engineer focused on architecture and performance. I work with Java/Spring Boot, well-structured SQL, scalable services on AWS, and GenAI solutions with RAG (LangChain + vector databases). I value readable code and well-justified decisions.