Lesson 12 / 19
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.
Contents
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.
mkdir -p provider leaky protected
// 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 }; }
// 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.
// 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; }
// 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.
// 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), }; }
// 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), }; }
// 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; }
// 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.
// 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}`); }
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.
// 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))}`);
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.