Back to Blog

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?

© 2026 Vidhya Sagar Thakur. All rights reserved.