BoundariesCONTESTEDDOMAIN-SPECIFICSCALE-SPECIFIC

Anti-Corruption Layer

When the other system's concepts are wrong for you — not just its formats — translate the model, not the fields. The layer exists so their vocabulary cannot colonise yours.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

What do I do when integrating a system whose model of the domain genuinely conflicts with mine?

The requirement

A new subscriptions product must read customer and plan data from a fifteen-year-old billing mainframe. In the mainframe a "customer" is a billing account, one customer may be several accounts, an account can exist without a person, and "cancelled" means eight different things depending on a status code and a date field.

The obvious build

Adopt their model. They are the system of record, their vocabulary is already the company's vocabulary, and inventing a second meaning for "customer" will cause endless confusion.

Why it breaks

It is a genuinely strong argument and it is right about the vocabulary risk — which is why this is a real decision rather than an obvious one. It breaks because their model was designed for a different problem: billing accounts, not subscriptions, and it has no state you need (Choosing the Model).

How it breaks as requirements change
  • It is a genuinely strong argument and it is right about the vocabulary risk — which is why this is a real decision rather than an obvious one. It breaks because their model was designed for a different problem: billing accounts, not subscriptions, and it has no state you need (Choosing the Model).
  • Their eight-way cancelled becomes eight-way branching in your rules, and each branch is a piece of mainframe knowledge that now lives in the new product forever (Boolean Flag Explosion).
  • Concepts they lack cannot be expressed at all. There is no place to put "paused", so it becomes a flag beside their status, and now the true state is a pair of fields with no single owner (State Ownership).
  • And their model spreads by the same blameless mechanism as any external type, except worse: it spreads as *language*. Once product managers say "account" meaning "subscription", the confusion is in requirements documents and cannot be refactored out (Ubiquitous Language).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The mainframe cannot be changed, will not be replaced this decade, and its team measures success by not changing it.
  • Its vocabulary is used by finance, so "account" already means something specific to the business — you cannot simply redefine it.
  • The new product needs a subscription lifecycle that the mainframe has no concept of (State Machines).
