Anti-Corruption Layer Pattern

The Anti-Corruption Layer (ACL) is a strategic architectural pattern from Eric Evans’s 2003 Domain-Driven Design: Tackling Complexity in the Heart of Software (Chapter 14, Maintaining Model Integrity) that addresses a single, recurring problem: when one bounded context must integrate with another bounded context that has a different domain vocabulary, the integration boundary must not allow the foreign vocabulary to leak into the consumer’s domain. If it does, the consumer’s domain model becomes “corrupted” — its entities, value objects, and behaviors start to reflect the foreign system’s conceptual structure rather than its own — and the consumer can no longer evolve its domain independently. The Anti-Corruption Layer is the translation layer placed between the two contexts: outbound calls from the consumer’s domain pass through it to be translated into the foreign system’s vocabulary; inbound data from the foreign system passes through it to be translated into the consumer’s vocabulary. Inside the consumer’s domain, the foreign vocabulary does not exist. The pattern is most often deployed in three operational contexts: integrating with a legacy mainframe during gradual modernization (often paired with the Strangler Fig Pattern), integrating with a third-party SaaS whose API is shaped by its own (often quirky) domain model (Salesforce, Stripe, NetSuite, ServiceNow), and integrating across internal bounded contexts in a microservices estate where two teams have evolved different vocabularies for overlapping concerns. The ACL combines patterns: a façade that simplifies the foreign system’s API surface, an adapter that translates wire formats, and a translator that converts between the two domain vocabularies. Together they preserve domain integrity while permitting integration. This note covers the original DDD framing, the structure and components, the worked example of integrating Salesforce CRM with a Customer Service domain, the connection to Hexagonal Architecture and Strangler Fig Pattern, the variants (one-way, two-way, messaging-based), the failure modes, and the interview discussion points around senior DDD/microservices roles.

1. The Problem ACL Solves — Vocabulary Corruption

To understand why ACL exists, you must first understand the concept of a bounded context from DDD. A bounded context is the boundary within which a particular ubiquitous language holds: every term has a single, agreed-upon meaning. Inside the Customer Service bounded context, “Customer” might mean a person who has called the support line; inside the Sales bounded context, “Customer” might mean a company that has signed a contract; inside the Billing context, “Customer” might mean an account that owes money. These are different customers — different attributes, different lifecycles, different operations — even though English uses the same word.

A bounded context’s value depends on the coherence of its language. If the language stays clean, the team can have productive conversations (“the customer’s case escalation”), the code can use the language directly (Customer.escalateCase(case)), and the domain logic remains tractable. If the language leaks — if the Customer Service team starts saying “Customer” sometimes meaning a support-caller and sometimes meaning a Salesforce Account — the language degrades and so does the code.

Now consider integration. The Customer Service team needs data from Salesforce. Salesforce’s domain has Account, Contact, Opportunity, Case, Lead — a particular conceptual structure shaped by Salesforce’s product history (it began as a sales-pipeline tool, hence Account/Contact/Opportunity/Lead; Case was added later for service). The integration must somehow bridge the two domains.

The naive approach is to import Salesforce types directly into the Customer Service codebase. Maybe an HTTP client returns SalesforceAccount objects; the team starts writing Customer findFromSalesforceAccount(SalesforceAccount acc) methods; soon, SalesforceAccount references appear in service classes, in controllers, in domain entities. Maybe the Customer entity gains a salesforceId field, then a salesforceAccountType enum, then a salesforceLastModifiedAt timestamp. The team’s ubiquitous language now includes Salesforce vocabulary; their domain has been corrupted.

The consequences:

  1. Coupling to Salesforce’s evolution. When Salesforce changes its API (which happens frequently — Salesforce releases three major API versions per year, with periodic breaking changes), the Customer Service code must change with it.
  2. Difficulty replacing Salesforce. If the company decides to migrate from Salesforce to HubSpot (or build its own CRM), the migration touches every place Salesforce vocabulary leaked into the codebase. What should be a localized integration change becomes a sprawling refactor.
  3. Loss of clarity. A reader of the Customer Service code now needs to know Salesforce’s domain model to understand the code. The cognitive load doubles.
  4. Test entanglement. Tests of Customer Service domain logic require Salesforce mocks deep in the call stack. Pure domain unit tests become difficult.
  5. Conceptual confusion. When the team says “Account,” they sometimes mean Salesforce Account, sometimes Customer Service Customer-with-an-account-tier. The ambiguity slows discussions and produces bugs.

The Anti-Corruption Layer solves all of this with a single discipline: the foreign vocabulary stops at the ACL. Inside the Customer Service domain, no Salesforce types exist; only Customer Service types. The ACL is the only place that knows both vocabularies, and it translates between them.

2. Origin — Eric Evans 2003 Domain-Driven Design

The pattern is introduced in Eric Evans’s 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software (the “Blue Book”), specifically in the strategic-patterns portion of the book where Evans is discussing how to maintain model integrity across system boundaries. The relevant material is in the section “Maintaining Model Integrity” within Part IV (Strategic Design).

Evans’s framing emphasizes that the ACL is defensive: you build it to protect your domain from a foreign system you do not control. The key passage paraphrased (with Evans’s emphasis): when you have to interface with a system whose model is incompatible with yours and you don’t have the leverage to change it, build an isolating layer that translates in both directions, and never let the foreign concepts cross into your domain.

