Keeping the customer experience stable while payment rails, bank directories, fees, and status semantics change underneath it.
The first integration with a payment provider often becomes tightly coupled to the product. Provider-specific field names enter database models, bank codes are assumed to be universal, and technical errors flow directly to customers.
The second provider exposes every one of those assumptions.
A provider-agnostic architecture does not pretend all providers are identical. It gives the application a stable domain contract while allowing each adapter to preserve the differences that matter.
Separate product intent from provider commands
The product wants to perform operations such as:
- list supported banks;
- resolve an account;
- initiate a bank payout;
- retrieve a transaction status; and
- create deposit instructions.
The provider adapter translates those operations into the external API’s authentication, paths, payloads, signatures, and response structure.
class PayoutProvider:
def list_banks(self): ...
def resolve_account(self, bank_code, account_number): ...
def send(self, destination, amount, narration): ...
def get_status(self, provider_reference): ...
Application services depend on this contract instead of importing a provider client directly.
Keep collection and disbursement configurable independently
The best provider for accepting deposits may not be the best provider for payouts. Operational issues may affect one direction without affecting the other.
Separate configuration for collection and disbursement gives operations a controlled switch without forcing a release or changing the customer flow.
The chosen provider should be written onto every transaction. Current configuration selects new work; it should not rewrite history.
Treat bank directories as provider-owned
Bank names and codes are not always universal across providers. Two providers may represent the same institution differently.
The bank selected during account creation should therefore retain its provider context:
recipient account
account number
account name
provider
provider bank code
provider bank name
When the active payout provider changes, accounts created with another provider should not be silently submitted using incompatible identifiers. The product can filter saved recipients by provider or ask the customer to validate a new provider-specific destination.
This is safer than guessing that similar names or codes refer to the same institution.
Cache directories with provider-specific keys
Bank lists are read frequently and change less often than transaction state, so caching is useful. The cache key must include the provider:
cache_key = f"bank-directory:{provider}:v2"
Otherwise, switching providers can continue serving the previous provider’s list until the shared cache expires. A controlled force-refresh action is also useful for operations.
Normalize statuses, preserve raw responses
Providers use different status vocabularies. The adapter should expose a small internal state while preserving the raw response for staff diagnostics.
The same applies to errors. Customer-facing responses should use stable product language. Internal logs and transaction metadata can retain the exact provider error under access control.
This prevents technical messages, provider account restrictions, and internal liquidity information from leaking to customers.
Model provider-specific fee behavior explicitly
One rail may deduct its fee from the amount delivered. Another may debit the fee separately. A common fee field does not guarantee common economic behavior.
The transaction should distinguish:
- amount the customer sends;
- platform fee charged to the customer;
- amount requested from the provider;
- provider fee;
- expected recipient amount; and
- actual recipient amount when known.
Provider adapters can then construct the correct payout amount without changing how the application quotes the transfer.
The design principle
Provider independence comes from isolating differences, not erasing them.
The domain layer defines the stable customer and transaction contract. Adapters own provider authentication and semantics. Transactions preserve the rail actually used. Provider-owned reference data stays scoped to that rail.
With those boundaries, switching providers becomes an operational decision rather than a rewrite of the payment product.
Top comments (0)