Skip to content
Unverified — AI-generated content. Help verify this page

Anti-Corruption Layer (ACL) ​

Why It Exists ​

When your system integrates with another system — a legacy monolith, a third-party API, a partner's SOAP service, or even another bounded context within your own organization — the external system's model will contaminate your domain model unless you actively prevent it.

Eric Evans introduced the Anti-Corruption Layer in Domain-Driven Design (2003) as a defensive integration pattern. The name is intentionally dramatic: external models corrupt your domain when they leak in. An ACL is a translation layer that intercepts all communication between your bounded context and the external system, converting foreign concepts into your domain's language.

The Problem It Solves ​

Consider an e-commerce system that integrates with a legacy ERP for inventory:

Without ACL:
┌──────────────────┐         ┌────────────────────┐
│  Order Service   │────────>│   Legacy ERP       │
│  (your domain)   │<────────│   (their model)    │
│                  │         │                    │
│  Order.erpSkuId  │         │  ITEM_MASTER table │
│  Order.erpLocId  │         │  LOC_CODE column   │
│  Order.erpQtyAvl │         │  QTY_AVL field     │
└──────────────────┘         └────────────────────┘

Your domain entities now contain ERP-specific field names, data formats, and assumptions. When the ERP is replaced (which always eventually happens), your entire domain must change.

With ACL:
┌──────────────────┐    ┌─────────────┐    ┌────────────────────┐
│  Order Service   │───>│    ACL      │───>│   Legacy ERP       │
│  (your domain)   │<───│  Translator │<───│   (their model)    │
│                  │    │  + Adapter  │    │                    │
│  Order.productId │    │             │    │  ITEM_MASTER table │
│  Order.warehouse │    │  maps       │    │  LOC_CODE column   │
│  Order.available │    │  concepts   │    │  QTY_AVL field     │
└──────────────────┘    └─────────────┘    └────────────────────┘

Your domain uses its own ubiquitous language. The ACL translates between the two models.

First Principles ​

Model Integrity ​

Every bounded context has a model integrity boundary — the perimeter within which the ubiquitous language is consistent and the domain model is coherent. The ACL is a guard at this perimeter.

Without the ACL, model integrity degrades according to the contamination rate:

I(t)=I0⋅e−λ⋅n⋅t

Where:

  • I0 = initial model integrity (1.0 = perfect)
  • λ = contamination rate per integration point
  • n = number of unprotected integration points
  • t = time in months

With even 3 unprotected integration points and a modest contamination rate of 0.05/month:

I(12)=1.0⋅e−0.05×3×12=e−1.8≈0.17

After 12 months, only 17% of the original model integrity remains. With an ACL (λ≈0), integrity is preserved.

The Three Components of an ACL ​

An Anti-Corruption Layer consists of three collaborating components:

  1. Facade: Provides a simplified, domain-aligned interface that your code calls. Hides the complexity of the external system.
  2. Translator: Converts between your domain model and the external system's model. Maps concepts, not just field names.
  3. Adapter: Handles protocol-level concerns — HTTP calls, SOAP envelopes, database queries, message formats.

Core Mechanics ​

Component 1: The Adapter (Infrastructure Concern) ​

The adapter handles the raw communication with the external system:

typescript
// acl/adapters/erp-api.adapter.ts
import type { AxiosInstance } from 'axios';

/** Raw ERP response shapes — exactly as the external API returns them */
export interface ErpItemResponse {
  ITEM_ID: string;
  SKU_CODE: string;
  ITEM_DESC: string;
  UOM: string;          // Unit of measure: 'EA', 'CS', 'PL'
  STD_COST: string;     // String representation of decimal
  LOC_CODE: string;
  QTY_ON_HAND: number;
  QTY_RESERVED: number;
  QTY_AVL: number;
  LAST_RECV_DT: string; // Format: 'YYYYMMDD'
  STATUS_FLG: string;   // 'A' = active, 'I' = inactive, 'D' = discontinued
}

