Skip to content

Anti-Corruption Layer

An anti-corruption layer doesn’t protect you from APIs. It protects you from someone else’s worldview.

An anti-corruption layer isn’t academic DDD decoration.

It’s the place in the system where you say:

Nice that you have a model. But it’s not ours.

And that’s exactly the point.

A lot of systems don’t break because an external API is bad. They break because its terms, states, IDs, error codes, workflows, and compromises migrate unchecked into your own domain.

At first it’s just a DTO.

Then it’s an interface.

Then it’s a use case.

Then suddenly your own business logic is called externalCustomerStatusCode.

And somewhere a developer sits there, staring at status = 7, saying the sentence that slowly kills every architecture:

That’s just how it comes from the backend.

Not every interface deserves your trust. Some deserve a translator.

The most common objection to anti-corruption layers sounds harmless:

Why bother? We can just use the DTO directly.

Of course you can.

You can also run sewage pipes straight through the kitchen. Technically, it flows.

The question isn’t whether it works. The question is what gets contaminated in the process.

An external model is rarely neutral. It carries assumptions with it:

  • how another area thinks about the world
  • which legacy baggage exists there
  • which fields grew historically
  • which states were never modeled cleanly
  • which workarounds got preserved in the API
  • which technical details accidentally look like domain meaning

When this model gets adopted directly into your own application, you’re not just adopting data.

You’re adopting someone else’s worldview.

External models aren’t wrong. They just belong somewhere else.

Section titled “External models aren’t wrong. They just belong somewhere else.”

An external service can be built in a perfectly sensible way.

For its own context.

That’s the part that often gets forgotten.

A billing system thinks in invoices, billing periods, dunning levels, and payment status. A sales system thinks in leads, opportunities, contacts, and close probabilities. A support system thinks in tickets, priorities, escalations, and SLA windows.

All of them can talk about the same customer.

But they don’t mean the same model.

The customer in the CRM isn’t automatically the customer in billing. The customer in billing isn’t automatically the user in the frontend. And the user in the frontend isn’t automatically the aggregate in your own domain.

Ignore these differences, and you save yourself a few mappers in the short term.

Long term, you pay for it with a system that can no longer speak in its own terms.

Same name doesn't mean same model.

Architectural corruption sounds dramatic.

In practice, it usually looks boring.

For example, like this:

if (customer.statusCode === 3 || customer.statusCode === 7) {
showUpgradeBanner();
}

Or like this:

const isActive = user.externalState !== 'D' && user.billingFlag !== 'BLOCKED' && user.legacyValidUntil > today;

Or like this:

interface Project {
id: string;
name: string;
projectTypeId: number;
crmAccountId: string;
sapCustomerNumber: string;
isArchivedFlag: 'Y' | 'N';
}

The problem isn’t that this code technically doesn’t run.

The problem is that, at some point, the application no longer describes what’s happening in domain terms. It describes how some other system delivers its data.

Then domain questions turn into guessing games:

When is a customer active?

Answer:

When statusCode is 3, except for customers migrated from system B, where it’s 7, but not if billingFlag is set, except when the feature comes from the partner portal.

Congratulations.

The domain has been successfully replaced by an export contract.

Using DTOs directly feels fast at first.

No mapping. No extra types. No apparent overhead.

The code looks lean.

Until the external interface changes.

Until a second external provider comes along.

Until the frontend needs special-case logic.

Until tests start recreating huge external payloads.

Until nobody knows anymore whether a field is relevant to the domain or just passed through technically.

Then “just mapping” suddenly turns into a system-wide overhaul.

Not in one place.

Everywhere.

In components. Stores. Services. Tests. Forms. Guards. Resolvers. Tables. Filters. Permission logic. Validation. Error messages.

That’s the moment you realize:

You didn’t save yourself any work.

You spread it around.

What doesn't get translated at the boundary gets interpreted everywhere else instead.

What an anti-corruption layer actually does

Section titled “What an anti-corruption layer actually does”

