How to Implement the Adapter Pattern in Go

Implement the Adapter Pattern in Go by wrapping an incompatible type in a struct that implements your target interface.

You hit a wall with a third-party API

You're building a notification service. Your core logic expects a Notifier interface with a single method: Notify(user string) error. You find a fantastic SMS library, but its type is OldSMS and the method is Send(phone string, msg string) string. The library returns a status string instead of an error, and it requires a phone number instead of a user ID.

You can't change the library. You don't want to rewrite your core logic to accept phone numbers and status strings. You need a bridge. That bridge is the adapter pattern. It wraps the incompatible type so it looks like the type you need, translating arguments and return values on the fly.

The adapter bridges the gap

The adapter pattern solves interface mismatches. You define a target interface that your code uses. You create an adapter struct that holds the incompatible type. The adapter implements the target interface by calling the incompatible type and converting the data.

Think of a USB-C to USB-A adapter. Your laptop has a USB-C port. Your old mouse has a USB-A plug. You don't cut the mouse cable. You don't drill a new hole in the laptop. You use a small adapter that plugs into the mouse and presents a USB-C face to the laptop. In Go, the adapter is a struct that embeds the legacy type and implements the target interface. The adapter doesn't change the legacy code. It changes your view of it.

Minimal example

Here's the simplest adapter: a struct that embeds the legacy type and implements the target interface.

// Notifier is the interface your application expects.
type Notifier interface {
	Notify(user string) error
}

// OldSMS is the legacy type with a mismatched signature.
type OldSMS struct{}

// Send takes a phone number and message, returning a status string.
func (o *OldSMS) Send(phone, msg string) string {
	return "sent"
}

// SMSAdapter embeds the legacy type to reuse its methods.
type SMSAdapter struct {
	*OldSMS
}

// Notify implements Notifier by translating the call.
func (a *SMSAdapter) Notify(user string) error {
	// Map the user ID to a phone number.
	phone := "555-0100"
	// Call the legacy method via embedding.
	status := a.Send(phone, "Hello " + user)
	// Convert the status string to an error.
	if status == "sent" {
		return nil
	}
	return fmt.Errorf("sms failed")
}

func main() {
	// Use the adapter where Notifier is expected.
	var n Notifier = &SMSAdapter{OldSMS: &OldSMS{}}
	err := n.Notify("Alice")
	if err != nil {
		fmt.Println(err)
	}
}

How the compiler connects the dots

Go interfaces are implicit. A type implements an interface if it has all the methods defined by that interface. The SMSAdapter struct has a Notify method, so it satisfies Notifier. The compiler checks the method signature. If Notify returns error and takes string, the assignment var n Notifier = &SMSAdapter{...} compiles.

The adapter uses embedding to access the legacy methods. SMSAdapter embeds *OldSMS. This promotes the methods of OldSMS to SMSAdapter. Inside Notify, you can call a.Send directly. The compiler rewrites a.Send to a.OldSMS.Send. Embedding reduces boilerplate. You don't need a field like sms *OldSMS and then call a.sms.Send. Embedding is the idiomatic way to build adapters in Go.

If you forget to implement a method, the compiler catches it immediately. The error message is explicit.

cannot use &SMSAdapter{} (type *SMSAdapter) as Notifier value in variable declaration: *SMSAdapter does not implement Notifier (missing Notify method)

The error tells you exactly which method is missing. Fix the method signature or add the method, and the code compiles.

Go interfaces are implicit. If the methods match, the type fits.

Realistic scenario: wrapping a payment SDK

Real adapters often handle more than renaming methods. They map complex data structures, wrap errors, and enforce domain invariants. Here's an adapter wrapping a payment SDK that returns raw JSON-like data, converting it to a clean Go result type.

First, define the interface and the domain types your service uses.

// PaymentProcessor is the contract for your checkout logic.
type PaymentProcessor interface {
	Charge(amount int, token string) (Transaction, error)
}

// Transaction is a clean domain type.
type Transaction struct {
	ID      string
	Success bool
}

// SDKClient is the third-party type you cannot modify.
type SDKClient struct{}

// ProcessPayment returns a raw map and a status code.
func (c *SDKClient) ProcessPayment(amount int, token string) (map[string]any, int) {
	return map[string]any{"id": "tx_123", "ok": true}, 200
}

// PaymentAdapter holds the SDK client.
type PaymentAdapter struct {
	*SDKClient
}

Next, implement the Charge method to bridge the gap.

// Charge implements PaymentProcessor by translating the SDK call.
func (a *PaymentAdapter) Charge(amount int, token string) (Transaction, error) {
	// Call the SDK method via embedding.
	raw, code := a.ProcessPayment(amount, token)
	// Check the status code for failures.
	if code != 200 {
		return Transaction{}, fmt.Errorf("sdk error: %d", code)
	}
	// Extract and validate the transaction ID.
	id, ok := raw["id"].(string)
	if !ok {
		return Transaction{}, fmt.Errorf("missing id")
	}
	// Extract and validate the success flag.
	success, ok := raw["ok"].(bool)
	if !ok {
		return Transaction{}, fmt.Errorf("missing success")
	}
	// Return the domain type your code understands.
	return Transaction{ID: id, Success: success}, nil
}

The adapter isolates the SDK. Your checkout logic calls Charge and gets a Transaction. It never sees map[string]any or status codes. If the SDK changes its API, you only update the adapter. The rest of your code stays safe.

Adapters are the glue of a clean architecture. Keep them thin and focused.

Pitfalls and compiler errors

Adapters can grow large if the target interface is wide. A "fat adapter" with dozens of methods often signals a bad interface. If you find yourself writing a massive adapter, consider splitting the interface. Go favors small, focused interfaces. The standard library follows this rule: io.Reader has one method. io.Writer has one method. Types can implement both without a huge adapter.

Another risk is hiding errors. The adapter should never swallow errors from the legacy type. Wrap them with context.

return Transaction{}, fmt.Errorf("payment adapter: %w", err)

The %w verb allows callers to unwrap the error later. This preserves the error chain for debugging.

Context handling is a common trap. If your target interface includes context.Context, the adapter must accept it. If the legacy library doesn't support cancellation, the adapter becomes a leak risk. You might need a timer or a background goroutine to simulate cancellation, or you accept that the legacy call can't be cancelled. Document this limitation clearly.

Convention matters. The receiver name should be short, usually one or two letters matching the type. (a *Adapter) is correct. (this *Adapter) or (self *Adapter) is not idiomatic Go. Also, follow the mantra "accept interfaces, return structs." Your adapter implements an interface, but functions should return concrete types like Transaction. This keeps dependencies flexible.

A fat adapter is a sign of a bad interface. Refactor the interface before you write the adapter.

When to use an adapter

Use an adapter when you need to integrate a third-party library whose API doesn't match your internal interfaces. Use an adapter when you want to swap implementations without changing the code that uses the interface. Use an adapter when you need to translate data formats or error types between two systems. Use a wrapper function when the translation is trivial and a full struct adds unnecessary noise. Use a direct call when you don't need abstraction: the simplest thing that works is usually the right thing.

Adapters solve integration problems. Don't use them to hide design flaws.

Where to go next