What is an anti-corruption layer, and when would you place one at a boundary between two systems?
answer
- translation layer at the edge; no foreign type crosses it
- port (yours) + adapter + mapper + error translation
- vs Conformist / Shared Kernel / Open Host Service
- strangler-fig: swap the ACL's implementation
- pitfall: leaky ACL or pointless pass-through
basics
~20 sAn anti-corruption layer is translation code at a boundary that converts another system's model, names and errors into your own. It stops a legacy or third-party design from leaking into and distorting your code, and localizes the damage when that system changes.
solid answer
~60 sAn anti-corruption layer (ACL), from Domain-Driven Design, is a dedicated translation layer at the edge of your model that maps between your concepts and a foreign system's — a legacy monolith, a vendor API, a partner feed, or another team's service. It owns the adapter/facade code, the DTO-to-domain mapping, the vocabulary mismatch (their `CUST_TYP=3` becomes your `CustomerTier.Gold`), and the error translation (their HTTP 409 or vendor code becomes your domain error). Without it, foreign types spread across your codebase and their model dictates yours; every upstream change becomes a shotgun edit, and your domain language degrades. With it, changes upstream are absorbed in one place, you can test your side against a fake implementing your own port, and you can strangle the foreign system later by swapping the ACL's implementation. Costs: extra code, extra mapping, and a risk of an anemic pass-through layer if the translation is trivial and the foreign model is already good — in that case a plain adapter is enough. ACLs are the standard tool for strangler-fig migrations and for integrating with any upstream you don't control.
code
pseudocode · 14 lines// Port — our language, owned by us
interface CreditBureau { CreditScore scoreFor(CustomerId id) }
// Adapter + translator — the ONLY place the vendor SDK is visible
class AcmeBureauAdapter implements CreditBureau {
CreditScore scoreFor(CustomerId id) {
try {
resp = acme.query(new AcmeReq(ssn = idMap.ssnOf(id), prod = "FICO8"))
return CreditScore.of(resp.scr_val, band(resp.scr_band)) // codes -> our enum
} catch (AcmeThrottled e) { throw new BureauUnavailable(retryable = true) }
catch (AcmeSubjectNotFound e) { throw new NoCreditFile(id) }
}
}
// Domain code depends on CreditBureau only; AcmeReq/AcmeThrottled never escape.go deeper
Say it's translation code at the edge that converts another system's types and names into yours so their model doesn't spread.
Name the parts (port, adapter, mapper, error translation), and the classic uses: legacy integration, replaceable vendors, testing with fakes.
Position it among DDD context-mapping patterns (Conformist, Shared Kernel, Open Host Service), discuss when it's not worth it, identity/semantic mismatch, and mechanical enforcement against leaks.
Weigh per-consumer ACLs against the upstream investing in a published language, decide where the layer is deployed and who owns it, and use it as the migration seam for strangler-fig replacement.
## The problem it solves When two systems integrate, their models meet. If you consume a foreign system directly — its client SDK types, its field names, its status codes, its notion of what a 'customer' is — that model starts colonizing yours. Symptoms: - Vendor/legacy classes appear in your business logic and even in your database. - Your code speaks their vocabulary (`acctStatCd`, `PARTY_ROLE`) instead of your domain's. - Their weird rules (nullable everything, sentinel values like `9999-12-31`, an `Order` that also means 'quote') infect your invariants. - Any upstream change is a shotgun surgery across dozens of files. In DDD terms, this is a failure at a **context boundary**. Different **bounded contexts** legitimately mean different things by the same word — 'Customer' in Billing (a payer with a credit limit) is not 'Customer' in Support (a person with tickets). Forcing one shared model across contexts produces a model that serves neither. ## What an ACL actually is A layer at your boundary composed of a few standard pieces: - **Port / interface** — expressed purely in *your* language, owned by *you*: `interface CreditBureau { CreditScore scoreFor(CustomerId id) }`. - **Adapter** — the implementation that calls the foreign system. - **Translator / mapper** — converts foreign representations to your types and back, including enum/code mapping, units, time zones, null handling, and identifier mapping. - **Error translation** — foreign faults (HTTP codes, SOAP faults, SDK exceptions, vendor error numbers) become your domain or application errors, with retryable/non-retryable classified. - Often also: a **facade** over an awkward multi-call protocol, so one of your operations maps to their three calls; plus resilience concerns (timeouts, retries, circuit breaker) that naturally live here. The defining property: **no foreign type crosses the ACL.** If a vendor DTO appears in your domain, you have an adapter, not an anti-corruption layer. ## DDD context-mapping vocabulary ACL is one of several relationship patterns between bounded contexts, and knowing the alternatives shows judgment: - **Shared Kernel** — two contexts deliberately share a small model, with joint ownership. High coupling; only viable with tight coordination. - **Customer/Supplier** — downstream has a voice in upstream's roadmap; upstream commits to the downstream's needs. - **Conformist** — downstream simply adopts upstream's model, no translation. Cheap; appropriate when the upstream model is decent and you have no leverage or no need to protect your own model. - **Anti-Corruption Layer** — downstream translates, protecting its model. Choose when the upstream model is poor, unstable, or conceptually foreign, or when you plan to replace it. - **Open Host Service / Published Language** — the *upstream* offers a well-designed public protocol so many downstreams don't each need an ACL. This is the mirror-image investment: the owner pays once instead of every consumer paying. - **Separate Ways** — no integration at all; duplication is cheaper than the coupling. ## When to use one Use an ACL when: - You integrate with a **legacy system** you cannot change, especially during a **strangler-fig** migration: new code talks only to the ACL, and you gradually reroute the ACL from the legacy system to the new implementation, one capability at a time, with no change to callers. - You depend on a **third-party vendor** you may want to replace (payments, email, KYC, maps). The ACL is where the swap happens; without it, 'change payment provider' is a rewrite. - The upstream model is **semantically different** from yours, or unstable/frequently versioned. - The upstream API is **chatty or awkward** and you want one clean operation on your side. ## When *not* to - The upstream model is already close to yours and stable (be a Conformist — don't map for the sake of mapping). - The integration is tiny and throwaway. - You'd end up with a pure pass-through: a layer that renames fields one-for-one, adds a build step and a test suite, and hides nothing. That's ceremony, not protection. ## Costs and pitfalls - **Mapping code and duplication** — two representations of similar data, and the mapping must be tested. Mitigate by keeping the ACL thin, generating clients from the foreign schema, and mapping only the fields you actually use. - **Leaky ACL** — the most common failure. A single foreign enum, exception, or `null` convention allowed through defeats the purpose. Enforce with visibility rules or dependency-rule tests: only the adapter package may import the vendor SDK. - **Semantic mismatch is real work** — where their model genuinely can't express yours (their 'account' merges two of your concepts), the ACL must make a judgment call, sometimes maintaining its own correlation/identity-mapping store. That state is a legitimate part of the layer, not a smell. - **Performance** — an extra hop or extra mapping, usually negligible next to the remote call it wraps. - **Placement** — an ACL normally lives on the *downstream* (consumer) side, because that's who is protecting their model. It can be deployed as its own service when several consumers share it, at the price of another network hop and another thing to operate. ## Testing benefit Because callers depend on *your* port, you can supply an in-memory fake for tests and contract-test only the adapter against the real system (or a recorded/contract stub). This is often the single biggest practical payoff.
- How does an anti-corruption layer support a strangler-fig migration?New code depends only on your port. The adapter initially delegates to the legacy system; as each capability is reimplemented, you route that operation to the new implementation behind the same port — optionally dual-writing or shadow-reading to validate. Callers never change, so the migration is incremental and reversible.
- When is it right to skip the ACL and be a Conformist instead?When the upstream model is stable and close enough to yours, the integration is small, you have no plausible reason to swap the provider, and the mapping would be a one-for-one rename. Paying for a layer that hides nothing is negative value.
- How do you stop an ACL from leaking in practice?Confine the vendor dependency to one package/module and enforce it mechanically — module visibility, a dependency-rule test (e.g. 'no class outside adapters may import com.vendor.*'), or a build-level module that simply doesn't put the SDK on the domain's classpath.
A diplomatic interpreter. They don't just swap words — they convert idioms, units, and forms of address so each side hears something meaningful in its own terms. If the interpreter starts leaving foreign phrases untranslated 'because everyone knows what that means', the other side gradually starts speaking a broken hybrid language: exactly the corruption the layer exists to prevent.