Skip to content

An anti-corruption layer translates a whole foreign model

Translate what the foreign record means in your model, and nothing else; keep every rule where every path meets it.

Predict first

Before you read: predict

ExamHall reads candidates from the university's student system (SIS) through sis-translator.ts. On exam day the test centre also signs up one walk-in at its desk. The files are below.

What does main.ts print?

Code for this question
// sis-records.ts (what the SIS sends)export interface StudentPerson { sp_id: string; name: string; fees_due: number } // candidate.ts (ExamHall's model)export interface Candidate { candidateId: string; name: string } // sis-translator.ts (the layer between the SIS and ExamHall)import type { StudentPerson } from './sis-records';import type { Candidate } from './candidate';export function toCandidates(people: StudentPerson[]): Candidate[] {  return people    .filter((p) => p.fees_due === 0)    .map((p) => ({ candidateId: p.sp_id.replace(/^S-0*/, ''), name: p.name }));} // desk-signup.ts (walk-ins the test centre types in on the day)import type { Candidate } from './candidate';export interface WalkIn { id: string; name: string; feesDue: number }export const fromDesk = (walkIns: WalkIn[]): Candidate[] => walkIns.map((w) => ({ candidateId: w.id, name: w.name })); // seating.tsimport type { Candidate } from './candidate';export const seatPlan = (cs: Candidate[]) => cs.map((c, i) => `${c.name}@${i + 1}`).join(', '); // main.tsimport { toCandidates } from './sis-translator';import { fromDesk } from './desk-signup';import { seatPlan } from './seating';const fromSis = toCandidates([  { sp_id: 'S-00042', name: 'Ana', fees_due: 0 },  { sp_id: 'S-00077', name: 'Ben', fees_due: 120 },]);const walkIns = fromDesk([{ id: '91', name: 'Cy', feesDue: 80 }]);console.log(seatPlan([...fromSis, ...walkIns]));
Pick one

The 30-second version

An anti-corruption layer sits where another system's model meets yours and translates meaning, not just names: which of your concepts each foreign record is, read from every field that decides it. The layer only translates; what is allowed stays in your domain, where every entry point meets it.

Translate what the foreign record means in your model, and nothing else; keep every rule where every path meets it.

How it works

Step 1 of 10

Another model. A legacy system, a partner's API or another team's service shapes the same things its own way (arch-bounded-contexts step 12).
In the kitchen
  • each shipping line sends its own bay plan and booking codes → another model (step 1)

Analogy: A container terminal's planning desk

Ships from several shipping lines unload at one terminal.

At the terminal In code
each shipping line sends its own bay plan and booking codes another model (step 1)
the planning desk writes every box into the terminal's yard terms: stack, weight class, needs power or not meaning, not names (step 3)
"RF" on one line's plan is a refrigerated box to plug in, on another's an empty one travelling switched off, so the desk reads which line sent the plan first read every field that decides (step 4)
customs, not the desk, decides which boxes go for inspection translation only (step 8)
a desk that pulled boxes for inspection itself would never see the boxes that arrive by rail a rule in the layer (step 9)

In real terms: list what your layer decides; anything other than "what this foreign record means in our model" belongs in your domain.

Breaks down when: a planner meeting a code she cannot place can radio the ship's officer; a translator cannot, so its mapping decides in advance.

Contrasting case: a private wharf that serves one shipping line and marks its yard map in that line's own codes needs no planning desk: it has adopted the line's model (arch-bounded-contexts step 11, conformist).

Examples, run for real

4.1 ExamHall: one candidate, three keys

EXH-4180: the SIS sends a candidate's sp_id in three formats: S-00042 from the registry, 00042 from exam registrations and 2024/42 from enrolment. Seating, grading and certificates each read it their own way:

// as shipped
export const seatKey = (p: StudentPerson) => p.sp_id;                          // seating.ts
export const resultKey = (p: StudentPerson) => p.sp_id.replace(/^S-0*/, '');  // grading.ts
export const certKey = (p: StudentPerson) => p.sp_id.split('/').pop()!;       // certificates.ts

MR !4480 adds SisTranslator, which resolves all three formats to one candidateId and hands ExamHall a Candidate; the three files now read only Candidate:

// sis-translator.ts (MR !4480)
export function toCandidate(p: StudentPerson): Candidate {
  const digits = p.sp_id.replace(/^S-/, '').split('/').pop()!.replace(/^0+/, '');
  return { candidateId: digits, name: p.name };
}
4.1 EXH-4180 as shipped: files using an SIS name 3 (certificates.ts, grading.ts, seating.ts) · keys for Ana 3 (S-00042, 00042, 42) · results matched to a seat 0 of 2
4.1 MR !4480: files using an SIS name 1 (sis-translator.ts) · keys for Ana 1 (42) · results matched to a seat 2 of 2 · tsc no diagnostics