export interface ErpReserveRequest {
  ITEM_ID: string;
  LOC_CODE: string;
  QTY: number;
  REF_NBR: string;      // Reference number for the reservation
  REF_TYPE: string;     // 'SO' = sales order, 'TO' = transfer order
}

export interface ErpReserveResponse {
  RSV_ID: string;
  STATUS: 'OK' | 'FAIL';
  MSG: string;
  QTY_RESERVED: number;
}

export class ErpApiAdapter {
  constructor(
    private readonly http: AxiosInstance,
    private readonly baseUrl: string,
  ) {}

  async getItem(itemId: string): Promise<ErpItemResponse | null> {
    try {
      const response = await this.http.get<ErpItemResponse>(
        `${this.baseUrl}/api/items/${itemId}`,
        {
          headers: { 'X-API-Key': process.env.ERP_API_KEY },
          timeout: 5000,
        },
      );
      return response.data;
    } catch (error: any) {
      if (error.response?.status === 404) return null;
      throw new ExternalSystemError('ERP', `Failed to fetch item ${itemId}`, error);
    }
  }

  async getItemBySku(skuCode: string): Promise<ErpItemResponse | null> {
    try {
      const response = await this.http.get<{ items: ErpItemResponse[] }>(
        `${this.baseUrl}/api/items`,
        {
          params: { SKU_CODE: skuCode },
          headers: { 'X-API-Key': process.env.ERP_API_KEY },
          timeout: 5000,
        },
      );
      return response.data.items[0] ?? null;
    } catch (error: any) {
      if (error.response?.status === 404) return null;
      throw new ExternalSystemError('ERP', `Failed to fetch item by SKU ${skuCode}`, error);
    }
  }

  async reserveInventory(request: ErpReserveRequest): Promise<ErpReserveResponse> {
    try {
      const response = await this.http.post<ErpReserveResponse>(
        `${this.baseUrl}/api/reservations`,
        request,
        {
          headers: { 'X-API-Key': process.env.ERP_API_KEY },
          timeout: 10000,
        },
      );
      return response.data;
    } catch (error: any) {
      throw new ExternalSystemError('ERP', 'Failed to reserve inventory', error);
    }
  }
}

Component 2: The Translator (Concept Mapping) ​

The translator maps between the external system's concepts and your domain's concepts:

typescript
// acl/translators/inventory.translator.ts
import type { ErpItemResponse, ErpReserveRequest, ErpReserveResponse } from '../adapters/erp-api.adapter';
import { ProductId } from '../../domain/value-objects/product-id';
import { WarehouseId } from '../../domain/value-objects/warehouse-id';
import { Money } from '../../domain/value-objects/money';
import { StockLevel } from '../../domain/value-objects/stock-level';
import { ProductAvailability } from '../../domain/value-objects/product-availability';
import type { InventoryReservation } from '../../domain/entities/inventory-reservation';
import type { OrderId } from '../../domain/value-objects/order-id';

export class InventoryTranslator {
  /**
   * Translates an ERP item response into our domain's ProductAvailability concept.
   *
   * Key translations:
   * - SKU_CODE → ProductId (our domain identifies products by SKU)
   * - LOC_CODE → WarehouseId (our domain calls locations "warehouses")
   * - QTY_AVL → StockLevel with availability semantics
   * - STATUS_FLG → boolean availability flag
   * - STD_COST → Money (our domain concept, not ERP's string format)
   * - UOM → handled internally (we always work in individual units)
   */
  static toProductAvailability(erpItem: ErpItemResponse): ProductAvailability {
    const productId = ProductId.of(erpItem.SKU_CODE);
    const warehouseId = WarehouseId.of(erpItem.LOC_CODE);

    // ERP tracks quantities in different UOMs; we normalize to individual units
    const availableQty = this.normalizeQuantity(erpItem.QTY_AVL, erpItem.UOM);
    const onHandQty = this.normalizeQuantity(erpItem.QTY_ON_HAND, erpItem.UOM);
    const reservedQty = this.normalizeQuantity(erpItem.QTY_RESERVED, erpItem.UOM);

    // ERP cost is a string like "12.5000"; we convert to Money
    const cost = Money.of(parseFloat(erpItem.STD_COST), 'USD');

    // ERP status flags map to our availability concept
    const isActive = erpItem.STATUS_FLG === 'A';
    const isDiscontinued = erpItem.STATUS_FLG === 'D';

    // ERP date format YYYYMMDD → Date
    const lastReceivedAt = this.parseErpDate(erpItem.LAST_RECV_DT);

    return ProductAvailability.create({
      productId,
      warehouseId,
      stockLevel: StockLevel.of(onHandQty, reservedQty, availableQty),
      unitCost: cost,
      isActive,
      isDiscontinued,
      lastReceivedAt,
    });
  }

