April 8, 2026 10 min read

Go Interface Patterns for Payment Provider Abstraction — One Interface, Fourteen Gateways

Two years ago our checkout service had Stripe calls hardcoded in 43 files. When the business wanted to add Adyen for European markets, the estimate came back at six weeks. That's when I learned the real cost of skipping the abstraction layer. Here's how we fixed it, and the interface patterns I've used on every payment system since.

14
Gateways, 1 Interface
Stripe · Adyen · Checkout.com · Braintree · Worldpay · Square · Mollie · PayU · Razorpay · dLocal · Payoneer · CyberSource · Authorize.net · 2Checkout

The Problem: Provider Lock-In and Scattered Switch Statements

If you've worked on a payment system long enough, you've seen the pattern. Someone integrates Stripe directly into the checkout handler. Then the refund service calls Stripe too. Then the subscription manager. Before you know it, stripe.ChargeParams is imported in half your codebase, and every function signature carries provider-specific types.

The real pain hits when you need a second provider. Maybe your acquiring bank in Southeast Asia doesn't support Stripe. Maybe you want Adyen as a failover. Suddenly you're staring at a codebase full of switch provider { blocks, and every new gateway means touching every file that processes payments. I've lived through this exact migration twice, and the second time I swore I'd never let it happen again.

The fix isn't complicated. It's a Go interface and the adapter pattern. But the details matter — especially which methods go on the interface and which don't.

The Architecture

Here's the shape of what we're building. Your application code talks to an interface. Each payment provider gets an adapter that implements that interface. The domain never imports a provider SDK directly.

Provider Abstraction Architecture
Your App
(Domain Layer)
PaymentProvider
interface
Stripe Adapter
Adyen Adapter
Checkout.com Adapter

Designing the Core Interface — Keep It Small

The single most important decision is what goes on the interface. My first attempt years ago had 22 methods. It was a nightmare. Every new adapter had to implement methods that half the providers didn't even support, so you'd end up with return ErrNotSupported scattered everywhere. That's a code smell telling you the interface is too big.

Here's what I've settled on after iterating across multiple systems. The core interface should cover the payment lifecycle and nothing else:

type PaymentProvider interface {
    Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error)
    Refund(ctx context.Context, req RefundRequest) (RefundResponse, error)
    GetTransaction(ctx context.Context, txnID string) (Transaction, error)
}

type ChargeRequest struct {
    IdempotencyKey string
    Amount         Money
    Currency       string
    PaymentMethod  PaymentMethod
    Description    string
    Metadata       map[string]string
}

type ChargeResponse struct {
    ProviderTxnID string
    Status        TransactionStatus
    RawResponse   json.RawMessage // for debugging, never for logic
}

Notice what's not here: no CreateCustomer, no ListDisputes, no SetupWebhook. Those are real capabilities, but they don't belong on the core interface. Not every provider models customers the same way, and forcing a common shape leads to lowest-common-denominator abstractions that help nobody.

The Adapter Pattern for Each Provider

Each adapter is a struct that holds the provider's SDK client and implements the interface. The adapter's job is translation — converting your domain types to provider types and back. All the provider-specific weirdness lives here and nowhere else.

type StripeAdapter struct {
    client *stripe.API
    logger *slog.Logger
}

func (s *StripeAdapter) Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error) {
    params := &stripe.PaymentIntentParams{
        Amount:   stripe.Int64(req.Amount.MinorUnits()),
        Currency: stripe.String(req.Currency),
    }
    params.SetIdempotencyKey(req.IdempotencyKey)

    intent, err := s.client.PaymentIntents.New(params)
    if err != nil {
        return ChargeResponse{}, mapStripeError(err)
    }

    return ChargeResponse{
        ProviderTxnID: intent.ID,
        Status:        mapStripeStatus(intent.Status),
    }, nil
}

func mapStripeError(err error) error {
    var stripeErr *stripe.Error
    if errors.As(err, &stripeErr) {
        switch stripeErr.Code {
        case stripe.ErrorCodeCardDeclined:
            return ErrCardDeclined
        case stripe.ErrorCodeInsufficientFunds:
            return ErrInsufficientFunds
        }
    }
    return fmt.Errorf("stripe: %w", err)
}

The mapStripeError function is critical. It translates Stripe-specific error codes into your domain errors. Without this, callers end up importing stripe-go just to check error types, and your abstraction is already leaking.

Interface Segregation — Don't Make One God Interface

This is where most teams go wrong. They look at all the things payment providers can do and try to cram it into one interface. Subscriptions, disputes, payouts, customer management, webhook parsing — it all ends up on a single 30-method monster.

Go's implicit interface satisfaction makes segregation almost free. Split capabilities into focused interfaces and compose them where needed:

type Charger interface {
    Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error)
}

type Refunder interface {
    Refund(ctx context.Context, req RefundRequest) (RefundResponse, error)
}