Invariants
  • A subscription in the new product has exactly one state at any time, drawn from a set we defined, and every mainframe record maps to exactly one of them or is rejected (Explicit State).
  • No mainframe status code appears anywhere in the new product's rules, storage or API.
  • The mainframe remains the source of truth for money; the new product never invents a balance (Source of Truth: The Question Every Inconsistency Incident Is Really Asking is Distributed Systems' treatment of the same idea).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The anti-corruption layer owns the entire translation between two models — not fields, but concepts: which of their accounts constitute one of our customers, which status-and-date combinations mean which of our states (Domain Modeling).
  • It owns the reconciliation rules for the cases where their model has no answer, and it owns being explicit that those are decisions rather than facts.
  • Our domain owns the subscription lifecycle, which exists nowhere in theirs and must not be contorted to fit (Aggregates).
  • Their system owns money and remains authoritative for it; the layer is a translator, not a second ledger.
Boundaries
  • The boundary is conceptual, not syntactic. A boundary adapter stops at "their JSON becomes our type"; an anti-corruption layer stops at "their idea of a customer becomes our idea of a customer", which may be a many-to-one mapping with rules (Boundary Adapters).
  • It has two faces and both matter: nothing of theirs comes in untranslated, and nothing of ours goes out in our terms. The second is easy to forget and is how their team ends up depending on your vocabulary.
  • The layer is also a *linguistic* boundary. It is where the glossary changes, and writing that glossary down is a substantial part of the work (Naming and Domain Language).

Their model, your model, and the fact that they disagree

The distinguishing feature of this situation is not that the other system is old, or badly designed, or hard to call. It is that its concepts do not line up with yours. Their customer is your set of accounts. Their cancelled is your cancelled, expired, suspended and refunded, disambiguated by a date field. Your paused does not exist for them at all.

A field mapper cannot fix that, because there is no field to map. What is needed is a translation with rules in it — and, crucially, with judgement calls that somebody must own and write down. The layer is where those judgements live, and its most valuable output is arguably the table that records them rather than the code (Types of Documentation).

  • Two faces: nothing of theirs comes in raw, nothing of ours goes out raw. Forgetting the second corrupts them instead (Stable Boundaries).
  • Quarantine is a first-class destination, not an error branch. A record that maps to no state must not be given a default one (Optional Values and Absence).
  • Their identifiers may be stored, but as an external reference on our aggregate — never as our identity (Entities).
  • The layer is the migration seam for the day the mainframe is replaced, which is a second benefit worth counting up front (The Strangler Pattern).
Two models, one translation, and the direction that must hold
their wire formattheir typesour types onlyunmappable, with contextour vocabularywrites back, translated the other wayBilling mainframe — accounts, status codes, datesTheir client — transport and formats onlyAnti-corruption layer — concept translation + judgement rulesQuarantine — records that map to no state of oursSubscriptions domain — our lifecycle, our vocabularyProduct API — speaks only our terms
UserLLMAgentToolDataDecisionHumanGuardrail

Building one, in the order that avoids the usual failure

The order matters more than the technique. Almost every anti-corruption layer that ends up as a renamed copy of the foreign model was built by starting from their schema, because starting there makes their decisions feel like constraints rather than choices.

The step teams skip is the fourth. Writing the concept map as a reviewable table, with the judgement calls marked and attributed, is what turns a mapping function into a piece of documented business logic — and it is what stops the eight cancellation codes being resolved silently by whoever had the ticket (Docs Close to Code).

Introducing an anti-corruption layer
  1. 1
    Model your side first

    Write the subscription model from your own requirements, with their documentation closed. Name the states you need, including the ones they have no word for.

    fails by Reading their schema first, which produces a renamed copy and a layer with nothing to translate.

  2. 2
    Map concepts, not fields

    For each of your concepts, write down what in their world produces it — which may be several records, a computation, or nothing at all.

    fails by Mapping one-to-one because the names are similar. Their "customer" and yours share a word and not a meaning.

  3. 3
    Find the mismatches deliberately

    List everything you need that they cannot express, and everything they have that you will discard. Both lists are decisions to be made, not accidents to be discovered later.

    fails by Treating a missing concept as an implementation detail and inventing a flag beside their status field.

  4. 4
    Write the concept map as a reviewable table

    Their concept, our concept, the rule, whether it is a fact or a judgement, and who decided. Get it reviewed by the people who own the old model.

    fails by Leaving the judgements in code comments, so finance discovers them during an audit rather than during design.

  5. 5
    Implement translation in one module

    One place converts, in both directions, with exhaustive handling and an explicit quarantine path for the unmappable.

    fails by Spreading translation across the callers that need it, which reintroduces their vocabulary everywhere (Shotgun Surgery).

  6. 6
    Test the table, not the code path

    One case per status-and-date combination, driven from the same table that was reviewed, so the specification and the tests cannot diverge.

    fails by Testing only the combinations that appear in a sample export, which omits precisely the rare states that cause incidents.

  7. 7
    Reconcile continuously in production

    Compare counts and totals across the boundary on a schedule and alarm on drift, because a wrong mapping is silent by construction.

    fails by Assuming correctness at cutover. Translation drift shows up months later as a small, steady discrepancy.

The first and fourth steps are the ones that decide whether this is an anti-corruption layer or an expensive renaming exercise. Everything else is mechanical.

The lifecycle of the layer, and the transition that must not exist

An anti-corruption layer is not a permanent fixture that appears fully formed. It has a lifecycle, and the reason to make that lifecycle explicit is that one transition in it is the failure this whole lesson exists to prevent: going from a translated integration back to a direct one, because a deadline made a shortcut attractive.

The other forbidden transition is subtler. Retiring the foreign system without ever having had a translation seam means the migration has no boundary to cut at, so it becomes a rewrite of every consumer at once — which is the project that historically does not finish (The Risk in a Rewrite).

How the integration itself evolves
Direct couplingAdapter onlyTranslatedDual-sourcedForeign system retired ·
FromOnToGuardEffect
Direct couplingA vendor or schema change forces a search across call sitesAdapter onlyExactly one module is allowed to import their client afterwardsFormat churn stops reaching consumers (Boundary Adapters)
Adapter onlyA required concept turns out to be inexpressible in their modelTranslatedOur model is written from our requirements, not derived from theirsA reviewed concept map exists, with the judgement calls attributed
Direct couplingThe conceptual mismatch is recognised before the integration is builtTranslatedOur model is written firstThe cheapest path — the layer is designed rather than retrofitted
TranslatedAn acquisition or a second provider supplies the same conceptsDual-sourcedThe internal model needed no change to accommodate the second sourceA second implementation behind the same seam
TranslatedTheir system is decommissionedForeign system retiredA native implementation passes the same concept-map testsThe layer is deleted; no consumer changes (Incremental Migration)
Dual-sourcedThe last foreign source is switched offForeign system retiredReconciliation shows no drift over a full billing cycleThe internal model becomes the only model
must be impossible
  • Translated → Direct couplingThe usual route is a deadline: a feature needs a field the layer does not expose, so the caller reaches past it "just this once". Their vocabulary is then back inside the domain, and because it entered through one urgent exception nobody records it — so the next migration is priced as though the layer still holds.
  • Adapter only → Foreign system retiredRetiring the source when only formats were ever translated means every consumer still encodes their concepts. There is no seam to cut at, so the replacement has to change every consumer simultaneously — the big-bang migration with no incremental path and no rollback (Designing the Migration).
  • Direct coupling → Dual-sourcedAdding a second source with no translation seam means branching on which system a record came from, at every site that touches it. The two foreign models then coexist inside the domain and the second one is now as expensive to remove as the first (Boolean Flag Explosion).

The valuable transition is the diagonal: recognising the conceptual mismatch up front and going straight to Translated. It costs a few days more than an adapter and removes the retrofit entirely.

How to build it

Most important first.

  • Model your domain first, from your requirements, with no reference to theirs. If you look at their schema first you will produce a renamed copy of it, and the layer will have nothing to do (Requirements Before Design).
  • Write the concept map explicitly as a table: their concept, our concept, the rule, and the cases where the rule is a judgement call. That table is the layer's specification and it is the artefact finance and product both need to review (Architecture Decision Records).
  • Translate at the concept level, not the field level. Their status code plus their date field becomes our single state, computed by a function that is the only thing in the system allowed to know that rule (State Machines).
  • Make the unmappable explicit. Records that fit none of your states must land in a rejection or quarantine path with enough context to act on, never in a default state (An Error Taxonomy That Survives Contact).
  • Keep the layer thin on behaviour and thick on translation. An anti-corruption layer that starts making business decisions has become a second domain model, and there is now no single owner of the rules (God Object).
  • Decide honestly whether you need one at all. If their model is close enough to yours, an adapter is cheaper and an anti-corruption layer is ceremony (When Domain-Driven Design Does Not Pay).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Their team adds a ninth cancellation code: one edit inside the layer, one new row in the concept table, one test. Nothing in the subscription rules opens.
  • We add a subscription state they have no concept of: one edit in our domain and one decision in the layer about how it is sourced. Their system does not need to know, which is the benefit that a shared model cannot provide.
  • The mainframe is eventually replaced: the layer is the migration seam. A second implementation is written against the same internal model and cut over gradually, which is the cheapest version of that project available (The Strangler Pattern).
  • What stayed expensive: any change that spans both models — a new concept that both systems must agree about — still requires two teams, a shared decision and a coordinated release. The layer contains their churn, not the coordination cost of genuine joint change.
What the recommended approach costs
  • You maintain two models and the mapping between them, permanently, and every new concept has to be placed in one, the other, or both.
  • Two vocabularies in one company is a genuine organisational cost. Meetings need a translator, and the concept table has to be maintained as a real document rather than a comment (Documentation Decay).
  • The layer is a single point of subtle failure: a wrong mapping rule is not a crash, it is a subscription in the wrong state, and those are found by customers.

What can go wrong

Failure modes
  • The layer is built and their identifiers are stored on our aggregates as the primary key, so their model is in our database regardless of what the layer does (Invariant Leaks).
  • Translation is one-way. Data comes in translated and goes back out in our vocabulary, which their team then has to interpret — corrupting them instead, which is the same failure mirrored.
  • The judgement calls in the concept map are made silently by whoever wrote the mapping function, and nobody in finance ever sees them. The first audit is the review (Design Review).
  • The mitigation fails in a specific way: the layer becomes the place every awkward requirement is put, because it is the only module that is allowed to know about both worlds. It grows into the most complex and least owned code in the system (The Utility Dumping Ground).
Dependencies, and their direction
  • Our domain depends on nothing. The layer depends on our domain and on their client. Their system depends on nothing of ours, which is what makes the arrangement survivable (Dependency Direction).
  • The layer depends on knowledge that lives in people — which status code combinations mean what — and that dependency is the fragile one. Capturing it in code and in the concept table is the actual deliverable (Bus Factor).
  • Beware a dependency on their availability leaking into your rules: if a subscription cannot be evaluated when the mainframe is down, the layer has made their uptime your uptime (Partial Failure).
Misreads
  • "Anti-corruption layer is just a fancy adapter." An adapter translates format; this translates concepts, and the difference is whether the mapping is a rename or a rule with judgement in it. If your mapping table has a "decided by" column, you are in this lesson (Boundary Adapters).
  • "So never adopt an external model." Sometimes adopting theirs is correct — when their model is genuinely better, when the integration is peripheral, or when the shared vocabulary matters more than the fit. The failure is adopting it by default because it was there first (Build, Library, SaaS or Managed Service).
  • "The layer should hide them completely, so we can pretend they do not exist." Their availability, their latency and their eventual consistency are real and cannot be translated away. Hiding those is how a design acquires a hidden synchronous dependency (What Changes at the Network Boundary).
  • "This is a DDD technique, so we need the rest of DDD." You need a model of your own and a place to translate. Aggregates, repositories and the rest are separate decisions with their own justification (When Domain-Driven Design Does Not Pay).
Smells this explains
  • primitive-obsession
  • divergent-change

Testing it, and how it ages

What to test, and at which boundary
  • Test the concept mapping as a table-driven suite with one case per combination of their status and date, including the combinations they claim are impossible (Characterization Tests).
  • Test that unmappable records are rejected with context, and assert the rejection is observable — a quarantine table or an alert, not a log line nobody reads (Error Boundaries).
  • Test our domain with no mainframe at all, using our own types. If that is impossible, their model is still inside (What a Unit Is).
  • Run a periodic reconciliation test in production: counts and totals from their system against ours, with a tolerance and an alarm, because translation drift is silent by nature (Reconciliation Is a Component, Not a Cleanup Script in Distributed Systems covers the technique).
How this design ages
  • The layer grows as their edge cases are discovered, and each addition is a piece of institutional knowledge captured rather than a defect. Expect it to double in size in the first year.
  • Once your model is stable and theirs is contained, the layer becomes the natural place to add a second source — a second billing system after an acquisition — at a fraction of the original cost.
  • It ends when their system does. The correct end state is that the layer is replaced by a direct implementation of your own model, and the fact that no domain code changes on that day is the proof it worked (Incremental Migration).
  • It should be deleted rather than kept if their model and yours converge, which happens occasionally when their system is modernised. Keeping a translation between two identical models is the mapping smell at a larger grain.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • CONTESTEDThe strongest opposing view: two vocabularies in one company is a serious and permanent tax, and the layer institutionalises a disagreement that would be better resolved by adopting the model finance already uses. Proponents of this view point out that most anti-corruption layers are built by a new team that believes the old model is wrong, and that this belief is frequently the new team being unfamiliar with the domain rather than the old model being bad. That is a real pattern and a good reason to require the concept map to be reviewed by the people who own the old model — but it does not answer the case here, where their model has no representation for a state the new product must have.
  • DOMAIN-SPECIFICThe layer is justified by conceptual mismatch, so its value depends on how far apart the two models are. Integrating a payment provider whose model of a charge matches yours needs an adapter and nothing more; integrating a system whose "customer" is a billing account and whose lifecycle you cannot express needs the full translation. The decision is about the concepts, not about the age or ugliness of the other system.
  • SCALE-SPECIFICAt small scale one engineer holds both models in their head and the layer is overhead — the translation happens correctly in a few functions because the same person wrote them all. It becomes necessary when the number of people who need to reason about your model exceeds the number who understand theirs, which is usually the second team.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — at the system grain the same decision is where the boundary between two organisations' systems falls, and who absorbs the impedance mismatch when neither model can change.
  • Testing & Reliability Engineering — continuous reconciliation across the boundary is the only detection mechanism for a translation that is wrong but not broken, which is the characteristic failure here.