System Design & Architecture
Part 0 of 9Anti-Corruption Layer Pattern: Protecting Your Domain From External System Pollution
Anti-Corruption Layer Pattern: Protecting Your Domain From External System Pollution
The Anti-Corruption Layer (ACL) is a translation boundary that prevents external system concepts, data models, and semantic drift from contaminating your domain model. It originates from Domain-Driven Design but has broader application whenever systems with incompatible models must integrate.
The Problem: Domain Model Corruption
Without Anti-Corruption Layer:
┌─────────────────────────────────────────────────────────────┐
│ Your Domain Model │
│ │
│ class Order { │
│ customerId: string; // Your concept │
│ items: OrderItem[]; │
│ │
│ // CORRUPTION: Legacy system concepts leak in │
│ legacyCustNo: string; // Legacy 8-char customer code │
│ legacyOrdTyp: number; // Legacy order type (1,2,3) │
│ legacyPrcLst: string; // Legacy price list code │
│ sapMaterialNo: string; // SAP material number │
│ salesforceId: string; // CRM opportunity ID │
│ │
│ // Business logic polluted with translation │
│ getCustomerForLegacy(): string { │
│ return this.customerId.substring(0, 8).toUpperCase(); │
│ } │
│ │
│ getLegacyOrderType(): number { │
│ switch(this.orderType) { │
│ case 'STANDARD': return 1; │
│ case 'RUSH': return 2; │
│ case 'BACKORDER': return 3; │
│ } │
│ } │
│ } │
│ │
│ Problems: │
│ - Domain model reflects N external systems' quirks │
│ - Changes in external system ripple through domain │
│ - Testing requires mocking external system concepts │
│ - New team members must understand all integrated systems │
│ - Adding new external system = modifying core domain │
└─────────────────────────────────────────────────────────────┘
Anti-Corruption Layer Architecture
With Anti-Corruption Layer:
┌─────────────────────────────────────────────────────────────┐
│ External Systems │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Legacy ERP │ │ SAP │ │ Salesforce │ │
│ │ (COBOL) │ │ (ABAP) │ │ (Apex) │ │
│ │ │ │ │ │ │ │
│ │ CUSTNO │ │ MATNR │ │ Opportunity__c │ │
│ │ ORDTYP │ │ WERKS │ │ Account__c │ │
│ │ PRCLST │ │ VKORG │ │ Contact__c │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
└─────────┼────────────────┼────────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ ANTI-CORRUPTION LAYER │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Adapters │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ │
│ │ │ LegacyERP │ │ SAP │ │ Salesforce │ │ │
│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │
│ │ └────────────┘ └────────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Translators │ │
│ │ │ │
│ │ LegacyCustomer → Customer │ │
│ │ SAPMaterial → Product │ │
│ │ SalesforceOpportunity → Lead │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Facades │ │
│ │ │ │
│ │ CustomerService, ProductService, LeadService │ │
│ │ (Exposes clean domain interfaces) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ YOUR DOMAIN MODEL │
│ (Pure, isolated) │
│ │
│ class Order { │
│ customerId: CustomerId; │
│ items: OrderItem[]; │
│ status: OrderStatus; │
│ │
│ // Pure domain logic, no external system knowledge │
│ calculateTotal(): Money { ... } │
│ canBeFulfilled(): boolean { ... } │
│ ship(): void { ... } │
│ } │
└─────────────────────────────────────────────────────────────┘
Implementation: Complete ACL Structure
// =====================================
// EXTERNAL SYSTEM TYPES (as they come from external APIs)
// =====================================
// Legacy ERP types - cryptic field names, packed data
interface LegacyCustomerRecord {
CUSTNO: string; // 8-char customer number, left-padded with zeros
CUSTNM: string; // 35-char customer name, EBCDIC encoded
CUSTST: string; // 1-char status: 'A'=Active, 'I'=Inactive, 'S'=Suspended
CREDLM: number; // Credit limit in cents (integer)
CUSTAD: string; // 200-char packed address field
LSTORD: string; // Last order date: YYYYMMDD
CRTUSR: string; // Created by user ID
CRTDAT: string; // Created date: YYYYMMDD
}
// SAP types - German abbreviations, complex structures
interface SAPCustomer {
KUNNR: string; // Customer number
NAME1: string; // Name line 1
NAME2: string; // Name line 2
STRAS: string; // Street
ORT01: string; // City
PSTLZ: string; // Postal code
LAND1: string; // Country key
BUKRS: string; // Company code
VKORG: string; // Sales organization
VTWEG: string; // Distribution channel
SPART: string; // Division
WAERS: string; // Currency key
KLIMK: number; // Credit limit
SKFOR: string; // Account determination
}
// Salesforce types - object-oriented, includes metadata
interface SalesforceAccount {
Id: string;
Name: string;
BillingStreet: string;
BillingCity: string;
BillingState: string;
BillingPostalCode: string;
BillingCountry: string;
Industry: string;
Type: 'Prospect' | 'Customer - Direct' | 'Customer - Channel' | 'Partner';
OwnerId: string;
CreatedDate: string; // ISO 8601
LastModifiedDate: string; // ISO 8601
Custom_Legacy_ID__c: string; // Custom field linking to legacy
}
// =====================================
// YOUR DOMAIN TYPES (clean, business-focused)
// =====================================
class CustomerId {
private constructor(private readonly value: string) {
if (!CustomerId.isValid(value)) {
throw new InvalidCustomerIdError(value);
}
}
static create(value: string): CustomerId {
return new CustomerId(value);
}
static isValid(value: string): boolean {
return /^[A-Z0-9]{8,12}$/.test(value);
}
equals(other: CustomerId): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
enum CustomerStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
SUSPENDED = 'SUSPENDED',
PENDING_APPROVAL = 'PENDING_APPROVAL',
}
class Money {
constructor(
private readonly amount: number,
private readonly currency: Currency
) {
if (amount < 0) {
throw new NegativeMoneyError(amount);
}
}
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchError(this.currency, other.currency);
}
return new Money(this.amount + other.amount, this.currency);
}
// Domain operations
isZero(): boolean {
return this.amount === 0;
}
isGreaterThan(other: Money): boolean {
this.assertSameCurrency(other);
return this.amount > other.amount;
}
}
interface Address {
street: string;
city: string;
state?: string;
postalCode: string;
country: Country;
}
// Domain entity - pure business logic
class Customer {
constructor(
public readonly id: CustomerId,
public readonly name: string,
public readonly status: CustomerStatus,
public readonly creditLimit: Money,
public readonly billingAddress: Address,
public readonly createdAt: Date,
public readonly metadata: CustomerMetadata
) {}
canPlaceOrder(orderTotal: Money): boolean {
return (
this.status === CustomerStatus.ACTIVE &&
!this.creditLimit.isZero() &&
this.creditLimit.isGreaterThan(orderTotal)
);
}
suspend(reason: string): Customer {
if (this.status === CustomerStatus.SUSPENDED) {
throw new CustomerAlreadySuspendedError(this.id);
}
return new Customer(
this.id,
this.name,
CustomerStatus.SUSPENDED,
this.creditLimit,
this.billingAddress,
this.createdAt,
{ ...this.metadata, suspensionReason: reason }
);
}
}
// =====================================
// ANTI-CORRUPTION LAYER: TRANSLATORS
// =====================================
// Translator for Legacy ERP system
class LegacyCustomerTranslator {
toDomain(legacy: LegacyCustomerRecord): Customer {
return new Customer(
this.translateCustomerId(legacy.CUSTNO),
this.translateName(legacy.CUSTNM),
this.translateStatus(legacy.CUSTST),
this.translateCreditLimit(legacy.CREDLM),
this.translateAddress(legacy.CUSTAD),
this.translateDate(legacy.CRTDAT),
{
sourceSystem: 'LEGACY_ERP',
sourceId: legacy.CUSTNO,
lastSyncedAt: new Date(),
}
);
}
toExternal(customer: Customer): Partial<LegacyCustomerRecord> {
return {
CUSTNO: this.formatCustomerNumber(customer.id),
CUSTNM: this.formatName(customer.name),
CUSTST: this.formatStatus(customer.status),
CREDLM: this.formatCreditLimit(customer.creditLimit),
CUSTAD: this.formatAddress(customer.billingAddress),
};
}
private translateCustomerId(custno: string): CustomerId {
// Legacy system uses 8-char zero-padded numbers
// Our system uses alphanumeric IDs
const normalized = custno.replace(/^0+/, '');
return CustomerId.create(`LEG${normalized.padStart(8, '0')}`);
}
private translateStatus(legacyStatus: string): CustomerStatus {
const statusMap: Record<string, CustomerStatus> = {
'A': CustomerStatus.ACTIVE,
'I': CustomerStatus.INACTIVE,
'S': CustomerStatus.SUSPENDED,
};
const status = statusMap[legacyStatus];
if (!status) {
// Log unknown status for monitoring
this.logger.warn(`Unknown legacy status: ${legacyStatus}`);
return CustomerStatus.INACTIVE; // Safe default
}
return status;
}
private translateCreditLimit(cents: number): Money {
// Legacy stores in cents, we use decimal dollars
return new Money(cents / 100, Currency.USD);
}
private translateAddress(packed: string): Address {
// Legacy packs address into fixed-width fields
// Format: STREET(80)|CITY(30)|STATE(2)|ZIP(10)|COUNTRY(3)
return {
street: packed.substring(0, 80).trim(),
city: packed.substring(80, 110).trim(),
state: packed.substring(110, 112).trim() || undefined,
postalCode: packed.substring(112, 122).trim(),
country: this.translateCountry(packed.substring(122, 125).trim()),
};
}
private translateDate(yyyymmdd: string): Date {
const year = parseInt(yyyymmdd.substring(0, 4));
const month = parseInt(yyyymmdd.substring(4, 6)) - 1;
const day = parseInt(yyyymmdd.substring(6, 8));
return new Date(year, month, day);
}
private formatCustomerNumber(id: CustomerId): string {
// Extract numeric part and pad to 8 chars
const numericPart = id.toString().replace(/^LEG/, '');
return numericPart.padStart(8, '0');
}
private formatStatus(status: CustomerStatus): string {
const reverseMap: Record<CustomerStatus, string> = {
[CustomerStatus.ACTIVE]: 'A',
[CustomerStatus.INACTIVE]: 'I',
[CustomerStatus.SUSPENDED]: 'S',
[CustomerStatus.PENDING_APPROVAL]: 'I', // No equivalent in legacy
};
return reverseMap[status];
}
}
// Translator for SAP
class SAPCustomerTranslator {
toDomain(sap: SAPCustomer): Customer {
return new Customer(
this.translateCustomerId(sap.KUNNR),
this.translateName(sap.NAME1, sap.NAME2),
CustomerStatus.ACTIVE, // SAP customers are always active if they exist
this.translateCreditLimit(sap.KLIMK, sap.WAERS),
this.translateAddress(sap),
new Date(), // SAP doesn't expose created date easily
{
sourceSystem: 'SAP',
sourceId: sap.KUNNR,
sapCompanyCode: sap.BUKRS,
sapSalesOrg: sap.VKORG,
lastSyncedAt: new Date(),
}
);
}
toExternal(customer: Customer): Partial<SAPCustomer> {
// SAP has complex partner functions - this is simplified
return {
KUNNR: this.formatCustomerNumber(customer.id),
NAME1: customer.name.substring(0, 35),
NAME2: customer.name.length > 35 ? customer.name.substring(35, 70) : '',
STRAS: customer.billingAddress.street.substring(0, 35),
ORT01: customer.billingAddress.city.substring(0, 35),
PSTLZ: customer.billingAddress.postalCode,
LAND1: this.formatCountry(customer.billingAddress.country),
KLIMK: this.formatCreditLimit(customer.creditLimit),
WAERS: customer.creditLimit.currency.iso4217Code,
};
}
private translateCustomerId(kunnr: string): CustomerId {
// SAP uses 10-digit customer numbers
return CustomerId.create(`SAP${kunnr}`);
}
private translateName(name1: string, name2: string): string {
const fullName = `${name1.trim()} ${name2.trim()}`.trim();
return fullName || 'UNKNOWN';
}
private translateCreditLimit(klimk: number, waers: string): Money {
const currency = Currency.fromISO4217(waers);
return new Money(klimk, currency);
}
private translateAddress(sap: SAPCustomer): Address {
return {
street: sap.STRAS.trim(),
city: sap.ORT01.trim(),
postalCode: sap.PSTLZ.trim(),
country: Country.fromISO(sap.LAND1),
};
}
}
// Translator for Salesforce
class SalesforceAccountTranslator {
toDomain(sf: SalesforceAccount): Customer {
return new Customer(
this.translateCustomerId(sf.Id, sf.Custom_Legacy_ID__c),
sf.Name,
this.translateType(sf.Type),
Money.zero(Currency.USD), // Salesforce doesn't track credit limits
this.translateAddress(sf),
new Date(sf.CreatedDate),
{
sourceSystem: 'SALESFORCE',
sourceId: sf.Id,
salesforceOwnerId: sf.OwnerId,
industry: sf.Industry,
lastSyncedAt: new Date(),
}
);
}
private translateCustomerId(sfId: string, legacyId?: string): CustomerId {
// Prefer legacy ID if available (for correlation)
if (legacyId) {
return CustomerId.create(`LEG${legacyId.padStart(8, '0')}`);
}
// Otherwise use Salesforce ID
return CustomerId.create(`SF${sfId.substring(0, 10)}`);
}
private translateType(type: SalesforceAccount['Type']): CustomerStatus {
switch (type) {
case 'Customer - Direct':
case 'Customer - Channel':
return CustomerStatus.ACTIVE;
case 'Prospect':
return CustomerStatus.PENDING_APPROVAL;
case 'Partner':
return CustomerStatus.ACTIVE;
default:
return CustomerStatus.INACTIVE;
}
}
private translateAddress(sf: SalesforceAccount): Address {
return {
street: sf.BillingStreet || '',
city: sf.BillingCity || '',
state: sf.BillingState,
postalCode: sf.BillingPostalCode || '',
country: Country.fromName(sf.BillingCountry) || Country.USA,
};
}
}
ACL Adapters: External System Communication
// =====================================
// ADAPTERS: Handle external system communication
// =====================================
interface ExternalCustomerAdapter {
findById(id: string): Promise<Customer | null>;
findByEmail(email: string): Promise<Customer | null>;
save(customer: Customer): Promise<void>;
sync(customerId: CustomerId): Promise<SyncResult>;
}
// Legacy ERP Adapter - handles SOAP/XML communication
class LegacyERPCustomerAdapter implements ExternalCustomerAdapter {
constructor(
private readonly soapClient: SOAPClient,
private readonly translator: LegacyCustomerTranslator,
private readonly circuitBreaker: CircuitBreaker,
private readonly metrics: MetricsClient
) {}
async findById(id: string): Promise<Customer | null> {
const timer = this.metrics.startTimer('legacy_erp_customer_fetch');
try {
const result = await this.circuitBreaker.execute(async () => {
// Legacy system uses SOAP with WS-Security
const envelope = this.buildSoapEnvelope('GetCustomer', {
CUSTNO: this.formatCustomerNumber(id),
});
const response = await this.soapClient.call(
'http://legacy.internal:8080/CustomerService',
envelope,
{ timeout: 5000 }
);
return this.parseSoapResponse(response);
});
if (!result) {
return null;
}
return this.translator.toDomain(result);
} catch (error) {
this.metrics.increment('legacy_erp_customer_fetch_error');
if (error instanceof CircuitBreakerOpenError) {
throw new ExternalSystemUnavailableError('LEGACY_ERP');
}
// Translate legacy error codes to domain exceptions
if (this.isNotFoundError(error)) {
return null;
}
throw new ExternalSystemError('LEGACY_ERP', error);
} finally {
timer.stop();
}
}
async save(customer: Customer): Promise<void> {
const legacyRecord = this.translator.toExternal(customer);
await this.circuitBreaker.execute(async () => {
const envelope = this.buildSoapEnvelope('UpdateCustomer', legacyRecord);
const response = await this.soapClient.call(
'http://legacy.internal:8080/CustomerService',
envelope,
{ timeout: 10000 }
);
this.validateResponse(response);
});
}
private buildSoapEnvelope(operation: string, data: object): string {
return `
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Header>
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>${this.config.username}</wsse:Username>
<wsse:Password>${this.config.password}</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
</soap:Header>
<soap:Body>
<${operation} xmlns="http://legacy.internal/customer">
${this.objectToXml(data)}
</${operation}>
</soap:Body>
</soap:Envelope>
`;
}
}
// SAP Adapter - handles RFC/BAPI calls
class SAPCustomerAdapter implements ExternalCustomerAdapter {
constructor(
private readonly rfcClient: SAPRFCClient,
private readonly translator: SAPCustomerTranslator,
private readonly cache: Cache,
private readonly metrics: MetricsClient
) {}
async findById(id: string): Promise<Customer | null> {
// Check cache first (SAP calls are expensive)
const cacheKey = `sap:customer:${id}`;
const cached = await this.cache.get<Customer>(cacheKey);
if (cached) {
this.metrics.increment('sap_customer_cache_hit');
return cached;
}
const timer = this.metrics.startTimer('sap_customer_fetch');
try {
// Call SAP BAPI
const result = await this.rfcClient.invoke('BAPI_CUSTOMER_GETDETAIL2', {
CUSTOMERNO: this.translator.formatCustomerNumber(id),
});
if (result.RETURN?.TYPE === 'E') {
// SAP error response
if (result.RETURN.NUMBER === '001') {
return null; // Customer not found
}
throw new SAPError(result.RETURN.MESSAGE);
}
const customer = this.translator.toDomain(result.CUSTOMERADDRESS);
// Cache for 5 minutes
await this.cache.set(cacheKey, customer, { ttl: 300 });
return customer;
} catch (error) {
this.metrics.increment('sap_customer_fetch_error', {
error_type: error.constructor.name,
});
throw new ExternalSystemError('SAP', error);
} finally {
timer.stop();
}
}
async save(customer: Customer): Promise<void> {
const sapData = this.translator.toExternal(customer);
await this.rfcClient.invoke('BAPI_CUSTOMER_CHANGEFROMDATA1', {
CUSTOMERNO: sapData.KUNNR,
PI_CUSTOMERGENERALDATA: sapData,
});
// Commit the BAPI transaction
await this.rfcClient.invoke('BAPI_TRANSACTION_COMMIT', {
WAIT: 'X',
});
// Invalidate cache
await this.cache.delete(`sap:customer:${customer.id}`);
}
}
// Salesforce Adapter - handles REST API
class SalesforceCustomerAdapter implements ExternalCustomerAdapter {
constructor(
private readonly httpClient: HttpClient,
private readonly translator: SalesforceAccountTranslator,
private readonly tokenManager: OAuth2TokenManager,
private readonly rateLimiter: RateLimiter
) {}
async findById(id: string): Promise<Customer | null> {
await this.rateLimiter.acquire(); // Salesforce has strict rate limits
const token = await this.tokenManager.getValidToken();
try {
const response = await this.httpClient.get(
`${this.config.instanceUrl}/services/data/v58.0/sobjects/Account/${id}`,
{
headers: {
'Authorization': `Bearer ${token.accessToken}`,
'Content-Type': 'application/json',
},
timeout: 10000,
}
);
if (response.status === 404) {
return null;
}
const account: SalesforceAccount = response.data;
return this.translator.toDomain(account);
} catch (error) {
if (error.response?.status === 401) {
// Token expired, clear and retry
await this.tokenManager.invalidate();
return this.findById(id);
}
throw new ExternalSystemError('SALESFORCE', error);
}
}
async findByEmail(email: string): Promise<Customer | null> {
await this.rateLimiter.acquire();
const token = await this.tokenManager.getValidToken();
// Use SOQL query
const query = `
SELECT Id, Name, BillingStreet, BillingCity, BillingState,
BillingPostalCode, BillingCountry, Industry, Type,
OwnerId, CreatedDate, LastModifiedDate, Custom_Legacy_ID__c
FROM Account
WHERE PersonEmail = '${this.escapeSOQL(email)}'
LIMIT 1
`;
const response = await this.httpClient.get(
`${this.config.instanceUrl}/services/data/v58.0/query`,
{
params: { q: query },
headers: {
'Authorization': `Bearer ${token.accessToken}`,
},
}
);
if (response.data.totalSize === 0) {
return null;
}
return this.translator.toDomain(response.data.records[0]);
}
}
ACL Facade: Unified Domain Interface
// =====================================
// FACADE: Single entry point for domain
// =====================================
interface CustomerRepository {
findById(id: CustomerId): Promise<Customer | null>;
findByEmail(email: string): Promise<Customer | null>;
save(customer: Customer): Promise<void>;
syncFromExternalSystems(id: CustomerId): Promise<Customer>;
}
class ACLCustomerRepository implements CustomerRepository {
constructor(
private readonly adapters: Map<string, ExternalCustomerAdapter>,
private readonly priorityOrder: string[], // Which system is source of truth
private readonly eventBus: EventBus,
private readonly logger: Logger
) {}
async findById(id: CustomerId): Promise<Customer | null> {
const sourceSystem = this.determineSourceSystem(id);
const adapter = this.adapters.get(sourceSystem);
if (!adapter) {
throw new ConfigurationError(`No adapter for system: ${sourceSystem}`);
}
return adapter.findById(id.toString());
}
async findByEmail(email: string): Promise<Customer | null> {
// Search across all systems in priority order
for (const system of this.priorityOrder) {
const adapter = this.adapters.get(system);
if (!adapter) continue;
try {
const customer = await adapter.findByEmail(email);
if (customer) {
return customer;
}
} catch (error) {
this.logger.warn(`Failed to search ${system} for email`, { error, email });
// Continue to next system
}
}
return null;
}
async save(customer: Customer): Promise<void> {
const sourceSystem = customer.metadata.sourceSystem;
const adapter = this.adapters.get(sourceSystem);
if (!adapter) {
throw new ConfigurationError(`No adapter for system: ${sourceSystem}`);
}
await adapter.save(customer);
// Publish domain event (ACL handles translation internally)
await this.eventBus.publish(new CustomerUpdatedEvent(customer));
}
async syncFromExternalSystems(id: CustomerId): Promise<Customer> {
const results: Map<string, Customer> = new Map();
const errors: Map<string, Error> = new Map();
// Fetch from all systems in parallel
await Promise.all(
Array.from(this.adapters.entries()).map(async ([system, adapter]) => {
try {
const customer = await adapter.findById(id.toString());
if (customer) {
results.set(system, customer);
}
} catch (error) {
errors.set(system, error);
}
})
);
if (results.size === 0) {
if (errors.size > 0) {
throw new SyncFailedError(id, errors);
}
throw new CustomerNotFoundError(id);
}
// Merge data from multiple systems using priority
const mergedCustomer = this.mergeCustomerData(results);
// Publish sync event for audit
await this.eventBus.publish(new CustomerSyncedEvent(id, results, mergedCustomer));
return mergedCustomer;
}
private determineSourceSystem(id: CustomerId): string {
const idString = id.toString();
if (idString.startsWith('LEG')) return 'LEGACY_ERP';
if (idString.startsWith('SAP')) return 'SAP';
if (idString.startsWith('SF')) return 'SALESFORCE';
// Default to primary system
return this.priorityOrder[0];
}
private mergeCustomerData(sources: Map<string, Customer>): Customer {
// Get highest priority source as base
let merged: Customer | null = null;
for (const system of this.priorityOrder) {
const customer = sources.get(system);
if (!customer) continue;
if (!merged) {
merged = customer;
continue;
}
// Merge specific fields from lower priority sources
merged = this.mergeFields(merged, customer, system);
}
if (!merged) {
throw new Error('No customer data to merge');
}
return merged;
}
private mergeFields(base: Customer, other: Customer, otherSystem: string): Customer {
// Example: Take credit limit from SAP if SAP has it
if (otherSystem === 'SAP' && !other.creditLimit.isZero()) {
return new Customer(
base.id,
base.name,
base.status,
other.creditLimit, // From SAP
base.billingAddress,
base.createdAt,
{
...base.metadata,
sapCreditLimit: other.creditLimit.toString(),
}
);
}
return base;
}
}
Testing Anti-Corruption Layers
// =====================================
// TESTING STRATEGY
// =====================================
describe('LegacyCustomerTranslator', () => {
let translator: LegacyCustomerTranslator;
beforeEach(() => {
translator = new LegacyCustomerTranslator();
});
describe('toDomain', () => {
it('should translate legacy customer record to domain model', () => {
const legacyRecord: LegacyCustomerRecord = {
CUSTNO: '00012345',
CUSTNM: 'ACME CORPORATION ', // Padded to 35 chars
CUSTST: 'A',
CREDLM: 100000, // $1000.00 in cents
CUSTAD: 'Street Address Here'.padEnd(80) +
'New York'.padEnd(30) +
'NY' +
'10001'.padEnd(10) +
'USA',
LSTORD: '20240115',
CRTUSR: 'SYSTEM01',
CRTDAT: '20200301',
};
const customer = translator.toDomain(legacyRecord);
expect(customer.id.toString()).toBe('LEG00012345');
expect(customer.name).toBe('ACME CORPORATION');
expect(customer.status).toBe(CustomerStatus.ACTIVE);
expect(customer.creditLimit.amount).toBe(1000);
expect(customer.creditLimit.currency).toBe(Currency.USD);
expect(customer.billingAddress.city).toBe('New York');
expect(customer.billingAddress.state).toBe('NY');
expect(customer.metadata.sourceSystem).toBe('LEGACY_ERP');
});
it('should handle unknown status codes gracefully', () => {
const legacyRecord: LegacyCustomerRecord = {
...validLegacyRecord,
CUSTST: 'X', // Unknown status
};
const customer = translator.toDomain(legacyRecord);
// Should default to INACTIVE for safety
expect(customer.status).toBe(CustomerStatus.INACTIVE);
});
it('should handle malformed dates', () => {
const legacyRecord: LegacyCustomerRecord = {
...validLegacyRecord,
CRTDAT: '00000000', // Invalid date
};
expect(() => translator.toDomain(legacyRecord)).toThrow(InvalidDateError);
});
});
describe('toExternal', () => {
it('should translate domain customer to legacy format', () => {
const customer = new Customer(
CustomerId.create('LEG00012345'),
'Acme Corporation',
CustomerStatus.ACTIVE,
new Money(1500, Currency.USD),
{
street: '123 Main St',
city: 'Boston',
state: 'MA',
postalCode: '02101',
country: Country.USA,
},
new Date('2020-03-01'),
{ sourceSystem: 'LEGACY_ERP', sourceId: '00012345' }
);
const legacy = translator.toExternal(customer);
expect(legacy.CUSTNO).toBe('00012345');
expect(legacy.CUSTST).toBe('A');
expect(legacy.CREDLM).toBe(150000); // Back to cents
});
it('should handle statuses without legacy equivalent', () => {
const customer = new Customer(
CustomerId.create('LEG00012345'),
'Acme Corporation',
CustomerStatus.PENDING_APPROVAL, // No legacy equivalent
// ...
);
const legacy = translator.toExternal(customer);
// Should map to closest equivalent
expect(legacy.CUSTST).toBe('I'); // Inactive
});
});
});
// Integration test with contract testing
describe('LegacyERPCustomerAdapter', () => {
let adapter: LegacyERPCustomerAdapter;
let mockSoapServer: MockSOAPServer;
beforeAll(async () => {
// Start mock SOAP server with recorded responses
mockSoapServer = await MockSOAPServer.start({
port: 8080,
wsdl: './contracts/legacy-customer-service.wsdl',
responses: './contracts/legacy-responses.json',
});
});
it('should handle SOAP fault responses', async () => {
mockSoapServer.setResponse('GetCustomer', {
fault: {
faultcode: 'Server',
faultstring: 'Database connection failed',
},
});
await expect(adapter.findById('12345')).rejects.toThrow(
ExternalSystemError
);
});
it('should respect circuit breaker after consecutive failures', async () => {
mockSoapServer.setResponse('GetCustomer', { fault: { faultcode: 'Server' } });
// Trigger circuit breaker (default: 5 failures)
for (let i = 0; i < 5; i++) {
await expect(adapter.findById('12345')).rejects.toThrow();
}
// Next call should fail fast
await expect(adapter.findById('12345')).rejects.toThrow(
ExternalSystemUnavailableError
);
});
});
ACL in Event-Driven Architectures
Event Translation Across Bounded Contexts:
┌─────────────────────────────────────────────────────────────┐
│ Billing Context │
│ │
│ Event: InvoicePaidEvent │
│ { │
│ invoiceId: "INV-2024-001234", │
│ customerId: "CUST-00012345", │
│ amountPaid: { value: 1500.00, currency: "USD" }, │
│ paymentMethod: "CREDIT_CARD", │
│ paidAt: "2024-01-15T10:30:00Z" │
│ } │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ANTI-CORRUPTION LAYER │
│ │
│ class BillingEventTranslator { │
│ │
│ translateInvoicePaid(event: InvoicePaidEvent): │
│ PaymentReceivedEvent { │
│ │
│ return new PaymentReceivedEvent({ │
│ // Translate to our domain concepts │
│ orderId: this.extractOrderId(event.invoiceId), │
│ customer: this.resolveCustomer(event.customerId), │
│ payment: { │
│ amount: Money.from(event.amountPaid), │
│ method: this.translatePaymentMethod( │
│ event.paymentMethod │
│ ), │
│ timestamp: Instant.parse(event.paidAt), │
│ }, │
│ source: { │
│ system: 'BILLING', │
│ originalEventId: event.invoiceId, │
│ }, │
│ }); │
│ } │
│ } │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Order Fulfillment Context │
│ │
│ Event: PaymentReceivedEvent │
│ { │
│ orderId: OrderId, │
│ customer: Customer, │
│ payment: Payment, │
│ source: EventSource │
│ } │
│ │
│ // Handler uses domain model, no billing concepts │
│ class PaymentReceivedHandler { │
│ handle(event: PaymentReceivedEvent) { │
│ const order = this.orderRepo.findById(event.orderId); │
│ order.markAsPaid(event.payment); │
│ this.orderRepo.save(order); │
│ } │
│ } │
└─────────────────────────────────────────────────────────────┘
When to Use Anti-Corruption Layer
Decision Matrix:
┌────────────────────────────────────────────────────────────────┐
│ Scenario │ Use ACL? │
├──────────────────────────────────────────┼─────────────────────┤
│ Integrating with legacy system │ ✅ Yes │
│ Third-party API with different model │ ✅ Yes │
│ Acquiring company with diff domain │ ✅ Yes │
│ Bounded context with different model │ ✅ Yes │
│ Same team, same bounded context │ ❌ No - overkill │
│ Internal microservice, same model │ ❌ No - direct │
│ Temporary integration during migration │ ✅ Yes │
│ Read-only data sync │ ⚠️ Maybe simpler │
└──────────────────────────────────────────┴─────────────────────┘
Cost-Benefit Analysis:
Benefits:
┌────────────────────────────────────────────────────────────────┐
│ ✅ Domain model stays pure and focused │
│ ✅ Changes to external systems are isolated │
│ ✅ Easier testing (mock at ACL boundary) │
│ ✅ Clear ownership (ACL team owns translation) │
│ ✅ Multiple external systems → single domain model │
│ ✅ Gradual migration support │
└────────────────────────────────────────────────────────────────┘
Costs:
┌────────────────────────────────────────────────────────────────┐
│ ❌ Additional code to maintain │
│ ❌ Runtime overhead (translation) │
│ ❌ Must keep ACL in sync with external changes │
│ ❌ Debugging spans two layers │
│ ❌ Risk of incomplete translation │
└────────────────────────────────────────────────────────────────┘
The Anti-Corruption Layer is essential when integrating systems with incompatible domain models. It preserves your domain's integrity by creating a clear translation boundary, making external system changes a localized concern rather than a system-wide problem. The implementation cost is justified when dealing with legacy systems, third-party integrations, or acquired company systems where direct integration would pollute your domain model with foreign concepts.
What did you think?