DEV Community

Daniel Parkson Tano
Daniel Parkson Tano

Posted on

Building a Provider-Agnostic Payout Architecture

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): ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)