  /**
   * Translates a domain reservation request into the ERP's format.
   */
  static toErpReserveRequest(
    productId: ProductId,
    warehouseId: WarehouseId,
    quantity: number,
    orderId: OrderId,
  ): ErpReserveRequest {
    return {
      ITEM_ID: productId.value,  // ERP uses ITEM_ID, we use productId
      LOC_CODE: warehouseId.value,
      QTY: quantity,
      REF_NBR: orderId.value,
      REF_TYPE: 'SO',            // Always 'Sales Order' from our context
    };
  }

  /**
   * Translates ERP reservation response into our domain's InventoryReservation.
   */
  static toInventoryReservation(
    erpResponse: ErpReserveResponse,
    productId: ProductId,
    orderId: OrderId,
  ): { success: boolean; reservationId: string | null; failureReason: string | null } {
    if (erpResponse.STATUS === 'OK') {
      return {
        success: true,
        reservationId: erpResponse.RSV_ID,
        failureReason: null,
      };
    }

    // Translate ERP error codes to domain-meaningful reasons
    const reasonMap: Record<string, string> = {
      'INSUF_QTY': 'Insufficient inventory available',
      'ITEM_INACTIVE': 'Product is no longer active',
      'LOC_CLOSED': 'Warehouse is closed for new reservations',
    };

    return {
      success: false,
      reservationId: null,
      failureReason: reasonMap[erpResponse.MSG] ?? `Unknown ERP error: ${erpResponse.MSG}`,
    };
  }

  /** ERP stores quantity in cases (CS=12 units) and pallets (PL=48 units) */
  private static normalizeQuantity(qty: number, uom: string): number {
    const multipliers: Record<string, number> = {
      'EA': 1,
      'CS': 12,
      'PL': 48,
    };
    return qty * (multipliers[uom] ?? 1);
  }

  /** Parse ERP date format 'YYYYMMDD' to Date */
  private static parseErpDate(erpDate: string): Date | null {
    if (!erpDate || erpDate === '00000000') return null;
    const year = parseInt(erpDate.substring(0, 4), 10);
    const month = parseInt(erpDate.substring(4, 6), 10) - 1;
    const day = parseInt(erpDate.substring(6, 8), 10);
    return new Date(year, month, day);
  }
}

Component 3: The Facade (Domain-Aligned Interface) ​

The facade provides the interface your domain code actually calls:

typescript
// acl/facades/inventory.facade.ts
import type { ErpApiAdapter } from '../adapters/erp-api.adapter';
import { InventoryTranslator } from '../translators/inventory.translator';
import type { ProductId } from '../../domain/value-objects/product-id';
import type { WarehouseId } from '../../domain/value-objects/warehouse-id';
import type { OrderId } from '../../domain/value-objects/order-id';
import type { ProductAvailability } from '../../domain/value-objects/product-availability';

/**
 * Facade for inventory operations.
 * This is what use case interactors depend on (via interface).
 * Implements the port defined in the application layer.
 */
export interface InventoryService {
  checkAvailability(productId: ProductId): Promise<ProductAvailability | null>;
  reserveForOrder(
    productId: ProductId,
    warehouseId: WarehouseId,
    quantity: number,
    orderId: OrderId,
  ): Promise<ReservationResult>;
}