Evans presents the ACL as one of several Context Mapping patterns — patterns for describing how bounded contexts relate to each other. The full list of Evans’s context-mapping patterns includes:

  • Shared Kernel — two contexts share a small piece of their model; they coordinate changes to it.
  • Customer/Supplier — one context’s downstream needs are taken into account by the upstream supplier.
  • Conformist — the downstream context simply accepts the upstream’s model and adopts it (the inverse of ACL — yielding to corruption rather than resisting).
  • Anti-Corruption Layer — protect your domain from a foreign one with a translation layer.
  • Open Host Service — your context publishes a stable, well-documented protocol for many downstreams to use.
  • Published Language — a shared format (XML schema, Avro schema) that contexts use to communicate.
  • Separate Ways — two contexts decline to integrate at all.
  • Big Ball of Mud — uncategorized legacy where vocabularies have already corrupted each other.

The ACL is the defensive answer; Conformist is the yielding answer. The choice depends on whether your domain has the importance and the leverage to defend itself. A central core domain almost always deserves an ACL; a peripheral utility integration may not warrant the cost.

In Vaughn Vernon’s 2013 Implementing Domain-Driven Design (the “Red Book”), Chapter 3 Context Maps fleshes out the ACL pattern with implementation guidance, code samples, and operational considerations. Vernon emphasizes that the ACL is not just a translation table; it is a complete subsystem with its own design, its own tests, and its own deployment unit. Treating it as a one-line mapping is a recipe for it to grow uncontrollably and become its own corrupted layer.

The pattern propagated into mainstream microservices guidance via the Microsoft Azure Architecture Center’s Anti-Corruption Layer pattern documentation (https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer), Vlad Khononov’s 2021 Learning Domain-Driven Design, and Sam Newman’s Building Microservices (2nd ed. 2021).

3. Structure of an Anti-Corruption Layer

An ACL is a composite of three patterns from the broader software engineering vocabulary:

3.1 The Façade

A façade (Gamma et al. 1994, Design Patterns) is a single high-level interface that simplifies access to a complex subsystem. The ACL exposes a façade to its consumers — the consumer’s domain code calls the ACL through a small, vocabulary-clean interface defined in the consumer’s terms.

For example, a Customer Service domain integrating with Salesforce might define:

public interface CrmClient {
    Customer findByEmail(EmailAddress email);
    Optional<Customer> findById(CustomerId id);
    void recordContactMade(CustomerId id, Channel channel, Instant when);
    void updateTier(CustomerId id, CustomerTier newTier);
}

This interface uses Customer Service vocabulary — Customer, EmailAddress, CustomerId, Channel, CustomerTier — none of which appear in Salesforce’s vocabulary. The consumer never sees Salesforce concepts. The façade is what the rest of the domain depends on.

3.2 The Adapter

An adapter (Gamma et al. 1994) wraps an interface so that a consumer can call it as if it were a different interface. The ACL’s adapter wraps the Salesforce API: it owns the HTTP client, the authentication token, the rate-limit handling, the retry policy, the JSON parsing. It exposes a Salesforce-shaped internal interface to the rest of the ACL — methods like salesforceClient.getAccount(accountId) that return SalesforceAccount objects. The adapter is the only piece of the ACL that knows about HTTP, OAuth, REST URLs, or the wire format.

The adapter typically uses a generated client or hand-written code that mirrors the foreign API. Salesforce, for instance, has multiple official clients (jsforce for Node, Salesforce CLI, Force.com Toolkit for Java); using one of these as the basis of the adapter is common.

3.3 The Translator

The translator is the heart of the ACL. It converts between Salesforce-shaped objects (returned by the adapter) and Customer Service-shaped objects (returned by the façade). The translator is bidirectional: outbound calls (Customer Service → Salesforce) flow through translator methods that convert the Customer Service argument into a Salesforce request shape; inbound responses flow through translator methods that convert the Salesforce response into Customer Service return values.

public class CustomerSalesforceTranslator {
    public Customer toCustomer(SalesforceAccount sfAccount,
                                List<SalesforceContact> sfContacts) {
        // pick the primary contact
        SalesforceContact primary = sfContacts.stream()
            .filter(c -> c.isPrimary)
            .findFirst()
            .orElse(sfContacts.get(0));
        return new Customer(
            CustomerId.of(sfAccount.id),
            CustomerName.of(sfAccount.name),
            EmailAddress.of(primary.email),
            CustomerTier.fromSalesforceType(sfAccount.accountType),
            extractAddress(sfAccount));
    }
 
    public SalesforceContactUpdate toSalesforceContactUpdate(
            CustomerId id, ContactRecord record) {
        return new SalesforceContactUpdate()
            .setLastContactedDate(record.when.toString())
            .setLastContactChannel(record.channel.toString())
            .setNumberOfTotalContacts(/* ... */);
    }
}

The translator is where the knowledge of the foreign vocabulary lives. It must know: Salesforce’s Account vs Contact distinction; that Salesforce uses accountType for what we call tier; that Salesforce stores dates as ISO 8601 strings while we use Instants; that Salesforce’s Account doesn’t have an email so we have to fetch the primary Contact. All of these foreign-system-specific decisions are localized inside the translator.

3.4 Composition

The three pieces together form the ACL:

Consumer Domain
     |
     v
[ Façade (Customer Service vocabulary) ]
     |
     v
[ Translator (knows both vocabularies) ]
     |
     v
[ Adapter (HTTP, JSON, Salesforce API) ]
     |
     v
Foreign System (Salesforce)

The consumer talks only to the façade. The façade delegates to the translator + adapter combination. The adapter handles the wire and returns foreign-shaped objects. The translator converts back and forth. The consumer never sees the boundary.

In practice the three roles can be distributed across multiple classes — a SalesforceCrmClient implementing the façade, several *Translator classes for different translations, several Salesforce*Adapter classes for different API surfaces. The structure depends on the integration’s complexity. A small ACL might be one class; a large ACL is its own mini-application.

4. Worked Example — Integrating Salesforce CRM into a Customer Service Domain

To anchor the abstract pattern, walk through a complete ACL implementation for Customer Service integrating with Salesforce.

4.1 The Two Domains

Customer Service domain:

public class Customer {
    private final CustomerId id;
    private final CustomerName name;
    private final EmailAddress email;
    private final CustomerTier tier; // BRONZE, SILVER, GOLD, PLATINUM
    private final Address address;
 
    public boolean qualifiesForPriorityRouting() {
        return tier == CustomerTier.GOLD || tier == CustomerTier.PLATINUM;
    }
    // ...
}
 
public class Ticket {
    private final TicketId id;
    private final CustomerId openedBy;
    private final Channel channel;        // PHONE, EMAIL, CHAT, SOCIAL
    private final Severity severity;       // LOW, MEDIUM, HIGH, CRITICAL
    private final TicketStatus status;     // OPEN, IN_PROGRESS, RESOLVED, CLOSED
    // ...
}

The Customer Service domain has clean concepts: Customer, Tier, Address, Ticket, Severity, Status. None of these reflect any particular CRM’s idiosyncrasies.

Salesforce domain (foreign):

// These objects mirror Salesforce's REST API; we DO NOT use them in our domain.
public class SalesforceAccount {
    public String id;
    public String name;
    public String accountType;        // "Customer - Direct", "Customer - Channel", "Prospect"
    public String accountSource;
    public String industry;
    public String billingStreet;
    public String billingCity;
    public String billingPostalCode;
    public String billingCountry;
    public String customerPriority__c;  // a custom field
    public Map<String, Object> attributes;
    // ... many more fields
}
 
public class SalesforceContact {
    public String id;
    public String accountId;
    public String firstName;
    public String lastName;
    public String email;
    public boolean isPrimary;
    // ...
}
 
public class SalesforceCase {
    public String id;
    public String accountId;
    public String contactId;
    public String subject;
    public String description;
    public String origin;       // "Phone", "Email", "Web", "Chat", "Social"
    public String priority;     // "High", "Medium", "Low"
    public String status;       // "New", "Working", "Escalated", "Closed"
    // ...
}

These are Salesforce types. They live inside the ACL only.

4.2 The Façade

public interface CrmClient {
    Optional<Customer> findCustomer(CustomerId id);
    Optional<Customer> findCustomerByEmail(EmailAddress email);
 
    /**
     * Push a customer-service ticket back to the CRM as a Case for sales-team visibility.
     */
    void publishTicket(Ticket ticket, Customer customer);
 
    /**
     * Update the customer's tier in the CRM (for example, after upgrading to Platinum
     * via support-driven rescue from churn).
     */
    void updateTier(CustomerId id, CustomerTier newTier);
}

This interface is what the rest of the Customer Service application sees. Notice: no Salesforce types. No accountId parameter. Methods named in our vocabulary (findCustomer, publishTicket).

4.3 The Adapter

public class SalesforceApiAdapter {
    private final HttpClient http;
    private final SalesforceAuth auth;
    private final RetryPolicy retry;
    private final RateLimiter limiter;
    private final ObjectMapper json;
 
    public Optional<SalesforceAccount> getAccount(String accountId) {
        return retry.execute(() -> {
            limiter.acquire();
            HttpResponse<String> resp = http.send(
                buildGet("/services/data/v59.0/sobjects/Account/" + accountId),
                BodyHandlers.ofString());
            if (resp.statusCode() == 404) return Optional.<SalesforceAccount>empty();
            requireOk(resp);
            return Optional.of(json.readValue(resp.body(), SalesforceAccount.class));
        });
    }
 
    public List<SalesforceContact> getContactsForAccount(String accountId) {
        // uses SOQL: SELECT ... FROM Contact WHERE AccountId = :accountId
        return retry.execute(() -> {
            limiter.acquire();
            String soql = "SELECT Id, AccountId, FirstName, LastName, Email, IsPrimary " +
                          "FROM Contact WHERE AccountId = '" + accountId + "'";
            HttpResponse<String> resp = http.send(
                buildGet("/services/data/v59.0/query?q=" + URLEncoder.encode(soql, UTF_8)),
                BodyHandlers.ofString());
            requireOk(resp);
            SalesforceQueryResult<SalesforceContact> result = json.readValue(
                resp.body(),
                new TypeReference<SalesforceQueryResult<SalesforceContact>>() {});
            return result.records;
        });
    }
 
    public Optional<SalesforceAccount> findAccountByEmail(String email) {
        // SOSL: FIND {email} IN EMAIL FIELDS RETURNING Account, Contact
        // ... actual implementation queries Salesforce's full-text-search API
    }
 
    public String createCase(SalesforceCaseCreateRequest req) {
        // POST to /services/data/v59.0/sobjects/Case
        // returns the new case ID
    }
 
    public void updateAccount(String accountId, SalesforceAccountUpdate update) {
        // PATCH to /services/data/v59.0/sobjects/Account/{id}
    }
 
    private HttpRequest buildGet(String path) {
        return HttpRequest.newBuilder()
            .uri(URI.create(auth.getInstanceUrl() + path))
            .header("Authorization", "Bearer " + auth.getAccessToken())
            .header("Accept", "application/json")
            .GET()
            .build();
    }
 
    private void requireOk(HttpResponse<?> resp) {
        if (resp.statusCode() / 100 != 2) {
            throw new SalesforceApiException(resp.statusCode(), resp.body().toString());
        }
    }
}

The adapter is only concerned with making Salesforce HTTP calls work. It owns:

  • HTTP request construction (URLs, headers).
  • OAuth bearer token management.
  • Retry on transient failures.
  • Rate limiting (Salesforce has API call limits per 24h window).
  • JSON deserialization into Salesforce-shaped types.
  • Salesforce-specific quirks (SOQL, SOSL, custom-field-naming with __c).

The adapter does not know about Customer, CustomerTier, or Ticket. It speaks Salesforce.

4.4 The Translator

public class CustomerSalesforceTranslator {
 
    /**
     * Combine a SalesforceAccount with its primary SalesforceContact to produce a Customer.
     * Salesforce splits "an organization" (Account) and "an individual" (Contact); our
     * Customer concept fuses them. The translation chooses the primary contact's email
     * if available, falling back to a general account email.
     */
    public Customer toCustomer(SalesforceAccount account,
                                List<SalesforceContact> contacts) {
        SalesforceContact primary = contacts.stream()
            .filter(c -> c.isPrimary)
            .findFirst()
            .orElseGet(() -> contacts.isEmpty() ? null : contacts.get(0));
 
        if (primary == null || primary.email == null) {
            throw new TranslationException(
                "Cannot construct Customer from Salesforce Account " + account.id +
                ": no contact with email");
        }
 
        return new Customer(
            CustomerId.of(account.id),
            CustomerName.of(account.name),
            EmailAddress.of(primary.email),
            tierFromAccountType(account.accountType, account.customerPriority__c),
            new Address(
                account.billingStreet,
                account.billingCity,
                account.billingPostalCode,
                account.billingCountry));
    }
 
    /**
     * Salesforce's accountType is a free-text picklist. Customer Service has a strict
     * tier enumeration. The mapping is opinionated: "Customer - Direct" with high
     * customerPriority__c becomes PLATINUM; "Customer - Channel" maps to GOLD; etc.
     * Unknown values default to BRONZE with a warning logged.
     */
    private CustomerTier tierFromAccountType(String accountType, String priority) {
        if (accountType == null) return CustomerTier.BRONZE;
        return switch (accountType) {
            case "Customer - Direct" ->
                "High".equalsIgnoreCase(priority) ? CustomerTier.PLATINUM : CustomerTier.GOLD;
            case "Customer - Channel" -> CustomerTier.SILVER;
            case "Prospect", "Lead" -> CustomerTier.BRONZE;
            default -> {
                log.warn("Unknown Salesforce accountType: {}, defaulting to BRONZE", accountType);
                yield CustomerTier.BRONZE;
            }
        };
    }
 
    /**
     * A Customer Service Ticket becomes a Salesforce Case for cross-team visibility.
     * Note: Salesforce's Case has its own status lifecycle and origin enumeration;
     * we map our Channel and Severity onto those.
     */
    public SalesforceCaseCreateRequest toSalesforceCase(Ticket ticket, Customer customer) {
        return new SalesforceCaseCreateRequest()
            .accountId(customer.id().value())
            .subject(ticket.title())
            .description(ticket.description())
            .origin(channelToOrigin(ticket.channel()))
            .priority(severityToPriority(ticket.severity()))
            .status("New");
    }
 
    private String channelToOrigin(Channel channel) {
        return switch (channel) {
            case PHONE  -> "Phone";
            case EMAIL  -> "Email";
            case CHAT   -> "Chat";
            case SOCIAL -> "Social";
        };
    }
 
    private String severityToPriority(Severity severity) {
        return switch (severity) {
            case CRITICAL, HIGH -> "High";
            case MEDIUM -> "Medium";
            case LOW -> "Low";
        };
    }
 
    public SalesforceAccountUpdate tierUpdate(CustomerTier newTier) {
        return new SalesforceAccountUpdate()
            .accountType(tierToAccountType(newTier))
            .customerPriority__c(tierToPriority(newTier));
    }
 
    private String tierToAccountType(CustomerTier tier) {
        return switch (tier) {
            case PLATINUM, GOLD -> "Customer - Direct";
            case SILVER -> "Customer - Channel";
            case BRONZE -> "Prospect";
        };
    }
 
