Anti-corruption layer
How the anti-corruption layer pattern translates between a legacy system's domain model and your modern domain. Its role in DDD bounded contexts, the types of translation it performs, and when it prevents domain contamination.
TL;DR
- The anti-corruption layer (ACL) is a translation boundary between a legacy system's domain model and your new domain model.
- Without it, legacy concepts (abbreviated field names, magic integers, flat data structures) leak into your clean domain and make it look like the old system.
- The ACL is the only place in your codebase that knows about both domain models. All other code uses only your language.
- Translation is bidirectional: incoming legacy data maps to your model before entering your system; outgoing calls convert your model back to legacy format.
- In Domain-Driven Design (DDD) terms, the ACL is used when integrating with a downstream context you cannot change and do not want to conform to.
The Problem
You're building a new service that needs to call a 15-year-old ERP system. The ERP uses its own data model: TXN_HDR for order headers, CUST_REC for customers, STAT_CD as a two-digit integer for status. Some of these integer codes haven't been documented since 2011.
If you let the ERP model leak into your new code, you absorb its problems. Your Order class starts carrying fields named TXN_HDR_NO. Your team has to look up what STAT_CD=7 means every time they read a test. Your new system starts to smell like the old one, and the migration has accomplished nothing.
This is domain contamination. Once it sets in, it is very hard to reverse. Any future attempt to replace the ERP becomes a re-migration of your "new" system too.
One-Line Definition
The anti-corruption layer is a translation boundary between a legacy or foreign domain model and your own, so your domain stays clean regardless of what the external system looks like.
Analogy
Think of a professional interpreter at a diplomatic meeting. The diplomat on one side speaks only English; the official on the other speaks only Mandarin. The interpreter doesn't just translate words, they translate concepts and implied meanings. "We should table this item" means something completely different in British English than in American English; the interpreter knows which meaning applies.
The ACL is your interpreter. It translates not just field names but semantics: STAT_CD=3 in the ERP means "pending manual review" in your domain, not just "status three." The interpreter is the only person in the room who knows both languages. Everyone else speaks their own.
Solution
The ACL sits at the boundary of integration. Every piece of data that crosses from the external system into your domain passes through the ACL. Every outgoing call from your domain to the external system passes through it too.
Your service layer never sees legacy data structures. The translator is the only code that knows field abbreviations, magic status codes, or the ERP's date integer format. When the ERP eventually changes its field naming convention, you update one file.
What Translation Looks Like
Legacy systems commonly carry several problems at once. Here is the full catalog and how the ACL handles each one.
Abbreviated field names: CUST_NO, TXN_DT, ACCT_BAL_AMT. The ACL renames them to human-readable identifiers in your model.
Status codes as magic integers: STAT_CD=3 means "pending review." The ACL decodes them into expressive enums. The magic number appears exactly once, inside the ACL.
Dates as integers: Many legacy systems store dates as YYYYMMDD integers or as days since a fixed epoch. The ACL parses them into proper date objects before they enter your domain.
Flat structures mixing concerns: One 50-column table per entity, joining order, customer, and line items in a single row. The ACL reshapes the structure into proper domain objects.
from decimal import Decimal
from datetime import date
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
PENDING_REVIEW = "pending_review"
SHIPPED = "shipped"
DELIVERED = "delivered"
UNKNOWN = "unknown"
class LegacyOrderAdapter:
"""ACL: translates legacy TXN_HDR records into clean Order domain objects."""
_STATUS_MAP = {
1: OrderStatus.PENDING,
2: OrderStatus.CONFIRMED,
3: OrderStatus.PENDING_REVIEW, # magic number decoded once, here only
4: OrderStatus.SHIPPED,
5: OrderStatus.DELIVERED,
}
def to_domain(self, row: dict) -> "Order":
return Order(
id=OrderId(row["TXN_NO"]),
customer_id=CustomerId(row["CUST_NO"]),
status=self._STATUS_MAP.get(row["STAT_CD"], OrderStatus.UNKNOWN),
created_at=self._parse_yyyymmdd(row["TXN_DT"]),
total=Money(
amount=Decimal(row["TOTAL_AMT"]) / Decimal("100"),
currency=Currency(row["CURR_CD"])
)
)
def to_legacy(self, order: "Order") -> dict:
reverse_status = {v: k for k, v in self._STATUS_MAP.items()}
return {
"TXN_NO": str(order.id),
"CUST_NO": str(order.customer_id),
"STAT_CD": reverse_status.get(order.status, 0),
"TXN_DT": int(order.created_at.strftime("%Y%m%d")),
"TOTAL_AMT": int(order.total.amount * 100),
"CURR_CD": order.total.currency.code,
}
def _parse_yyyymmdd(self, value: int) -> date:
s = str(value)
return date(int(s[:4]), int(s[4:6]), int(s[6:8]))
Notice that to_legacy() is just as important as to_domain(). The ACL handles both directions. When you write an order status update back to the ERP, the ACL converts OrderStatus.PENDING_REVIEW back to 3. The ERP never knows your model changed.
The ACL must never leak legacy types
If any legacy type (a raw dict with ERP field names, a generated ERP client class) escapes the ACL into your domain code, the layer has failed. Add type annotations and code review rules to catch this. Your domain methods should only accept and return your own domain objects.
ACL in Domain-Driven Design
In Domain-Driven Design (DDD), a bounded context is a cohesive area of a system with its own domain model and ubiquitous language. When two bounded contexts integrate, their models need an explicit relationship.
Eric Evans defined several context map patterns for these relationships. The ACL is the one you use when the upstream model is legacy or messy and you want to protect your domain:
The relationship options, in order from most to least effort:
| Context Map Pattern | What it means | When to use it |
|---|---|---|
| Anti-Corruption Layer | Downstream translates upstream model | Upstream is legacy/messy |
| Conformist | Downstream adopts upstream model wholesale | Upstream is clean but dominant |
| Open-Host Service | Upstream provides a clean published API | Upstream is well-designed |
| Partnership | Both contexts evolve together | Same team owns both |
The ACL is explicitly a protection choice. You choose it when you cannot change the upstream system and refuse to mirror its mess in your own model.
For your interview: the moment you identify integration with a legacy system, name the ACL. Say: "I'd put an anti-corruption layer between our service and the legacy system to prevent the legacy model from contaminating our domain." Then move on. One sentence is enough.
ACL vs. Open-Host Service
An Open-Host Service is when the upstream provides a well-designed, versioned, publicly documented API specifically for external consumers. If the upstream API is clean and stable, you may not need an ACL at all. The ACL is for integrations where the upstream is a mess you cannot fix.
Deployment Options
The ACL is a logical pattern, not a required deployment topology. It can live at three different levels:
In-process module is the default. The ACL is a module or adapter class within your service, with no network hop. This is the right choice for most integrations: simple, fast, easy to test, and the translation is local to the consumer.
Sidecar or adapter service makes sense when the translation is non-trivial and you want to isolate it from your business logic. The ACL runs as a separate process, your service calls it via a clean internal API, and the ACL calls the legacy system. Adds a network hop but provides stronger isolation.
Shared integration service is appropriate when multiple services all need to call the same legacy system. Rather than each service implementing its own ACL, a single integration service owns the translation and exposes a clean API. Reduces duplication at the cost of a shared dependency.
My recommendation: start in-process as a module. Promote to a shared integration service only when two or more services need the same translations. Never share an in-process ACL module as a library across services; that creates a shared deployment coupling that's worse than the duplication.
ACL vs. adapter vs. facade
Developers sometimes confuse the ACL with the Adapter pattern or the Facade pattern. The differences matter:
| Pattern | What it does | Scope |
|---|---|---|
| Adapter | Converts one interface to another (structural) | Single class / method signature |
| Facade | Simplifies a complex subsystem behind a clean API | Subsystem access |
| Anti-Corruption Layer | Translates between two domain models (semantic) | Full domain boundary |
The ACL is higher-level than an adapter. An adapter converts an interface; an ACL converts meaning. The ACL may use adapter classes internally, but the ACL's job is domain translation, not interface conversion.
When to Use
The ACL is the right choice when:
- You're integrating with a legacy system or third-party service with a messy domain model
- You cannot change the upstream system's model
- You don't want your domain to mirror the upstream model (you don't want to be a Conformist)
- The integration is expected to last for years, making the cost of translation worthwhile
It is unnecessary when:
- The upstream provides a clean, well-designed API that maps naturally to your concepts (use it directly)
- The integration is very short-lived (a one-week spike or a soon-to-be-retired connection)
- Your domain genuinely is the same as the upstream domain (same bounded context, genuinely shared model)
Don't add an ACL just because systems are different
The ACL has a cost: it's more code, more tests, more indirection. Add it when the upstream model would contaminate your domain if it leaked in. Don't add it as a reflex every time you integrate with an external system. If the upstream model maps cleanly to your domain, use it directly.
The ACL in a Migration Context
The most common context where an ACL appears is a strangler fig migration: you're incrementally replacing a legacy system with a new one while both run in parallel. The ACL protects your new service from the legacy system's model during the transition.
As the migration progresses and the legacy system is replaced piece by piece, the ACL shrinks. Some translations become identity functions (the new system uses the same concept). Eventually the ACL disappears entirely when the legacy system is fully replaced. This is its intended lifecycle: the ACL is not a permanent component, it is scaffolding for safe coexistence.
Interview Cheat Sheet
What it is: A translation boundary between a legacy system's domain model and your clean domain model. Prevents legacy concepts from contaminating your new system.
The core problem it solves: Domain contamination. Without an ACL, legacy field names, magic integers, and flat structures leak into your codebase and make the new system look like the old one.
Where it sits: At the boundary of integration: inside your service as a module (default), or as a separate service if multiple consumers need the same translation.
The DDD connection: The ACL is the context map pattern for "I'm integrating with a messy upstream context and I refuse to conform to its model." The alternative is Conformist.
Key implementation rules:
- The ACL is the only code that imports legacy types
- Translation is bidirectional:
to_domain()andto_legacy() - Magic numbers, abbreviations, and legacy data shapes never escape the ACL
Common interview prompt: You're designing a new microservice that needs to talk to a legacy monolith. When should you use an ACL vs. calling the legacy API directly? Answer: use the ACL when the legacy domain model would contaminate your clean domain if it leaked in. Call directly if the legacy API maps naturally to your concepts.
Quick Recap
- The anti-corruption layer translates between a legacy system's domain model and your new domain model, preventing legacy concepts from contaminating your clean domain.
- The ACL is the only code in your system that knows both models. All other code uses only your domain's language. Magic numbers, abbreviated field names, and legacy data shapes never escape it.
- Translation works in both directions:
to_domain()converts incoming legacy data to your model;to_legacy()converts your model back to legacy format for writes. - In DDD terms, the ACL is the context map pattern for integrating with a messy upstream you cannot change and refuse to conform to. The alternative (Conformist) means your domain adopts the upstream's mess.
- Deploy as an in-process module by default. Promote to a shared integration service only when multiple services need identical translations.
- In a strangler fig migration, the ACL shrinks as the legacy system is replaced. When the migration is complete, the ACL disappears. It is scaffolding, not permanent infrastructure.
The Problem
You're building a new system that needs to integrate with a legacy system. The legacy system has its own domain model — different concepts, different field names, different semantics, different invariants. Some of those concepts don't even map cleanly to your model.
If you let the legacy model leak into your new code, you absorb its problems. Your clean domain starts to carry legacy concepts that make no sense in your context. Developers on your team need to understand both models. Tests become harder to write. The "new" system starts looking like the old system.
This is domain contamination.
What the Anti-Corruption Layer (ACL) Does
The ACL is a translation layer between the legacy domain and your domain. It sits at the boundary of integration and does two things:
- Translates legacy data structures and concepts into your domain model before they enter your system
- Translates your domain model into legacy format when you need to write back
The new service never sees legacy data structures. The ACL is the only place that knows about both models.
What Translation Looks Like
Legacy systems often have:
- Abbreviated field names (
cust_no,txn_dt,acct_bal_amt) - Flat structures that mix concerns (one 50-column table per "entity")
- Status codes as magic integers (
STATUS=3means "pending review") - Dates as integers (days since epoch, or YYYYMMDD integers)
- Nulls used as both "not applicable" and "unknown"
Your ACL translates these into your model:
class LegacyOrderAdapter:
def to_domain(self, legacy_txn: dict) -> Order:
return Order(
id=OrderId(legacy_txn["TXN_NO"]),
customer_id=CustomerId(legacy_txn["CUST_NO"]),
status=self._translate_status(legacy_txn["STAT_CD"]),
created_at=self._parse_legacy_date(legacy_txn["TXN_DT"]),
total=Money(
amount=Decimal(legacy_txn["TOTAL_AMT"]) / 100,
currency=Currency(legacy_txn["CURR_CD"])
)
)
def _translate_status(self, status_code: int) -> OrderStatus:
return {
1: OrderStatus.PENDING,
2: OrderStatus.CONFIRMED,
3: OrderStatus.PENDING_REVIEW, # magic number → intent
4: OrderStatus.SHIPPED,
5: OrderStatus.DELIVERED,
}.get(status_code, OrderStatus.UNKNOWN)
The ACL is the only place the magic number 3 appears. Everywhere in your domain, it's OrderStatus.PENDING_REVIEW.
ACL in Domain-Driven Design
In DDD, bounded contexts are the units of domain separation. Each context has its own ubiquitous language. When two contexts integrate, their models need translation.
The ACL is the structural pattern for handling downstream integration with a conformist context — where you can't change the upstream model and don't want to adopt it wholesale. The ACL is explicitly an act of protection: you are protecting your domain from contamination.
This is different from an Open-Host Service, where the upstream provides a well-designed published API you can integrate with without needing translation. ACLs are for integrations where the upstream is messy, legacy, or not designed with your context in mind.
Placement and Deployment
The ACL doesn't have to be a separate service. It's frequently:
- A module within your service (internal ACL — no network hop)
- An adapter class per external system
- A separate integration service when the translation is complex or reused by multiple consumers
For high-volume integrations, a separate ACL service can cache translated representations and reduce load on the legacy system. For low-volume or simple integrations, keeping the ACL in-process is simpler.
Quick Recap
- The anti-corruption layer translates between a legacy system's domain model and your new domain model, preventing legacy concepts from contaminating your clean domain.
- The ACL is the only layer that knows about both domain models. All other code in your system uses only your domain's language. Magic numbers, abbreviated field names, and legacy data shapes never escape the ACL.
- Translation works in both directions: incoming legacy data is mapped to your model before entering your system, and outgoing calls to the legacy system convert your model back to legacy format.
- In DDD terms, the ACL is used when integrating with a downstream context you can't change and don't want to conform to. It is an explicit act of domain protection.
- The ACL can be an in-process module, an adapter class, or a separate microservice. Keep it in-process unless the translation is complex, high-volume, or reused by multiple services.