Skip to content
Back to writing

Adapter Boundaries That Make Change Cheaper

Adapter Boundaries That Make Change Cheaper architecture illustration

A business wants to change payment providers. The checkout still needs to take payments, but the new provider uses different requests, references, and payment states. If those details are scattered through the application, a vendor change can become a rewrite of the checkout itself.

An adapter is a small piece of code that translates between a business operation and a provider's interface. It gives the team one place to handle those differences. The payment example here is illustrative; it is not a claim that every provider can support the same checkout process.

An adapter is valuable only when it changes the cost of a future decision. Renaming a provider's software library does not accomplish that.

The practical question is not “should we use the adapter pattern?” It is: “which provider details would be expensive or dangerous if they spread through this workflow?”

For payments, that includes how the provider prevents duplicate requests, reports an uncertain result, and identifies a transaction for later investigation. For a database, it includes how records are queried and which changes can succeed or fail together. For an ERP, it includes account-specific rules and how to find out whether a submitted record was created.

Design the contract around a business operation

Start with the decision the calling code needs to make. Checkout needs to authorize a payment: ask the provider to approve a hold on funds. It should not have to construct the provider's raw request.

A contract defines what the operation accepts and what its caller can expect back. This simplified example shows three payment outcomes that checkout must distinguish:

type AuthorizationResult =
  | { status: 'approved'; providerReference: string }
  | { status: 'declined'; reason: string }
  | { status: 'indeterminate'; providerReference?: string }

interface PaymentCapability {
  authorize(input: AuthorizationRequest): Promise<AuthorizationResult>
}

indeterminate means the system does not yet know the outcome. It is different from a decline. If the provider accepts an authorization but the response is lost, another attempt could create a second hold on the customer's funds.

The workflow must check the original operation before trying again. Keep a stable request reference even when no provider reference came back. The full contract also needs to define input validation and technical failures; the example focuses on the business outcomes.

Calling all failures “payment failed” would hide the decision that matters: stop, continue, or investigate before retrying.

Keep translation at the edge

The adapter builds the provider's request, handles provider authentication, and translates the response. The application owns the sequence of work, saved progress, business rules, and the tools staff use to resolve problems.

Checkout workflow

Owns the order, business rules, and recovery decision.

↓ Request: authorize payment
Payment adapter

Translates requests and outcomes. Keeps provider references.

↕ Provider-specific request and response
Payment provider

Processes the authorization under its own rules.

Back to checkout: approved, declined, or indeterminate. An unknown outcome requires investigation before another attempt.

Illustrative boundary: the adapter translates provider behavior; the workflow decides what to do about it.

That separation also keeps an order workflow readable:

validate order -> claim operation -> submit through adapter -> persist evidence -> reconcile if needed

“Claim operation” means recording that a worker owns this attempt, so another worker does not perform the same work at the same time. “Reconcile” means checking the saved operation against the provider's records to resolve an uncertain or conflicting outcome.

The workflow can then be tested with a small substitute for the payment capability. Tests do not need to imitate the provider's entire software library. Separate adapter tests must still check whether the translation matches the provider's actual behavior.

Keep the provider identifiers and the evidence needed by support, finance, or recovery tools. Containment is not deletion. It means ordinary business decisions do not depend on provider-specific details scattered across the system.

Do not force false equivalence

Suppose Provider A lets the business authorize a payment now and collect it later. Provider B only supports an immediate charge. Those are different business capabilities, even if both have a method called pay.

An interface that hides the difference may let checkout promise a payment process the provider cannot perform.

There are three honest choices:

  1. Make the difference part of the capability contract.
  2. Support only the workflow that both providers can perform safely.
  3. Reject the unsupported workflow clearly and route it through a different business process.

What does not work is letting the distinction appear only after the business already depends on it.

An adapter is not a migration plan

The MongoDB-to-PostgreSQL migration case study puts a useful limit on adapter enthusiasm. Stable interfaces for reading and writing data reduced the code that needed to change. They did not move historical records, verify that the data matched, keep live writes synchronized, or provide a safe rollback.

Those tasks still needed a migration plan: move data in resumable batches, compare the results, and control when the new database took over.

The same is true for commerce and ERP integrations. A clean interface does not prevent duplicate notifications or resolve a missing order mapping. The order-sync architecture pairs the boundary with duplicate prevention, saved processing states, and checks against the destination system. Correctness extends beyond one API call.

Keep the seam proportionate

An adapter should be as small as the decision it protects. Avoid a universal “integration layer” with generic verbs, unrestricted any payloads, and configuration for every imagined vendor. The framework can become harder to understand than the dependency it hides.

Prefer a small contract when:

  • A dependency is critical to revenue, customers, or operations.
  • Replacement or using more than one provider is plausible.
  • Provider terminology makes the business workflow harder to understand.
  • Different failure outcomes require different recovery decisions.

Direct code can be reasonable for a local or short-lived dependency with no meaningful replacement path. A second implementation often reveals where the boundary belongs. A known failure risk can justify introducing it sooner.

The test for a useful boundary

A useful adapter gives a future engineer three answers without searching across the repository:

  • What business operation is this dependency responsible for?
  • Which provider-specific behavior is intentionally contained here?
  • How are success, failure, and uncertainty represented to the caller?

If the answers are clear, the boundary lowers the cost of change. If the adapter only repeats a vendor API with different names, it adds another layer to navigate without making the next change easier.

Discuss on Twitter