---
title: 'Anti-Corruption Layer'
source: 'https://academia.sh/en/courses/domain-driven-design/anticorruption-layer'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:18+00:00'
license: 'CC BY-SA 4.0'
---

# Anti-Corruption Layer

Preventing the outside model from leaking in: counting how many files two carrier providers' names, state sets, and units appear in, measuring the number of external state values the business rule recognizes, showing that leakage in the core files drops to zero once a translation layer is added, and working out the layer's cost in files and lines.

The previous lesson's translation files worked under favorable conditions: the upstream
contract context was part of the same codebase, its names were tidy, its types were known.
Some of the upstream contexts the library talks to are not like that. Carrier providers have
their own models, their own state sets, their own identity formats, and their own weight
units; none of them has to conform to the library's domain language, and none of them
consults anyone before changing.

This lesson's question is not an interface question. Two providers calling in different
shapes is a problem already measured in the Design Principles and Design Patterns courses,
and its solution is an adapter. The problem here is one level deeper: if the provider's
concepts leak in, the internal model starts speaking the outside's language. The delivery
context's states turn into the carrier's movement codes, a shipment's weight gets tied to the
carrier's unit, and a transport error such as "record not found" behaves like a domain state.
This lesson counts that leakage.

## Two Levels of Mismatch

An adapter reconciles the two sides' signatures: the call's name, the parameter order, the
return shape. What it reconciles is form, not concept. An adapter can gather two providers
under a single function name and still pass both of their state codes upward unchanged.

An **anti-corruption layer**, by contrast, reconciles the two sides' models: the outside's
state set is translated into the inside's, the outside's unit into the inside's, the
outside's error shape into the inside's, and that translation stands in one place. The name
says as much: what is protected is the internal model itself. Its measure follows from
this — how many times do the outside's names and values appear in the internal model's files?

The two providers are written as two external systems standing outside the library. Their
vocabularies are deliberately unlike each other.

```sh
mkdir -p provider leaky protected
```

```js
// provider/carrier-a.mjs — external system A: its own movement codes, epoch time, pound weight
const RECORDS = {
  "AA-1041": { movementCode: "H25", lastMovementEpoch: 1700000000, weightPounds: 5.3, errorCode: 0 },
  "AA-1042": { movementCode: "H30", lastMovementEpoch: 1700050000, weightPounds: 1.5, errorCode: 0 },
};

export function dispatchState(dispatchId) {
  const r = RECORDS[dispatchId];
  return r === undefined
    ? { dispatchId, movementCode: "H99", lastMovementEpoch: 0, weightPounds: 0, errorCode: 404 }
    : { dispatchId, ...r };
}
```

```js
// provider/carrier-b.mjs — external system B: its own state tags, ISO time, ounce weight
const RECORDS = {
  "BB/2210": { stateTag: "completed", lastMovementTime: "2023-11-14T22:13:20Z", weightOunces: 84 },
  "BB/2211": { stateTag: "en-route", lastMovementTime: "2023-11-15T20:00:00Z", weightOunces: 12 },
};

export function track(reference) {
  const r = RECORDS[reference];
  return r === undefined
    ? { reference, stateTag: "no-record", lastMovementTime: null, weightOunces: 0 }
    : { reference, ...r };
}
```

The differences can be counted: A gives state as six codes, B as five tags; A gives time as
epoch seconds, B as ISO text; A gives weight in pounds, B in ounces; A reports a record not
found with an error code, B with a state tag. The domain side's business rule, meanwhile,
asks a single question: is this shipment delayed?

## The Leaky Arrangement

In the first arrangement, the business rule directly recognizes both providers' vocabularies.

```js
// leaky/delay.mjs — the business rule recognizes both providers' state dictionaries and units directly
import { dispatchState } from "../provider/carrier-a.mjs";
import { track } from "../provider/carrier-b.mjs";

export function isDelayed(carrier, reference, windowHours, nowEpoch) {
  if (carrier === "A") {
    const r = dispatchState(reference);
    if (r.errorCode !== 0 || r.movementCode === "H99") return null;
    if (r.movementCode === "H30" || r.movementCode === "H40") return false;
    return (nowEpoch - r.lastMovementEpoch) / 3600 > windowHours;
  }
  const r = track(reference);
  if (r.stateTag === "no-record") return null;
  if (r.stateTag === "completed" || r.stateTag === "returned-to-sender") return false;
  return (nowEpoch - Math.floor(Date.parse(r.lastMovementTime) / 1000)) / 3600 > windowHours;
}
```

