Steven's Knowledge

Anticorruption Layer

Defending a clean domain model from a messy upstream — the translation pattern that keeps a bounded context's ubiquitous language intact

Anticorruption Layer

An anticorruption layer (ACL) is a translation boundary between two systems with different models, where you do not want one to pollute the other. The upstream might be a legacy system, a third-party service, an internal team with a different (and worse) model, or a vendor API whose shape you do not control. The ACL receives data from the upstream in its native model, translates it into your bounded context's model, and exposes only the translated, well-shaped version to the rest of your code.

The pattern's name is precise: it prevents corruption of your model. Without an ACL, awkward upstream names, missing fields, denormalized blobs, and one-off semantics leak into your codebase. With an ACL, your domain model stays clean and you have a single place to change when the upstream changes.

This is one of the patterns from Evans's strategic design context-map vocabulary, and it is important enough — and frequently enough mishandled — to deserve its own page.

When You Need One

The signal is straightforward: you are integrating with a system whose model would distort yours if exposed directly. Specifically:

  • Legacy system integration. The 1990s ERP, the homegrown CRM, the partner's batch file export. Their model is what it is; you cannot change it.
  • Third-party APIs. A payment processor's API, a shipping carrier's webhook, a marketing platform's webhook payload. You are a customer; you cannot ask them to change the schema.
  • Vendor SDKs. A library that gives you objects with strange names, denormalized fields, vendor-specific concepts (x_pp_internal_id).
  • Internal upstream with a different model. Another team's service exposing data shaped by their domain, when yours is different. (Strategic design has more on this case.)
  • Migration in progress. Your team is rewriting an old system; new code calls the old one through an ACL until the migration completes.

If you are not integrating with such a system — every external dependency is well-shaped for your needs — you do not need an ACL. The pattern is a tool for a specific problem.

What the ACL Does

The ACL has three responsibilities:

  1. Translate incoming data. Upstream objects in their model → your bounded context's model.
  2. Translate outgoing requests. Your domain operations → calls the upstream understands.
  3. Hide upstream concepts entirely. Nothing leaks past the layer. Inside your bounded context, code never imports types from the upstream's SDK, never uses upstream names.
┌──────────────────────────────────────────────────────────┐
│  Your bounded context                                     │
│                                                            │
│  Domain code uses: Customer, Order, Money, Address        │
│  (your ubiquitous language)                                │
│                                                            │
│  ┌──────────────────────────────────────────┐             │
│  │  Anticorruption Layer                     │             │
│  │                                            │             │
│  │  - Translates LegacyParty → Customer       │             │
│  │  - Translates Vendor.Currency → Money      │             │
│  │  - Hides upstream's PartyId, etc.          │             │
│  └────────┬───────────────────────┬─────────┘             │
│           │                       │                        │
└───────────┼───────────────────────┼────────────────────────┘
            │                       │
   ┌────────▼──────────┐  ┌─────────▼─────────┐
   │ Legacy ERP API    │  │ Vendor SDK         │
   │ (messy model)     │  │ (different model)  │
   └───────────────────┘  └────────────────────┘

The shape of an ACL in code:

// Domain (your bounded context — clean)
class Customer {
  readonly id: CustomerId
  readonly name: string
  readonly email: EmailAddress
  // ...
}

// Anticorruption layer (the translation boundary)
class LegacyCRMAdapter {
  constructor(private legacyClient: LegacyCRMClient) {}
  
  async fetchCustomer(id: CustomerId): Promise<Customer> {
    // Call the legacy API
    const party = await this.legacyClient.getParty({ party_id: id.value })
    
    // Translate: legacy's "Party" → our "Customer"
    return new Customer(
      new CustomerId(party.party_id),
      party.full_name_concat,
      new EmailAddress(party.contact_email_primary)
    )
  }
}

// Inside the bounded context, only `Customer` is ever seen.
// `Party`, `LegacyCRMClient`, and the field name oddities are invisible.

What the ACL Is Not

Two common misreadings, both of which produce a worse design than no ACL at all:

Not a Thin Pass-Through

A "translation layer" that just renames fields without thinking about the model is not an ACL. If LegacyParty.full_name_concat becomes Customer.full_name_concat, you have a wrapper, not an anticorruption layer. The upstream's model has been preserved; you just changed namespaces. The next change to full_name_concat upstream still ripples through your code.

A real ACL makes design decisions: Customer.name (or Customer.firstName + Customer.lastName, or Customer.displayName) — whatever your domain calls this concept. The translation is active, not mechanical.

Not an Excuse to Leak Across Bounded Contexts

Some teams put an ACL between two internal services to "stay decoupled." If both services belong to the same bounded context (and they share a domain model), an ACL between them is just bureaucracy. ACLs are for crossing bounded contexts (or external systems), not for adding ceremony within one.