export interface ReservationResult {
  success: boolean;
  reservationId: string | null;
  failureReason: string | null;
}

/** Concrete implementation using the ERP ACL */
export class ErpInventoryService implements InventoryService {
  constructor(private readonly erpAdapter: ErpApiAdapter) {}

  async checkAvailability(productId: ProductId): Promise<ProductAvailability | null> {
    // Use the adapter to call the ERP
    const erpItem = await this.erpAdapter.getItemBySku(productId.value);

    if (!erpItem) return null;

    // Use the translator to convert ERP concepts to our domain
    return InventoryTranslator.toProductAvailability(erpItem);
  }

  async reserveForOrder(
    productId: ProductId,
    warehouseId: WarehouseId,
    quantity: number,
    orderId: OrderId,
  ): Promise<ReservationResult> {
    // Translate domain request to ERP format
    const erpRequest = InventoryTranslator.toErpReserveRequest(
      productId, warehouseId, quantity, orderId,
    );

    // Call ERP through adapter
    const erpResponse = await this.erpAdapter.reserveInventory(erpRequest);

    // Translate ERP response back to domain concepts
    return InventoryTranslator.toInventoryReservation(
      erpResponse, productId, orderId,
    );
  }
}

ACL for Event-Based Integration ​

When integrating via events (Kafka, RabbitMQ, etc.), the ACL translates incoming events from the external system's schema to your domain's event format:

typescript
// acl/event-translators/shipping-event.translator.ts

/** External shipping system's event format */
interface ShippingSystemEvent {
  event_type: 'SHIPMENT_CREATED' | 'SHIPMENT_IN_TRANSIT' | 'SHIPMENT_DELIVERED' | 'SHIPMENT_FAILED';
  shipment_id: string;
  order_ref: string;
  carrier_code: string;
  tracking_nbr: string;
  status_dt: string;      // ISO 8601
  est_delivery_dt: string; // ISO 8601
  items: Array<{
    sku: string;
    qty: number;
  }>;
}

/** Our domain event format */
interface OrderShipmentUpdate {
  type: 'OrderShipped' | 'OrderInTransit' | 'OrderDelivered' | 'ShipmentFailed';
  orderId: string;
  shipmentId: string;
  carrier: string;
  trackingNumber: string;
  statusAt: Date;
  estimatedDelivery: Date | null;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
}

export class ShippingEventTranslator {
  static translate(externalEvent: ShippingSystemEvent): OrderShipmentUpdate | null {
    const typeMap: Record<string, OrderShipmentUpdate['type']> = {
      'SHIPMENT_CREATED': 'OrderShipped',
      'SHIPMENT_IN_TRANSIT': 'OrderInTransit',
      'SHIPMENT_DELIVERED': 'OrderDelivered',
      'SHIPMENT_FAILED': 'ShipmentFailed',
    };

    const domainType = typeMap[externalEvent.event_type];
    if (!domainType) {
      // Unknown event type — log and skip
      console.warn(`Unknown shipping event type: ${externalEvent.event_type}`);
      return null;
    }

    const carrierMap: Record<string, string> = {
      'FEDX': 'FedEx',
      'UPS1': 'UPS',
      'USPS': 'USPS',
      'DHL1': 'DHL',
    };

    return {
      type: domainType,
      orderId: externalEvent.order_ref,
      shipmentId: externalEvent.shipment_id,
      carrier: carrierMap[externalEvent.carrier_code] ?? externalEvent.carrier_code,
      trackingNumber: externalEvent.tracking_nbr,
      statusAt: new Date(externalEvent.status_dt),
      estimatedDelivery: externalEvent.est_delivery_dt
        ? new Date(externalEvent.est_delivery_dt)
        : null,
      items: externalEvent.items.map((item) => ({
        productId: item.sku,
        quantity: item.qty,
      })),
    };
  }
}
typescript
// acl/consumers/shipping-event.consumer.ts
import type { Consumer, EachMessagePayload } from 'kafkajs';
import { ShippingEventTranslator } from '../event-translators/shipping-event.translator';
import type { OrderShipmentHandler } from '../../application/handlers/order-shipment.handler';