```js
// leaky/report.mjs — the report is also written with both providers' units and state dictionaries
import { dispatchState } from "../provider/carrier-a.mjs";
import { track } from "../provider/carrier-b.mjs";
import { isDelayed } from "./delay.mjs";

export function summary(records, windowHours, nowEpoch) {
  let delayed = 0, unresolved = 0, grams = 0;
  for (const [carrier, reference] of records) {
    const d = isDelayed(carrier, reference, windowHours, nowEpoch);
    if (d === null) unresolved += 1; else if (d) delayed += 1;
    grams += carrier === "A"
      ? Math.round(dispatchState(reference).weightPounds * 453.59237)
      : Math.round(track(reference).weightOunces * 28.349523125);
  }
  return { delayed, unresolved, grams };
}
```

The files work, and they are short. The problem is in the sentence the rule is telling. "An
undelivered shipment is delayed if it has exceeded the service window since its last
movement" is the domain expert's sentence; the sentence sitting in the file reads "if the
movement code is not H30 or H40, and the state tag is not completed or
returned-to-sender." The two may say the same thing, but the second one goes wrong when the
provider's contract changes, and the place it goes wrong is the business-rule file.

## The Protected Arrangement

In the second arrangement, each provider has a translation file. The translation does four
things at once: it maps the state set, brings time into a single form, converts weight to
grams, and reduces a not-found record to the internal model's `unknown` state.

```js
// protected/carrier-a-translation.mjs — translates provider A's model into the internal model
import { dispatchState } from "../provider/carrier-a.mjs";

const STATE = { H10: "accepted", H20: "in-transfer", H25: "out-for-delivery", H30: "delivered", H40: "returned" };

export function getShipment(reference) {
  const r = dispatchState(reference);
  if (r.errorCode !== 0) return { shipmentNo: reference, lastState: "unknown", lastMovementAt: 0, weightGrams: 0 };
  return {
    shipmentNo: reference,
    lastState: STATE[r.movementCode] ?? "unknown",
    lastMovementAt: r.lastMovementEpoch,
    weightGrams: Math.round(r.weightPounds * 453.59237),
  };
}
```

```js
// protected/carrier-b-translation.mjs — translates provider B's model into the same internal model
import { track } from "../provider/carrier-b.mjs";

const STATE = { received: "accepted", "en-route": "in-transfer", "handed-to-courier": "out-for-delivery",
                completed: "delivered", "returned-to-sender": "returned" };

export function getShipment(reference) {
  const r = track(reference);
  const lastState = STATE[r.stateTag] ?? "unknown";
  return {
    shipmentNo: reference,
    lastState,
    lastMovementAt: lastState === "unknown" ? 0 : Math.floor(Date.parse(r.lastMovementTime) / 1000),
    weightGrams: Math.round(r.weightOunces * 28.349523125),
  };
}
```

```js
// protected/delay.mjs — the business rule recognizes only the internal model's state set
const CLOSED = ["delivered", "returned"];

export function isDelayed(shipment, windowHours, nowEpoch) {
  if (shipment.lastState === "unknown") return null;
  if (CLOSED.includes(shipment.lastState)) return false;
  return (nowEpoch - shipment.lastMovementAt) / 3600 > windowHours;
}
```

```js
// protected/report.mjs — the report also reads only the internal model
import { isDelayed } from "./delay.mjs";

export function summary(shipments, windowHours, nowEpoch) {
  let delayed = 0, unresolved = 0, grams = 0;
  for (const s of shipments) {
    const d = isDelayed(s, windowHours, nowEpoch);
    if (d === null) unresolved += 1; else if (d) delayed += 1;
    grams += s.weightGrams;
  }
  return { delayed, unresolved, grams };
}
```

The rule file no longer knows which provider is speaking. The weight field's name changed
too: the internal model's time field is `lastMovementAt`, not provider A's
`lastMovementEpoch`. Keeping two names for the same number apart looks needless; but taking
the name from the outside means that name changes along with the outside.

## Counting the Leakage

The measure consists of three numbers: the count of files where external names and external
state values appear, how many of those are core files, and the number of external state
values the business rule recognizes.