type TransactionQuerier interface {
    GetTransaction(ctx context.Context, txnID string) (Transaction, error)
}

// Only used by services that need subscriptions
type SubscriptionManager interface {
    CreateSubscription(ctx context.Context, req SubRequest) (Subscription, error)
    CancelSubscription(ctx context.Context, subID string) error
}

// Compose when you need the full set
type PaymentProvider interface {
    Charger
    Refunder
    TransactionQuerier
}

Now your refund service can accept a Refunder instead of the full PaymentProvider. Your reporting service takes a TransactionQuerier. Each consumer declares exactly what it needs, and you can test each capability in isolation.

Aspect Fat Interface Segregated Interfaces
New provider effort Must implement all methods, even unsupported ones Implement only the interfaces the provider supports
Testing surface Fakes need 20+ methods, most returning nil Fakes are 2–3 methods each, focused and readable
Compile-time safety Catches missing methods but allows ErrNotSupported at runtime Type assertion at startup catches unsupported capabilities
Dependency weight Every consumer depends on the entire contract Consumers depend only on what they use
Refactoring risk Adding a method breaks every adapter New interface, existing adapters untouched

Testing with Fakes vs Mocks

I've gone back and forth on this. Early on I used gomock for everything. The generated mocks were huge, the assertion syntax was noisy, and the tests were brittle — they broke every time I reordered parameters or added a field to a struct.

These days I write fakes by hand. A fake is a struct that implements the interface with canned behavior you control directly. It's just Go code, no codegen, no framework:

type FakeCharger struct {
    ChargeFunc func(ctx context.Context, req ChargeRequest) (ChargeResponse, error)
}

func (f *FakeCharger) Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error) {
    return f.ChargeFunc(ctx, req)
}

// In your test:
provider := &FakeCharger{
    ChargeFunc: func(_ context.Context, req ChargeRequest) (ChargeResponse, error) {
        if req.Amount.MinorUnits() > 100000 {
            return ChargeResponse{}, ErrCardDeclined
        }
        return ChargeResponse{
            ProviderTxnID: "fake_txn_123",
            Status:        StatusSuccess,
        }, nil
    },
}

This pattern scales well. Each segregated interface gets its own tiny fake. Your test reads like a story: "when the charge amount exceeds $1,000, the provider declines it." No mock framework DSL to parse.

Mistakes I've Made

I want to be honest about the things that bit me, because the interface pattern looks clean on a blog post but gets messy in production.

Leaking provider types into the domain. Our ChargeResponse originally had a StripePaymentIntent field "for convenience." Within a month, three services were type-asserting it and calling Stripe-specific methods. When we added Adyen, those services broke. The fix was painful: we replaced the typed field with json.RawMessage for raw responses and added explicit domain fields for everything the callers actually needed. If you catch yourself importing a provider SDK outside the adapter package, stop. That's the abstraction leaking.

Over-Abstracting Too Early

On another project, I built the full interface layer before we even had a second provider. I guessed at what the interface should look like based on Stripe's API shape. When Adyen came along six months later, half the abstractions were wrong because Adyen models the payment lifecycle differently — they use a separate capture step that Stripe handles implicitly. I had to rip out and redesign the interface anyway.

My rule now: build the abstraction when you're adding the second provider, not the first. With one provider, you don't have enough information to know what the common contract should look like. You'll over-fit to the first provider's API shape every time.

Ignoring Provider-Specific Capabilities

Some things genuinely don't abstract well. Stripe's radar fraud scoring, Adyen's risk signals, Checkout.com's intelligent retry logic — these are differentiators, not commodities. Trying to force them behind a common interface waters them down. I use type assertions for these:

if fraudChecker, ok := provider.(FraudScorer); ok {
    score, err := fraudChecker.GetFraudScore(ctx, req)
    // use provider-specific fraud data
} else {
    // fall back to our own fraud rules
}

This keeps the core interface clean while still letting you leverage provider-specific features when they're available. The key is that the calling code works fine without the capability — it's an enhancement, not a requirement.

Wiring It Up

At startup, you pick the adapter based on configuration and inject it. The rest of the application never knows which provider is behind the interface:

func NewPaymentProvider(cfg Config) (PaymentProvider, error) {
    switch cfg.Provider {
    case "stripe":
        return NewStripeAdapter(cfg.Stripe)
    case "adyen":
        return NewAdyenAdapter(cfg.Adyen)
    case "checkout":
        return NewCheckoutAdapter(cfg.Checkout)
    default:
        return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
    }
}

This is the only switch on provider name in the entire codebase. If you find yourself writing another one somewhere else, something has gone wrong with the abstraction.

After running this pattern across three different payment platforms, the thing I keep coming back to is restraint. Small interfaces, domain-owned types, provider details locked inside adapters. It's not clever, but it's the kind of boring that lets you add gateway number fifteen without breaking a sweat.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Code examples are simplified for clarity — always review and adapt for your specific use case and security requirements. This is not financial or legal advice.