The test: if the upstream changes a field, should you change your code? If yes, you do not have an ACL — you have a wrapper. If no (you absorb the change in the ACL), you have one.

Implementation Patterns

Adapter Class

The most common form. A class that takes the upstream client as a dependency and exposes your domain interface.

interface PaymentGateway {  // your domain interface
  charge(amount: Money, card: Card): Promise<Receipt>
}

class StripeAdapter implements PaymentGateway {  // ACL
  constructor(private stripe: StripeClient) {}
  
  async charge(amount: Money, card: Card): Promise<Receipt> {
    const stripeCharge = await this.stripe.charges.create({
      amount: amount.amount * 100,  // cents
      currency: amount.currency.toLowerCase(),
      source: card.token,
    })
    return new Receipt(
      stripeCharge.id,
      Money.fromCents(stripeCharge.amount, stripeCharge.currency)
    )
  }
}

Inbound vs Outbound

ACLs handle both directions. Inbound ACL translates data coming into your context (webhook receivers, polling jobs, event subscribers). Outbound ACL translates calls you make to upstream systems.

A single integration usually needs both. Webhooks from the upstream arrive in an inbound ACL; calls to the upstream go through an outbound ACL.

Translation Layer Plus Caching

When the upstream is slow or rate-limited, the ACL is a natural place to cache. The domain code asks for a Customer; the ACL checks its cache, calls upstream on miss, translates, returns. The cache is on the translated model, not the upstream's raw response.

Event Translation

For event-driven integration, the ACL subscribes to upstream events in their format, translates each into a domain event in your context's vocabulary, and republishes (or routes directly to domain handlers).

Upstream publishes: vendor_party_updated { ... }

Inbound ACL receives, translates

Republishes as domain event: CustomerUpdated { customerId, name, email }

Your domain handlers consume CustomerUpdated, knowing nothing of "vendor_party_updated"

Common Mistakes

  • Not having an ACL when you need one. The most common mistake. "We'll just use the vendor's SDK directly." Six months later, vendor concepts are throughout your codebase and a migration is impossible without rewriting most of it.
  • Pass-through "ACL" that does not actually translate. If your Customer has the same field names as the upstream's Party, you have not done the translation work. Pick names from your ubiquitous language.
  • ACL leaks upstream types. Returning LegacyCustomer from the adapter, even occasionally. Once upstream types are imported anywhere outside the ACL, they spread. The ACL must return your types, always.
  • ACL inside a single bounded context. Adding an ACL "for decoupling" between two of your own services with the same model. This adds ceremony without benefit.
  • One ACL for many upstreams. A "monolithic adapter" that handles three external systems with different models. Each integration is different; each gets its own ACL.
  • ACL captures all the complexity, not just translation. The ACL accumulates business logic, retry logic, caching, validation, until it is the largest module in the codebase. The ACL should translate and call; business logic belongs in the domain layer.
  • No ACL because "the vendor SDK is clean." Even a clean vendor SDK uses the vendor's names and concepts. Whether you need an ACL depends on whether those names are right for your domain, not on the SDK's quality.

When You Can Skip It

Not every external integration needs a heavyweight ACL. Skip when:

  • The upstream is genuinely a primitive. A logging library, a UUID generator, a hash function. There is no model to corrupt.
  • The upstream and your domain happen to use the same model. Rare but possible. Document the alignment so you notice if either side drifts.
  • The integration is throwaway / one-off. A data migration that runs once, a manual import script. Don't over-engineer a single-use tool.

For everything else — payment, shipping, identity, third-party data, vendor APIs — invest in the ACL up front. Retrofitting one after upstream concepts have spread is much harder than adding one at first integration.

Relation to Other Pages

Further Reading

  • Eric Evans, Domain-Driven Design (2003), Chapter 14 — the original definition.
  • Vaughn Vernon, Implementing Domain-Driven Design (2013), Chapter 3 — context maps with ACL coverage.
  • Martin Fowler, Patterns of Enterprise Application Architecture (2002) — Adapter, Service Layer, and related patterns provide the foundational vocabulary.
  • Sam Newman, Building Microservices (2nd ed., 2021), Chapter 4 — ACLs in the microservices context.
  • Eric Evans, DDD Reference (free PDF) — short summary of the context-map patterns.

Pre-commit Checklist

  • For each external system my bounded context integrates with, do I have an ACL between us and them?
  • Does my ACL return my domain types — Customer, Order, Money — never the upstream's types?
  • Are upstream types imported only inside the ACL, with my domain code unable to see them?
  • Does my ACL perform real translation (different field names, different shape, different concepts) rather than just passing through?
  • Is my ACL focused on translation, or has it accumulated business logic that belongs in the domain?
  • For each integration that does not have an ACL, am I confident the upstream model is actually clean for my purposes, or am I deferring the cost of retrofitting one?

On this page