Skip to main content
DDD Without the Book Club

Anti-Corruption Layers

Ravinder··6 min read
DDDDomain-Driven DesignArchitectureAnti-Corruption LayerLegacy Integration
Share:
Anti-Corruption Layers

Every non-trivial system eventually has to integrate with something it doesn't control: a legacy ERP, a third-party payment gateway, an acquired company's API, a vendor data feed. The temptation is to map their concepts directly into your domain. Don't. Their model is built for their context. Letting it leak into yours is how you end up with CustomerNumber_Legacy fields and LegacyProductCode enums sprinkled through your core domain.

The anti-corruption layer (ACL) is the translation boundary. It speaks the external system's language at the edge and your domain's language inside.

The Problem Without an ACL

Imagine your clean domain has an Order aggregate and a Product entity. You integrate with a legacy ERP that uses a flat document structure:

// Legacy ERP response — their model, their conventions
{
  "CUST_NO": "C-10045",
  "ORD_DT": "20260101",
  "PROD_LIST": [
    { "PROD_CD": "WDG-001", "QTY": 3, "PRC_UNIT": "12.50", "UOM": "EA" }
  ],
  "TOT_AMT": "37.50",
  "STS_CD": "O"  // O = Open, C = Closed, H = Hold
}

Without an ACL, this leaks into your domain:

// Anti-pattern: legacy concepts leak into domain
public class Order {
    private String CUST_NO;       // ← legacy naming convention
    private String ORD_DT;        // ← string date from external system
    private String STS_CD;        // ← external status codes
}

Now your domain is coupled to the ERP's field names and data formats. When the ERP vendor changes STS_CD values, you're hunting through domain logic to fix it.

The ACL Structure

The ACL sits at the boundary between your bounded context and the external system. It has two responsibilities:

  1. Translate inbound: convert external representations to your domain concepts.
  2. Translate outbound: convert your domain concepts to what the external system expects.
graph LR subgraph Your Domain OS[OrderService] O[Order Aggregate] C[Customer] end subgraph ACL OADPT[OrderAdapter] TRANS[Translator] CADPT[CustomerAdapter] end subgraph Legacy ERP EAPI[ERP API] EMOD[ERP Data Model] end OS -- "Order / Customer (domain model)" --> OADPT OADPT -- "translate" --> TRANS TRANS -- "ERP document (CUST_NO, ORD_DT, STS_CD)" --> EAPI EAPI -- "ERP response" --> CADPT CADPT -- "translate" --> TRANS TRANS -- "Customer / Order (domain model)" --> OS

Implementing the Translator

// ACL Translator: knows both languages, exposes only domain language outward
public class ErpOrderTranslator {
 
    public Order toDomain(ErpOrderDocument erpDoc) {
        return Order.reconstitute(
            new OrderId(erpDoc.getOrderNumber()),
            translateCustomerId(erpDoc.getCustNo()),
            translateStatus(erpDoc.getStsCode()),
            erpDoc.getProdList().stream()
                .map(this::translateLineItem)
                .toList(),
            parseErpDate(erpDoc.getOrdDt())
        );
    }
 
    public ErpOrderDocument toErp(Order order) {
        ErpOrderDocument doc = new ErpOrderDocument();
        doc.setCustNo(order.getCustomerId().toErpFormat());
        doc.setOrdDt(formatErpDate(order.getOrderDate()));
        doc.setStsCode(translateStatusToErp(order.getStatus()));
        doc.setProdList(order.getLineItems().stream()
            .map(this::translateLineItemToErp)
            .toList());
        return doc;
    }
 
    private OrderStatus translateStatus(String erpStatusCode) {
        return switch (erpStatusCode) {
            case "O" -> OrderStatus.OPEN;
            case "C" -> OrderStatus.CLOSED;
            case "H" -> OrderStatus.ON_HOLD;
            default -> throw new UnknownErpStatusException(erpStatusCode);
        };
    }
 
    private String translateStatusToErp(OrderStatus status) {
        return switch (status) {
            case OPEN -> "O";
            case CLOSED -> "C";
            case ON_HOLD -> "H";
            case CANCELLED -> throw new IllegalArgumentException(
                "ERP does not support CANCELLED status — handle upstream"
            );
        };
    }
 