Before the MR, every file met the SIS's three formats and none agreed on Ana's key, so no result found its seat. After it, one file knows the SIS, and every file compares one id (steps 2, 7).

4.2 What the MR does not decide

Who may sit an exam is the exam board's rule, not something an SIS record means; it is checked in ExamHall's own code, where walk-ins and SIS candidates both meet it (steps 8–9).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A ledger reads its bank's feed through a translator. main.ts prints settled in 120.00 · refunds to pay 0.00; the bank's operations sheet for the same three lines lists 40.00 of refunds still to pay. The files are below.

Which line of bank-translator.ts is the defect?

Code for this question
// bank-feed.ts (what the bank's feed sends, with its documentation)// kind PAY: a payment from a customer. kind RFD: a refund to a customer.// st on a PAY line: S settled, R returned (the payment bounced).// st on an RFD line: S settled (paid out), R requested (not paid out yet).export interface BankLine { acct: string; kind: 'PAY' | 'RFD'; st: 'S' | 'R'; amt: string } // ledger.ts (the ledger's model)export type State = 'settled' | 'returned' | 'requested';export interface Movement { accountId: string; kind: 'payment' | 'refund'; state: State; cents: number }const total = (ms: Movement[]) => (ms.reduce((s, m) => s + m.cents, 0) / 100).toFixed(2);export const settledIn = (ms: Movement[]) => total(ms.filter((m) => m.kind === 'payment' && m.state === 'settled'));export const refundsToPay = (ms: Movement[]) => total(ms.filter((m) => m.kind === 'refund' && m.state === 'requested')); // bank-translator.ts (the layer between the feed and the ledger)import type { BankLine } from './bank-feed';import type { Movement, State } from './ledger';const STATES: Record<BankLine['st'], State> = { S: 'settled', R: 'returned' };export function toMovement(l: BankLine): Movement {  const accountId = l.acct.replace(/\s/g, '').slice(-8);  const state = STATES[l.st];  const cents = Math.round(Number(l.amt) * 100);  return { accountId, kind: l.kind === 'PAY' ? 'payment' : 'refund', state, cents };} // main.tsimport { toMovement } from './bank-translator';import { settledIn, refundsToPay } from './ledger';const lines = [  { acct: '40-11-22 31926819', kind: 'PAY', st: 'S', amt: '120.00' },  { acct: '40-11-22 31926819', kind: 'RFD', st: 'R', amt: '40.00' },  { acct: '20-15-30 09345911', kind: 'PAY', st: 'R', amt: '75.50' },] as const;const ms = lines.map(toMovement);console.log(`settled in ${settledIn(ms)} · refunds to pay ${refundsToPay(ms)}`);
Pick one

Question 2 of 7

A ledger imports its card processor's settlement file through a translator. processor-file.ts, ledger.ts and main.ts are the same in every version; settlement-translator.ts comes in three versions. The files are below.

The processor's own statement for these two payments totals what the customers paid and what it kept.

Which version's report agrees with that statement, compiled with ledger.ts as it is?

Code for this question
// processor-file.ts (the card processor's settlement file, with its documentation)// one line per card payment: gross is what the customer paid, fee is what the processor kept, both in pence.export interface Settlement { ref: string; gross: number; fee: number } // ledger.ts (the ledger's model)export interface Movement { ref: string; kind: 'payment' | 'fee'; cents: number }export function report(ms: Movement[]): string {  const sum = (k: Movement['kind']) => ms.filter((m) => m.kind === k).reduce((s, m) => s + m.cents, 0);  return `payments ${sum('payment')} · fees ${sum('fee')}`;} // settlement-translator.ts (version 1)import type { Settlement } from './processor-file';import type { Movement } from './ledger';export const toMovements = (s: Settlement): Movement[] => [  { ref: s.ref, kind: 'payment', cents: s.gross },  { ref: s.ref, kind: 'fee', cents: s.fee },]; // settlement-translator.ts (version 2)import type { Settlement } from './processor-file';import type { Movement } from './ledger';export const toMovements = (s: Settlement): Movement[] => [{ ref: s.ref, kind: 'payment', cents: s.gross - s.fee }]; // settlement-translator.ts (version 3)import type { Settlement } from './processor-file';import type { Movement } from './ledger';export const toMovements = (s: Settlement): Movement[] => [{ ref: s.ref, kind: 'payment', cents: s.gross, fee: s.fee }]; // main.tsimport { toMovements } from './settlement-translator';import { report } from './ledger';console.log(report([{ ref: 'p-1', gross: 10000, fee: 290 }, { ref: 'p-2', gross: 5000, fee: 175 }].flatMap(toMovements)));
Pick one