    private String tierToPriority(CustomerTier tier) {
        return tier == CustomerTier.PLATINUM ? "High" : "Medium";
    }
}

The translator embodies the negotiated semantic correspondence between the two domains. Notice the things it has to handle:

  • Field merging: SalesforceAccount + primary SalesforceContact → one Customer.
  • Vocabulary mapping: “Customer - Direct” ↔ PLATINUM/GOLD (with priority disambiguation).
  • Data shape conversion: Salesforce’s Address-by-billing-fields ↔ our Address value object.
  • Lossy conversions logged: unknown accountType defaults to BRONZE with warning.
  • Asymmetric mappings: PLATINUM and GOLD both map to “Customer - Direct” on the way out, with the priority field distinguishing them. The translation is not a one-to-one bijection.

The translator is also where we decide what to drop. Salesforce has dozens of fields per Account that Customer Service doesn’t care about (industry, accountSource, employee count, parent Account). The translator silently ignores them. That dropping decision is a design choice — recorded in the translator, not leaking into the consumer.

4.5 The Implementation of the Façade

public class SalesforceCrmClient implements CrmClient {
    private final SalesforceApiAdapter adapter;
    private final CustomerSalesforceTranslator translator;
 
    public Optional<Customer> findCustomer(CustomerId id) {
        return adapter.getAccount(id.value())
            .map(account -> translator.toCustomer(
                account,
                adapter.getContactsForAccount(account.id)));
    }
 
    public Optional<Customer> findCustomerByEmail(EmailAddress email) {
        return adapter.findAccountByEmail(email.value())
            .map(account -> translator.toCustomer(
                account,
                adapter.getContactsForAccount(account.id)));
    }
 
    public void publishTicket(Ticket ticket, Customer customer) {
        SalesforceCaseCreateRequest req = translator.toSalesforceCase(ticket, customer);
        String caseId = adapter.createCase(req);
        log.info("Created Salesforce Case {} for ticket {}", caseId, ticket.id());
    }
 
    public void updateTier(CustomerId id, CustomerTier newTier) {
        adapter.updateAccount(id.value(), translator.tierUpdate(newTier));
    }
}

The implementation is small. All complexity lives in the adapter and translator. The façade just wires them.

4.6 The Consumer Code

public class TicketRoutingService {
    private final CrmClient crm;
    private final TicketRouter router;
 
    public void onTicketCreated(Ticket ticket) {
        Customer customer = crm.findCustomer(ticket.openedBy())
            .orElseThrow(() -> new CustomerNotFoundException(ticket.openedBy()));
        if (customer.qualifiesForPriorityRouting()) {
            router.routeToPrioritySupport(ticket, customer);
        } else {
            router.routeToStandardQueue(ticket);
        }
        // Push the ticket to CRM for sales visibility:
        crm.publishTicket(ticket, customer);
    }
}

Notice what is absent: no SalesforceAccount, no accountType, no SOQL, no Salesforce auth tokens. Even more notice: the TicketRoutingService could be tested by mocking CrmClient. The mock returns a Customer object directly — no Salesforce mocking at all. The domain test pyramid stays clean.

4.7 Replacing Salesforce

Suppose two years later the company switches from Salesforce to HubSpot. The migration path:

  1. Implement a new adapter (HubspotApiAdapter) targeting HubSpot’s API.
  2. Implement a new translator (CustomerHubspotTranslator) translating HubSpot’s domain (Companies, Contacts, Deals, Tickets — different from Salesforce’s vocabulary).
  3. Implement a new façade (HubspotCrmClient implements CrmClient).
  4. Switch the dependency injection wiring from SalesforceCrmClient to HubspotCrmClient.
  5. Test, deploy.

The rest of the codebase — TicketRoutingService, TicketRouter, Customer, Ticket, every domain entity and service — does not change. The CRM swap is a localized integration change, not a full-codebase refactor. This is the operational payoff of having had an ACL all along.

5. The Connection to the Strangler Fig Pattern

