In an earlier post I made the usual case for an anti-corruption layer: pen a foreign model behind a boundary you own, so someone else’s vocabulary — and someone else’s change — can’t reach into your system and hold it hostage. That’s the pitch everyone makes, and it’s true.
It also stops one move too early. Because once you’ve built that seam, you haven’t just bought protection from the provider. You’ve quietly bought the ability to swap the thing in front of the layer too — the consumer — without the provider ever noticing. The layer isn’t a wall. It’s a double-sided socket, and most write-ups only ever mention one side of it.
The seam, in code
Strip an anti-corruption layer down and there are two parts. A port your domain owns, written entirely in your own language — the provider’s name appears nowhere:
public interface IFinanceProviderAgreement
{
Task<AgreementResponse> StartAgreement(Quote quote, AgreementRequest request);
Task<AdjustmentResponse> AdjustAgreement(Agreement existing, AdjustmentRequest request);
Task<AgreementStatement> GetByReference(string agreementReference); // ← the stable seam
}
And an adapter behind it that does the dirty work — and is invisible to the domain:
// internal: the domain can't reference this even if it wanted to.
internal class AcmeFinanceAdapter : IFinanceProviderAgreement
{
// HTTP and SOAP clients, an auth dance, and a Mappers/ folder whose
// entire job is translating Acme's wire DTOs ↔ our domain types.
// That folder *is* the anti-corruption layer.
}
Two tells separate a real ACL from a pass-through wearing the costume. The port speaks only
your types — Quote, Agreement, not AcmeClient or AcmeStatusCode. And the adapter is
internal, sealed inside its own assembly, so nothing downstream can accidentally take a
dependency on the vendor. Everything corrupting lives in the mappers, where it can be contained
and stared at.
Direction one: swap the provider
This is the side everyone sells. A second provider is a second adapter against the same port.
Your domain never learns there was a change, because it only ever knew IFinanceProviderAgreement.
I’ve lived this one. I mentioned before changing payment vendors more than once at the same company, and watching a finance function go from external to in-house and back out again. Each move was a new translator behind the same interface, not a quarter spent chasing a foreign type through every corner of the code. The vendor you’re certain you’ll never change is the one that moves — and the layer is what turns that move from archaeology into a contained piece of work.
Direction two: swap the consumer
Here’s the side that took me by surprise the first time I needed it.
We were migrating a book of business off a legacy platform onto a new one — incrementally, a cohort at a time, with the old and new systems running side by side. The finance agreements behind all of it sat with a third party, reached through exactly the kind of ACL above. The question was: when a policy moves from the old platform to the new one, what happens to its finance agreement?
The tempting answer is “re-create it on the new side.” It’s also the wrong one — re-originating an agreement means a fresh application to the provider, a new schedule, a customer who suddenly gets two direct debits or none. What you actually want is for the new platform to adopt the existing agreement, untouched.
And the seam already allowed it, because of one method on the port: GetByReference. Every
operation keys off a stable, provider-independent reference. So the new platform doesn’t
re-originate anything — it stores that reference, calls GetByReference to hydrate the current
state, and from then on speaks to the same agreement through the same port. The provider
integration doesn’t move an inch. We swapped the consumer — legacy platform out, new platform
in — across a seam built to protect against the provider.
Same socket. Other side.
Why it works — and when it won’t
It’s worth being honest about why this was cheap, so you can tell when it won’t be:
- It’s a re-link, not a transform. The agreement is the system of record and stays exactly where it is. We moved a pointer to it, not the thing itself. Re-linking is cheap; transforming live financial state is not.
- The cost doesn’t vanish, it relocates — onto the new consumer, which has to learn to speak the port. That’s real work. The seam doesn’t make migration free; it makes it contained.
- It only works if the port is genuinely provider-agnostic. If the vendor’s vocabulary has
leaked into the interface — their IDs, their enums, their wire-shaped fields — then you don’t
have an anti-corruption layer, you have a thin coat of paint over a hard dependency, and
neither swap is cheap. The
GetByReference(string)above works precisely because that reference is a plain, stable string both sides agree on, not a vendor-flavoured object.
The landmine
There’s a specific way to get the consumer swap catastrophically wrong, and I’ve now seen it
bite in more than one migration. If the swap path falls back to the new-business flow when it
can’t find an existing record — the classic existingReference ?? Guid.NewGuid() —
// Looks harmless. Fabricates an orphan on every lookup miss.
var reference = existing?.AgreementReference ?? Guid.NewGuid().ToString();
— then a missed link doesn’t fail, it fabricates. You mint a brand-new reference, fire off a duplicate application to the provider, and orphan the real agreement that was sitting there the whole time. At a few percent miss rate across a large book, that’s silent data loss measured in real customers.
The rule is unforgiving and worth tattooing on the migration: preserve the reference verbatim, fail loud on a missing link, and never, ever fabricate one. A migration that can’t find the agreement should stop and shout, not invent.
Telling a two-way layer from a leaky one
If you want to know whether your ACL actually swings both ways, ask:
- Does the provider’s name or types appear anywhere in the port? (They shouldn’t.)
- Is the adapter invisible to the domain —
internal, or in its own module? - Is there a stable, provider-independent reference every operation keys on?
- Could you stand up a second implementation of the port without touching the domain?
- Is there a translation layer that exists only to convert — or is mapping smeared through your business logic?
The more of those you can answer the right way, the more your layer is a socket and not a wall.
The one-line version
You justify an anti-corruption layer by the mess it keeps out. You get paid back by the swaps it lets you make — the provider behind it and the platform in front of it, each without disturbing the other. Build the seam for the vendor you’re fighting today; collect the portability you’ll need tomorrow. It’s the same virtue as easy to delete and two-way doors, seen from the side you weren’t looking at.