Question 3 of 7

A ledger holds large transfers for review. Today transfers arrive from the bank's old feed through bank-translator.ts; next quarter the old feed is switched off, bank-translator.ts is deleted, and the same customers' transfers arrive from the bank's new API through obank-adapter.ts. main.ts books the same three transfers both ways, one after the other. The files are below.

What does main.ts print?

Code for this question
// bank-feed.ts (the old feed)export interface BankLine { ref: string; amt_p: number } // obank-api.ts (the new API that replaces it)export interface ObPayment { id: string; amount: { minor: number } } // ledger.ts (the ledger's model)export interface Transfer { id: string; cents: number; held?: boolean }export const book: Transfer[] = [];export const record = (t: Transfer) => { book.push(t); };export const heldCount = () => book.filter((t) => t.held === true).length; // bank-translator.ts (the layer for the old feed)import type { BankLine } from './bank-feed';import { record } from './ledger';export const fromBank = (l: BankLine) => record({ id: l.ref, cents: l.amt_p, held: l.amt_p > 1_000_000 }); // obank-adapter.ts (the adapter for the new API)import type { ObPayment } from './obank-api';import { record } from './ledger';export const fromOpenBanking = (p: ObPayment) => record({ id: p.id, cents: p.amount.minor }); // main.tsimport { fromBank } from './bank-translator';import { fromOpenBanking } from './obank-adapter';import { book, heldCount } from './ledger';const amounts = [2_500_000, 40_000, 1_200_000];amounts.forEach((a, i) => fromBank({ ref: `b-${i}`, amt_p: a }));                         // before the cut-overamounts.forEach((a, i) => fromOpenBanking({ id: `o-${i}`, amount: { minor: a } }));       // after itconsole.log(`${book.length} transfers booked, ${heldCount()} held for review`);
Pick one

Question 4 of 7

Write toCandidate(rec) for ExamHall's SIS translator, from a blank file that imports UnmappedSisRecord from ./candidate.mjs and exports toCandidate. The SIS's documentation:

  • rec is REG (an exam registration) or ENR (an enrolment). A REG record names the candidate in cand as C42; an ENR record names them in stu as 2024-0042 (the enrolment year, then the number). Both are candidate 42.
  • st A is active on both; st W is waitlisted on a REG record and withdrawn on an ENR record.
  • given and family are the candidate's names.

ExamHall's Candidate is { candidateId, name, status }, with status one of registered, waitlisted, withdrawn. A record the documentation does not cover throws UnmappedSisRecord.

Write toCandidate in full.

Question 5 of 7

A ledger's translator for its bank's statement is complete apart from one line. The ledger's balance counts money that moved. The files are below.

Which line, in place of // the missing line, makes main.ts print the ledger's balance for this statement?

Code for this question
// bank-feed.ts (the bank's statement lines, with its documentation)// POST: money that moved, in pence. HOLD: money set aside for a card payment that has not settled;// when it settles, the bank sends the same amount again as a POST line.export interface StatementLine { kind: 'POST' | 'HOLD'; dc: 'D' | 'C'; amt: number } // ledger.ts (the ledger's model)export interface Movement { direction: 'in' | 'out'; cents: number }export class UnknownBankLine extends Error {}export const balance = (ms: Movement[]) => ms.reduce((s, m) => s + (m.direction === 'in' ? m.cents : -m.cents), 0); // bank-translator.tsimport type { StatementLine } from './bank-feed';import { UnknownBankLine, type Movement } from './ledger';export function toMovements(l: StatementLine): Movement[] {  switch (l.kind) {    case 'POST': return [{ direction: l.dc === 'C' ? 'in' : 'out', cents: l.amt }];    // the missing line  }} // main.tsimport { toMovements } from './bank-translator';import { balance } from './ledger';const statement = [  { kind: 'POST', dc: 'C', amt: 50000 },  { kind: 'HOLD', dc: 'D', amt: 1200 },  { kind: 'POST', dc: 'D', amt: 1200 },  { kind: 'HOLD', dc: 'D', amt: 800 },] as const;try { console.log(`balance ${balance(statement.flatMap(toMovements))}`); }catch (e) { console.log(`import failed: ${(e as Error).constructor.name}`); }
Pick one

Question 6 of 7

A chat app imports its old platform's export through a translator before the old platform shuts down. The translator is complete apart from one line. The files are below.

Which line, in place of // the line, makes main.ts print each person's message count as the export's documentation says?

