Building a Provider-Neutral Payment Interface
The interface between your billing engine and a payment provider is usually not an interface at all — it is the provider’s SDK, called from wherever a charge is needed, with provider objects flowing into templates, admin tools, and reporting queries. That arrangement works indefinitely until the day it must change, at which point the migration is a rewrite. This guide sits under Payment Provider Migration & Portability and covers the seam itself, which is worth building whether or not a migration ever happens.
The value is not hypothetical portability. A seam gives you a single place to implement retry policy, a single decline taxonomy that the rest of the system can reason about, and a test surface where payment behaviour can be exercised without the network. Those benefits arrive immediately; portability is the bonus.
The design mistake to avoid is building a lowest-common-denominator wrapper over two SDKs. The interface should express what your billing engine needs in your vocabulary, and each provider implementation should do whatever contortions are required to satisfy it.
Trade-offs
| Design | Portability | Leaks provider concepts | Effort | Test surface |
|---|---|---|---|---|
| Direct SDK calls everywhere | None | Completely | Zero | Network or heavy mocks |
| Thin SDK wrapper | Low | Mostly | Low | Still provider-shaped |
| Domain-verb seam | High | No | Medium | Your own types |
| Full anti-corruption layer with its own store | Very high | No | High | Fully local |
The full anti-corruption layer, with its own mirror of customers and payment methods, is worth it once you run more than one provider permanently. Below that, the domain-verb seam captures most of the benefit for a fraction of the effort.
Step-by-Step Implementation
1. Enumerate the verbs, not the endpoints
List what the billing engine does, in business language: create a customer record at the provider, store a payment instrument, charge off-session, charge on-session with authentication, refund, and parse an inbound event. That list is short — usually under a dozen entries — and it is deliberately narrower than any provider’s API surface.
export interface PaymentProvider {
readonly key: ProviderKey;
createCustomer(i: { accountId: string; email: string }): Promise<{ providerCustomerId: string }>;
attachInstrument(i: { providerCustomerId: string; setupToken: string }): Promise<Instrument>;
chargeOffSession(i: ChargeInput): Promise<ChargeResult>;
refund(i: { chargeRef: string; amountMinor: number; idempotencyKey: string }): Promise<RefundResult>;
parseWebhook(rawBody: Buffer, signature: string): Promise<NormalizedEvent>;
}
Resist adding a raw escape hatch. The moment one exists, a caller uses it, and the provider type is back in the application.
2. Normalise the result, especially the failures
export type DeclineCategory =
| "insufficient_funds"
| "instrument_expired"
| "do_not_honour"
| "suspected_fraud"
| "authentication_required"
| "permanent_block"
| "provider_error";
export type ChargeResult =
| { status: "succeeded"; chargeRef: string; settledAt: string }
| { status: "processing"; chargeRef: string }
| { status: "requires_action"; clientSecret: string }
| { status: "failed"; category: DeclineCategory; providerCode: string; retryable: boolean };
Keeping providerCode alongside the category is deliberate: the category drives logic, the raw code drives support and analysis. Discarding the raw code makes provider-specific investigation impossible; branching on it in business logic defeats the seam.
3. Own the idempotency key
The rule follows the same reasoning as the idempotent webhook consumer pattern: the key must be derivable from facts you already hold, so that a process which crashed mid-charge can reconstruct it exactly.
4. Normalise inbound events too
@dataclass(frozen=True)
class NormalizedEvent:
provider: str
provider_event_id: str # namespaced downstream as f"{provider}:{id}"
kind: str # 'charge.succeeded', 'charge.failed', 'dispute.opened', ...
subscription_ref: str | None
charge_ref: str | None
occurred_at: str
payload: dict # raw, for support and replay
The kind vocabulary is yours. Providers name the same occurrence differently, and a consumer switching on provider event names is a consumer that must be rewritten per provider — which is most of the migration cost teams underestimate.
5. Hold both implementations to one contract test
The suite runs the same scenarios against each provider’s sandbox through the interface and asserts identical normalised results: success, insufficient funds, authentication required, permanent decline, partial refund, and a duplicate request under one key. Anything that differs is either a mapping bug or a genuine capability gap worth knowing about before a cutover.
Where the Seam Should Stop
An over-general seam is as harmful as none, and knowing what to leave outside it keeps the abstraction honest.
Leave the checkout UI outside. Providers’ client-side components differ substantially, and wrapping them produces a lowest-common-denominator experience that converts worse than either native integration. The seam covers server-side money movement; the front end integrates directly and is replaced wholesale during a migration.
Leave provider-specific configuration outside. Webhook endpoint registration, API version pinning, and acquirer routing rules are operational concerns that belong in each implementation’s own configuration, not in a generalised settings object that must express the union of every provider’s options.
Leave reporting outside, but normalise its inputs. Payout files, fee structures, and reserve mechanics differ so much that a common abstraction is not worth building — instead, each implementation writes into your own ledger with a common vocabulary, as described in reconciling payouts against your ledger, and reporting reads only from there.
Leave anything you use exactly once outside. A single admin tool that needs a provider-specific capability can call the SDK directly, clearly marked, rather than forcing a verb into the interface that only one caller and one provider will ever use.
The test for whether something belongs is simple: would the billing engine need it if the provider changed tomorrow? If yes, it is a verb. If it exists only because this provider models the world a particular way, it belongs inside that provider’s implementation.
Migrating an Existing Codebase Onto the Seam
Introducing the interface into a codebase that has been calling an SDK directly for years is a refactor with a natural order, and following it keeps every step shippable.
Start by finding the call sites. A grep for the SDK’s import across the repository usually returns more results than anyone expects, and the surprising ones are in templates, admin tooling, background jobs, and analytics queries rather than in the charge path. Catalogue them before changing anything; the list is the actual scope of the work.
Then implement the interface over the incumbent provider only, and migrate call sites one at a time, starting with the charge path because it is the best covered by tests. Each migrated call site is a small, reviewable change that leaves the system working, which matters because this refactor competes with feature work and will be paused at least once.
Handle the leaks last and deliberately. A template that renders a provider’s status string, an admin page that lists provider objects, and a report that groups by a provider field are all genuine coupling, and each needs a decision: translate into your own vocabulary, or accept that it is provider-specific tooling that will be rebuilt. Both answers are fine; leaving them undecided is what turns a migration into a rewrite.
Add the lint rule as soon as the charge path is migrated, restricting provider imports to the implementation module with an allowlist for the remaining unmigrated files. Shrinking that allowlist becomes the progress metric, and it prevents new call sites appearing faster than old ones are removed.
Finally, resist adding the second provider until the allowlist is empty. A seam with leaks is a seam that will not hold under a real migration, and the leaks are far easier to close while there is still only one implementation to reason about.
Verification & Testing
Contract tests are the centrepiece and should run against real sandboxes on a schedule, not only in CI, because provider behaviour changes without your code changing. A nightly run that fails when an API version deprecation alters a response is exactly the early warning the seam exists to give you.
Add a fake implementation of the interface for unit tests — one that returns scripted results without any network — and use it everywhere above the seam. The measure of a good seam is that the billing engine’s own tests never mention a provider, and that measure is worth asserting explicitly with a lint rule banning provider imports outside the implementation modules.
Test the decline mapping exhaustively against the provider’s documented code list, including a case for an unmapped code. The default must be conservative: unknown codes map to a non-retryable category, because retrying an unrecognised failure is how a small mapping gap becomes a large volume of pointless attempts.
Gotchas & Production Pitfalls
- A
rawescape hatch on the interface. It will be used, and the seam stops holding. If a caller genuinely needs provider specifics, add a verb or let it call the SDK explicitly and visibly. - Provider types in the database. Storing a provider’s status string in your subscription table means the second provider’s vocabulary has to be translated into the first’s. Store your own status.
- Idempotency keys generated by the SDK. They cannot be reconstructed after a crash, which removes the guarantee you were relying on.
- Event names passed through unchanged. Every consumer becomes provider-aware, which is the largest hidden cost in a migration.
- One implementation tested, the other assumed. A contract suite that only runs against the incumbent proves nothing about the challenger.
Frequently Asked Questions
Is this worth building without a migration planned? Yes. The decline taxonomy, the local test surface, and the single retry policy pay for themselves regardless. Portability is a side effect of a design that is better anyway.
How many verbs is too many? If the interface exceeds roughly a dozen methods, some of them are probably provider endpoints rather than domain verbs. Look for methods only one caller uses and consider whether they belong inside an implementation.
What about providers that cannot do something the interface requires? That is a genuine finding, discovered cheaply. Either the verb is too specific, or that provider is not viable for your model — both are better known before a cutover than during one.
Should the seam handle retries? Yes, at the policy level: the interface returns whether a failure is retryable, and one scheduler above it decides timing. Retry loops inside each implementation diverge immediately.