```js
// leak-count.mjs — counts how many files the external model's names and state values appear in
import { readFileSync } from "node:fs";

const EXTERNAL_NAMES = ["movementCode", "lastMovementEpoch", "weightPounds", "errorCode",
                        "stateTag", "lastMovementTime", "weightOunces"];
const EXTERNAL_VALUES = ["H10", "H20", "H25", "H30", "H40", "H99",
                         "received", "en-route", "handed-to-courier", "completed", "returned-to-sender", "no-record"];
const INTERNAL_VALUES = ["accepted", "in-transfer", "out-for-delivery", "delivered", "returned", "unknown"];

const present = (text, names) => names.filter((a) => new RegExp(`(?<![\\w-])${a}(?![\\w-])`).test(text));

for (const [arrangement, files, rule] of [
  ["leaky", ["leaky/delay.mjs", "leaky/report.mjs"], "leaky/delay.mjs"],
  ["protected", ["protected/carrier-a-translation.mjs", "protected/carrier-b-translation.mjs",
                 "protected/delay.mjs", "protected/report.mjs"], "protected/delay.mjs"],
]) {
  const lines = files.reduce((t, d) => t + readFileSync(d, "utf8").trimEnd().split("\n").length, 0);
  console.log(`${arrangement}/ (${files.length} files, ${lines} lines)`);
  let leaking = 0, leakingCore = 0, core = 0;
  for (const d of files) {
    const text = readFileSync(d, "utf8");
    const names = present(text, EXTERNAL_NAMES), extVals = present(text, EXTERNAL_VALUES), intVals = present(text, INTERNAL_VALUES);
    const boundary = d.includes("-translation.mjs");
    if (!boundary) core += 1;
    if (names.length > 0 || extVals.length > 0) { leaking += 1; if (!boundary) leakingCore += 1; }
    console.log(`  ${d.split("/")[1].padEnd(26)} ext name ${names.length}  ext value ${String(extVals.length).padStart(2)}  int value ${intVals.length}`);
  }
  console.log(`  files with an external name or value = ${leaking} / ${files.length}, in core files = ${leakingCore} / ${core}`);
  const k = readFileSync(rule, "utf8");
  console.log(`  state values the business rule recognizes: external ${present(k, EXTERNAL_VALUES).length}, internal ${present(k, INTERNAL_VALUES).length}`);
}
```

```sh
node leak-count.mjs
```

```
leaky/ (2 files, 32 lines)
  delay.mjs                  ext name 5  ext value  6  int value 0
  report.mjs                 ext name 2  ext value  0  int value 0
  files with an external name or value = 2 / 2, in core files = 2 / 2
  state values the business rule recognizes: external 6, internal 0
protected/ (4 files, 51 lines)
  carrier-a-translation.mjs  ext name 4  ext value  5  int value 6
  carrier-b-translation.mjs  ext name 3  ext value  5  int value 6
  delay.mjs                  ext name 0  ext value  0  int value 3
  report.mjs                 ext name 0  ext value  0  int value 0
  files with an external name or value = 2 / 4, in core files = 0 / 2
  state values the business rule recognizes: external 0, internal 3
```

In the leaky arrangement, both of the two files recognize the outside's vocabulary, and both
are core files: 2/2. In the protected arrangement, the outside's vocabulary still appears in
two files — the count is the same — but both are boundary files; in core, 0/2. The total
amount of leakage does not shrink; its location changes. That is the whole claim of an
anti-corruption layer: the outside's model has to show up somewhere, and that place should
not be the business rule.

The second pair of lines is sharper. In the leaky arrangement, the business rule recognizes
six external state values and no internal state value; the internal model's state set is
nowhere in the code, existing only indirectly as the union of the two providers' sets. In the
protected arrangement, the rule recognizes zero external values and three internal values.
As a domain concept, "state" is a single set for the first time.

## Behavior and Cost

The translation's correctness is not asserted; it is tested. Six records must produce the
same decision and the same summary in both arrangements.