Code for this question
// legacy-export.ts (the old platform's export, with its documentation)// One account per sign-up. When two accounts turned out to be one person, the old platform merged them:// the account merged away carries merged_into, the uid of the account that was kept and stands for the person.export interface LegacyAccount { uid: string; handle: string; merged_into?: string }export interface LegacyMsg { uid: string; body: string } // message.ts (the new app's model)export interface Message { personId: string; text: string }export function tally(ms: Message[]): string {  const n = new Map<string, number>();  for (const m of ms) n.set(m.personId, (n.get(m.personId) ?? 0) + 1);  return [...n].map(([id, c]) => `${id}:${c}`).join(' ');} // from-legacy.tsimport type { LegacyAccount, LegacyMsg } from './legacy-export';import type { Message } from './message';export function fromLegacy(accounts: LegacyAccount[], msgs: LegacyMsg[]): Message[] {  const byUid = new Map(accounts.map((a) => [a.uid, a]));  return msgs.map((m) => {    // the line    return { personId, text: m.body };  });} // main.tsimport type { LegacyAccount, LegacyMsg } from './legacy-export';import { fromLegacy } from './from-legacy';import { tally } from './message';const accounts: LegacyAccount[] = [  { uid: 'u1', handle: 'ana' },  { uid: 'u3', handle: 'sam' },  { uid: 'u7', handle: 'ana_m', merged_into: 'u1' },  { uid: 'u9', handle: 'sam.k' },];const msgs: LegacyMsg[] = [  { uid: 'u7', body: 'new phone, same Ana' },  { uid: 'u1', body: 'see you at 8' },  { uid: 'u3', body: 'who has the slides?' },  { uid: 'u7', body: 'on my way' },  { uid: 'u9', body: 'running late' },];console.log(tally(fromLegacy(accounts, msgs)));
Pick one

Question 7 of 7

A streaming app imports its old catalogue, which has one row per title and audio language, through a translator. The files are below.

What does main.ts print?

Code for this question
// legacy-catalogue.ts (the old catalogue's export)export interface CatalogueRow { code: string; name: string; year: number; audio: string } // title.ts (the app's model)export interface Title { titleId: string; label: string; audio: string[] } // catalogue-translator.tsimport type { CatalogueRow } from './legacy-catalogue';import type { Title } from './title';export function toTitles(rows: CatalogueRow[]): Title[] {  const byCode = new Map<string, Title>();  for (const r of rows) {    const t = byCode.get(r.code) ?? { titleId: r.code, label: `${r.name} (${r.year})`, audio: [] };    t.audio.push(r.audio);    byCode.set(r.code, t);  }  return [...byCode.values()];} // main.tsimport { toTitles } from './catalogue-translator';const titles = toTitles([  { code: 'D84', name: 'Dune', year: 1984, audio: 'en' },  { code: 'D21', name: 'Dune', year: 2021, audio: 'en' },  { code: 'D21', name: 'Dune', year: 2021, audio: 'fr' },]);console.log(titles.map((t) => `${t.label}: ${t.audio.join('+')}`).join(' | '));
Pick one

Go deeper

Pro tips
  1. Test the layer with recorded payloads, the odd ones included. Keep real records from the other system as test fixtures, especially the ones that surprised you; when the other side changes, those fixtures fail first. When: writing or changing any translator (steps 3–4). Verify (RAN, kata-check.mjs): over kata/recorded-sis.json, a translator built from one table of status codes fails 3 of 7 tests, among them a recorded waitlisted registration.
  2. List what the layer decides. Read the translator for any comparison against a limit or a policy rather than a lookup of what a code means; each one is a rule that another entry point will skip. When: reviewing an MR that touches a translation layer (steps 8–9). Verify (RAN, tips.mjs): in t1's files, a search of sis-translator.ts for comparisons finds 1 line (fees_due === 0), and desk-signup.ts holds none.
Source
  • Microsoft, Azure Architecture Center: Anti-corruption Layer pattern (the row's first source): https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer · fetched 2026-10-14. Teaching only; every key here is executed. "Implement a facade or adapter layer between different subsystems that don't share the same semantics. This layer translates requests that one subsystem makes to the other subsystem." (steps 1–3); "In this scenario, it's important to focus the anti-corruption layer on translation logic. Avoid placing business rules or orchestration in the layer." (step 8); "The anti-corruption layer adds latency to calls between the two systems." and "If the anti-corruption layer is part of an application migration strategy, consider whether it's permanent or whether you plan to retire it after you migrate all legacy functionality." (step 10). Valid as of 2026-10-14.
  • The row's second source, Eric Evans, Domain-Driven Design (2003), is a book and keys nothing; the pattern's place among the context-map relationships is taught by the prerequisite arch-bounded-contexts, which quotes Evans's own pattern summaries.

Checked against the tools and docs as of 2026-10-14.