Most integration pain doesn't come from the integration itself. It comes from how many places it lives.
The failure mode is always the same. A system starts with one service calling a payment provider. Then a second service needs payments too, so it calls the provider directly. Then a third. A year later, a provider changes a field, and three unrelated features break — with no single place to fix them and no obvious owner to call.
The fix is boring, which is exactly why it works: stop letting your services talk to the outside world directly. Put one service in front of each third-party relationship, and make everyone go through it.
The anti-corruption layer
The pattern has an old name — an anti-corruption layer — but the idea is simple. For each external system, you own a proxy service that:
- speaks the third party's protocol on one side,
- exposes a stable internal contract on the other,
- and absorbs the churn in between.
Your domain services stop integrating with vendors. They integrate with you.
// Internal contract — stable, owned by you.
public interface IPaymentGateway
{
Task<PaymentResult> CaptureAsync(PaymentRequest request, CancellationToken ct);
}
// The proxy translates your contract into the provider's API,
// and the provider's response back into your model.
public sealed class AcmePaymentProxy : IPaymentGateway
{
public async Task<PaymentResult> CaptureAsync(PaymentRequest request, CancellationToken ct)
{
var providerPayload = MapToAcme(request); // your model -> theirs
var response = await _http.PostAsJsonAsync("/v3/charges", providerPayload, ct);
return MapFromAcme(response); // theirs -> your model
}
}
When the provider renames charge_id to transaction_id, you change one mapping function. Nothing downstream knows it happened. That is the entire payoff.
Why this is worth the extra hop
People resist the proxy because it's "another network call." Here's what you get for that hop:
- One owner per integration. A contract change has exactly one place to land.
- A seam for resilience. Retries, timeouts, and circuit breakers live in the proxy, not scattered across every caller.
- A seam for observability. Every call to the outside world passes through one service you can log, trace, and alert on.
- A seam for testing. Downstream services mock your contract, which is stable, instead of the provider's, which isn't.
Make failure explicit
Once everything funnels through one service, that service has to be honest about failure. A struggling provider should degrade one feature, not take down the platform.
services.AddHttpClient<AcmePaymentProxy>()
.AddStandardResilienceHandler(); // timeouts, retries, circuit breaker
The circuit breaker matters more than the retry. Retrying a dead provider just spends your latency budget confirming it's still dead. Breaking the circuit fails fast and gives the rest of the system room to stay up.
Migrating without a maintenance window
You rarely get to build this on a greenfield. You're retrofitting it under live traffic, which means a strangler approach, one integration at a time:
- Shadow. The proxy receives real traffic but its result is ignored; you compare it against the existing direct path in logs.
- Take over reads. Once the shadow agrees with reality, route read traffic through the proxy. Keep the old path warm.
- Take over writes. Move writes last, with the old path still available as an instant fallback.
- Delete the old path — only once you've stopped reaching for the fallback.
No single switch is ever flipped on the whole system. Each integration moves on its own timeline, and any one of them can roll back without touching the others.
The one rule that keeps it clean
Don't let business logic leak into the proxy. Its job is translation and resilience — protocol in, your contract out. Pricing rules, approval flows, and domain decisions belong in your own services, where you can test them and where they aren't entangled with someone else's API.
A proxy layer won't show up in a demo. It shows up the day a provider has an outage and only one feature notices.