- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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)
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
}
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
}
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
}
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
}
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
}
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
}
}
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,
}
}
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
}
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
}
}
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)
}
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,
)
}
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
}
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,
)
}
}
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,
}
}
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,
)
}
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.

Top comments (0)