export class ShippingEventConsumer {
  constructor(
    private readonly consumer: Consumer,
    private readonly handler: OrderShipmentHandler,
  ) {}

  async start(): Promise<void> {
    await this.consumer.subscribe({ topic: 'shipping.events', fromBeginning: false });

    await this.consumer.run({
      eachMessage: async (payload: EachMessagePayload) => {
        const raw = JSON.parse(payload.message.value!.toString());

        // ACL: Translate external event to domain event
        const domainEvent = ShippingEventTranslator.translate(raw);

        if (domainEvent) {
          await this.handler.handle(domainEvent);
        }
      },
    });
  }
}

ACL for Database Integration (Shared Database) ​

When you must read from a legacy database shared with another team:

typescript
// acl/adapters/legacy-customer-db.adapter.ts
import type { Pool } from 'pg';

/** Legacy database schema — columns named in Hungarian notation */
interface LegacyCustomerRow {
  intCustID: number;
  strCustName: string;
  strAddr1: string;
  strAddr2: string | null;
  strCity: string;
  strState: string;
  strZip: string;
  strCountry: string;
  dtCreated: Date;
  bitActive: boolean;
  intCreditLimit: number;    // Stored in cents
  strTaxExemptNbr: string | null;
}

export class LegacyCustomerDbAdapter {
  constructor(private readonly pool: Pool) {}

  async findById(customerId: number): Promise<LegacyCustomerRow | null> {
    const result = await this.pool.query<LegacyCustomerRow>(
      'SELECT * FROM tblCustomers WHERE intCustID = $1',
      [customerId],
    );
    return result.rows[0] ?? null;
  }
}
typescript
// acl/translators/customer.translator.ts
import type { LegacyCustomerRow } from '../adapters/legacy-customer-db.adapter';
import { Customer } from '../../domain/entities/customer';
import { CustomerId } from '../../domain/value-objects/customer-id';
import { Address } from '../../domain/value-objects/address';
import { Money } from '../../domain/value-objects/money';

export class CustomerTranslator {
  static toDomain(row: LegacyCustomerRow): Customer {
    return Customer.reconstitute({
      id: CustomerId.of(row.intCustID.toString()),
      name: row.strCustName.trim(),
      address: Address.of({
        line1: row.strAddr1.trim(),
        line2: row.strAddr2?.trim() ?? null,
        city: row.strCity.trim(),
        state: row.strState.trim(),
        postalCode: row.strZip.trim(),
        country: this.normalizeCountry(row.strCountry),
      }),
      isActive: row.bitActive,
      creditLimit: Money.of(row.intCreditLimit / 100, 'USD'), // Cents to dollars
      taxExempt: row.strTaxExemptNbr !== null,
      createdAt: row.dtCreated,
    });
  }

  private static normalizeCountry(legacy: string): string {
    const map: Record<string, string> = {
      'US': 'US', 'USA': 'US', 'United States': 'US',
      'CA': 'CA', 'CAN': 'CA', 'Canada': 'CA',
      'UK': 'GB', 'GB': 'GB', 'United Kingdom': 'GB',
    };
    return map[legacy.trim()] ?? legacy.trim();
  }
}

Edge Cases & Failure Modes ​

1. External System Downtime ​

The ACL must handle external system failures gracefully:

typescript
export class ResilientErpInventoryService implements InventoryService {
  private readonly circuitBreaker: CircuitBreaker;
  private readonly cache: Map<string, { data: ProductAvailability; expiresAt: number }>;

  constructor(
    private readonly erpAdapter: ErpApiAdapter,
    private readonly fallbackTtlMs: number = 300_000, // 5 min cache
  ) {
    this.circuitBreaker = new CircuitBreaker({
      failureThreshold: 5,
      resetTimeMs: 30_000,
    });
    this.cache = new Map();
  }

