Replacing or integrating with a legacy system is one of the most common situations in enterprise software. The legacy system has a data model the new system should not adopt — fields named for departments that no longer exist, statuses that encode three different things, relationships that made sense in 1998. The temptation is to mirror the legacy model in the new system "for compatibility." The cost of giving in is that the legacy mess metastasizes into the new code.
The anti-corruption layer (ACL) is a deliberate translation layer between the two systems. It hides the legacy model from the new system and translates between the two whenever they communicate. Eric Evans introduced the term in Domain-Driven Design, but the pattern shows up anywhere new code has to talk to old code without becoming it.
What the Layer Actually Does
The anti-corruption layer is a translator and a firewall. From the new system's perspective, it exposes a clean domain model. Inside, it does the messy work of mapping calls and data to and from the legacy system's representation.
New System (clean domain)
│
▼
+------------------------+
| Anti-Corruption Layer |
| - translates calls |
| - maps data shapes |
| - hides legacy quirks |
+------------------------+
│
▼
Legacy System (messy schema)
The pattern matters because the alternative — letting the new system import the legacy schema directly — pulls every legacy concept into the new design. Every developer working on the new system has to know what cust_st_cd_xtnd means, and the new system inherits the legacy system's lifetime, not its own.
Anatomy of an ACL
Concretely, an ACL has three responsibilities:
1. Vocabulary translation. Convert legacy names and codes into domain terms.
final class LegacyCustomerTranslator
{
public function toDomain(LegacyCustomerRow $row): Customer
{
return new Customer(
id: new CustomerId($row->cust_id),
name: trim("{$row->fname} {$row->lname}"),
status: $this->mapStatus($row->cust_st_cd_xtnd),
tier: $this->mapTier($row->prgm_lvl_n),
email: $this->normalizeEmail($row->email_addr_1),
);
}
private function mapStatus(string $code): CustomerStatus
{
return match ($code) {
'A', 'AC' => CustomerStatus::Active,
'I', 'IN', 'IX' => CustomerStatus::Inactive,
'S', 'SP' => CustomerStatus::Suspended,
default => CustomerStatus::Unknown,
};
}
}
2. Shape translation. Reorganize the data into structures the new domain expects.
// Legacy: flat row with cust_addr_1, cust_addr_2, cust_addr_city...
// Domain: nested Address value object
$customer = new Customer(
address: new Address(
line1: $row->cust_addr_1,
line2: $row->cust_addr_2,
city: $row->cust_addr_city,
postalCode: $row->cust_addr_zip,
),
);
3. Behavioral translation. Convert intent in the new system into the API the legacy system understands.
final class LegacyCustomerGateway implements CustomerRepository
{
public function suspend(CustomerId $id, string $reason): void
{
// New system thinks: "suspend the customer with a reason"
// Legacy system needs: set status code, write to audit table,
// call the COBOL service over MQ to invalidate cached entitlements
$this->legacyDb->statement(
'UPDATE cust_master SET cust_st_cd_xtnd = ?, last_chg_d = ? WHERE cust_id = ?',
['SP', now(), $id->value()]
);
$this->legacyDb->insert('cust_audit_log', [
'cust_id' => $id->value(),
'evt_typ' => 'SUSP',
'evt_dsc' => substr($reason, 0, 80),
]);
$this->mqClient->send('CUST.ENTITLEMENT.INVALIDATE', $id->value());
}
}
The domain code that suspends a customer never knows about audit tables or message queues. It calls $customers->suspend($id, $reason) and trusts the gateway.
Where to Put the ACL
The layer lives at the boundary, but "boundary" has several plausible interpretations.
- In-process module. When the new code and the legacy code share a process — for example, a Laravel application that still uses a few legacy database tables — the ACL is a set of classes between the new domain and the legacy data access code.
- Out-of-process service. When the legacy system is a separate application or mainframe, the ACL becomes a dedicated service or library that handles the protocol translation. The new system talks REST or gRPC to the ACL; the ACL talks SOAP, MQ, or stored procedures to the legacy.
- Database view. Sometimes a read-only ACL can be a database view that presents legacy tables in a clean shape. This works for simple translations but breaks down quickly when you need to translate writes.
A common pattern is to start with an in-process ACL and extract it to a service later if the legacy system becomes a shared dependency for multiple new applications.
ACLs Are Not Free
The pattern adds real code. Every legacy concept needs a translator. Every translator needs tests. Schema changes on either side require updating the translation layer.
The cost is justified by what you do not pay: you do not have legacy concepts polluting your new domain model, you do not have every new service inheriting legacy schema decisions, and you have a single place to update when the legacy system finally goes away.
If the legacy system will be retired in a few months, an ACL may be unnecessary overhead. If the legacy system will be around for years — which it almost always will — the ACL pays for itself within months.
ACLs and the Strangler Fig
The anti-corruption layer is one of the standard tools used inside the strangler fig migration pattern. As you carve functionality out of the legacy system into new services, each new service uses an ACL to interact with whatever still lives in the legacy. Over time, the ACL shrinks as more of the legacy responsibilities move to new code.
When the last legacy responsibility moves out, the ACL becomes unnecessary and can be deleted — which is usually the day you celebrate the legacy system's retirement.
When to Use It
- You are building new code that has to integrate with a legacy system you do not control or cannot change quickly
- The legacy data model has terminology, encodings, or shapes that would degrade the new code if imported directly
- You expect the legacy system to live for years before being retired
- Multiple new components will interact with the legacy system, and you want a single, owned translation layer instead of N partial reimplementations
When to Skip It
- The integration is read-only, simple, and short-lived
- The legacy system already exposes a clean domain API (rare, but it happens)
- The new system will be retired before the legacy one
Building new code that has to live alongside a legacy system you cannot rewrite tomorrow? We help teams design boundaries that keep the legacy contained instead of contagious. scopeforged.com