Platform integration
API strategy
Adapter pattern for third party APIs: a seven-step guide to building an api adapter layer that isolates vendor changes, translates errors, and holds in production.

Third party APIs are the ones you don't control. They change on their schedule. Their field names, auth flows, rate limits, and error shapes leak into your codebase the moment you call them directly — and every one of those leaks becomes a future outage.
The adapter pattern is how you stop that. You wrap each third party API in a thin layer of your own code that speaks your domain language on one side and the vendor's contract on the other. When the vendor changes, one file changes. Your business logic doesn't move.
By the end of this guide you'll have an adapter layer built to a repeatable pattern: an interface your app depends on, a concrete adapter per vendor, a translation boundary that neither side leaks across, and tests that catch drift before it ships. Prerequisites: a working service that already calls at least one third party API directly, and a language with interfaces or protocols (TypeScript, Python, Go, Java — all fine). Time required: about half a day to refactor one integration end to end.
Start with what your application needs, not what the vendor gives you. Write the interface in your own vocabulary — the nouns and verbs your product uses internally.
If you jump straight to wrapping the vendor SDK, you've built a proxy, not an adapter. The whole point of the api adapter layer is that the interface stays stable when the vendor swaps out.
// domain/payments.ts
export interface PaymentProcessor {
charge(input: ChargeRequest): Promise<ChargeResult>;
refund(chargeId: string, amount?: Money): Promise<RefundResult>;
getCharge(chargeId: string): Promise<Charge>;
}
export type Money = { amount: number; currency: string };
export type ChargeRequest = {
customerId: string;
amount: Money;
idempotencyKey: string;
};
Notice what's not there: no Stripe, no token, no payment_intent. The interface should read the same whether you're on Stripe, Adyen, or a homegrown ledger.
Each vendor gets its own class or module that implements the domain interface. This is where the vendor SDK lives, where their field names live, and where their quirks get absorbed.
// adapters/stripe-payment-adapter.ts
import Stripe from 'stripe';
import { PaymentProcessor, ChargeRequest, ChargeResult } from '../domain/payments';
export class StripePaymentAdapter implements PaymentProcessor {
constructor(private client: Stripe) {}
async charge(input: ChargeRequest): Promise<ChargeResult> {
const intent = await this.client.paymentIntents.create(
{
amount: input.amount.amount,
currency: input.amount.currency.toLowerCase(),
customer: input.customerId,
confirm: true,
},
{ idempotencyKey: input.idempotencyKey },
);
return this.toChargeResult(intent);
}
// ...
}
One rule: nothing outside the adapters/ directory imports Stripe. If a domain service reaches for stripe.PaymentIntent, the boundary has already leaked.
Adapters are translators, not tunnels. Every value crossing in or out gets converted between vendor shape and domain shape. Do it explicitly in a toDomain / toVendor helper — never let a raw vendor object escape.
private toChargeResult(intent: Stripe.PaymentIntent): ChargeResult {
return {
chargeId: intent.id,
status: this.mapStatus(intent.status),
amountCaptured: {
amount: intent.amount_received,
currency: intent.currency.toUpperCase(),
},
createdAt: new Date(intent.created * 1000),
};
}
private mapStatus(s: Stripe.PaymentIntent.Status): ChargeStatus {
switch (s) {
case 'succeeded': return 'captured';
case 'requires_action': return 'pending_customer_action';
case 'canceled': return 'cancelled';
default: return 'pending';
}
}
This mapping is the article you'll read at 2am when the vendor renames canceled to cancelled. Keep it exhaustive and keep the switch statements non-defaulting where you can — a new vendor status should be a compile error, not a silent shrug.
Vendor errors are the worst kind of leak. They differ by SDK version, come with vendor-specific codes, and change wording between releases. Catch them at the adapter boundary and rethrow as your own error types.
try {
const intent = await this.client.paymentIntents.create(...);
return this.toChargeResult(intent);
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
throw new PaymentDeclinedError(err.decline_code ?? 'unknown', { cause: err });
}
if (err instanceof Stripe.errors.StripeRateLimitError) {
throw new UpstreamRateLimitedError({ retryAfter: 30, cause: err });
}
throw new UpstreamUnavailableError({ cause: err });
}
Your retry logic, circuit breakers, and user-facing messages all key off the domain error types. We've written more about designing this taxonomy in our guide to structured error responses for AI agents — the same principles apply here.
The domain services should receive PaymentProcessor, not StripePaymentAdapter. Wire the concrete adapter at your composition root — the single place where your app boots and dependencies get resolved.
// composition-root.ts
const stripe = new Stripe(process.env.STRIPE_SECRET!, { apiVersion: '2024-06-20' });
const paymentProcessor: PaymentProcessor = new StripePaymentAdapter(stripe);
const checkoutService = new CheckoutService(paymentProcessor, orderRepo);
Everywhere else, the type is PaymentProcessor. Swapping vendors becomes one line at the composition root plus one new adapter file. This is what people mean when they talk about an anti-corruption layer integrations pattern from Domain-Driven Design — the domain stays clean because the boundary is enforced by the type system, not by discipline.
Unit tests with mocked SDKs prove your mapping is internally consistent. They don't catch drift. For that you need contract tests that hit the vendor's sandbox on a schedule.
Build two test suites for every adapter:
When the vendor ships a breaking change, the contract test fails before your customers do. The mock vs live third party APIs trade-offs go deeper — the short version is you need both.
When the vendor releases a new API version, resist the urge to plumb it through as a breaking change in your domain interface. Instead, add a second adapter implementation and toggle between them.
const adapter: PaymentProcessor = featureFlags.stripeV2
? new StripeV2PaymentAdapter(stripe)
: new StripeV1PaymentAdapter(stripe);
Roll the new adapter out behind a flag, compare results in shadow mode, cut over when you're confident, delete the old file. Your domain never noticed the vendor moved. That's the whole promise of wrapping third party apis behind a boundary.
Letting vendor types leak into the interface. If your PaymentProcessor returns Stripe.PaymentIntent, you've built a wrapper, not an adapter. The next vendor can't implement your interface. Refactor the return type into a domain shape before you go further.
One giant adapter for many vendors. Some teams build a PaymentAdapter that takes a vendor: 'stripe' | 'adyen' argument and branches internally. Don't. One class per vendor keeps the branches at the composition root where they belong.
Skipping the error taxonomy. Rethrowing err unchanged is the same as no adapter at all — the vendor's error hierarchy is now a public part of your API surface. If you only do one translation, do the error one.
Adapters that call other adapters. An adapter for Vendor A that internally calls the adapter for Vendor B creates a dependency graph the domain can't see. If two vendor calls need to compose, do it in a domain service that depends on both interfaces.
Testing only the happy path. Vendor sandboxes have card numbers for declines, 3DS challenges, rate limits, and disputes. Use them. The interesting bugs live in the branches you didn't map.
Building adapters by hand at portfolio scale. One adapter is a weekend. Fifty adapters, each with its own auth, pagination, error mapping, and contract tests, is a permanent engineering line item. If you're staring down that curve, the connector maintenance cost article is worth a read before you commit the headcount.
Stay up to date on the ever changing agentic landscape.