Adapter pattern: integrating a legacy payment gateway without dragging its API into checkout Basic
Estimated reading time: 7 min read
The problem
Section titled “The problem”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 and after
Section titled “Before and after”| 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…
Core concepts
Section titled “Core concepts”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.
Relation to hexagonal and ACL
Section titled “Relation to hexagonal and ACL”- Hexagonal (ports and adapters):
PaymentPortis a port (driven/outbound),LegacyGatewayAdapteris 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.
How it works
Section titled “How it works”- Checkout verifies that
amountMinoris a safe positive integer, then callsPaymentPort.charge. - The adapter calls
legacy.pay(input.orderId, input.amountMinor), receives{ transaction, ok }, and builds aChargeResult. - Checkout only reads
paymentIdandstatusaccording 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.
Error classification in the adapter
Section titled “Error classification in the adapter”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 reconciliationIf 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.
Real-world example
Section titled “Real-world example”Run the demo
Section titled “Run the demo”npx --prefix examples ts-node examples/src/adapter-pattern/payment-adapter-demo.tsReal 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:
- Unit tests for the adapter with a fake gateway: field mapping and error classification.
- Contract/integration tests against the provider’s sandbox: catch where the real API differs from your assumptions.
- Checkout use case tests with a fake
PaymentPort: handlingaccepted,declined,pending.
Adapter, Strategy, ACL: how they differ
Section titled “Adapter, Strategy, ACL: how they differ”| 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 |
When to use / not use
Section titled “When to use / not use”- 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.
Trade-offs
Section titled “Trade-offs”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.
Common mistakes
Section titled “Common mistakes”Adapter review checklist
Section titled “Adapter review checklist”- The port uses the application’s language (
orderId,paymentId,status), not a copy of the SDK signature. - The use case imports only the port; the SDK appears only in the adapter, and the adapter is created in the composition root.
- The mapping preserves semantics: uncertain states get their own
pending, not forced intoaccepted | declined. - Errors are classified as declined, permanent, retryable or unknown outcome according to the provider’s real contract; no catching every exception and returning
declined. - Money unit conversion follows explicit, tested rules; currency lives in the port, not hard-coded in the adapter.
- All three test layers exist: adapter unit tests with a fake gateway, contract tests against the sandbox, use case tests with a fake port.
Summary
Section titled “Summary”Self-check:
- If the SDK renames
transaction, which file needs to change? - Why can a timeout not always be mapped to
declined? - When does a thin adapter only make the code harder to read?