Introduction
When you integrate a third-party CRM, legacy ERP, or payment gateway into your system, you quickly discover that their domain model rarely matches yours. The anti-corruption layer: a practical Design Patterns guide is your shield against letting their messy, foreign abstractions leak into your clean business logic. This pattern acts as a translator and boundary, ensuring your core domain remains insulated from external complexity.
Why the boundary matters
Imagine your e-commerce platform has a well-defined Order entity with clear state transitions. Your new CRM partner sends order data with field names like PO_NUM_EXT, STAT_CD, and timestamps in three different formats across different endpoints. Without a boundary layer, you face a painful choice: pollute your domain model with CRM quirks, or scatter translation logic across controllers and services.
The anti-corruption layer sits between your domain and external systems, absorbing all translation, validation, and impedance mismatch. This keeps your business logic clean and makes integration changes isolated events rather than domain-wide refactors.
Translate external APIs into your domain language
The core responsibility of an anti-corruption layer is translation. External systems speak their own language; your job is to listen, interpret, and respond in your domain's terminology.
Consider a real scenario: you're integrating with a legacy travel booking system that returns flight data like this:
// External API response (messy, inconsistent)
{
"flt_id": "BA123",
"dep_tm": "2024-02-15T10:30:00Z",
"arr_tm": "2024-02-15T14:45:00Z",
"sts": "CONF",
"prc_usd": 450.00,
"seats_avail": 12,
"aircraft": "Boeing 737",
"stops": 0
}
Your domain model expects clean, typed abstractions:
// Your domain model (clean)
class Flight {
flightNumber: string;
departureTime: Date;
arrivalTime: Date;
status: FlightStatus; // ENUM
pricePerSeat: Money;
availableSeats: number;
aircraftType: AircraftType; // ENUM
numberOfStops: number;
}
enum FlightStatus {
CONFIRMED = 'CONFIRMED',
PENDING = 'PENDING',
CANCELLED = 'CANCELLED'
}
Your anti-corruption layer adapts the external response into your domain:
// Anti-corruption layer (adapter/translator)
class ExternalFlightAdapter {
translateFromLegacyTravelSystem(externalData: any): Flight {
return new Flight({
flightNumber: externalData.flt_id,
departureTime: new Date(externalData.dep_tm),
arrivalTime: new Date(externalData.arr_tm),
status: this.mapStatus(externalData.sts),
pricePerSeat: Money.fromUsd(externalData.prc_usd),
availableSeats: externalData.seats_avail,
aircraftType: this.mapAircraftType(externalData.aircraft),
numberOfStops: externalData.stops
});
}
private mapStatus(externalStatus: string): FlightStatus {
const statusMap: Record<string, FlightStatus> = {
'CONF': FlightStatus.CONFIRMED,
'PEND': FlightStatus.PENDING,
'CANC': FlightStatus.CANCELLED
};
return statusMap[externalStatus] || FlightStatus.PENDING;
}
private mapAircraftType(aircraftName: string): AircraftType {
// Normalize aircraft naming
if (aircraftName.includes('737')) return AircraftType.BOEING_737;
if (aircraftName.includes('A380')) return AircraftType.AIRBUS_A380;
return AircraftType.UNKNOWN;
}
}
Now your domain layer never sees the messy external format. Controllers and business logic work only with clean Flight objects. When the external API changes its field names or adds new quirks, you modify only the adapter, not your entire codebase.
Isolate legacy systems
Legacy systems are often black boxes with undocumented behavior, slow APIs, and rigid data formats. An anti-corruption layer quarantines them, preventing their problems from spreading.
Suppose you're running an old on-premise SAP ERP system for inventory. It has:
- Eventual consistency (batch jobs run every 4 hours)
- Cryptic error messages
- Custom XML payloads with namespace collisions
- Flaky network calls that timeout randomly
Rather than asking your entire application to handle these quirks, the anti-corruption layer absorbs them:
// Anti-corruption layer for legacy ERP
class ERPInventoryGateway {
constructor(private erpClient: ERPClient, private cache: Cache, private logger: Logger) {}
async getStockLevel(sku: string): Promise<StockLevel> {
// Try cache first (ERP data is eventually consistent anyway)
const cached = await this.cache.get(`sku:${sku}`);
if (cached) return cached;
try {
// Retry logic for flaky network
const erpResponse = await this.retryWithBackoff(
() => this.erpClient.queryInventory(sku),
3
);
// Parse messy XML and translate to domain
const stockLevel = this.parseERPResponse(erpResponse);
// Cache for 4 hours (ERP batch cycle)
await this.cache.set(`sku:${sku}`, stockLevel, 4 * 60 * 60);
return stockLevel;
} catch (error) {
this.logger.error(`ERP lookup failed for SKU ${sku}`, error);
// Fallback behavior: return last known value or empty
return this.getFallbackStock(sku);
}
}
private async retryWithBackoff<T>(
fn: () => Promise<T>,
maxAttempts: number
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) throw error;
await this.delay(Math.pow(2, attempt - 1) * 1000);
}
}
throw new Error('Max retries exceeded');
}
private parseERPResponse(xml: string): StockLevel {
// Parse messy XML, handle namespace issues, extract quantity
// Return clean domain object
}
private async getFallbackStock(sku: string): Promise<StockLevel> {
// Return safe default when ERP fails
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Your business logic now depends on a reliable, predictable interface. The messy ERP complexity is hidden. If you migrate to a modern inventory system later, you swap the gateway implementation, not your entire application.
Change happens in one layer
This is where the anti-corruption layer pays dividends. When external systems change—APIs get versioned, field names shift, data formats evolve—you modify a single, focused location.
Without this layer, changes cascade. If your domain model directly uses external field names, renaming a CRM field means updating controllers, services, repositories, and tests. With the layer, you touch only the adapter.
Here's a practical example. Your CRM provider announces they're deprecating the STAT_CD field in favor of status_enum:
// Before: scattered translation logic
// In 10 different places across your codebase:
const orderStatus = response.STAT_CD || response.status_enum;
const mappedStatus = orderStatus === 'C' ? OrderStatus.CONFIRMED : OrderStatus.PENDING;
// After: centralized in anti-corruption layer
class CRMOrderAdapter {
translateFromCRM(crmData: any): Order {
// Single place to handle both old and new fields
const statusField = crmData.status_enum ?? crmData.STAT_CD;
const status = this.mapCRMStatus(statusField);
return new Order({
id: crmData.order_id,
customerId: crmData.cust_id,
status,
createdAt: new Date(crmData.created_datetime),
total: Money.fromCents(crmData.total_cents)
});
}
private mapCRMStatus(crmStatus: string): OrderStatus {
const map: Record<string, OrderStatus> = {
'C': OrderStatus.CONFIRMED,
'P': OrderStatus.PENDING,
'X': OrderStatus.CANCELLED
};
return map[crmStatus] || OrderStatus.UNKNOWN;
}
}
When the CRM fully removes STAT_CD in three months, you make one change. Your business logic remains unaware of the transition.
Essential when integrating old CRMs or ERPs
Legacy CRM and ERP systems are the classic use case. These systems were designed decades ago, use proprietary protocols, and carry decades of technical debt. Integration without an anti-corruption layer is painful.
Real-world example: integrating Salesforce with a legacy on-premise CRM that serves your largest customer base. Salesforce uses modern REST APIs and clean JSON. The legacy system exposes a SOAP interface with XML, requires custom authentication tokens, and has API rate limits that change without notice.
// Anti-corruption layer bridges the gap
class LegacyCRMBridge {
constructor(
private legacyClient: LegacyCRMClient,
private salesforceClient: SalesforceClient
) {}
async syncCustomerToSalesforce(customerId: string): Promise<void> {
// Fetch from legacy system (SOAP, XML, slow)
const legacyCustomer = await this.legacyClient.getCustomer(customerId);
// Translate to domain model
const customer = this.adaptLegacyCustomer(legacyCustomer);
// Map domain to Salesforce format
const sfPayload = this.mapCustomerToSalesforce(customer);
// Push to Salesforce (REST, JSON, fast)
await this.salesforceClient.updateAccount(sfPayload);
}
private adaptLegacyCustomer(legacyData: any): Customer {
// Handle legacy quirks: custom date formats, encoded fields, missing data
return new Customer({
id: legacyData.CUST_NUM,
name: `${legacyData.FIRST_NAME} ${legacyData.LAST_NAME}`.trim(),
email: this.extractEmail(legacyData.CONTACT_XML),
status: this.mapStatus(legacyData.CUST_STATUS),
accountValue: Money.fromCents(legacyData.LIFETIME_VALUE_CENTS)
});
}
private mapCustomerToSalesforce(customer: Customer): Record<string, any> {
return {
Name: customer.name,
Email__c: customer.email,
Customer_Status__c: customer.status,
Lifetime_Value__c: customer.accountValue.toUsd()
};
}
private extractEmail(contactXml: string): string {
// Parse messy XML to find email
}
private mapStatus(legacyStatus: string): string {
// Map legacy codes to your enums
}
}
The anti-corruption layer becomes your integration backbone, allowing your application to work comfortably with both systems without compromise.
Common mistakes
- Leaking external abstractions into the adapter. If your adapter returns the external object instead of your domain object, you've built no layer at all. Always translate to domain types.
- Making the adapter too thin. It should handle not just data mapping but also retry logic, caching, error handling, and validation. These are integration concerns, not business logic.
- Skipping tests for the adapter. Mock both the external system's happy path and its failure modes. Test that bad data is handled gracefully.
- Assuming external APIs never change. Version your adapters. When an external system releases a new API version, create a new adapter class and route traffic gradually.
- Over-abstracting too early. Don't build elaborate adapter hierarchies before you have multiple systems to integrate. Start with a focused adapter, refactor when patterns emerge.
Key takeaways
- The anti-corruption layer translates external data into your domain language, keeping business logic clean and decoupled.
- It isolates legacy and third-party systems, absorbing their quirks, eventual consistency, and failure modes so they don't spread through your codebase.
- Changes to external APIs are contained within the adapter, reducing ripple effects across your application.
- For CRM, ERP, and payment gateway integrations, the layer is not optional—it's the only way to maintain sanity as systems evolve independently.
- Include retry logic, caching, validation, and error handling in the adapter; it's the integration boundary's responsibility to handle these concerns.






Comments
Loading comments…