DEV Community

Cover image for The Anti-Corruption Layer That Saves Your Next Vendor Migration
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Anti-Corruption Layer That Saves Your Next Vendor Migration


A team I talked to last year had more than a dozen services calling the Stripe SDK directly. Charge creation in the order service. Refund logic in the support service. Webhook parsing in three different packages. Customer sync scattered across multiple repositories.

Then the business decided to switch to Adyen.

The migration took months. Not because Adyen was hard to integrate. Because Stripe was everywhere. Every service that touched money had stripe-go as a direct import. Every business rule knew about Stripe's charge model, Stripe's error types, Stripe's webhook format. The payment provider wasn't behind a boundary. It was the boundary.

This is what the anti-corruption layer pattern prevents. Your domain defines what it needs. The vendor adapter translates. When the vendor changes, you write a new adapter. The domain never notices.

What the Anti-Corruption Layer Actually Is

The term comes from Eric Evans's Domain-Driven Design. An anti-corruption layer (ACL) is a translation boundary between your domain model and an external system whose model you do not control.

In hexagonal architecture terms: the domain defines a port (an interface). The ACL is the adapter that implements that port by translating between your domain's language and the vendor's language.

The key distinction: this is not a thin wrapper. A thin wrapper around stripe.ChargeParams still leaks Stripe into your domain. An ACL translates Stripe's model into your model. Your domain speaks your language. The adapter speaks Stripe's.

The Problem: Direct SDK Coupling

Most Go services start with a payment provider wired straight through:

package checkout

import (
    "context"
    "fmt"

    "github.com/stripe/stripe-go/v82"
    "github.com/stripe/stripe-go/v82/charge"
)