```js
// run.mjs — do the two arrangements give the same delay decision and the same summary?
import { isDelayed as leakyDecision } from "./leaky/delay.mjs";
import { summary as leakySummary } from "./leaky/report.mjs";
import { getShipment as translateA } from "./protected/carrier-a-translation.mjs";
import { getShipment as translateB } from "./protected/carrier-b-translation.mjs";
import { isDelayed as protectedDecision } from "./protected/delay.mjs";
import { summary as protectedSummary } from "./protected/report.mjs";

const RECORDS = [["A", "AA-1041"], ["A", "AA-1042"], ["A", "AA-9999"],
                 ["B", "BB/2210"], ["B", "BB/2211"], ["B", "BB/9999"]];
const WINDOW_HOURS = 24, NOW = 1700100000;

const internalModel = RECORDS.map(([t, r]) => (t === "A" ? translateA(r) : translateB(r)));
let differing = 0;
for (let i = 0; i < RECORDS.length; i += 1) {
  const a = leakyDecision(RECORDS[i][0], RECORDS[i][1], WINDOW_HOURS, NOW);
  const b = protectedDecision(internalModel[i], WINDOW_HOURS, NOW);
  if (a !== b) differing += 1;
  console.log(`${RECORDS[i][1].padEnd(9)} ${String(internalModel[i].lastState).padEnd(17)} leaky ${String(a).padEnd(5)} protected ${b}`);
}
console.log(`differing decision = ${differing} / ${RECORDS.length}`);
console.log(`leaky summary     = ${JSON.stringify(leakySummary(RECORDS, WINDOW_HOURS, NOW))}`);
console.log(`protected summary = ${JSON.stringify(protectedSummary(internalModel, WINDOW_HOURS, NOW))}`);
```

```sh
node run.mjs
```

```
AA-1041   out-for-delivery  leaky true  protected true
AA-1042   delivered         leaky false protected false
AA-9999   unknown           leaky null  protected null
BB/2210   delivered         leaky false protected false
BB/2211   in-transfer       leaky false protected false
BB/9999   unknown           leaky null  protected null
differing decision = 0 / 6
leaky summary     = {"delayed":1,"unresolved":2,"grams":5805}
protected summary = {"delayed":1,"unresolved":2,"grams":5805}
```

The decision matches on all six records, and all three fields of the summary match too.
Including the two not-found records and the two different weight units, the translation
produced no numerical difference.

The cost is two files and nineteen lines: four files totaling 51 lines instead of two files
totaling 32. The second cost is not measured in lines: a translation only passes through what
it maps. A concept found only in provider A — customs hold, say — cannot reach the business
rule unless it has a counterpart in the internal model's state set. This looks like a loss,
but it fixes where the decision gets made: the internal model decides whether a new domain
concept is needed, not the provider.

The layer's justification follows from this, and it does not hold for every external system.
For an upstream whose contract is under the library's control and whose model draws the same
distinctions as the domain, the layer is nothing but an added indirection. The justification
is that the outside's model draws different distinctions and changes without consulting the
library.

## Summary

- An adapter reconciles signatures; an anti-corruption layer reconciles models. What is
  protected is the internal model's own concepts.
- The two providers gave state as six codes and five tags, time as epoch seconds and ISO
  text, weight in pounds and ounces; one reported a not-found record with an error code, the
  other with a state tag.
- In the leaky arrangement, the outside vocabulary appeared in both of two files, and both
  were core files (2/2); in the protected arrangement it still appeared in two files, but
  both were boundary files, 0/2 in core.
- In the leaky arrangement, the business rule recognized six external state values and no
  internal values; in the protected arrangement it recognized zero external and three
  internal values — state became a single set for the first time.
- The decision and all three fields of the summary came out the same in both arrangements on
  six records; the layer's cost is 2 files and 19 lines, plus a translation that passes
  through only the concepts it maps.
- The layer's justification is that the outside's model draws different distinctions and
  changes without consulting the library; for an upstream whose contract is under the
  library's own control, the layer is nothing but an indirection.

## Next Step

Both defenses so far rested on the same assumption: the other side is a stranger, and a
translation stands in between. That assumption does not hold when two contexts belong to the
same team rather than two separate ones, or when the downstream team has no power at all to
change the upstream. In the first case, the teams can choose to hold a part in common; in the
second, downstream can give up translation and adopt the upstream's model as it is. Both are
measurable decisions. The next lesson builds these two collaboration patterns, counts how
many files two teams are forced to edit together once the shared part grows, and shows the
cost of giving up translation on the same scale.
