Anti-Corruption Layer Explained
An anti-corruption layer (ACL) is a translation boundary between a modernized slice and the legacy system it still has to interact with. It converts data and calls between the legacy model and the new model in both directions, so the legacy system's outdated structure, naming, and assumptions never leak into the clean new code. Coined by Eric Evans in "Domain-Driven Design" (2003), it is the strangler facade's companion — the facade routes traffic, the ACL keeps the models from corrupting each other.
Part 3 gave us the facade: the layer that decides whether the legacy system or a modern slice answers each request. But routing is only half the seam. The moment a modern slice has to read from or write to the legacy system — and early slices almost always do, because the legacy database and its neighbors are still live — a second problem appears. The legacy model wants in. If you let it, the clean new code you just wrote becomes a thin wrapper around the old system’s assumptions, and you’ve rebuilt the legacy system in a new language. The anti-corruption layer is what holds that line.
The problem: models corrupt across boundaries
A legacy system carries a data model shaped by decades of accretion — columns repurposed twice, a status field with eleven magic values, an “address” that’s really three concepts in one table, business rules encoded as flags nobody can fully explain. That model works. It’s also exactly what you’re trying to leave behind.
Here’s the trap. Your first modern slice needs the customer record, and the customer record lives in the legacy database. The path of least resistance is to read it as-is — magic values, repurposed columns, and all — and let those shapes flow straight into your new code. Do that across a few slices and the new system’s model is just the old model with fresh syntax. The migration moved the code and preserved the corruption.
This isn’t hypothetical. It’s the default outcome when old and new share data and nothing stands between them. A clean migration needs a place where the old meaning is translated into the new meaning, deliberately, at the boundary. That place is the anti-corruption layer.
What the anti-corruption layer is
Eric Evans defined the anti-corruption layer in Domain-Driven Design (2003) as a layer that isolates a clean domain model from a legacy or external system by translating between the two. In a modernization, it sits between a modern slice and the legacy system it must still talk to, and it does one job in both directions:
- Inbound: legacy data and calls are translated into the slice’s clean model. The eleven magic status values become a proper enum; the three-concepts-in-one address becomes three fields; the undocumented flag becomes a named, understood rule.
- Outbound: when the slice has to write back to legacy or call it, the ACL translates the clean model into the shape the legacy system expects, so the old system keeps working exactly as it did.
Inside the ACL boundary, the slice speaks only its own clean language. Outside it, the legacy model lives on, untouched. The translation is the whole point — and translation, not avoidance, is what lets a clean new context coexist with a messy old one.
A worked example: one customer record
Abstractions are easy to nod along to, so take a concrete shape. The legacy CUSTOMER table stores a customer like this:
| Legacy column | What it actually holds |
|---|---|
CUST_STAT | An integer from 0 to 10. 3 means active, 7 means active but past due, 9 means closed for fraud. The rest are dormant historical states nobody sets anymore. |
ADDR_LINE | Street, city, and postal code concatenated into one string with pipe separators. |
FLAG_B | The string "Y" if the account was migrated from the 2009 system. Three unrelated reports read this column and each treats it as meaning something different. |
Your billing slice does not want any of that. It wants a customer it can reason about:
Customer(
status, # an enum: ACTIVE, PAST_DUE, CLOSED_FRAUD
address, # a value object with street, city, postal
migrated_2009, # a plain boolean
) The ACL is the one place where the first shape becomes the second:
def to_customer(row):
status = STATUS_BY_CODE[row.CUST_STAT] # 3 -> ACTIVE, 7 -> PAST_DUE, 9 -> CLOSED_FRAUD
street, city, postal = row.ADDR_LINE.split("|")
return Customer(
status=status,
address=Address(street, city, postal),
migrated_2009=(row.FLAG_B == "Y"),
) That is the entire idea, made small. The magic values are decoded exactly once, right here. Every slice downstream sees status == PAST_DUE, never CUST_STAT == 7, and no billing rule ever has to remember that 9 means fraud. When the slice writes back, an outbound translator runs the reverse: it turns CLOSED_FRAUD back into 9 and rejoins the address into a pipe-delimited string, so the legacy system reads exactly what it always did. And when the CUSTOMER table is finally retired, this pair of functions is what you delete. The knowledge of the old model’s quirks was quarantined in one file, not smeared across the new codebase.
Facade and ACL: routing versus meaning
These two layers are easy to conflate and important to separate. The simplest framing:
| Strangler facade | Anti-corruption layer | |
|---|---|---|
| Concern | Traffic | Meaning |
| Decides | Which implementation answers a request | How data and calls translate between models |
| Lives | In front of legacy and modern, on the request path | Between a modern slice and the legacy system it calls |
| Question it answers | “Legacy or slice?” | “What does this legacy shape mean in the new model — and vice versa?” |
They work as a pair. The facade routes a request to a modern slice; that slice, needing legacy data, reaches through its ACL, which hands it clean inputs and accepts clean outputs to translate back. The facade keeps the traffic coherent; the ACL keeps the model clean. Skip the facade and you can’t migrate incrementally. Skip the ACL and you migrate the corruption along with the code.
ACL vs adapter, gateway, and facade
The facade is not the only pattern the ACL gets confused with. Adapter, gateway, and translator all live in the same neighborhood, and the labels get used loosely. The clean way to hold them apart is scope and intent, not mechanism:
- An adapter (from the classic design patterns) converts one interface into another a caller expects. It is about shape. Legacy concepts can pass straight through an adapter unchanged.
- A facade presents one simple interface over a messy subsystem. It is about convenience of access, not about meaning.
- A gateway encapsulates how an external system or resource is reached, so the rest of your code doesn’t know the mechanics of the call. It is about isolating access.
- A translator (or mapper) converts data from one model into another. It is the moving part that actually does the reshaping.
The anti-corruption layer is not a fifth thing competing with these. It is usually built out of them. Evans describes the ACL as typically implemented as some combination of facades, adapters, and translators. What separates an ACL from a bare adapter is a decision: the ACL deliberately keeps a separate model on your side of the boundary and translates meaning across it, precisely so the legacy or external model can’t dictate yours. An adapter that reshapes a call while the legacy concepts still leak through is not an ACL. The intent to protect a clean model is the whole distinction.
| Pattern | What it does | Keeps a separate model? |
|---|---|---|
| Adapter | Converts one interface to another the caller expects | No, it only reshapes calls |
| Facade | Presents one simple interface over a complex subsystem | No, it only simplifies access |
| Gateway | Encapsulates how an external system is reached | No, it only isolates access |
| Translator / mapper | Converts data between two models | It is the part that does the reshaping |
| Anti-corruption layer | Holds a separate clean model and translates meaning across the boundary | Yes, that is its entire purpose, usually built from the patterns above |
Where the ACL earns its keep — and where it doesn’t
The ACL is not mandatory on every seam, and applying it everywhere is its own waste. It earns its cost where the model gap is real:
- A legacy model you’re deliberately leaving behind. The bigger the gap between old and new shapes, the more the ACL protects — this is its core case.
- A third-party or external system you don’t control. Even outside modernization, an ACL is the standard way to keep someone else’s model from dictating yours. These ACLs may well be permanent.
- A boundary crossed by many slices. A shared, well-built ACL in front of a heavily-used legacy datastore pays for itself across every slice that reads through it.
It’s overkill when the legacy model and the new model are already close enough that translation is a near-identity mapping — then a thin adapter, not a full ACL, is the honest choice. The discipline is to translate where meaning actually diverges, not to ceremonially wrap every call.
How to build one
An ACL should be small and boring. A few patterns keep it that way:
- Keep two models and never share types across the line. The slice has its own
Customer, legacy has its row. The instant a legacy type appears in your clean domain code, the boundary has leaked and the ACL has stopped doing its job. - Translate at the edge, keep the core pure. All of the decoding, the magic values, the split addresses, the flag interpretation, lives in the ACL’s translators. Domain logic downstream only ever receives clean inputs, so it never learns the old model’s quirks.
- Give the ACL its own module, pointing inward. The ACL depends on your clean model, not the reverse. Nothing in the domain imports from the ACL. That direction is what lets you delete the ACL later without the clean code noticing.
- Scope each ACL to a boundary. Many slices reading one legacy datastore can share a single ACL in front of it. What you avoid is one global catch-all layer spanning several unrelated legacy systems, because that quietly grows back into a shared model, which is the exact thing you built the ACL to prevent.
None of this is exotic. It is a mapper, an interface or two, and the discipline to route every crossing through them. The engineering difficulty is not the structure. It is being honest about what each legacy field really means before you translate it.
The ACL is throwaway code, and that’s fine
An anti-corruption layer that bridges to your own legacy system is, by design, temporary. It exists only while the slices it serves still depend on the legacy model. As the strangle completes and the legacy side it translated for is retired, that ACL goes with it — its job done, its code deleted. Writing code you intend to throw away can feel wrong, but here it’s the point: the ACL is scaffolding that lets clean construction happen next to a structure still in use.
The cost to weigh is real. An ACL is extra code, extra latency on the calls that cross it, and a place where translation bugs can hide. A translation that’s subtly wrong — drops an edge case, mishandles a magic value — produces exactly the kind of silent divergence the whole migration is built to prevent. That’s why translations through the ACL are held to the same parity bar as the slices themselves: proven against real behavior, not assumed correct. The ACL is a boundary you build to keep the model clean; it is not a place to relax the evidence standard.
Where this leads
You now have the two structural pieces of the strangler fig pattern: the facade that routes traffic, and the anti-corruption layer that keeps the models from contaminating each other. What you don’t yet have is the order of operations — how to actually run a migration that assembles them, slice by slice, from the first seam to the last decommissioned legacy module. Part 5, How to Implement the Strangler Fig Pattern, is that playbook: identifying slices, standing up the facade, choosing what to migrate first, and knowing when the strangle is actually done.
Frequently asked questions
- What is the difference between the strangler facade and the anti-corruption layer?
- The facade is about traffic — it routes each request to either the legacy system or a modern slice. The anti-corruption layer is about meaning — it translates data and calls between the legacy model and the modern model so the two can interoperate without the old model contaminating the new one. They work together: the facade decides who answers, the ACL keeps the answer clean when old and new have to talk.
- Where did the anti-corruption layer come from?
- From Eric Evans's "Domain-Driven Design" (2003), where it's defined as a layer that isolates a clean domain model from a legacy or external system by translating between the two. It's a strategic pattern for keeping a bounded context from being polluted by the model of a system it can't change. Modernization adopted it because every strangler-fig slice has to coexist with a legacy model it's trying to leave behind.
- Is the anti-corruption layer permanent?
- Usually not. An ACL that bridges to the legacy system is throwaway code by design — it exists only while the slice it serves still depends on the old model. As the strangle completes and the legacy side it translated for is retired, that ACL is removed with it. An ACL that bridges to a third-party system you don't control may be permanent; one that bridges to your own legacy should die when the legacy does.
- What is the difference between an anti-corruption layer and an adapter?
- An adapter converts one interface into another a caller expects. It reshapes how something is called, but legacy concepts can still flow straight through it. An anti-corruption layer goes further. It keeps a separate, clean model on your side of the boundary and translates meaning across it, so the legacy model never dictates yours. In practice an ACL is often built out of adapters, facades, and translators, but the intent to protect a clean model is what makes it an ACL rather than a plain adapter.
- How do you implement an anti-corruption layer?
- Keep two models and never share types across the boundary. Put all translation, meaning the decoding of magic values and the reshaping of data, in the ACL's translators at the edge, so the domain logic downstream only ever sees clean inputs. Give the ACL its own module that depends on your clean model, not the reverse. Scope each ACL to a boundary: many slices reading one legacy datastore can share an ACL, but avoid a single catch-all layer spanning unrelated systems, which drifts back into a shared model. Prove the translations against real legacy data so a mishandled edge case cannot cause silent divergence.