StackPractices
intermediate By Mathias Paulenko

Adapter Pattern for Integrating External REST APIs

Use the Adapter pattern to normalize responses from external REST APIs into a consistent internal model without leaking third-party formats into your domain

Overview

The Adapter pattern converts the interface of one class into another interface clients expect. Applied to third-party REST APIs, it becomes an anti-corruption layer: the boundary where messy external contracts — Stripe’s paymentIntents, PayPal’s purchase_units, whatever next year’s renames bring — translate into clean domain models your code actually owns.

Without it, every provider’s field names, error shapes, and versioning quirks leak into services that shouldn’t know they exist. With it, a provider swap is a config change, not a refactor.

When to Use This

  • You consume two or more third-party APIs with different response formats
  • API contracts change and you want to contain breaking changes at the boundary
  • Your domain model must remain independent from external representation
  • A provider’s payload changes between versions and migrations must be staged
  • You need to test against provider responses without live calls

Skip it for a single, stable integration — a plain mapper function does the job until the second provider or the first breaking change shows up.

Problem

Stripe, PayPal, and Square all expose payment data differently. Stripe nests amounts as integer cents under payment_intent.amount; PayPal exposes purchase_units[0].amount.value as a string in major units; Square returns money.amount as integer minor units — three ways to say “how much,” none of them yours.

If your checkout service accesses all three directly, every API version bump pollutes your domain with conditional logic: if provider branches, three status enums, three error shapes, three date formats. Add a fourth provider and it’s exponential.

The adapter draws the line: one Payment interface your services speak, one adapter per provider that does the translating. The domain never learns the providers exist.

Solution

// domain/Payment.ts
interface Payment {
  id: string;
  amount: number;
  currency: string;
  status: 'pending' | 'completed' | 'failed';
  createdAt: Date;
}

// adapters/PaymentAdapter.ts
interface PaymentAdapter {
  fetchPayment(externalId: string): Promise<Payment>;
}

// adapters/StripeAdapter.ts
class StripeAdapter implements PaymentAdapter {
  async fetchPayment(externalId: string): Promise<Payment> {
    const stripePayment = await stripeClient.paymentIntents.retrieve(externalId);
    return {
      id: stripePayment.id,
      amount: stripePayment.amount / 100,
      currency: stripePayment.currency.toUpperCase(),
      status: this.mapStatus(stripePayment.status),
      createdAt: new Date(stripePayment.created * 1000),
    };
  }

  private mapStatus(status: string): Payment['status'] {
    const map: Record<string, Payment['status']> = {
      'requires_payment_method': 'pending',
      'succeeded': 'completed',
      'canceled': 'failed',
    };
    return map[status] || 'failed';
  }
}

// adapters/PayPalAdapter.ts
class PayPalAdapter implements PaymentAdapter {
  async fetchPayment(externalId: string): Promise<Payment> {
    const paypalPayment = await paypalClient.orders.retrieve(externalId);
    return {
      id: paypalPayment.id,
      amount: parseFloat(paypalPayment.purchase_units[0].amount.value),
      currency: paypalPayment.purchase_units[0].amount.currency_code,
      status: paypalPayment.status === 'COMPLETED' ? 'completed' : 'pending',
      createdAt: new Date(paypalPayment.create_time),
    };
  }
}

// services/PaymentService.ts
class PaymentService {
  constructor(private adapter: PaymentAdapter) {}

  async getPaymentSummary(id: string) {
    const payment = await this.adapter.fetchPayment(id);
    return {
      total: payment.amount,
      currency: payment.currency,
      isPaid: payment.status === 'completed',
    };
  }
}

Usage

const service = new PaymentService(new StripeAdapter());
// Switch provider without touching domain logic
// const service = new PaymentService(new PayPalAdapter());

Error Normalization and Versioning

The adapter’s hardest job isn’t field mapping — it’s failure mapping. Stripe returns card_error with code and decline_code; PayPal returns ISSUE enums and INSTRUMENT_DECLINED; Square nests errors under errors[]. If those shapes reach your services, every error handler becomes a provider switch statement.

// Domain error the adapter produces — provider-agnostic
type PaymentError =
  | { kind: 'card_declined'; reason: string }
  | { kind: 'not_found' }
  | { kind: 'rate_limited'; retryAfter: number }
  | { kind: 'unknown'; raw: unknown };

// Inside StripeAdapter — the only place that knows Stripe's shape
private mapError(err: StripeError): PaymentError {
  if (err.code === 'card_declined')
    return { kind: 'card_declined', reason: err.decline_code };
  if (err.code === 'rate_limit')
    return { kind: 'rate_limited', retryAfter: 60 };
  return { kind: 'unknown', raw: err };
}

The same logic applies to versioning. When Stripe bumps their API, the wrong move is if (version === 'v2') branches inside the adapter; the right move is StripeV2Adapter — a new class implementing the same PaymentAdapter interface, selected by factory. Old and new run side by side during the migration window, then the old one deletes clean.

A domain service calls the PaymentAdapter interface; StripeAdapter and PayPalAdapter translate each provider's JSON into one shared Payment domain model

Contract Testing the Boundary

Unit tests with mocks verify your mapping is right; they can’t verify the API still does what you mapped. Contract tests close that gap: recorded or live calls against the real provider that assert the payload shape your adapter depends on.

// Runs against Stripe's test environment on a schedule, not per-commit
test('Stripe contract: paymentIntent shape', async () => {
  const pi = await stripeClient.paymentIntents.retrieve(TEST_ID);
  expect(pi).toMatchObject({
    id: expect.any(String),
    amount: expect.any(Number),
    currency: expect.any(String),
    status: expect.any(String),
  });
});

When Stripe renames paymentIntents or drops a field, the contract test fails before your users do — that’s the whole point of paying for an adapter layer.

When Not to Reach for It

The adapter earns its complexity only when the boundary it protects is real. For a single provider with a stable contract, an adapter is ceremony — a mapper function plus a typed client does the same job with a third of the code. The pattern pays off at two or more providers, or one provider whose contract is known to drift.

It also fights you when the external API is the domain. If you’re building a Stripe dashboard — where Stripe’s objects ARE the product — wrapping them in your own model adds indirection without isolation; the domain isn’t yours to protect.

And it doesn’t fix bad APIs, it contains them. A provider with broken semantics stays broken inside a clean interface; the adapter stops the leak, it doesn’t cure the disease.

The honest summary of the boundary: an adapter is a bet that providers will change more often than your domain will. Every integration in production eventually confirms the bet — the question is whether you paid for the layer before or after the first breaking change forced you to.

Picking the Boundary Line

Where you draw the adapter line matters more than how you implement it. Too thin — a mapper that only renames fields — and error shapes and pagination semantics still leak through. Too thick — an adapter that validates, retries, caches, and translates — and you’ve built a service inside a service that nobody can test.

The line that works in production: the adapter owns representation (fields, types, errors, pagination shape) and nothing else. Retries, caching, rate limits, and auth live in decorators or the HTTP client — composable, separately testable. The Advanced Techniques below show that composition: each wrapper adds one concern without touching the core translation.

Variations

The adapter composes well — most production integrations stack one or more of these on the core translation:

  • Two-Way Adapter: convert internal models back to external format for updates — same translation benefits for writes.
  • Caching Adapter: combine with Proxy to cache external lookups; payment and identity data needs tight TTLs.
  • Batch Adapter: adapt list endpoints into paginated internal queries — handles the “fetch 500 records in pages of 100” mismatch.
  • Adapter Factory: pick the right adapter by config or request context — provider selection without if chains.
  • Retry Adapter: wrap calls in exponential backoff — transient failures absorbed at the boundary, not in business logic.
  • Validating Adapter: normalize errors and validate payloads at the edge — bad responses never reach the domain.

Advanced Techniques

Two-way adapter for bidirectional conversion

Support converting internal models back to external formats for updates:

// adapters/BiDirectionalPaymentAdapter.ts
interface BiDirectionalPaymentAdapter extends PaymentAdapter {
  toExternal(payment: Payment): Promise<ExternalPayment>;
}

interface ExternalPayment {
  provider: 'stripe' | 'paypal';
  data: any;
}

class StripeBiDirectionalAdapter implements BiDirectionalPaymentAdapter {
  async fetchPayment(externalId: string): Promise<Payment> {
    const stripePayment = await stripeClient.paymentIntents.retrieve(externalId);
    return this.fromStripe(stripePayment);
  }

  async toExternal(payment: Payment): Promise<ExternalPayment> {
    const stripePayment = await this.toStripe(payment);
    return { provider: 'stripe', data: stripePayment };
  }

  private fromStripe(stripePayment: any): Payment {
    return {
      id: stripePayment.id,
      amount: stripePayment.amount / 100,
      currency: stripePayment.currency.toUpperCase(),
      status: this.mapStatus(stripePayment.status),
      createdAt: new Date(stripePayment.created * 1000),
    };
  }

  private async toStripe(payment: Payment): Promise<any> {
    return {
      amount: Math.round(payment.amount * 100),
      currency: payment.currency.toLowerCase(),
      metadata: { internal_id: payment.id },
    };
  }

  private mapStatus(status: string): Payment['status'] {
    const map: Record<string, Payment['status']> = {
      'requires_payment_method': 'pending',
      'succeeded': 'completed',
      'canceled': 'failed',
    };
    return map[status] || 'failed';
  }
}

Caching adapter with proxy pattern

Combine adapter with proxy to cache external lookups and reduce API calls:

// adapters/CachingPaymentAdapter.ts
class CachingPaymentAdapter implements PaymentAdapter {
  private cache = new Map<string, { payment: Payment; expiresAt: number }>();
  private readonly ttl = 60000; // 1 minute

  constructor(private delegate: PaymentAdapter) {}

  async fetchPayment(externalId: string): Promise<Payment> {
    const cached = this.cache.get(externalId);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.payment;
    }

    const payment = await this.delegate.fetchPayment(externalId);
    this.cache.set(externalId, {
      payment,
      expiresAt: Date.now() + this.ttl,
    });
    return payment;
  }

  invalidate(externalId: string): void {
    this.cache.delete(externalId);
  }
}

// Usage
const stripeAdapter = new StripeAdapter();
const cachingAdapter = new CachingPaymentAdapter(stripeAdapter);
const service = new PaymentService(cachingAdapter);

Batch adapter for paginated endpoints

Adapt list endpoints into paginated internal queries:

// adapters/BatchPaymentAdapter.ts
interface PaginatedResult<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
}

class StripeBatchAdapter implements PaymentAdapter {
  async fetchPayment(externalId: string): Promise<Payment> {
    // Single-fetch translation identical to StripeAdapter — elided for brevity
    const sp = await stripeClient.paymentIntents.retrieve(externalId);
    return {
      id: sp.id,
      amount: sp.amount / 100,
      currency: sp.currency.toUpperCase(),
      status: this.mapStatus(sp.status),
      createdAt: new Date(sp.created * 1000),
    };
  }

  async fetchPayments(options: {
    page?: number;
    pageSize?: number;
    status?: Payment['status'];
  }): Promise<PaginatedResult<Payment>> {
    const page = options.page || 1;
    const pageSize = options.pageSize || 20;

    const stripePayments = await stripeClient.paymentIntents.list({
      limit: pageSize,
      starting_after: page > 1 ? this.getPageCursor(page - 1) : undefined,
    });

    const payments = stripePayments.data.map(sp => ({
      id: sp.id,
      amount: sp.amount / 100,
      currency: sp.currency.toUpperCase(),
      status: this.mapStatus(sp.status),
      createdAt: new Date(sp.created * 1000),
    }));

    return {
      items: payments,
      total: stripePayments.total_count || 0,
      page,
      pageSize,
    };
  }

  private mapStatus(status: string): Payment['status'] {
    const map: Record<string, Payment['status']> = {
      'requires_payment_method': 'pending',
      'succeeded': 'completed',
      'canceled': 'failed',
    };
    return map[status] || 'failed';
  }

  private getPageCursor(page: number): string {
    // Stripe paginates by item id — starting_after is the last id of the
    // previous page, not a page number. Wire this to your cursor store.
    return '';
  }
}

Adapter factory for provider selection

Use factory pattern to select appropriate adapter at runtime:

// adapters/SquareAdapter.ts — Square returns money.amount in minor units
class SquareAdapter implements PaymentAdapter {
  async fetchPayment(externalId: string): Promise<Payment> {
    const sq = await squareClient.payments.get(externalId);
    return {
      id: sq.id,
      amount: sq.money.amount / 100,
      currency: sq.money.currency,
      status: sq.status === 'COMPLETED' ? 'completed' : 'pending',
      createdAt: new Date(sq.created_at),
    };
  }
}

// adapters/PaymentAdapterFactory.ts
class PaymentAdapterFactory {
  private adapters = new Map<string, PaymentAdapter>();

  register(provider: string, adapter: PaymentAdapter): void {
    this.adapters.set(provider, adapter);
  }

  getAdapter(provider: string): PaymentAdapter {
    const adapter = this.adapters.get(provider);
    if (!adapter) {
      throw new Error(`No adapter registered for provider: ${provider}`);
    }
    return adapter;
  }

  getAdapterFromConfig(config: { provider: string }): PaymentAdapter {
    return this.getAdapter(config.provider);
  }
}

// Usage
const factory = new PaymentAdapterFactory();
factory.register('stripe', new StripeAdapter());
factory.register('paypal', new PayPalAdapter());
factory.register('square', new SquareAdapter());

// Runtime selection
const config = { provider: 'stripe' };
const adapter = factory.getAdapterFromConfig(config);
const service = new PaymentService(adapter);

Retry adapter with exponential backoff

Add retry logic to adapters for handling transient failures:

// adapters/RetryPaymentAdapter.ts
class RetryPaymentAdapter implements PaymentAdapter {
  private readonly maxRetries = 3;
  private readonly baseDelay = 1000;

  constructor(private delegate: PaymentAdapter) {}

  async fetchPayment(externalId: string): Promise<Payment> {
    let lastError: Error;

    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        return await this.delegate.fetchPayment(externalId);
      } catch (error) {
        lastError = error;
        if (attempt < this.maxRetries) {
          const delay = this.baseDelay * Math.pow(2, attempt);
          await this.sleep(delay);
        }
      }
    }

    throw lastError;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage
const stripeAdapter = new StripeAdapter();
const retryAdapter = new RetryPaymentAdapter(stripeAdapter);
const service = new PaymentService(retryAdapter);

Adapter with validation and error normalization

Normalize errors from different providers into consistent internal errors:

// adapters/ValidatingPaymentAdapter.ts
class PaymentAdapterError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly provider: string
  ) {
    super(message);
    this.name = 'PaymentAdapterError';
  }
}

class ValidatingPaymentAdapter implements PaymentAdapter {
  constructor(
    private delegate: PaymentAdapter,
    private provider: string
  ) {}

  async fetchPayment(externalId: string): Promise<Payment> {
    try {
      const payment = await this.delegate.fetchPayment(externalId);
      this.validatePayment(payment);
      return payment;
    } catch (error) {
      throw this.normalizeError(error);
    }
  }

  private validatePayment(payment: Payment): void {
    if (!payment.id) {
      throw new PaymentAdapterError(
        'Payment ID is required',
        'MISSING_ID',
        this.provider
      );
    }
    if (payment.amount < 0) {
      throw new PaymentAdapterError(
        'Payment amount cannot be negative',
        'INVALID_AMOUNT',
        this.provider
      );
    }
  }

  private normalizeError(error: any): PaymentAdapterError {
    if (error instanceof PaymentAdapterError) {
      return error;
    }

    // Normalize provider-specific errors
    if (error.code === 'resource_missing') {
      return new PaymentAdapterError(
        'Payment not found',
        'NOT_FOUND',
        this.provider
      );
    }

    return new PaymentAdapterError(
      error.message || 'Unknown error',
      'UNKNOWN',
      this.provider
    );
  }
}

Best Practices

  1. Keep adapters stateless. Translation only, no state — easier to test and reuse.
  2. Map external IDs to internal UUIDs. Don’t leak provider-specific ID formats into the domain.
  3. Version adapters independently. StripeV1Adapter, StripeV2Adapter — support both during migrations.
  4. Normalize errors at the boundary. Convert each provider’s error shapes into your domain’s error types inside the adapter, nowhere else.
  5. Test with recorded responses. Mocks for unit tests; real API calls only in contract tests.
  6. Log transformations. When an adapter misbehaves, you want the raw payload and what it became.

Common Mistakes

  1. Business logic inside the adapter. Adapters translate; domain services decide. Mixing them makes both untestable.
  2. Returning external types directly. Map to internal models even when you only need two fields today — the other fields leak tomorrow.
  3. Ignoring missing/null fields. External APIs omit fields freely; handle it in the adapter or it crashes downstream.
  4. Hard-coding provider logic in services. if (provider === 'stripe') in a service means the adapter layer failed.
  5. Skipping error normalization. Stripe’s card_error and PayPal’s ISSUE enum aren’t the same thing; translate them or your error handling becomes a switch statement.
  6. No contract tests. Mocks verify your adapter logic; only contract tests verify the API still does what you adapted.
  7. Caching financial data without strategy. Payment data goes stale fast — if you cache, bound the TTL tightly.

Further Reading

Frequently Asked Questions

How is this different from a mapper function?

An adapter satisfies a known interface so the consuming service doesn't depend on which provider is active. A mapper is typically a one-off function — fine until the second provider shows up.

Should I test adapters with real HTTP calls?

Recorded responses or stubs for unit tests — fast and deterministic. Real API calls belong to a separate contract test suite that runs less often.

Can I use adapters for write operations?

Yes. Two-way adapters convert internal models back to external formats for updates — same translation benefits for writes.

How do I version adapters when external APIs change?

Separate adapter classes per API version (StripeV1Adapter, StripeV2Adapter) selected by a factory on configuration. Never version inside a single adapter with if branches.

Should adapters handle authentication?

No — auth belongs in the HTTP client or interceptor layer. Adapters translate data; mixing concerns makes both harder to test.

How do I handle partial failures in batch adapters?

Return both successfully transformed items and failed items with error details — let the caller decide retry, alert, or discard.

Is this pattern suitable for small projects?

For one API integration, probably overkill — a plain mapper does the job. Introduce it when the second provider appears or the first's contract starts drifting.