Adapter Pattern #
You just found the perfect Go library for your SMS gateway needs — full-featured, fast, and battle-tested. One problem: the library exposes a SendTextMessage(phone, body string) (string, error) method, while your whole system already depends on a Notifier interface with a Notify(recipient, message string) error method. You cannot change the third-party library, and changing the Notifier interface means modifying dozens of files already running in production. The Adapter Pattern exists for exactly this kind of situation — it lets two incompatible interfaces work together without changing either of them, simply by adding a thin translation layer between the two.
What Is the Adapter Pattern? #
The Adapter Pattern is a structural design pattern that converts an object’s interface into another interface the client expects. Like a power plug adapter that lets a type-A device work in a type-C outlet, the Adapter Pattern lets two components that “speak different languages” collaborate without either one having to change.
There are three parties in an Adapter scenario:
- Client — existing code that expects a specific interface (the Target)
- Adaptee — an existing object with a different interface you want to use
- Adapter — a thin layer that implements the Target interface and delegates calls to the Adaptee
The Adapter Pattern answers the question “how do I use X without changing Y?” — a question that comes up constantly when integrating third-party libraries, migrating legacy systems, or connecting microservices with different contracts.
flowchart LR
subgraph "Without Adapter — Impossible"
C1[Client\\nneeds Notify] -. "interface\\ndoes not match" .-> A1[SMS Library\\nhas SendTextMessage]
end
subgraph "With Adapter — Works"
C2[Client\\nneeds Notify] -->|Notify| AD[SMSAdapter\\nimplements Notify]
AD -->|SendTextMessage| A2[SMS Library]
endAnatomy of the Problem It Solves #
Before looking at the implementation, it is important to understand three real scenarios where Adapter is the right solution — not just “different interfaces” in the abstract.
Scenario 1: Third-Party Library Integration #
A third-party library cannot be changed, but its interface does not match your system. This is the most common Adapter use case.
// ANTI-PATTERN: client depends directly on a concrete library
import "github.com/twilio/twilio-go"
type NotificationService struct {
twilioClient *twilio.RestClient // depends on a concrete implementation
}
func (s *NotificationService) SendAlert(phone, message string) error {
// Calling the Twilio API directly
params := &openapi.CreateMessageParams{}
params.SetTo(phone)
params.SetBody(message)
_, err := s.twilioClient.Api.CreateMessage(params)
return err
}
// Problem: swapping Twilio for another provider = changing all this code
// CORRECT: client depends on an interface — the adapter handles library details
type SMSProvider interface {
SendSMS(phone, message string) error
}
type NotificationService struct {
sms SMSProvider // interface, not a concrete type
}
func (s *NotificationService) SendAlert(phone, message string) error {
return s.sms.SendSMS(phone, message)
}
// Swapping Twilio for Vonage = swapping the adapter only, NotificationService stays untouched
Scenario 2: Legacy System Migration #
When migrating a system step by step, old code and new code must coexist. An Adapter lets new code use a modern interface while the implementation still delegates to the old system.
Scenario 3: Unifying Multiple Providers #
When several vendors with different APIs must appear uniform to business code — payment gateways like Midtrans, Xendit, and Stripe, for example — adapters unify all three behind one consistent interface.
Structure and Components #
The Adapter Pattern involves four components with clear, non-overlapping roles.
flowchart TD
subgraph Client Layer
C[OrderService\\nclient code]
end
subgraph Target Interface
T[PaymentProcessor\\ninterface\\n+Pay amount error]
end
subgraph Adapter Layer
MA[MidtransAdapter\\n+Pay amount error]
XA[XenditAdapter\\n+Pay amount error]
end
subgraph Adaptee Layer
M[Midtrans SDK\\n+ChargeWithCreditCard]
X[Xendit SDK\\n+CreateInvoice]
end
C -->|only knows| T
T --> MA
T --> XA
MA -->|translate| M
XA -->|translate| X| Component | Role | What Changes When Swapping Providers |
|---|---|---|
| Target (interface) | The contract the client expects | Never changes |
| Client | Uses the Target interface | Never changes |
| Adapter | Translates Target to Adaptee | Swap or add a new Adapter |
| Adaptee | Library/system with a different interface | Untouched |
Full Implementation: Multi Payment Gateway #
Let’s build a payment system that works with Midtrans, Xendit, and Stripe — three very different APIs — behind one uniform interface.
The Target Interface #
This interface is the “language” understood by all business code. Every adapter must implement it.
package payment
import "time"
// TransactionResult stores the result of a successful transaction.
type TransactionResult struct {
TransactionID string
Amount int
Currency string
Method string
ProcessedAt time.Time
RedirectURL string // for payments that need a redirect (ewallet, VA)
}
// PaymentProcessor is the Target interface — the contract for all payment providers.
// Business code only knows this interface; no concrete provider is ever mentioned.
type PaymentProcessor interface {
// Pay processes a payment and returns the transaction result.
Pay(amount int, currency, description string) (*TransactionResult, error)
// Refund cancels a transaction and returns the funds.
Refund(transactionID string, amount int) error
// CheckStatus checks a transaction's status by its ID.
CheckStatus(transactionID string) (string, error)
// ProviderName returns the provider name for logging and auditing.
ProviderName() string
}
Adaptee 1: The Midtrans SDK #
Midtrans uses the concept of “charge” with a request structure specific to each payment method.
package midtrans
// This represents the Midtrans Go SDK that you cannot modify.
// Its interface is completely different from PaymentProcessor.
type ChargeRequest struct {
PaymentType string
TransactionID string
GrossAmount int64
Description string
CustomerName string
CustomerEmail string
}
type ChargeResponse struct {
TransactionID string
OrderID string
GrossAmount string
PaymentType string
TransactionStatus string
FraudStatus string
RedirectURL string
}
type Client struct {
serverKey string
baseURL string
}
func NewClient(serverKey, baseURL string) *Client {
return &Client{serverKey: serverKey, baseURL: baseURL}
}
func (c *Client) Charge(req ChargeRequest) (*ChargeResponse, error) {
// Midtrans API call implementation
return &ChargeResponse{
TransactionID: "midtrans-" + req.TransactionID,
TransactionStatus: "capture",
}, nil
}
func (c *Client) CancelTransaction(transactionID string) error {
// Cancel implementation via the Midtrans API
return nil
}
func (c *Client) GetTransactionStatus(transactionID string) (*ChargeResponse, error) {
return &ChargeResponse{TransactionStatus: "capture"}, nil
}
Adaptee 2: The Xendit SDK #
Xendit uses an “invoice” concept that is far removed from Midtrans.
package xendit
// Xendit Go SDK — yet another different interface.
type InvoiceRequest struct {
ExternalID string
Amount float64
Description string
PayerEmail string
Currency string
CallbackURL string
SuccessURL string
}
type InvoiceResponse struct {
ID string
ExternalID string
Amount float64
Status string
InvoiceURL string
Created string
Expiry string
}
type Client struct {
apiKey string
}
func NewClient(apiKey string) *Client {
return &Client{apiKey: apiKey}
}
func (c *Client) CreateInvoice(req InvoiceRequest) (*InvoiceResponse, error) {
return &InvoiceResponse{
ID: "xendit-inv-123",
Status: "PENDING",
InvoiceURL: "https://checkout.xendit.co/inv/xendit-inv-123",
}, nil
}
func (c *Client) ExpireInvoice(invoiceID string) error {
return nil
}
func (c *Client) GetInvoice(invoiceID string) (*InvoiceResponse, error) {
return &InvoiceResponse{Status: "PAID"}, nil
}
Adapter 1: MidtransAdapter #
package adapter
import (
"fmt"
"time"
"myapp/midtrans"
"myapp/payment"
)
// MidtransAdapter implements payment.PaymentProcessor
// by delegating all calls to the Midtrans SDK.
type MidtransAdapter struct {
client *midtrans.Client
paymentType string // "credit_card", "gopay", "bank_transfer", etc.
}
func NewMidtransAdapter(client *midtrans.Client, paymentType string) payment.PaymentProcessor {
return &MidtransAdapter{
client: client,
paymentType: paymentType,
}
}
// Pay converts a PaymentProcessor.Pay() call into a Midtrans Charge().
// This is the heart of the adapter — translation between two different "languages".
func (a *MidtransAdapter) Pay(amount int, currency, description string) (*payment.TransactionResult, error) {
req := midtrans.ChargeRequest{
PaymentType: a.paymentType,
TransactionID: fmt.Sprintf("order-%d", time.Now().UnixNano()),
GrossAmount: int64(amount),
Description: description,
}
resp, err := a.client.Charge(req)
if err != nil {
return nil, fmt.Errorf("midtrans charge failed: %w", err)
}
// Translate the Midtrans response into a standard TransactionResult
return &payment.TransactionResult{
TransactionID: resp.TransactionID,
Amount: amount,
Currency: currency,
Method: "midtrans/" + a.paymentType,
ProcessedAt: time.Now(),
RedirectURL: resp.RedirectURL,
}, nil
}
func (a *MidtransAdapter) Refund(transactionID string, amount int) error {
// Midtrans uses "cancel" for refunds
return a.client.CancelTransaction(transactionID)
}
func (a *MidtransAdapter) CheckStatus(transactionID string) (string, error) {
resp, err := a.client.GetTransactionStatus(transactionID)
if err != nil {
return "", fmt.Errorf("midtrans status check failed: %w", err)
}
// Translate the Midtrans status into a standard status
return normalizeMidtransStatus(resp.TransactionStatus), nil
}
func (a *MidtransAdapter) ProviderName() string {
return "Midtrans"
}
// normalizeMidtransStatus converts Midtrans-specific statuses into standard statuses.
func normalizeMidtransStatus(midtransStatus string) string {
switch midtransStatus {
case "capture", "settlement":
return "success"
case "pending":
return "pending"
case "cancel", "expire":
return "failed"
default:
return "unknown"
}
}
Adapter 2: XenditAdapter #
package adapter
import (
"fmt"
"time"
"myapp/xendit"
"myapp/payment"
)
// XenditAdapter implements payment.PaymentProcessor
// by delegating all calls to the Xendit SDK.
type XenditAdapter struct {
client *xendit.Client
callbackURL string
successURL string
}
func NewXenditAdapter(client *xendit.Client, callbackURL, successURL string) payment.PaymentProcessor {
return &XenditAdapter{
client: client,
callbackURL: callbackURL,
successURL: successURL,
}
}
// Pay converts a PaymentProcessor.Pay() call into a Xendit CreateInvoice().
// Xendit is invoice-based — a different concept from Midtrans's charge-based model.
func (a *XenditAdapter) Pay(amount int, currency, description string) (*payment.TransactionResult, error) {
req := xendit.InvoiceRequest{
ExternalID: fmt.Sprintf("inv-%d", time.Now().UnixNano()),
Amount: float64(amount),
Description: description,
Currency: currency,
CallbackURL: a.callbackURL,
SuccessURL: a.successURL,
}
resp, err := a.client.CreateInvoice(req)
if err != nil {
return nil, fmt.Errorf("xendit create invoice failed: %w", err)
}
return &payment.TransactionResult{
TransactionID: resp.ID,
Amount: amount,
Currency: currency,
Method: "xendit/invoice",
ProcessedAt: time.Now(),
RedirectURL: resp.InvoiceURL, // Xendit always requires a redirect to the invoice page
}, nil
}
func (a *XenditAdapter) Refund(transactionID string, amount int) error {
// Xendit uses "expire" to cancel an invoice
return a.client.ExpireInvoice(transactionID)
}
func (a *XenditAdapter) CheckStatus(transactionID string) (string, error) {
resp, err := a.client.GetInvoice(transactionID)
if err != nil {
return "", fmt.Errorf("xendit status check failed: %w", err)
}
return normalizeXenditStatus(resp.Status), nil
}
func (a *XenditAdapter) ProviderName() string {
return "Xendit"
}
func normalizeXenditStatus(xenditStatus string) string {
switch xenditStatus {
case "PAID", "SETTLED":
return "success"
case "PENDING":
return "pending"
case "EXPIRED":
return "failed"
default:
return "unknown"
}
}
Client Code: Clean, Unaware of Providers #
package service
import (
"fmt"
"myapp/payment"
)
// OrderService is the client — it only knows the PaymentProcessor interface.
// Not a single "Midtrans" or "Xendit" name appears here.
type OrderService struct {
processor payment.PaymentProcessor
}
func NewOrderService(processor payment.PaymentProcessor) *OrderService {
return &OrderService{processor: processor}
}
func (s *OrderService) Checkout(orderID string, amount int) (*payment.TransactionResult, error) {
result, err := s.processor.Pay(amount, "IDR", fmt.Sprintf("Payment for order %s", orderID))
if err != nil {
return nil, fmt.Errorf("checkout failed for order %s: %w", orderID, err)
}
fmt.Printf("Payment processed via %s: %s\n", s.processor.ProviderName(), result.TransactionID)
return result, nil
}
func (s *OrderService) ProcessRefund(transactionID string, amount int) error {
if err := s.processor.Refund(transactionID, amount); err != nil {
return fmt.Errorf("refund failed for transaction %s: %w", transactionID, err)
}
fmt.Printf("Refund processed via %s\n", s.processor.ProviderName())
return nil
}
func (s *OrderService) CheckPaymentStatus(transactionID string) (string, error) {
return s.processor.CheckStatus(transactionID)
}
Wiring in main.go — the only place concrete providers are mentioned:
func main() {
// Pick the provider based on configuration — the only place providers are named
var processor payment.PaymentProcessor
switch cfg.PaymentProvider {
case "midtrans":
midtransClient := midtrans.NewClient(cfg.MidtransServerKey, cfg.MidtransBaseURL)
processor = adapter.NewMidtransAdapter(midtransClient, "credit_card")
case "xendit":
xenditClient := xendit.NewClient(cfg.XenditAPIKey)
processor = adapter.NewXenditAdapter(xenditClient, cfg.CallbackURL, cfg.SuccessURL)
default:
log.Fatalf("unknown payment provider: %s", cfg.PaymentProvider)
}
// OrderService does not know which provider is active
orderSvc := service.NewOrderService(processor)
result, err := orderSvc.Checkout("ORD-001", 150000)
// ...
}
Adapter for Incremental System Migration #
One of the most important Adapter Pattern use cases is enabling migration from a legacy system to a new one step by step, without a risky big-bang rewrite.
sequenceDiagram
participant C as Client (new code)
participant A as LegacyAdapter
participant L as Legacy System
Note over C,L: Phase 1: Adapter bridges new code to the legacy system
C->>A: Notify(recipient, message)
A->>L: SendEmail(to, subject, body)
L-->>A: legacy response
A-->>C: standard error
Note over C,L: Phase 2: When the new system is ready, swap the adapter — client unchanged
C->>A: Notify(recipient, message)
Note over A: new adapter — talks to the new system directly
A-->>C: standard error// LegacyEmailSystem is the old system that cannot be changed
type LegacyEmailSystem struct {
smtpServer string
smtpPort int
}
func (l *LegacyEmailSystem) SendEmail(to, subject, body, fromName, fromEmail string) int {
// Returns an integer status code — not an error
fmt.Printf("Sending via legacy SMTP to %s\n", to)
return 200
}
func (l *LegacyEmailSystem) GetQueueSize() int {
return 0
}
// Target interface — the new system's contract
type Notifier interface {
Notify(recipient, message string) error
}
// LegacyEmailAdapter wraps the legacy system to make it compatible with Notifier
type LegacyEmailAdapter struct {
legacy *LegacyEmailSystem
}
func NewLegacyEmailAdapter(legacy *LegacyEmailSystem) Notifier {
return &LegacyEmailAdapter{legacy: legacy}
}
func (a *LegacyEmailAdapter) Notify(recipient, message string) error {
// Translation: Notifier.Notify() → LegacyEmailSystem.SendEmail()
// Including the return-type translation: int → error
statusCode := a.legacy.SendEmail(
recipient,
"Notification", // default subject — the legacy system always needs one
message,
"System", // default fromName
"[email protected]",
)
if statusCode != 200 {
return fmt.Errorf("legacy email failed with status code: %d", statusCode)
}
return nil
}
// When the new system is ready, create a new adapter pointing at the new implementation.
// No client code changes.
type ModernEmailAdapter struct {
modernClient *modernmail.Client
}
func NewModernEmailAdapter(client *modernmail.Client) Notifier {
return &ModernEmailAdapter{modernClient: client}
}
func (a *ModernEmailAdapter) Notify(recipient, message string) error {
return a.modernClient.Send(recipient, message)
}
Testing an Adapter #
Testing an Adapter focuses on two things: ensuring the parameter translation is correct, and ensuring the response/error translation is correct. Because the Adapter delegates to the Adaptee, you can mock the Adaptee for isolated tests.
// MockMidtransClient for testing MidtransAdapter without a connection to Midtrans
type MockMidtransClient struct {
ChargeFunc func(req midtrans.ChargeRequest) (*midtrans.ChargeResponse, error)
CancelTransactionFunc func(transactionID string) error
GetTransactionStatusFn func(transactionID string) (*midtrans.ChargeResponse, error)
}
func (m *MockMidtransClient) Charge(req midtrans.ChargeRequest) (*midtrans.ChargeResponse, error) {
return m.ChargeFunc(req)
}
func (m *MockMidtransClient) CancelTransaction(id string) error {
return m.CancelTransactionFunc(id)
}
func (m *MockMidtransClient) GetTransactionStatus(id string) (*midtrans.ChargeResponse, error) {
return m.GetTransactionStatusFn(id)
}
func TestMidtransAdapter_Pay_Success(t *testing.T) {
mockClient := &MockMidtransClient{
ChargeFunc: func(req midtrans.ChargeRequest) (*midtrans.ChargeResponse, error) {
// Verify the parameters sent to Midtrans
if req.GrossAmount != 150000 {
t.Errorf("expected GrossAmount 150000, got %d", req.GrossAmount)
}
return &midtrans.ChargeResponse{
TransactionID: "mid-txn-123",
TransactionStatus: "capture",
}, nil
},
}
adapter := adapter.NewMidtransAdapter(mockClient, "credit_card")
result, err := adapter.Pay(150000, "IDR", "Test payment")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.TransactionID != "mid-txn-123" {
t.Errorf("expected TransactionID 'mid-txn-123', got %q", result.TransactionID)
}
if result.Method != "midtrans/credit_card" {
t.Errorf("unexpected method: %s", result.Method)
}
}
func TestMidtransAdapter_CheckStatus_TranslatesStatus(t *testing.T) {
tests := []struct {
midtransStatus string
expectedStatus string
}{
{"capture", "success"},
{"settlement", "success"},
{"pending", "pending"},
{"cancel", "failed"},
{"expire", "failed"},
{"unknown_status", "unknown"},
}
for _, tt := range tests {
t.Run(tt.midtransStatus, func(t *testing.T) {
mockClient := &MockMidtransClient{
GetTransactionStatusFn: func(id string) (*midtrans.ChargeResponse, error) {
return &midtrans.ChargeResponse{TransactionStatus: tt.midtransStatus}, nil
},
}
adpt := adapter.NewMidtransAdapter(mockClient, "credit_card")
status, err := adpt.CheckStatus("txn-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != tt.expectedStatus {
t.Errorf("for midtrans status %q: expected %q, got %q",
tt.midtransStatus, tt.expectedStatus, status)
}
})
}
}
Where to Place an Adapter #
An Adapter is infrastructure code, not business code. Placing it correctly keeps the domain clean.
lib/
├── domain/
│ ├── payment/
│ │ └── processor.go ← Target interface lives here (domain/port)
│ └── notification/
│ └── notifier.go ← Target interface lives here
│
├── infrastructure/ ← Adapters always live here
│ ├── payment/
│ │ ├── midtrans_adapter.go
│ │ ├── xendit_adapter.go
│ │ └── stripe_adapter.go
│ └── notification/
│ ├── twilio_adapter.go
│ └── sendgrid_adapter.go
│
└── main.go ← Adapter wiring + injection
Adapter and Hexagonal Architecture
The Adapter Pattern is the foundation of Hexagonal Architecture (also known as Ports & Adapters). The interfaces in the domain layer are “ports” — contracts that define what the domain needs. The adapters in the infrastructure layer are “adapters” — concrete implementations that connect ports to the outside world. Understanding the Adapter Pattern means already understanding half of Hexagonal Architecture.
Adapter vs Other Structural Patterns #
Adapter is often confused with Facade and Decorator because all three “wrap” something. The difference lies in the purpose of the wrapping.
| Pattern | Purpose | Output Interface |
|---|---|---|
| Adapter | Converts an incompatible interface | Different from what is wrapped |
| Facade | Simplifies a complex subsystem | Different from what is wrapped, but simpler |
| Decorator | Adds behavior without changing the interface | Same as what is wrapped |
| Proxy | Controls access to an object | Same as what is wrapped |
// Adapter: the OUTPUT interface differs from the Adaptee's interface
type LegacyGateway interface { MakePayment(total int) error }
type PaymentProcessor interface { Pay(amount int, currency string) error }
// LegacyGateway → PaymentProcessor: two different interfaces
// Decorator: the OUTPUT interface is the same as what is wrapped
type PaymentProcessor interface { Pay(amount int, currency string) error }
// LoggingDecorator implements PaymentProcessor, wraps PaymentProcessor
// Same interface, added behavior
// Facade: hides complexity, does not convert
type PaymentFacade struct { /* hide auth, retry, logging */ }
func (f *PaymentFacade) SimpleCheckout(amount int) error { /* simplify */ }
When to Use and When Not to #
USE Adapter if:
✓ You want to use a third-party library with a different interface
✓ You are migrating from a legacy system to a new one step by step
✓ You need to unify several different providers behind one interface
✓ You want to isolate business code from external API changes
✓ You need to make a legacy component testable with mocks
AVOID Adapter if:
✗ You control both sides of the code — refactoring directly is better
✗ The interface that needs "adapting" is too wide (many methods) — a Facade may be needed
✗ The Adapter contains business logic — that is not an adapter's job
✗ There is only one provider and no plan to swap it — over-engineering
Adapter Review Checklist #
DESIGN:
□ The Target interface is defined in the domain/business layer, not in infrastructure
□ The Adapter only contains translation — no business logic
□ The client depends on an interface, not a concrete adapter
IMPLEMENTATION:
□ The Adapter implements every method of the Target interface
□ Errors from the Adaptee are wrapped with informative context (fmt.Errorf + %w)
□ Return types are converted to standard domain types (not third-party SDK types)
□ Vendor-specific statuses/codes are translated into standard domain statuses
PLACEMENT:
□ The Adapter lives in the infrastructure layer, not the domain layer
□ Imports of third-party libraries exist only in adapters — not in the domain
□ Adapter wiring (injection) happens in main or the composition root
TESTING:
□ The Adaptee is mocked for isolated tests
□ Parameter translation is verified (the values sent to the Adaptee are correct)
□ Response and error translation are verified
□ Status mapping is tested for every possible vendor value
Summary #
- Adapter bridges two incompatible interfaces — without changing either of them; it only adds a thin translation layer in between.
- Three key components: the Target interface (what the client expects), the Adaptee (what already exists), and the Adapter (the translator between them).
- In Go it is always an Object Adapter — composition, not inheritance; the Adapter holds a reference to the Adaptee and delegates calls to it.
- An Adapter may only contain translation — method mapping, parameter conversion, status normalization; if business logic creeps in, that is a sign of a bad design.
- Place it in the infrastructure layer — the Target interface in the domain layer, the Adapter in the infrastructure layer; this keeps the domain clean of dependencies on external libraries.
- Foundation of Hexagonal Architecture — domain interfaces are “ports”, infrastructure adapters are “adapters”; understanding the Adapter Pattern means understanding the core of Ports & Adapters architecture.
- Distinguish it from Decorator and Facade: Adapter converts a different interface; Decorator adds behavior with the same interface; Facade simplifies a complex subsystem.
- Use it for incremental migration — new code can run on top of a legacy system through an adapter, buying time for migration without a big-bang rewrite.