An anti-corruption layer isn’t a magic architecture wall.

It’s a deliberate translation layer between two models.

It accepts foreign structures and produces its own terms.

Not:

ExternalCustomerDto;

passed through everywhere.

But:

CustomerProfile;

shaped for your own application.

Not:

statusCode: 7;

checked in the UI.

But:

accountState: 'active' | 'restricted' | 'closed';

modeled in your own language.

Not:

errorCode: 'X-991-A';

dragged all the way to the component.

But:

CustomerProfileUnavailable;

or:

CustomerRequiresManualReview;

treated as a meaningful domain case.

That sounds trivial.

It isn’t, though.

Because that’s exactly the point where the team decides which foreign details are relevant, which get ignored, and which have to be translated into its own concepts.

That’s architecture work.

Not typing work.

A mapper isn’t an anti-corruption layer yet

Section titled “A mapper isn’t an anti-corruption layer yet”

A common self-deception looks like this:

We already have mappers.

Yes.

But a mapper that just renames fields doesn’t protect much.

function mapCustomer(dto: CustomerDto): Customer {
return {
id: dto.id,
name: dto.name,
status: dto.status,
type: dto.type,
};
}

That’s syntactic cosmetics.

An anti-corruption layer does more:

  • It decides which external fields are even allowed into your own model.
  • It translates technical codes into domain states.
  • It encapsulates external failure patterns.
  • It protects your own domain from optional-payload chaos.
  • It prevents foreign IDs and workflows from becoming part of your internal architecture.
  • It makes external quirks explicit instead of letting them spread.

A good ACL, then, isn’t just a file called mapper.ts.

It’s a boundary.

And boundaries only help when they control what’s allowed through.

Renaming isn't protection. Translating is.

Frontend: where corruption becomes visible especially fast

Section titled “Frontend: where corruption becomes visible especially fast”

In the frontend, you often see architectural corruption earlier than in the backend.

Not because frontends are fundamentally more chaotic.

But because frontends touch many worlds at once:

  • API contracts
  • UI states
  • form logic
  • validation
  • permissions
  • routing
  • error messages
  • i18n
  • user expectations
  • feature flags
  • loading states

When external models land here directly, components quickly turn into small, burned-out interpreters.

Then a component no longer says:

canEditProject(project);

but:

project.typeId !== 4 && project.externalState !== 'LOCKED' && user.roles.includes('PROJECT_ADMIN') && project.sourceSystem !== 'LEGACY_X';

That’s not “close to the UI.”

That’s domain logic in camouflage.

And of course it gets copied. Once into the list. Once into the detail view. Once into the context menu. Once into the button. Once into the test.

Afterward, someone asks why the refactor is taking so long.

Especially in Angular, React, or Vue applications, it’s worth having your own model.

Not as dogma.

But because UI isn’t automatically API.

An API might deliver:

{
"id": "123",
"state": "A",
"validUntil": "2026-12-31",
"permissions": ["P_EDIT", "P_ARCHIVE"],
"flags": {
"migrated": true,
"billingBlocked": false
}
}

But the interface might need:

{
id: '123',
availability: 'usable',
actions: {
canEdit: true,
canArchive: false
},
warnings: ['migratedAccount']
}

That’s not redundant.

That’s a different view of the same reality.

The API describes delivery.

The frontend model describes use.

Confuse the two, and you build interfaces that visualize API contracts instead of solving user problems.

The API delivers data. The application needs meaning.

There’s no single perfect spot.

An anti-corruption layer can look different depending on the architecture:

  • in the backend, between a foreign service and your own domain
  • in the API gateway, between a legacy system and the frontend
  • in the frontend, between the HTTP client and the store
  • in a BFF layer
  • in an adapter around SDKs or external clients
  • in an import process
  • in a consumer for events

The folder name doesn’t matter.

The direction of the dependency matters.

Your own domain shouldn’t be forced to think in the terms of the foreign system.

A useful test is simple:

If the external provider is replaced tomorrow, which parts of our system would have to change their domain assumptions?

If the answer is “pretty much everything,” the boundary wasn’t a boundary.

It was just a decorative fence with open gates.

An anti-corruption layer doesn’t mean: abstract everything

Section titled “An anti-corruption layer doesn’t mean: abstract everything”

Of course you can overdo it.

Not every API needs a full-grown protective wall.

Not every small data structure has to be broken into five models.

Not every GET /countries call deserves a DDD seminar with a projector and cold coffee.

The decisive question is risk.

An anti-corruption layer is especially worth it when:

  • the external model represents a different domain model than your own application
  • the external system carries legacy baggage
  • the API is unstable
  • multiple providers are possible
  • domain decisions get derived from the data
  • the payload contains a lot of technical codes, flags, or edge cases
  • external terms would otherwise land in the UI, tests, and use cases
  • the team needs to stay independent of the provider long term

For trivial data, a simple adapter can be enough.

For domain-critical integrations, using DTOs directly is a loan with a bad interest rate.

You get speed right away.

The bill comes later.

With interest.

Not every boundary needs a wall. But every critical boundary needs control.

The classic:

First you build everything directly against the API.

Then edge cases show up.

Then mappers show up.

Then helpers show up.

Then utils show up.

Then come normalizeCustomer, enhanceCustomer, mapCustomerForUi, mapCustomerForDetail, mapCustomerForEdit, mapCustomerButDifferent.

And eventually half the application consists of scattered translation logic, but nobody calls it architecture.

The problem isn’t that mapping exists.

The problem is that mapping exists everywhere.

An anti-corruption layer makes this work visible, central, and testable.

It forces the team to make decisions at the boundary:

  • Which external states do we know about?
  • Which of them are relevant to us?
  • What’s our own term for it?
  • What happens with unknown codes?
  • Which data do we guarantee internally?
  • Which external errors become which domain scenarios?
  • Which edge cases deliberately stay at the boundary?

This isn’t hard because TypeScript is complicated.

It’s hard because you have to think.

Unfortunately, thinking can’t be retrofitted with npm install architecture.

Another objection:

Then we’ll have to test even more.

Yes.

But in the right place.

Without an anti-corruption layer, tests often have to imitate external payloads. Component tests suddenly know API codes. Store tests need legacy flags. Use-case tests work with data structures that nobody can explain in domain terms.

With an ACL, you test the translation once, deliberately:

it('maps blocked billing flag to restricted account state', () => {
const result = mapCustomerDtoToProfile({
statusCode: 3,
billingFlag: 'BLOCKED',
});
expect(result.accountState).toBe('restricted');
});

After that, inner tests can work with your own model.

That’s the difference between:

We keep retesting the same external semantics everywhere, alongside everything else.

and:

We test the boundary once, cleanly, and test our own business logic on the inside.

That’s not more testing effort.

That’s less contamination.

Test the translation at the boundary — not the foreign contract in every component.

Management perspective: why this isn’t just developer aesthetics

Section titled “Management perspective: why this isn’t just developer aesthetics”

Anti-corruption layers often get dismissed as a developer preference.

Something like:

They just want to build pretty architecture again.

Yes, terrible.

Developers want to keep the system from looking, in six months, like a poorly ventilated basement full of adapter cables. Very eccentric of them.

From a management perspective, though, it’s about something else:

  • the ability to swap out external systems
  • lower dependency on vendors
  • better predictability around API changes
  • fewer regressions from external model changes
  • clearer responsibilities between teams
  • faster ongoing development of your own business logic
  • less hidden coupling

Direct integration is often a bet:

This external model will remain stable and continue to fit our domain.

That can be true.

But if it’s not true, the coupling is already everywhere.

An ACL isn’t insurance against change.

But it limits the blast radius.

And that’s exactly what architecture is about.

Not preventing every change.

But making sure one change doesn’t take half the building down with it.

A simple layout is often enough:

