Managing Multiple Payment Integrations in C#: Strategies for Unified Interfaces and Scalable Code



This content originally appeared on DEV Community and was authored by syauqi fuadi

Modern applications often integrate with multiple payment providers (e.g., Stripe, PayPal, or Adyen), each with unique APIs, request formats, and response structures. As a C# developer, managing these differences can become complex. This article explores strategies to unify payment integrations into a maintainable code structure while allowing flexibility for future changes.

1. Why Use a Unified Interface?

A unified interface simplifies interactions with payment providers by abstracting their differences. For example, regardless of the provider, your application needs to:

  • Process payments.
  • Handle refunds.
  • Check transaction statuses.

By defining a common IPaymentService interface, you enforce consistency:

public interface IPaymentService
{
    Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request);
    Task<RefundResult> ProcessRefundAsync(RefundRequest request);
    Task<TransactionStatus> GetTransactionStatusAsync(string transactionId);
}

Each provider (e.g., StripePaymentService, PayPalPaymentService) implements this interface, hiding provider-specific logic.

2. Code Structure Strategies

Strategy 1: Adapter Pattern for Request/Response Mapping

Payment providers require different payloads and return varied responses. Use adapter classes to convert your application’s standardized models into provider-specific formats.

Example:

public class StripePaymentAdapter : IPaymentService
{
    public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
    {
        // Convert PaymentRequest to Stripe's API model
        var stripeRequest = new StripePaymentRequest 
        { 
            Amount = request.Amount, 
            Currency = request.Currency 
        };

        // Call Stripe API
        var stripeResponse = await _stripeClient.Process(stripeRequest);

        // Convert Stripe response to your app's PaymentResult
        return new PaymentResult 
        { 
            Success = stripeResponse.IsSuccessful, 
            TransactionId = stripeResponse.Id 
        };
    }
}

Strategy 2: Factory Pattern for Provider Selection

Use a factory to instantiate the correct payment service based on configuration or user choice:

public class PaymentServiceFactory
{
    public IPaymentService Create(string providerName)
    {
        return providerName switch
        {
            "Stripe" => new StripePaymentAdapter(),
            "PayPal" => new PayPalPaymentAdapter(),
            _ => throw new NotSupportedException()
        };
    }
}

Strategy 3: Centralized Error Handling

Wrap provider-specific exceptions into a common error type:

public class StripePaymentAdapter : IPaymentService
{
    public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
    {
        try 
        {
            // Call Stripe API
        }
        catch (StripeException ex)
        {
            throw new PaymentException("Stripe payment failed", ex);
        }
    }
}

3. Adding New Payment Methods or Features

Approach 1: Extend the Interface

If a new feature (e.g., recurring payments) is supported by most providers, extend the interface:

public interface IPaymentService
{
    // Existing methods...
    Task<SubscriptionResult> CreateSubscriptionAsync(SubscriptionRequest request);
}

Providers that don’t support the feature can throw NotImplementedException, but this violates the Interface Segregation Principle.

Approach 2: Segregate Interfaces

Split the interface into smaller, focused contracts:

public interface IRecurringPaymentService
{
    Task<SubscriptionResult> CreateSubscriptionAsync(SubscriptionRequest request);
}

Only providers supporting subscriptions implement this interface.

Approach 3: Use Optional Parameters or Middleware

For minor differences (e.g., a provider requires an extra field), add optional parameters to methods:

public Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request, string providerSpecificField = null);

4. Key Takeaways

  • Unified Interface: Use a base interface to standardize core operations.
  • Adapters: Isolate provider-specific logic in adapter classes.
  • Dependency Injection: Inject the correct adapter at runtime using a factory.
  • Modular Design: Follow the Open/Closed Principle—your code should be open for extension (new providers) but closed for modification.

By adopting these strategies, you reduce technical debt and ensure that adding a new payment channel (e.g., Bitcoin) requires minimal changes to existing code—just a new adapter and factory registration. This approach keeps your codebase clean, testable, and scalable.


This content originally appeared on DEV Community and was authored by syauqi fuadi