    private LocalDate parseErpDate(String erpDate) {
        return LocalDate.parse(erpDate, DateTimeFormatter.ofPattern("yyyyMMdd"));
    }
}

The translator knows about STS_CD = "O" so your domain never has to. When the ERP vendor changes their status codes, you fix the translator — not the domain.

Gateway: Hiding the External Client

Pair the translator with a gateway that hides the HTTP/SOAP/file details:

// Port: domain-level interface
interface LegacyErpGateway {
  fetchOrder(orderId: OrderId): Promise<Order>;
  submitOrder(order: Order): Promise<ErpOrderReference>;
}
 
// Adapter: implements the port, hides ERP details
class ErpHttpGateway implements LegacyErpGateway {
  constructor(
    private readonly httpClient: HttpClient,
    private readonly translator: ErpOrderTranslator,
    private readonly baseUrl: string,
  ) {}
 
  async fetchOrder(orderId: OrderId): Promise<Order> {
    const response = await this.httpClient.get(
      `${this.baseUrl}/orders/${orderId.value}`,
      { headers: this.erpAuthHeaders() }
    );
 
    if (!response.ok) {
      throw new ErpIntegrationException(`ERP returned ${response.status}`);
    }
 
    const erpDoc: ErpOrderDocument = await response.json();
    return this.translator.toDomain(erpDoc);
  }
 
  async submitOrder(order: Order): Promise<ErpOrderReference> {
    const erpDoc = this.translator.toErp(order);
    const response = await this.httpClient.post(
      `${this.baseUrl}/orders`,
      erpDoc,
      { headers: this.erpAuthHeaders() }
    );
    return new ErpOrderReference(response.headers.get('X-ERP-Order-ID')!);
  }
 
  private erpAuthHeaders(): Record<string, string> {
    return { 'X-ERP-API-Key': process.env.ERP_API_KEY! };
  }
}

Your application service calls erpGateway.fetchOrder(id) and gets back a clean Order domain object. It never sees HTTP, authentication headers, or ERP status codes.

Strangler Fig: ACL During Migration

When you're migrating away from a legacy system, the ACL becomes the strangler fig interface. The legacy system starts as the upstream, and your new domain grows behind the ACL. As you migrate functionality, you re-point the ACL from "call legacy" to "call new service."

sequenceDiagram participant CLIENT as Client participant ACL as ACL / Facade participant NEW as New Domain Service participant OLD as Legacy System Note over ACL: Phase 1: Route everything to legacy CLIENT->>ACL: fetchCustomer(id) ACL->>OLD: GET /customers?cust_no=id OLD-->>ACL: Legacy document ACL-->>CLIENT: Customer (domain model) Note over ACL: Phase 2: Route migrated features to new service CLIENT->>ACL: fetchCustomer(id) ACL->>NEW: GET /customers/id NEW-->>ACL: Customer (domain model, native) ACL-->>CLIENT: Customer (domain model)

The client never knows which system is answering. The ACL controls the routing decision. This is the least risky path to replacing a legacy system.

Detecting ACL Erosion

Signs that your ACL is leaking:

  • Domain classes have fields named legacyId, erpCode, vendorReference that travel through business logic.
  • Domain events carry external system identifiers as primary fields rather than as metadata.
  • Business rules reference external status codes: if (order.getStatusCode().equals("O")).
  • Application services import classes from the external system's SDK.

The fix for each: move the translation back to the ACL boundary. External identifiers stored for traceability belong in a separate ExternalReference value object attached to the aggregate — they don't drive logic.

Key Takeaways

  • The ACL translates between an external system's model and your domain model at the boundary — your domain never sees raw external concepts.
  • Implement it as a pair: a translator (knows both languages) and a gateway (hides the external client's protocol details).
  • The gateway implements a port defined in your application layer — the domain never imports from the ACL or the external SDK.
  • During migrations, the ACL becomes the strangler fig interface — re-pointing it from legacy to new service moves traffic without touching clients.
  • Detect ACL erosion by looking for legacy, erp, or vendor terminology inside your domain model — that's a boundary violation.
  • Failing fast on unknown external codes (unknown STS_CD value) is correct — it forces the translator to be kept up to date with the external system's schema.
Share: