Skip to content
Knowledge Hub

Adapter pattern: integrating a legacy payment gateway without dragging its API into checkout Basic

Estimated reading time: 7 min read

Checkout wants to call charge({ orderId, amountMinor }) and get back a paymentId and a status. But the legacy payment library takes pay(invoice, amountInCents) and returns { transaction, ok }.

If checkout calls the library directly, the name invoice, the ok flag and every provider-specific change leak into order processing logic. Switching gateways means editing the whole checkout flow. Adapter puts a small translation layer right at that boundary.

Before: calling the SDK directly After: port + adapter
Checkout imports LegacyGateway PaymentPort (owned by the application)
Names used in the use case invoice, transaction, ok orderId, paymentId, status
Switching gateways Edit checkout Write a new adapter, checkout stays the same
Testing checkout Must mock the SDK Use a fake PaymentPort
Where errors and money units are translated Scattered across the use case One place that can be reviewed

Loading diagram…

Adapter has three roles:

  • Client: checkout.
  • Target: the interface the client wants, here PaymentPort.charge.
  • Adaptee: the existing API that does not fit, here LegacyGateway.pay.

The adapter implements PaymentPort, holds a LegacyGateway, calls it, then converts the result into a type checkout understands. This is an object adapter (composition, not inheritance).

The part worth remembering is the direction of dependency: checkout depends on PaymentPort, owned by the application, while the concrete gateway appears only in the adapter. PaymentPort must describe the action the application needs, not copy the SDK’s signature; otherwise the gateway’s language still dominates the inner code.

  • Hexagonal (ports and adapters): PaymentPort is a port (driven/outbound), LegacyGatewayAdapter is its adapter. The Adapter pattern is the class-level mechanism for this architectural idea.
  • Anti-corruption layer (ACL): when the other side has a very different model (legacy, external systems), the adapter acts as an ACL that keeps the internal model clean. According to Microsoft Learn, an ACL should only translate and should not contain business rules or orchestration. See also the context map.
  1. Checkout verifies that amountMinor is a safe positive integer, then calls PaymentPort.charge.
  2. The adapter calls legacy.pay(input.orderId, input.amountMinor), receives { transaction, ok }, and builds a ChargeResult.
  3. Checkout only reads paymentId and status according to the internal contract.

The adapter is created in the composition root (bootstrap or module wiring), not inside the checkout function. That lets tests swap in a fake and lets you add providers without opening the use case.

An adapter can translate field names, types, states and errors, but it must preserve semantics. ok: true may only mean “request received”, not “money captured”.

Read the provider’s documentation to learn:

  • The smallest currency unit.
  • Whether processing is synchronous or asynchronous.
  • Error codes.
  • Idempotency.
  • When a transaction is final.

If the provider only returns a token to look up later, ChargeResult must have a pending state instead of being forced into accepted | declined.

Do not catch every exception and return declined. The adapter is where provider signals are classified into groups that checkout handles differently:

Provider signal Classification What checkout should do Retry?
Card declined, insufficient funds Declined (business) Tell the user, do not retry No
Malformed request, bad auth Permanent error (our fault) Alert, fix code/config No
Provider reports a transient error before processing (for example 503 per the contract) Retryable Retry with backoff and jitter Yes, with an idempotency key
Timeout, connection lost after sending Unknown outcome Set a pending state, look up/reconcile before doing anything else Only with an idempotency key

The “Classification” column must follow each provider’s real contract; the table is only a skeleton. Correct retry behavior needs backoff and jitter, an overall deadline and an idempotency key. The adapter only classifies; the larger payment workflow decides on pending, reconciliation and retries.

The port’s result should honestly reflect the states that can occur, for example (illustrative, not in the demo file):

type ChargeResult =
| { status: 'accepted'; paymentId: string }
| { status: 'declined'; reason: string }
| { status: 'pending'; paymentId?: string }; // unknown outcome: needs reconciliation

If two providers use different money units, the adapter must convert by explicit, tested rules, not silently multiply or divide by 100. Currency is a business attribute, so it belongs in the port, not hard-coded in the adapter.

Terminal window
npx --prefix examples ts-node examples/src/adapter-pattern/payment-adapter-demo.ts

Real output:

{ paymentId: 'tx-order-42-12500', status: 'accepted' }

LegacyGateway in the demo is a simulated class that makes no network calls. Rename transaction to transactionId in the gateway: TypeScript only asks you to fix the adapter. That is a small proof of where the coupling lives.

Production needs three layers of tests with different purposes:

  1. Unit tests for the adapter with a fake gateway: field mapping and error classification.
  2. Contract/integration tests against the provider’s sandbox: catch where the real API differs from your assumptions.
  3. Checkout use case tests with a fake PaymentPort: handling accepted, declined, pending.
Adapter Strategy ACL
Solves Mismatched interfaces Choosing among several algorithms with the same contract Protecting the model from external systems
Level Class-level pattern Class-level pattern Architecture level, usually implemented with adapters/facades
Used together One adapter per gateway Pick the gateway per merchant An adapter is one way to build an ACL
  • Use it when there is a concrete gap between the interface the application wants and the external interface, and the cost of change is real (the SDK may change, multiple providers).
  • Do not use it for stable internal helpers just because two functions have different names, or when there is only one gateway and the contract already fits.

Value

  • Provider-specific names and states live in one place and do not spread into checkout.
  • Easy to swap SDKs/providers and test the use case with a fake port.
  • The mapping is a clear review point for money, errors and states.

Cost

  • Adds a layer and a contract to maintain; many thin adapters around internal code add noise.
  • A shared contract that is too simple can hide important differences between providers.
  • Does not by itself solve retries, idempotency, timeouts or uncertain transactions.
  1. The port uses the application’s language (orderId, paymentId, status), not a copy of the SDK signature.
  2. The use case imports only the port; the SDK appears only in the adapter, and the adapter is created in the composition root.
  3. The mapping preserves semantics: uncertain states get their own pending, not forced into accepted | declined.
  4. Errors are classified as declined, permanent, retryable or unknown outcome according to the provider’s real contract; no catching every exception and returning declined.
  5. Money unit conversion follows explicit, tested rules; currency lives in the port, not hard-coded in the adapter.
  6. All three test layers exist: adapter unit tests with a fake gateway, contract tests against the sandbox, use case tests with a fake port.

Self-check:

  1. If the SDK renames transaction, which file needs to change?
  2. Why can a timeout not always be mapped to declined?
  3. When does a thin adapter only make the code harder to read?