  async checkAvailability(productId: ProductId): Promise<ProductAvailability | null> {
    try {
      return await this.circuitBreaker.execute(async () => {
        const erpItem = await this.erpAdapter.getItemBySku(productId.value);
        if (!erpItem) return null;

        const availability = InventoryTranslator.toProductAvailability(erpItem);

        // Cache for fallback
        this.cache.set(productId.value, {
          data: availability,
          expiresAt: Date.now() + this.fallbackTtlMs,
        });

        return availability;
      });
    } catch (error) {
      // Fallback to cached data
      const cached = this.cache.get(productId.value);
      if (cached && cached.expiresAt > Date.now()) {
        return cached.data;
      }
      throw error; // No cache, propagate failure
    }
  }
}

2. Schema Drift ​

External systems change their API without warning. Defensive parsing prevents cascading failures:

typescript
static toProductAvailability(erpItem: unknown): ProductAvailability {
  // Validate shape before translation
  const item = erpItem as Record<string, unknown>;

  const skuCode = typeof item.SKU_CODE === 'string' ? item.SKU_CODE : '';
  if (!skuCode) {
    throw new TranslationError('Missing SKU_CODE in ERP response');
  }

  const qtyAvl = typeof item.QTY_AVL === 'number' ? item.QTY_AVL : 0;
  const stdCost = typeof item.STD_COST === 'string'
    ? parseFloat(item.STD_COST)
    : 0;

  // Continue with known defaults for optional fields...
}

3. Bidirectional Translation Loss ​

Some concepts exist in one system but not the other. Document these gaps:

Our ConceptERP EquivalentTranslation Notes
Product.categoryNoneWe derive from SKU prefix
Product.weightITEM_WEIGHT (in lbs)Convert to kg
NoneITEM_CLASSIgnored — not relevant to our domain
Order.priorityNoneWe add REF_TYPE suffix: 'SO-RUSH'
NoneCOST_CENTERHard-coded based on warehouse

4. Performance Degradation ​

ACL adds latency. Measure and mitigate:

ConcernImpactMitigation
HTTP call to external API50-500 msCaching, circuit breaker
Object translation10-50 µsNegligible
Schema validation5-20 µsNegligible
Retry on failure200-2000 msExponential backoff with jitter

Performance Characteristics ​

Throughput Impact ​

With a circuit breaker and 5-minute cache:

ScenarioLatencyThroughput
Cache hit~0.1 ms~100,000 req/s
Cache miss, ERP responds~80 ms~12 req/s (per connection)
ERP down, circuit open~0.1 ms (cached)~100,000 req/s
ERP down, no cacheimmediate errorN/A

Memory Overhead ​

For caching 10,000 products:

Memory=10,000×500bytes/product=5MB

Negligible for any production Node.js process.

War Story

The Integration That Corrupted Three Bounded Contexts

A retail company integrated their order management, warehouse management, and customer service systems directly with a third-party loyalty program API. The loyalty API used a "points" model that represented monetary value differently in each market (1 point = $0.01 in US, 1 point = 1 yen in Japan, 1 point = 0.1 RMB in China).

Without an ACL, all three systems adopted the loyalty API's "points" abstraction directly. When the loyalty program provider changed their points-to-currency ratio during a promotional period, all three systems calculated discounts incorrectly. The bug lived in production for 6 hours before detection, resulting in $340K in over-discounted orders.

After the incident, the team built an ACL with a LoyaltyTranslator that converted "points" to their domain's Money value object at the boundary. The translator encapsulated the market-specific conversion logic in one place. When the next promotion changed the ratio, they updated one line in the translator and deployed in 10 minutes.

Mathematical Foundations — Coupling Theory ​

Afferent and Efferent Coupling ​

Without an ACL, every class that touches the external system has efferent coupling to it:

Ceno ACL=∑i=1nci

Where ci is the number of external type references in class i and n is the number of classes that use the external system.

With an ACL, only the ACL components have efferent coupling:

CeACL=cadapter+ctranslator