func (s *CheckoutService) CompleteOrder(
    ctx context.Context,
    orderID string,
    amount int64,
    currency string,
    customerEmail string,
) error {
    params := &stripe.ChargeParams{
        Amount:   stripe.Int64(amount),
        Currency: stripe.String(currency),
        Source:   &stripe.PaymentSourceSourceParams{
            Token: stripe.String("tok_visa"),
        },
    }
    params.SetIdempotencyKey(orderID)
Enter fullscreen mode Exit fullscreen mode

The params struct builds the request. Now we fire the charge and handle Stripe-specific errors:

    result, err := charge.New(params)
    if err != nil {
        if stripeErr, ok := err.(*stripe.Error); ok {
            if stripeErr.Code == stripe.ErrorCodeCardDeclined {
                return ErrPaymentDeclined
            }
        }
        return fmt.Errorf("charge failed: %w", err)
    }

    s.repo.SavePaymentRef(ctx, orderID, result.ID)
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Count the Stripe imports. Count the Stripe types. This service knows about stripe.ChargeParams, stripe.Int64, stripe.ErrorCodeCardDeclined, and the charge.New function. Every one of those is a coupling point.

Now multiply that across a dozen services and four years of organic growth.

Step 1: Define the Port

The domain defines what it needs from a payment provider. Not what Stripe offers. Not what Adyen offers. What your domain needs.

package payment

import (
    "context"
    "errors"
)

var (
    ErrDeclined       = errors.New("payment declined")
    ErrInvalidAmount  = errors.New("invalid amount")
    ErrProviderDown   = errors.New("payment provider unavailable")
)

type ChargeRequest struct {
    IdempotencyKey string
    AmountCents    int64
    Currency       string
    CustomerEmail  string
    Description    string
}

type ChargeResult struct {
    ProviderRef string
    Status      ChargeStatus
}
Enter fullscreen mode Exit fullscreen mode

The status enum keeps the domain from leaking string comparisons:

type ChargeStatus int

const (
    ChargeSucceeded ChargeStatus = iota
    ChargePending
    ChargeFailed
)

type Provider interface {
    Charge(
        ctx context.Context,
        req ChargeRequest,
    ) (ChargeResult, error)

    Refund(
        ctx context.Context,
        providerRef string,
        amountCents int64,
    ) error
}
Enter fullscreen mode Exit fullscreen mode

Notice what is not here. No Stripe types. No Adyen types. No SDK imports. The domain speaks in ChargeRequest, ChargeResult, and ChargeStatus. These are your types. You control them.

The Provider interface is the port. Two methods. Small enough that a test double is trivial. Specific enough that it captures the payment operations your domain actually uses.

Step 2: Build the Stripe Adapter

The adapter lives in its own package, pulling in both the Stripe SDK and your domain's payment package, then translating between the two.

package stripepay

import (
    "context"
    "fmt"

    "github.com/stripe/stripe-go/v82"
    "github.com/stripe/stripe-go/v82/charge"
    "github.com/stripe/stripe-go/v82/refund"

    "yourapp/domain/payment"
)

type Adapter struct {
    apiKey string
}

func New(apiKey string) *Adapter {
    stripe.Key = apiKey
    return &Adapter{apiKey: apiKey}
}

func (a *Adapter) Charge(
    ctx context.Context,
    req payment.ChargeRequest,
) (payment.ChargeResult, error) {
    if req.AmountCents <= 0 {
        return payment.ChargeResult{},
            payment.ErrInvalidAmount
    }
Enter fullscreen mode Exit fullscreen mode

The Charge method translates your domain request into Stripe params, fires the call, and maps the result back:

    params := &stripe.ChargeParams{
        Amount:      stripe.Int64(req.AmountCents),
        Currency:    stripe.String(req.Currency),
        Description: stripe.String(req.Description),
    }
    params.SetIdempotencyKey(req.IdempotencyKey)
    params.Context = ctx

    result, err := charge.New(params)
    if err != nil {
        return payment.ChargeResult{},
            translateStripeError(err)
    }

    return payment.ChargeResult{
        ProviderRef: result.ID,
        Status:      mapStripeStatus(result.Status),
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Refunds follow the same shape. The error-translation helpers at the bottom are where the anti-corruption actually happens:

func (a *Adapter) Refund(
    ctx context.Context,
    providerRef string,
    amountCents int64,
) error {
    params := &stripe.RefundParams{
        Charge: stripe.String(providerRef),
        Amount: stripe.Int64(amountCents),
    }
    params.Context = ctx

    _, err := refund.New(params)
    if err != nil {
        return translateStripeError(err)
    }
    return nil
}

func translateStripeError(err error) error {
    stripeErr, ok := err.(*stripe.Error)
    if !ok {
        return payment.ErrProviderDown
    }
    switch stripeErr.Code {
    case stripe.ErrorCodeCardDeclined:
        return payment.ErrDeclined
    default:
        return fmt.Errorf(
            "stripe error %s: %w",
            stripeErr.Code,
            payment.ErrProviderDown,
        )
    }
}

func mapStripeStatus(
    s stripe.ChargeStatus,
) payment.ChargeStatus {
    switch s {
    case stripe.ChargeStatusSucceeded:
        return payment.ChargeSucceeded
    case stripe.ChargeStatusPending:
        return payment.ChargePending
    default:
        return payment.ChargeFailed
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things matter here.

First, all Stripe types stop at the adapter boundary. stripe.ChargeParams goes in, payment.ChargeResult comes out. The domain never sees stripe.ChargeStatus or stripe.Error.

Second, error translation is explicit. The translateStripeError function maps Stripe's error taxonomy to your domain's error taxonomy. This is where the anti-corruption happens. Stripe's ErrorCodeCardDeclined becomes your ErrDeclined. Every other Stripe error becomes ErrProviderDown. Your domain handles two payment error cases, not forty Stripe error codes.

Step 3: The Migration Is a New File

Six months later, the business switches to Adyen. Here is the Adyen adapter:

package adyenpay

import (
    "context"
    "fmt"
    "net/http"

    "github.com/adyen/adyen-go-api-library/v12/src/checkout"
    adyencommon "github.com/adyen/adyen-go-api-library/v12/src/common"

    "yourapp/domain/payment"
)

type Adapter struct {
    client   *checkout.APIClient
    merchant string
}

func New(
    apiKey string,
    merchant string,
    env adyencommon.Environment,
) *Adapter {
    cfg := checkout.NewConfiguration()
    cfg.MerchantAccount = merchant
    cfg.ApiKey = apiKey
    cfg.Environment = env
    return &Adapter{
        client:   checkout.NewAPIClient(cfg),
        merchant: merchant,
    }
}
Enter fullscreen mode Exit fullscreen mode

The Charge method builds an Adyen PaymentRequest, calls the API, and maps the result back to domain types:

func (a *Adapter) Charge(
    ctx context.Context,
    req payment.ChargeRequest,
) (payment.ChargeResult, error) {
    if req.AmountCents <= 0 {
        return payment.ChargeResult{},
            payment.ErrInvalidAmount
    }

    body := checkout.PaymentRequest{
        Amount: checkout.Amount{
            Value:    req.AmountCents,
            Currency: req.Currency,
        },
        Reference:       req.IdempotencyKey,
        MerchantAccount: a.merchant,
    }

    res, httpRes, err := a.client.PaymentsApi.
        Payments(ctx).
        PaymentRequest(body).
        Execute()
    if err != nil {
        return payment.ChargeResult{},
            translateAdyenError(err, httpRes)
    }

    return payment.ChargeResult{
        ProviderRef: *res.PspReference,
        Status: mapAdyenResult(
            res.ResultCode,
        ),
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The refund follows the same translation pattern. The error and result mappers close out the adapter:

func (a *Adapter) Refund(
    ctx context.Context,
    providerRef string,
    amountCents int64,
) error {
    return nil
}

func translateAdyenError(
    err error,
    httpRes *http.Response,
) error {
    if httpRes != nil && httpRes.StatusCode >= 500 {
        return payment.ErrProviderDown
    }
    return fmt.Errorf(
        "adyen error: %w",
        payment.ErrProviderDown,
    )
}

func mapAdyenResult(
    code string,
) payment.ChargeStatus {
    switch code {
    case "Authorised":
        return payment.ChargeSucceeded
    case "Pending", "Received":
        return payment.ChargePending
    default:
        return payment.ChargeFailed
    }
}
Enter fullscreen mode Exit fullscreen mode

The SDK, the API model, and the error shapes are all different. But the output is the same: payment.ChargeResult and payment.ChargeStatus. The domain does not care.

Step 4: Swap the Adapter, Keep the Domain Clean

The composition root is where the swap happens:

func main() {
    // Before
    // pay := stripepay.New(os.Getenv("STRIPE_KEY"))

    // After
    pay := adyenpay.New(
        os.Getenv("ADYEN_KEY"),
        os.Getenv("ADYEN_MERCHANT"),
        adyencommon.LiveEnv,
    )

    orderSvc := domain.NewOrderService(repo, pay)
}
Enter fullscreen mode Exit fullscreen mode

One line in main(), per service, that constructs the new adapter instead of the old one. Meanwhile the checkout service from the earlier example now looks like this:

package checkout

import (
    "context"
    "fmt"

    "yourapp/domain/payment"
)

type CheckoutService struct {
    repo     OrderRepository
    payments payment.Provider
}

func NewCheckoutService(
    repo OrderRepository,
    pay payment.Provider,
) *CheckoutService {
    return &CheckoutService{
        repo:     repo,
        payments: pay,
    }
}

func (s *CheckoutService) CompleteOrder(
    ctx context.Context,
    orderID string,
    amount int64,
    currency string,
) error {
    result, err := s.payments.Charge(
        ctx,
        payment.ChargeRequest{
            IdempotencyKey: orderID,
            AmountCents:    amount,
            Currency:       currency,
        },
    )
    if err != nil {
        return fmt.Errorf(
            "payment failed: %w", err,
        )
    }

    return s.repo.SavePaymentRef(
        ctx, orderID, result.ProviderRef,
    )
}
Enter fullscreen mode Exit fullscreen mode

Zero vendor imports. Zero vendor types. The CheckoutService knows it needs a payment.Provider. It does not know, and should not know, whether that provider is Stripe, Adyen, a test double, or a carrier pigeon with a credit card terminal strapped to its leg.

Testing Without a Vendor

Because the port is a two-method interface, test doubles are five lines:

type fakePayments struct {
    shouldDecline bool
    lastCharge    payment.ChargeRequest
}

func (f *fakePayments) Charge(
    _ context.Context,
    req payment.ChargeRequest,
) (payment.ChargeResult, error) {
    f.lastCharge = req
    if f.shouldDecline {
        return payment.ChargeResult{},
            payment.ErrDeclined
    }
    return payment.ChargeResult{
        ProviderRef: "fake-ref-123",
        Status:      payment.ChargeSucceeded,
    }, nil
}

func (f *fakePayments) Refund(
    _ context.Context,
    _ string,
    _ int64,
) error {
    return nil
}
Enter fullscreen mode Exit fullscreen mode

You drop Stripe test keys, Adyen sandbox credentials, and flaky network calls out of CI entirely. Your domain tests run in microseconds and they test your logic, not the vendor's HTTP client.

func TestCompleteOrder_Declined(t *testing.T) {
    repo := newInMemoryRepo()
    pay := &fakePayments{shouldDecline: true}
    svc := NewCheckoutService(repo, pay)

    err := svc.CompleteOrder(
        context.Background(),
        "order-1", 5000, "usd",
    )

    if !errors.Is(err, payment.ErrDeclined) {
        t.Fatalf(
            "expected ErrDeclined, got %v", err,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

When You Need Both Providers at Once

Migrations are rarely instant. Sometimes you run both providers in parallel: new customers go to Adyen, existing customers stay on Stripe until their subscriptions renew.

A routing adapter handles this without touching the domain:

package routing

import (
    "context"

    "yourapp/domain/payment"
)

type Router struct {
    stripe   payment.Provider
    adyen    payment.Provider
    isLegacy func(ctx context.Context) bool
}

func New(
    stripe, adyen payment.Provider,
    isLegacy func(ctx context.Context) bool,
) *Router {
    return &Router{
        stripe:   stripe,
        adyen:    adyen,
        isLegacy: isLegacy,
    }
}
Enter fullscreen mode Exit fullscreen mode

The Router itself satisfies payment.Provider, so the domain sees a single provider regardless of what sits behind it:

func (r *Router) Charge(
    ctx context.Context,
    req payment.ChargeRequest,
) (payment.ChargeResult, error) {
    if r.isLegacy(ctx) {
        return r.stripe.Charge(ctx, req)
    }
    return r.adyen.Charge(ctx, req)
}

func (r *Router) Refund(
    ctx context.Context,
    providerRef string,
    amountCents int64,
) error {
    if r.isLegacy(ctx) {
        return r.stripe.Refund(
            ctx, providerRef, amountCents,
        )
    }
    return r.adyen.Refund(
        ctx, providerRef, amountCents,
    )
}
Enter fullscreen mode Exit fullscreen mode

The domain never knows there are two providers behind the port. When the migration finishes, you remove the router, remove the Stripe adapter, and the domain code does not change.


If this was useful

The anti-corruption layer is one pattern from hexagonal architecture. The full picture covers domain modeling, port design, error translation across boundaries, testing at every layer, and how to migrate an existing spaghetti service incrementally. That is what Hexagonal Architecture in Go walks through, chapter by chapter, with tested code you can run.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)