TechLead
Lesson 26 of 27
5 min read
Software Architecture

Anti-Corruption Layer

Learn how to protect your domain model from external system models using the anti-corruption layer pattern

What is an Anti-Corruption Layer?

The Anti-Corruption Layer (ACL) is a pattern from Domain-Driven Design that creates a translation layer between your domain model and an external system's model. It prevents the external system's concepts, naming conventions, and data structures from leaking into and corrupting your carefully designed domain model. The ACL translates between two different models, keeping each side clean and independent.

When to Use an ACL

  • Legacy integration: When integrating with a legacy system that has a different (often messy) data model
  • Third-party APIs: When consuming external APIs whose models you do not control
  • Context boundaries: When two bounded contexts have different models for the same concept
  • Migration: When gradually migrating from an old system to a new one

ACL Architecture

The ACL consists of three components: a Facade that provides a simplified interface, a Translator that converts between models, and an Adapter that handles the actual communication with the external system.

// Our clean domain model
interface Customer {
  id: CustomerId;
  fullName: string;
  email: EmailAddress;
  tier: CustomerTier;
  registeredAt: Date;
}

// Legacy system's messy model (we do NOT control this)
interface LegacyCustomerRecord {
  CUST_ID: number;
  FNAME: string;
  LNAME: string;
  EMAIL_ADDR: string | null;
  CUST_TYPE: 1 | 2 | 3 | 4;  // Magic numbers!
  REG_DT: string;  // "MM/DD/YYYY" format
  IS_ACTIVE: "Y" | "N";
  LAST_MOD_TS: string;
  CREATED_BY: string;
  MOD_BY: string;
}

// Translator: Converts between our domain and the legacy model
class CustomerTranslator {
  toDomain(legacy: LegacyCustomerRecord): Customer {
    return {
      id: CustomerId.from(String(legacy.CUST_ID)),
      fullName: `${legacy.FNAME} ${legacy.LNAME}`.trim(),
      email: EmailAddress.from(legacy.EMAIL_ADDR || "unknown@legacy.com"),
      tier: this.translateTier(legacy.CUST_TYPE),
      registeredAt: this.parseDate(legacy.REG_DT),
    };
  }

  toLegacy(customer: Customer): Partial {
    const [firstName, ...lastNameParts] = customer.fullName.split(" ");
    return {
      FNAME: firstName,
      LNAME: lastNameParts.join(" ") || firstName,
      EMAIL_ADDR: customer.email.value,
      CUST_TYPE: this.translateTierToLegacy(customer.tier),
      IS_ACTIVE: "Y",
    };
  }

  private translateTier(legacyType: number): CustomerTier {
    const mapping: Record = {
      1: "bronze",
      2: "silver",
      3: "gold",
      4: "platinum",
    };
    return mapping[legacyType] || "bronze";
  }

  private translateTierToLegacy(tier: CustomerTier): 1 | 2 | 3 | 4 {
    const mapping: Record = {
      bronze: 1, silver: 2, gold: 3, platinum: 4,
    };
    return mapping[tier];
  }

  private parseDate(legacyDate: string): Date {
    const [month, day, year] = legacyDate.split("/");
    return new Date(Number(year), Number(month) - 1, Number(day));
  }
}

// Adapter: Communicates with the legacy system
class LegacyCustomerAdapter {
  constructor(
    private readonly legacyApiUrl: string,
    private readonly httpClient: HttpClient
  ) {}

  async fetchCustomer(id: number): Promise {
    try {
      const response = await this.httpClient.get(
        `${this.legacyApiUrl}/api/customers/${id}`
      );
      return response.data;
    } catch (error) {
      if ((error as any).status === 404) return null;
      throw error;
    }
  }

  async updateCustomer(id: number, data: Partial): Promise {
    await this.httpClient.put(
      `${this.legacyApiUrl}/api/customers/${id}`,
      data
    );
  }

  async searchCustomers(query: string): Promise {
    const response = await this.httpClient.get(
      `${this.legacyApiUrl}/api/customers?search=${encodeURIComponent(query)}`
    );
    return response.data.results;
  }
}

// Facade: The clean interface our domain uses
class CustomerGateway implements CustomerRepository {
  constructor(
    private readonly adapter: LegacyCustomerAdapter,
    private readonly translator: CustomerTranslator
  ) {}

  async findById(id: CustomerId): Promise {
    const legacyRecord = await this.adapter.fetchCustomer(Number(id.value));
    if (!legacyRecord) return null;
    if (legacyRecord.IS_ACTIVE === "N") return null; // Domain rule: inactive = not found
    return this.translator.toDomain(legacyRecord);
  }

  async save(customer: Customer): Promise {
    const legacyData = this.translator.toLegacy(customer);
    await this.adapter.updateCustomer(Number(customer.id.value), legacyData);
  }

  async search(query: string): Promise {
    const legacyRecords = await this.adapter.searchCustomers(query);
    return legacyRecords
      .filter(r => r.IS_ACTIVE === "Y")
      .map(r => this.translator.toDomain(r));
  }
}

// Our domain code stays clean — no legacy concepts leak through
class CustomerService {
  constructor(private readonly customerRepo: CustomerRepository) {}

  async upgradeCustomerTier(customerId: CustomerId): Promise {
    const customer = await this.customerRepo.findById(customerId);
    if (!customer) throw new Error("Customer not found");
    // Pure domain logic — no legacy concepts here
    const newTier = this.calculateTierUpgrade(customer.tier);
    const upgraded = { ...customer, tier: newTier };
    await this.customerRepo.save(upgraded);
  }
}

ACL for Event Translation

// Translate events from an external system to your domain events
class ExternalEventTranslator {
  constructor(private readonly eventBus: EventBus) {}

  // External system publishes events in its own format
  async handleExternalEvent(rawEvent: Record): Promise {
    const eventType = rawEvent.type as string;

    switch (eventType) {
      case "ext.order.status_change": {
        // Translate from external format to our domain event
        const domainEvent = this.translateOrderStatusChange(rawEvent);
        if (domainEvent) {
          await this.eventBus.publish(domainEvent);
        }
        break;
      }
      case "ext.inventory.adjustment": {
        const domainEvent = this.translateInventoryAdjustment(rawEvent);
        await this.eventBus.publish(domainEvent);
        break;
      }
      default:
        console.warn(`Unknown external event type: ${eventType}`);
    }
  }

  private translateOrderStatusChange(raw: Record): DomainEvent | null {
    const statusMapping: Record = {
      "STAT_01": "confirmed",
      "STAT_02": "processing",
      "STAT_03": "shipped",
      "STAT_04": "delivered",
      "STAT_99": "cancelled",
    };

    const status = statusMapping[raw.new_status as string];
    if (!status) return null;

    return {
      type: "OrderStatusUpdated",
      orderId: String(raw.order_ref),
      newStatus: status,
      occurredAt: new Date(raw.timestamp as string),
    };
  }
}

ACL Best Practices

  • Keep translation in one place: All translation logic belongs in the ACL — nowhere else in your domain
  • Test translations thoroughly: The ACL is a critical boundary — test every edge case in the translation
  • Handle missing data: External systems may have null fields or missing data — the ACL must handle this gracefully
  • Log translation failures: When translation fails, log the raw external data for debugging
  • ACL is temporary: If you are migrating away from a legacy system, the ACL should shrink over time and eventually be removed

Continue Learning