You’ve built a clean domain model. Your entities are well-named, your business logic is encapsulated, and your code actually reads like the business it models. Then you need to integrate with a legacy CRM that uses cust_nm as a field name, has no concept of null, conflates customers with contacts, and returns XML.

Without protection, the legacy system’s worldview creeps into yours. Before long, cust_nm appears in your models. Your clean domain starts speaking the language of the legacy system instead of the language of your business.

The Anti-Corruption Layer (ACL) is the pattern that prevents this contamination.

What Is an Anti-Corruption Layer?

An ACL is a translation layer between your domain and an external system (legacy or third-party). It converts the external system’s model and language into your domain model, and vice versa. Nothing from the external world leaks past it.

Your Domain  ←──→  [Anti-Corruption Layer]  ←──→  Legacy System
 (clean)                 (translation)              (messy)

The ACL owns the adapter, the translation logic, and the isolation. Your domain never knows the external system exists.

The Problem Without an ACL

// ❌ Legacy system model leaking into your domain
interface Order {
  ord_id: string;          // legacy naming
  cust_nm: string;         // abbreviated field name
  ord_sts_cd: string;      // 'A' = active, 'C' = cancelled, 'P' = pending
  ln_itms: LegacyLineItem[]; // legacy structure
}

class OrderService {
  async createOrder(legacyData: LegacyOrderRequest): Promise<void> {
    // Your business logic is now coupled to legacy field names
    if (legacyData.ord_sts_cd === 'A') { ... }
  }
}

When the legacy system changes its status codes or field names, your entire domain breaks. You’re maintaining two systems’ worth of complexity.

Building an ACL

The ACL translates at the boundary. Your domain only sees your model:

// Your clean domain model
interface Order {
  id: string;
  customerName: string;
  status: 'active' | 'cancelled' | 'pending';
  lineItems: LineItem[];
  createdAt: Date;
}

// The legacy system's model (lives only in the ACL)
interface LegacyOrderRecord {
  ord_id: string;
  cust_nm: string;
  ord_sts_cd: 'A' | 'C' | 'P';
  ord_dt: string; // "YYYYMMDD"
  ln_itms: Array<{ itm_cd: string; qty: number; prc: number }>;
}

// The ACL: translates legacy → domain
class LegacyOrderAdapter {
  private statusMap: Record<string, Order['status']> = {
    A: 'active',
    C: 'cancelled',
    P: 'pending',
  };

  toDomain(legacy: LegacyOrderRecord): Order {
    return {
      id: legacy.ord_id,
      customerName: legacy.cust_nm,
      status: this.statusMap[legacy.ord_sts_cd] ?? 'pending',
      lineItems: legacy.ln_itms.map((item) => ({
        sku: item.itm_cd,
        quantity: item.qty,
        unitPrice: item.prc / 100, // legacy stores cents
      })),
      createdAt: this.parseDate(legacy.ord_dt),
    };
  }

  fromDomain(order: Order): LegacyOrderRecord {
    const reverseStatusMap = Object.fromEntries(
      Object.entries(this.statusMap).map(([k, v]) => [v, k])
    );

    return {
      ord_id: order.id,
      cust_nm: order.customerName,
      ord_sts_cd: reverseStatusMap[order.status] as 'A' | 'C' | 'P',
      ord_dt: this.formatDate(order.createdAt),
      ln_itms: order.lineItems.map((item) => ({
        itm_cd: item.sku,
        qty: item.quantity,
        prc: Math.round(item.unitPrice * 100),
      })),
    };
  }

  private parseDate(yyyymmdd: string): Date {
    const year = parseInt(yyyymmdd.slice(0, 4));
    const month = parseInt(yyyymmdd.slice(4, 6)) - 1;
    const day = parseInt(yyyymmdd.slice(6, 8));
    return new Date(year, month, day);
  }

  private formatDate(date: Date): string {
    return date.toISOString().slice(0, 10).replace(/-/g, '');
  }
}

Using the ACL in Your Repository

The repository is the natural home for the ACL — it converts between persistence/external models and domain models:

class OrderRepository {
  constructor(
    private legacyClient: LegacyCRMClient,
    private adapter: LegacyOrderAdapter
  ) {}

  async findById(id: string): Promise<Order | null> {
    const legacyRecord = await this.legacyClient.fetchOrder(id);
    if (!legacyRecord) return null;
    return this.adapter.toDomain(legacyRecord); // ← translation happens here
  }

  async save(order: Order): Promise<void> {
    const legacyRecord = this.adapter.fromDomain(order); // ← and here
    await this.legacyClient.saveOrder(legacyRecord);
  }
}

// Domain service never sees legacy types
class OrderService {
  constructor(private repository: OrderRepository) {}

  async activateOrder(id: string): Promise<void> {
    const order = await this.repository.findById(id);
    if (!order) throw new OrderNotFoundError(id);

    // Pure domain logic — no legacy knowledge here
    if (order.status === 'active') throw new OrderAlreadyActiveError(id);
    order.status = 'active';

    await this.repository.save(order);
  }
}

ACL for Third-Party APIs

The same pattern applies to external SaaS APIs:

// Stripe's model vs your payment domain
class StripePaymentAdapter {
  toDomain(stripeCharge: Stripe.Charge): Payment {
    return {
      id: stripeCharge.id,
      amount: stripeCharge.amount / 100, // Stripe uses cents
      currency: stripeCharge.currency.toUpperCase(),
      status: this.mapStatus(stripeCharge.status),
      paidAt: stripeCharge.created ? new Date(stripeCharge.created * 1000) : null,
    };
  }

  private mapStatus(stripeStatus: string): Payment['status'] {
    switch (stripeStatus) {
      case 'succeeded': return 'completed';
      case 'pending': return 'processing';
      case 'failed': return 'failed';
      default: return 'unknown';
    }
  }
}

When you switch from Stripe to another provider, you replace the adapter. Your domain never changes.

When to Use an ACL

Use an ACL when:

  • Integrating with legacy systems that have poor naming, weird data types, or outdated concepts
  • Using third-party APIs that you might want to replace later
  • The external model conflicts with your domain’s ubiquitous language
  • You want to protect your domain from external schema changes

Skip it when:

  • The integration is trivial and short-lived
  • The external model IS your model (rare, but it happens)
  • The overhead of translation isn’t worth it

Key Takeaways

  • The ACL prevents external models from contaminating your domain
  • Translation happens at the boundary — your domain only speaks its own language
  • The repository is the natural place to host ACL translation logic
  • ACLs protect you from external changes — when the legacy system changes, only the ACL changes
  • The pattern applies to legacy systems, third-party APIs, and anything external