Typically CeACL≪Ceno ACL because n grows with the codebase but the ACL is fixed-size.

Change Impact Analysis ​

The probability that an external API change requires code changes in your domain:

P(domain change|API change)={0with ACLnaffectedntotalwithout ACL

With an ACL, external changes are absorbed entirely within the translation layer.

Decision Framework ​

When to Build an ACL ​

ACL vs Other Integration Patterns ​

PatternWhen to UseComplexityProtection
Shared KernelTwo teams co-own a model subsetLowNone (by design)
ConformistYou adopt external model as-isLowNone
ACLYou protect your model from externalMedium-HighFull
Open Host ServiceYou publish a clean API for othersMediumProtects consumers
Published LanguageStandard format (e.g., FHIR, FIX)MediumBy standardization

ACL Sizing Guide ​

Integration ComplexityACL SizeTeam Effort
Simple REST API, 3-5 endpoints~300 LoC2-3 days
Complex API with 20+ endpoints~1,500 LoC1-2 weeks
Legacy SOAP/XML system~3,000 LoC2-4 weeks
Shared database with 50+ tables~5,000 LoC1-2 months
Full ERP integration~10,000+ LoC2-6 months

Advanced Topics ​

ACL for Microservice Boundaries ​

Even between your own microservices, ACLs prevent model leakage:

typescript
// In the Order service, an ACL for the Product service
export class ProductServiceAcl implements ProductCatalog {
  constructor(private readonly httpClient: AxiosInstance) {}

  async getProduct(productId: ProductId): Promise<Product | null> {
    // Call Product service API
    const response = await this.httpClient.get(`/api/products/${productId.value}`);
    if (!response.data) return null;

    // Translate Product service's model to Order service's concept of a product
    // Order service only cares about: id, name, price, isAvailable
    // Product service returns: 30+ fields about manufacturing, SEO, images, etc.
    return {
      id: ProductId.of(response.data.id),
      name: response.data.title, // They call it "title", we call it "name"
      price: Money.of(response.data.pricing.retail, response.data.pricing.currency),
      isAvailable: response.data.inventory.status === 'IN_STOCK',
    };
  }
}

Bi-directional ACL ​

When two bounded contexts communicate in both directions:

Each context has its own ACL for both directions. The translation is not necessarily symmetric — each side extracts only the concepts it cares about.

Testing ACLs ​

typescript
describe('InventoryTranslator', () => {
  it('should translate ERP item to ProductAvailability', () => {
    const erpItem: ErpItemResponse = {
      ITEM_ID: 'ITM-001',
      SKU_CODE: 'WIDGET-100',
      ITEM_DESC: 'Standard Widget',
      UOM: 'CS',           // Case = 12 units
      STD_COST: '15.5000',
      LOC_CODE: 'WH-EAST',
      QTY_ON_HAND: 100,    // 100 cases = 1200 units
      QTY_RESERVED: 20,    // 20 cases = 240 units
      QTY_AVL: 80,         // 80 cases = 960 units
      LAST_RECV_DT: '20260315',
      STATUS_FLG: 'A',
    };

    const result = InventoryTranslator.toProductAvailability(erpItem);

    expect(result.productId.value).toBe('WIDGET-100');
    expect(result.warehouseId.value).toBe('WH-EAST');
    expect(result.stockLevel.available).toBe(960); // Normalized to units
    expect(result.unitCost.amount).toBe(15.5);
    expect(result.isActive).toBe(true);
    expect(result.lastReceivedAt).toEqual(new Date(2026, 2, 15));
  });

  it('should handle discontinued items', () => {
    const erpItem: ErpItemResponse = {
      // ... same as above but:
      STATUS_FLG: 'D',
      QTY_AVL: 0,
    };

    const result = InventoryTranslator.toProductAvailability(erpItem);
    expect(result.isDiscontinued).toBe(true);
    expect(result.stockLevel.available).toBe(0);
  });
});

Further Reading ​

"What I cannot create, I do not understand." — Richard Feynman