feature/
data-access/
external-customer.dto.ts
customer.api.ts
customer-acl.mapper.ts
domain/
customer-profile.ts
customer-state.ts
application/
customer.facade.ts
customer.store.ts
ui/
customer-card.component.ts
customer-detail.component.ts

The rule:

External DTOs stay in data-access.

The application speaks in its own models.

The UI never sees API codes.

The business logic never checks legacy flags.

Tests outside the boundary never use external payloads.

That’s not rocket science.

But it’s a deliberate decision against the convenient pass-through.

And that exact decision is what separates architecture from file sorting.

Example: from an external status to your own meaning

Section titled “Example: from an external status to your own meaning”

External API:

interface ExternalSubscriptionDto {
id: string;
status: 'A' | 'I' | 'S' | 'X';
paymentState: 'OK' | 'FAILED' | 'UNKNOWN';
migratedFlag?: boolean;
}

Own application:

type SubscriptionAvailability = 'usable' | 'temporarilyBlocked' | 'inactive' | 'requiresReview';
interface Subscription {
id: string;
availability: SubscriptionAvailability;
warnings: SubscriptionWarning[];
}

ACL:

function mapSubscription(dto: ExternalSubscriptionDto): Subscription {
return {
id: dto.id,
availability: mapAvailability(dto),
warnings: mapWarnings(dto),
};
}
function mapAvailability(dto: ExternalSubscriptionDto): SubscriptionAvailability {
if (dto.status === 'X') {
return 'requiresReview';
}
if (dto.paymentState === 'FAILED') {
return 'temporarilyBlocked';
}
if (dto.status === 'I') {
return 'inactive';
}
return 'usable';
}

The important part isn’t the code.

The important part is that the inner application no longer has to know what X, FAILED, and migratedFlag historically mean.

It works with terms that are relevant to it.

For every external integration, a team should ask itself:

Do we actually want this model living inside us?

Not:

Can we use it?

Of course we can.

We can use almost anything.

The question is whether we can still get rid of it later.

An anti-corruption layer is the architecture answer to exactly that question.

It doesn’t say:

We don’t trust anyone.

It says:

We take responsibility for our own terms.

That’s a huge difference.

An anti-corruption layer is often missing when you hear things like this:

The field has a weird name, but that’s just how it comes.

You have to interpret the status using the table in the wiki.

Unfortunately, the component needs the entire response object.

For the new provider, we just need to touch a few spots.

We already have the logic in three components, just slightly different.

The test needs this huge JSON file, otherwise it doesn’t run.

The flag usually means active.

By the way, “usually active” should set off a quiet alarm somewhere in the building.

Not loud.

Just loud enough that nobody can later claim they didn’t hear it.

When domain meaning has to be explained in comments, a model is often missing.

The alternative isn’t turning every DTO into a religious event.

The alternative is a deliberate model boundary.

In practice, that means:

  1. Treat external contracts as external contracts.
  2. Define your own models for your own use cases.
  3. Concentrate translation at one clear boundary.
  4. Translate foreign codes and flags into your own meaning.
  5. Handle unknown and broken cases explicitly.
  6. Concentrate tests on the boundary.
  7. Do not let external details leak past the boundary.

That’s not over-architecture.

That’s hygiene.

And like with hygiene, you often only notice its value once it’s missing.

An anti-corruption layer doesn’t protect you from getting data from the outside.

It protects you from losing your own language.

Because the moment foreign models land unfiltered in components, stores, use cases, and tests, your own application becomes dependent on terms it doesn’t control.

That’s convenient at first.

Later it’s expensive.

Good architecture doesn’t mean making every external integration complicated.

Good architecture means deciding deliberately at critical boundaries:

What do we adopt? What do we translate? What stays outside?

The anti-corruption layer is exactly that point.

Not more.

But not less, either.

What you don't translate at the boundary, your entire system will end up explaining later.

External APIs don’t corrupt your system.

Adopting their models unchecked does.