Platform integration

API strategy

Adapter pattern for third party APIs: a seven-step guide to a boundary that holds

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.

7 minute read
Decorative imagery showcasing Pontil's brand

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.

Step 1 — Define the domain interface first

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.

Step 2 — Build one concrete adapter per vendor

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.

Step 3 — Translate at the boundary, both ways

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.

Step 4 — Normalise errors into a domain error taxonomy

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.

Step 5 — Wire dependency injection so the domain never sees the vendor

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.

Step 6 — Test the adapter against the real API in CI

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:

  1. Unit tests — mock the SDK, assert that domain input maps to the expected vendor call and that vendor responses map back to the expected domain output. Fast, run on every commit.
  2. Contract tests — run against the vendor's sandbox environment. Create a charge, refund it, fetch it, assert the domain-level shape. Slow, run nightly or on adapter changes only.

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.

Step 7 — Version the adapter, not the interface

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.

Common pitfalls

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.

Join our weekly newsletter

Stay up to date on the ever changing agentic landscape.

POSTS

Related content

Platform integration

Agents in production

Third party API integration best practices when agents are the consumer

8 minute read

Platform integration

Agents in production

Third party API change management is the tax nobody put on the roadmap

6 minute read

Agent infrastructure

API strategy

Circuit breaker pattern for APIs: a practical guide for agent-era systems

7 minute read