The Strangler Fig Pattern (Strangler Fig Pattern, coined by Martin Fowler in his 2004 essay https://martinfowler.com/bliki/StranglerFigApplication.html) is the canonical pattern for gradually replacing a legacy system. The metaphor is the strangler fig vine that grows around a host tree, eventually replacing it. New functionality is built around the legacy system; over time the new system absorbs more and more of the legacy’s responsibility; eventually the legacy is small enough to retire.

ACL and Strangler Fig are frequently paired. During a Strangler Fig migration:

  • The new domain has its own clean vocabulary.
  • The legacy system has its own (often arcane) vocabulary.
  • The new domain integrates with the legacy via an ACL during the migration.
  • As pieces of legacy functionality move into the new domain, they leave behind only their integration points; the ACL grows narrower over time.
  • Eventually, the legacy is gone, and the ACL is gone with it.

The combination — Strangler Fig as the migration strategy, ACL as the integrity-preservation mechanism — is the standard playbook for legacy modernization in mature engineering organizations. Microsoft’s Azure documentation specifically recommends pairing them: see Migrate from a monolith using the Strangler Fig pattern on Microsoft Learn (https://learn.microsoft.com/en-us/azure/architecture/patterns/strangler-fig).

A typical Strangler Fig + ACL setup:

Old System  <--ACL-->  New System
                          ^
                          |
                       Users / new clients

Users hit the new system. The new system uses its clean domain. When it needs data still owned by the old system, it asks via the ACL. The ACL translates. As features migrate to the new system, the ACL’s surface shrinks. The migration is done when the ACL is empty.

6. The Connection to Hexagonal Architecture

Hexagonal Architecture (Alistair Cockburn 2005, Hexagonal Architecture; https://alistair.cockburn.us/hexagonal-architecture/) describes an application as a hexagonal core (the domain) with ports on its edges and adapters outside the ports. Inbound ports receive incoming calls (HTTP, message bus, CLI); outbound ports represent the domain’s needs from outside (database, external services, message broker).

An ACL is essentially an outbound adapter for an external service whose vocabulary differs from the core’s. The port — declared inside the core — is the façade interface (in our example, CrmClient). The adapter — sitting outside the core — is the ACL implementation that translates and calls Salesforce.

This connection is more than incidental. Hexagonal Architecture requires something like an ACL whenever an outbound integration has a different domain language. Cockburn doesn’t use the term “Anti-Corruption Layer” but the pattern he prescribes for outbound adapters facing complex external systems is structurally identical.

The two patterns describe the same thing from different angles:

  • Hexagonal Architecture says “your domain has ports; adapters live outside, implementing the ports against external services.”
  • DDD’s ACL says “when you integrate with a system whose vocabulary differs, build a translation layer at the boundary.”

Combine them and you get: the outbound adapter at a hexagonal port that translates between vocabularies is an Anti-Corruption Layer. Most production hexagonal applications have several ACLs at their outbound edges.

7. Variants

7.1 One-Way ACL (Ingress Only)

Sometimes the integration is read-only: your domain consumes data from the foreign system but never writes back. A reporting dashboard pulling sales data from a CRM is one-way. The ACL needs only inbound translation; outbound translation is unnecessary.

7.2 Two-Way ACL (Ingress and Egress)

The standard case. Reads come in (translated foreign → ours); writes go out (translated ours → foreign). The Salesforce example above is two-way.

7.3 Messaging-Based ACL (Asynchronous)

When the integration is event-driven — your domain emits events that the foreign system consumes (or vice versa) — the ACL operates on event streams rather than synchronous API calls. The pieces:

  • An event consumer subscribed to the foreign system’s events (Kafka topic, RabbitMQ queue, Salesforce Platform Events, AWS EventBridge).
  • A translator converting foreign events into your domain events.
  • An event publisher pushing your translated events into your internal event bus.

The asymmetry is interesting: in the synchronous ACL, errors propagate back to the caller; in the messaging-based ACL, errors typically go to a dead-letter queue and are handled out-of-band. The messaging variant requires more care around eventual consistency, idempotency, and ordering.

7.4 ACL as a Separate Service

For larger integrations, the ACL is sometimes deployed as its own service rather than as a library inside the consumer. This is common when:

  • Multiple consumer services need the same translation logic — extracting the ACL into a service prevents duplication.
  • The foreign system has rate limits that need to be coordinated across consumers — the ACL service is the single point that calls the foreign API.
  • The foreign system is being replaced and the ACL needs to evolve independently of any one consumer.

The trade-off: the ACL service adds a network hop, requires its own deployment infrastructure, and becomes a critical-path dependency. Whether to extract it depends on consumer count and rate-of-change.

7.5 Database-Backed ACL (Stable Snapshot)

When the foreign system’s API is slow, unreliable, or rate-limited, a variant ACL may cache the foreign data in a local database, refreshing it asynchronously. The consumer reads from the cache; the cache is updated via background sync from the foreign system. This is structurally the same pattern (translation + adapter + façade) but with a persistence layer for resilience and performance. It introduces eventual consistency: the local snapshot may lag the foreign system by minutes. Domain logic must accept this lag.

8. Pitfalls

8.1 ACL Bloat

The ACL grows as the foreign system’s API grows. If the foreign system is large (Salesforce has thousands of object types and hundreds of API methods) and the consumer needs many of them, the ACL becomes its own substantial codebase — sometimes larger than the consumer’s domain. Bloat by itself is not a problem; an unmaintained bloated ACL is. Investing in ACL test coverage, documentation, and code quality is essential because the ACL is the single point that knows the foreign system’s quirks.

8.2 Under-Investing in the ACL

The opposite failure: treating the ACL as a one-off mapping function. Engineers add a new field translation in 5 minutes; they don’t write tests for it; they don’t update the documentation; the ACL becomes a pile of ad-hoc transformations. When the foreign system changes (Salesforce deprecates the API version you’re using), the ACL is brittle. The fix is to treat the ACL as its own subsystem with the same engineering discipline as any other production code: tests, code review, documentation, observability.

8.3 Over-Investing in the ACL for Trivial Integrations

If the foreign system is small, simple, and stable (a single REST endpoint that returns basic data), a fully-fledged ACL may be overkill. The cost of designing the façade, translator, and adapter exceeds the benefit. For small integrations, a thinner approach (a simple client class with a few translation functions) is reasonable. The judgment is “how much complexity does the foreign system have, and how likely is it to change?” Heavy ACL for heavy/changing systems; light ACL for light/stable systems.

8.4 Letting Foreign IDs Leak

A subtle corruption: even if your Customer has its own CustomerId, you might be tempted to use the Salesforce Account ID as the Customer’s identifier. After all, it’s a unique identifier; why generate a new one? But now your CustomerId is a Salesforce Account ID — you’ve leaked Salesforce’s identifier scheme into your domain. If you migrate to HubSpot, the IDs are wrong; if you ever stop integrating with Salesforce, the IDs are arbitrary. The discipline: even identifiers should be your identifiers, with the foreign ID stored as an integration detail inside the ACL or as a dedicated field on the Customer that is clearly external.

The Salesforce example in §4 actually violates this — using CustomerId.of(account.id) directly. A more pure design would generate a domain CustomerId and store the SalesforceAccountId in a separate externalIdMappings table maintained by the ACL. The choice depends on operational practicality (a separate mapping table requires more infrastructure) versus purity. Production systems frequently compromise here.

8.5 Synchronization Costs When Both Sides Change

When both your domain and the foreign system evolve, the ACL must keep up with both. A new field on Customer requires a translator update; a new Salesforce field deprecation requires another. If both move quickly, the ACL becomes a synchronization bottleneck. Mitigations: contract testing (Pact, Spring Cloud Contract) to catch foreign-API changes; clear ownership of the ACL (one team owns it, not “everyone who happens to be in the integration”); ADRs documenting integration decisions so context isn’t lost.

8.6 Confusing the ACL with the Domain

Sometimes engineers put domain logic inside the translator because the translation requires “knowing what the customer needs to be.” But domain logic is the consumer’s responsibility, not the ACL’s. The translator should map vocabularies; it should not decide whether a Customer is eligible for priority routing. If you find domain rules drifting into the ACL, extract them back into the domain and have the ACL just deliver the data the domain needs to make decisions.

8.7 The ACL Itself Becomes Corrupted

A long-lived ACL can absorb its own technical debt. The translator gets long; the adapter accumulates retry policies, caching layers, fallback paths. Eventually the ACL is itself a tangled subsystem that nobody fully understands. The fix is normal software engineering — refactor, decompose, write tests, document — applied to the ACL with the same rigor as the rest of the system. ACLs are not exempt from quality discipline.

8.8 Cross-Cutting ACL Mistakes

If the foreign system serves many use cases, the ACL might serve many too. A monolithic ACL that handles “everything Salesforce” is harder than several focused ACLs. Decomposing by use case (CrmCustomerLookupClient, CrmCaseCreationClient, CrmReportingClient) allows each to evolve independently and prevents one consumer’s changes from breaking another’s.

8.9 Ignoring Failure Modes of the Foreign System

The foreign system will be unavailable, slow, or buggy. If the ACL passes failures through naively, the consumer’s domain logic is exposed to integration failure in places that should be handling pure domain concerns. The ACL should have a failure-mode policy: timeout durations, retry counts, fallbacks (return cached data? return Empty? raise a typed exception?), circuit breakers. The policy is operational; it lives in the ACL; the consumer should see only typed failure outcomes that fit their domain (Optional.empty, CustomerNotFoundException, IntegrationUnavailableException).

9. Real-World Deployments

  • Stripe-as-payment-context with internal Order context. Almost every e-commerce engineering team has an ACL around Stripe. Stripe’s Charges, Customers, PaymentIntents, Refunds vocabulary differs from Order/OrderLine/Payment domain vocabulary. The ACL translates.
  • Salesforce CRM in customer-service tools. As in the worked example. Many Salesforce integrations are real-world ACLs.
  • Mainframe modernization. Banks, airlines, insurance companies migrating from COBOL on z/OS to modern microservices invariably use ACLs. The mainframe’s CICS/IMS/DB2 vocabulary doesn’t infect the new domain; the ACL translates VSAM records into domain entities.
  • Legacy ERP integration. SAP, Oracle EBS, NetSuite, Workday — all are domain-rich systems with idiosyncratic vocabularies. New systems integrating with them use ACLs to keep their domains clean.
  • Microservices boundaries. Within an internal microservices estate, two services owned by different teams often have different vocabularies for related concerns. An ACL between them protects each from the other’s evolution.
  • Third-party identity providers. Auth0, Okta, Ping, Keycloak. The provider’s User/Group/Permission vocabulary differs from your domain’s. The ACL handles the mapping.
  • Shipping carrier integrations. UPS, FedEx, DHL each have their own vocabulary for shipments, tracking events, and rate quotes. Logistics systems use ACLs for each.
  • Cloud provider abstractions. Some applications wrap AWS/GCP/Azure SDKs with their own domain abstractions to enable provider-switching; this is essentially an ACL at the cloud-services boundary.

The pattern is so prevalent that mature engineering teams write new integrations with an ACL by default — the question is not “should we have one” but “how thick should it be.”

10. Diagram — ACL Structure

flowchart LR
    subgraph Consumer["Customer Service Domain"]
        DC[Domain Code]
        FC[Façade<br/>CrmClient interface<br/>(our vocabulary)]
        DC --> FC
    end
    subgraph ACL["Anti-Corruption Layer"]
        FI[Façade Implementation]
        TR[Translator]
        AD[Adapter<br/>HTTP, Auth, JSON]
        FC -.implements.-> FI
        FI --> TR
        FI --> AD
        TR <--> AD
    end
    subgraph Foreign["Salesforce CRM"]
        SF[(Salesforce<br/>REST API)]
    end
    AD -->|"HTTP / OAuth / SOQL"| SF
    SF -->|"JSON responses"| AD

What this diagram shows. The three-zone architecture of an ACL deployment. The leftmost zone is the consumer’s domain — in our example, Customer Service. The domain code calls a Façade interface that uses our vocabulary (Customer, CustomerId, Ticket). The middle zone is the ACL itself, with three internal pieces: the Façade Implementation (which implements the interface declared in the consumer’s zone), the Translator (which converts between vocabularies), and the Adapter (which speaks Salesforce’s wire protocol). The rightmost zone is the foreign system. Critical observations: (1) The consumer’s domain code never sees anything inside the ACL or the foreign system — only the Façade interface. (2) The Adapter is the only piece that knows about HTTP, OAuth, JSON, or Salesforce-specific protocols (SOQL); it speaks the foreign wire format. (3) The Translator is the only piece that knows both vocabularies — its purpose is to convert between them. (4) The Façade Implementation is a thin orchestrator that wires Translator and Adapter together. (5) The boundaries between zones are semantic boundaries — nothing inside the ACL leaks out to the consumer; nothing inside the consumer leaks into the ACL. The arrows that cross boundaries cross typed translation interfaces, not raw data dumps. This is the key discipline of the pattern. (6) The architecture is symmetric in principle: a future replacement of Salesforce with HubSpot would replace only the rightmost zone and the contents of the ACL — the Façade interface remains stable, and the consumer’s domain code is unchanged.

11. Interview Discussion Points

  • “How would you protect your domain when integrating with a third-party API like Stripe or Salesforce?” A strong answer cites the Anti-Corruption Layer pattern by name, explains the façade-translator-adapter structure, and gives an example. A weak answer says “I’d write a wrapper” without articulating the vocabulary-protection motivation.
  • “What is a bounded context?” ACL-related interviews almost always probe DDD strategic vocabulary first. A bounded context is the boundary within which a particular ubiquitous language has consistent meaning. Two contexts can use the same English term (“Customer”) with different meanings; integration requires translation.
  • “When wouldn’t you use an Anti-Corruption Layer?” When the foreign system is small, simple, and unlikely to change and its vocabulary already aligns with yours. Or when your domain isn’t a “core domain” — a peripheral utility integration may rationally accept some corruption to save effort. DDD calls this Conformist.
  • “Have you done a Strangler Fig migration?” This question often pairs with ACL questions because the patterns combine. A strong answer ties them together: Strangler Fig as the migration strategy, ACL as the integrity-preservation mechanism during migration.
  • “How does ACL fit into Hexagonal Architecture?” ACL is the outbound adapter at a hexagonal port whose external system has a different vocabulary. Cockburn doesn’t use the term but the structure is identical.
  • “What goes in the translator vs the adapter?” Adapter = wire protocol (HTTP, JSON, auth, retries, rate limits). Translator = vocabulary mapping (Salesforce Account ↔ our Customer, accountType picklist ↔ our enum). Façade = the consumer’s vocabulary interface.
  • “How do you handle the case where the translation is lossy?” Either drop fields silently (with logging), surface them as opaque metadata on the consumer’s entity (with discipline that the consumer doesn’t read it), or fail the translation. The choice depends on whether the dropped data matters to the consumer. Document the decision in code comments or an ADR.
  • “How do you test an ACL?” Unit tests for the translator (deterministic input/output mapping). Integration tests for the adapter (against a sandbox foreign system or a contract-test fake). End-to-end tests for the whole ACL using recorded fixtures. The translator is the easiest to test, often with property-based testing.
  • “When the foreign API breaks, where does the failure surface?” Inside the ACL. The adapter catches the underlying failure; the façade returns a typed result (Optional, typed exception). The consumer’s domain sees only domain-level outcomes. This isolation is part of the ACL’s value.
  • “Have you seen an ACL fail?” A canonical question filtering for actual experience. Common failures: ACL bloat from undisciplined growth; under-investment leading to brittle one-off translations; foreign vocabulary leaking through poorly-designed façade methods. A candidate who has lived through these has the experience to design ACLs better.

12. Open Questions

  • How thick should an ACL be? Where is the line between “translation layer” and “fully-fledged integration service”?
  • Should the ACL own its own data (cached snapshots of foreign data) or always pass through? Trade-offs around availability, freshness, complexity.
  • How does ACL design change for asynchronous/event-driven integrations versus synchronous request/response? The patterns are similar but operational concerns differ.
  • In a microservices estate where every internal boundary has subtle vocabulary drift, should every internal call have an ACL? Pragmatically, no — but where is the threshold?
  • When the foreign system’s vocabulary is more expressive than yours (it has distinctions you don’t), what does the inbound translator do — collapse the distinctions, or expose them somehow? This is a real design question.
  • Are there cases where ACL is anti-pattern — where defensive translation actually makes things worse than a Conformist embrace of the foreign vocabulary? When does pragmatism override purity?

13. See Also