Adapter Pattern in Go: Wrapper Structs
The adapter pattern solves a straightforward problem: you have code that expects one interface, but you're working with a type that provides a different one. Rather than modifying either side, you...
Key Insights
- Go’s implicit interface satisfaction makes adapters remarkably clean—wrapper structs only need to implement the target interface methods, with no inheritance hierarchies or explicit declarations required.
- Adapters should remain thin translation layers; the moment you add business logic to an adapter, you’ve created a maintenance burden and violated the single responsibility principle.
- Strategic adapter placement at system boundaries isolates your domain code from third-party dependencies, making vendor swaps and testing dramatically easier.
Introduction to the Adapter Pattern
The adapter pattern solves a straightforward problem: you have code that expects one interface, but you’re working with a type that provides a different one. Rather than modifying either side, you create a wrapper that translates between them.
In Go applications, adapters become essential in three scenarios. First, when integrating third-party libraries whose APIs don’t match your internal contracts. Second, when connecting legacy code to modern interfaces without rewriting everything. Third, when you need to swap implementations without touching consuming code.
Go’s interface system makes this pattern particularly elegant. Unlike languages requiring explicit interface declarations, Go types satisfy interfaces implicitly. Your adapter struct just needs the right method signatures—no implements keyword, no inheritance chains.
Adapter Pattern Fundamentals in Go
The core mechanism is simple: create a struct that holds the incompatible type, then implement the target interface’s methods by delegating to the wrapped type with appropriate translations.
Consider a logging scenario. Your application expects a Logger interface, but you’re using a third-party library with a different method signature:
// Your application's expected interface
type Logger interface {
Log(level string, message string, fields map[string]any)
}
// Third-party logger with incompatible signature
type ExternalLogger struct{}
func (e *ExternalLogger) Write(msg string, attrs ...any) {
// External implementation
fmt.Printf("[EXTERNAL] %s %v\n", msg, attrs)
}
// Adapter wrapping the external logger
type ExternalLoggerAdapter struct {
external *ExternalLogger
}
func NewExternalLoggerAdapter(ext *ExternalLogger) *ExternalLoggerAdapter {
return &ExternalLoggerAdapter{external: ext}
}
func (a *ExternalLoggerAdapter) Log(level string, message string, fields map[string]any) {
// Translate to external logger's expected format
formatted := fmt.Sprintf("[%s] %s", level, message)
attrs := make([]any, 0, len(fields)*2)
for k, v := range fields {
attrs = append(attrs, k, v)
}
a.external.Write(formatted, attrs...)
}
The adapter holds a reference to the external logger and implements Logger by translating calls. Your application code works with Logger exclusively, unaware of the underlying implementation.
You might consider struct embedding as an alternative:
type EmbeddedAdapter struct {
*ExternalLogger
}
Embedding promotes the external type’s methods, but this rarely helps with adapters. You typically need different method signatures, not method promotion. Explicit wrapping gives you control over the translation layer.
Real-World Use Case: Database Driver Adaptation
Database access is where adapters shine. Your domain layer should depend on abstractions, not concrete database clients. Here’s an adapter wrapping a Redis client to match a generic cache interface:
// Your application's cache contract
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}
// Third-party Redis client (simplified representation)
type RedisClient struct {
addr string
}
func (r *RedisClient) Do(ctx context.Context, cmd string, args ...any) (any, error) {
// Actual Redis command execution
return nil, nil
}
// Adapter implementing Cache with Redis
type RedisCacheAdapter struct {
client *RedisClient
}
func NewRedisCacheAdapter(client *RedisClient) *RedisCacheAdapter {
return &RedisCacheAdapter{client: client}
}
func (a *RedisCacheAdapter) Get(ctx context.Context, key string) ([]byte, error) {
result, err := a.client.Do(ctx, "GET", key)
if err != nil {
return nil, fmt.Errorf("redis get failed: %w", err)
}
if result == nil {
return nil, ErrCacheMiss
}
data, ok := result.([]byte)
if !ok {
return nil, fmt.Errorf("unexpected type from redis: %T", result)
}
return data, nil
}
func (a *RedisCacheAdapter) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
_, err := a.client.Do(ctx, "SET", key, value, "PX", ttl.Milliseconds())
if err != nil {
return fmt.Errorf("redis set failed: %w", err)
}
return nil
}
func (a *RedisCacheAdapter) Delete(ctx context.Context, key string) error {
_, err := a.client.Do(ctx, "DEL", key)
if err != nil {
return fmt.Errorf("redis delete failed: %w", err)
}
return nil
}
var ErrCacheMiss = errors.New("cache miss")
Your service layer depends on Cache, not *RedisClient. Swapping to Memcached or an in-memory cache means writing a new adapter—zero changes to business logic.
Adapting External APIs and SDKs
Payment processing demonstrates adapter value at API boundaries. Payment providers have wildly different SDKs, but your checkout flow shouldn’t care which one you’re using:
// Your domain's payment contract
type PaymentProcessor interface {
Charge(ctx context.Context, req ChargeRequest) (*ChargeResult, error)
Refund(ctx context.Context, chargeID string, amount int64) error
}
type ChargeRequest struct {
CustomerID string
AmountCents int64
Currency string
PaymentMethod string
Description string
}
type ChargeResult struct {
ID string
Status string
CreatedAt time.Time
}
// Stripe SDK types (simplified)
type StripeClient struct {
apiKey string
}
type StripeChargeParams struct {
Amount int64
Currency string
Customer string
Source string
Description string
}
type StripeCharge struct {
ID string
Status string
Created int64
}
func (s *StripeClient) CreateCharge(params *StripeChargeParams) (*StripeCharge, error) {
// Stripe API call
return &StripeCharge{ID: "ch_123", Status: "succeeded", Created: time.Now().Unix()}, nil
}
func (s *StripeClient) CreateRefund(chargeID string, amount int64) error {
// Stripe refund API call
return nil
}
// Adapter translating between your domain and Stripe
type StripePaymentAdapter struct {
client *StripeClient
}
func NewStripePaymentAdapter(apiKey string) *StripePaymentAdapter {
return &StripePaymentAdapter{
client: &StripeClient{apiKey: apiKey},
}
}
func (a *StripePaymentAdapter) Charge(ctx context.Context, req ChargeRequest) (*ChargeResult, error) {
params := &StripeChargeParams{
Amount: req.AmountCents,
Currency: req.Currency,
Customer: req.CustomerID,
Source: req.PaymentMethod,
Description: req.Description,
}
charge, err := a.client.CreateCharge(params)
if err != nil {
return nil, fmt.Errorf("stripe charge failed: %w", err)
}
return &ChargeResult{
ID: charge.ID,
Status: charge.Status,
CreatedAt: time.Unix(charge.Created, 0),
}, nil
}
func (a *StripePaymentAdapter) Refund(ctx context.Context, chargeID string, amount int64) error {
if err := a.client.CreateRefund(chargeID, amount); err != nil {
return fmt.Errorf("stripe refund failed: %w", err)
}
return nil
}
When your company decides to add PayPal support, you write PayPalPaymentAdapter implementing the same interface. The checkout service remains unchanged.
Bidirectional Adapters and Data Transformation
Adapters frequently handle data format translation, especially at API boundaries where internal domain models differ from external representations:
// Internal domain model
type User struct {
ID uuid.UUID
Email string
FullName string
CreatedAt time.Time
Settings UserSettings
}
type UserSettings struct {
NotificationsEnabled bool
Theme string
}
// Protobuf generated type (simplified representation)
type UserProto struct {
Id string
Email string
FullName string
CreatedAtUnix int64
NotificationsEnabled bool
Theme string
}
// Bidirectional adapter for User <-> UserProto
type UserProtoAdapter struct{}
func (a *UserProtoAdapter) ToProto(u *User) *UserProto {
return &UserProto{
Id: u.ID.String(),
Email: u.Email,
FullName: u.FullName,
CreatedAtUnix: u.CreatedAt.Unix(),
NotificationsEnabled: u.Settings.NotificationsEnabled,
Theme: u.Settings.Theme,
}
}
func (a *UserProtoAdapter) FromProto(p *UserProto) (*User, error) {
id, err := uuid.Parse(p.Id)
if err != nil {
return nil, fmt.Errorf("invalid user id: %w", err)
}
return &User{
ID: id,
Email: p.Email,
FullName: p.FullName,
CreatedAt: time.Unix(p.CreatedAtUnix, 0),
Settings: UserSettings{
NotificationsEnabled: p.NotificationsEnabled,
Theme: p.Theme,
},
}, nil
}
This adapter handles the structural differences—nested settings become flat fields, UUIDs become strings, timestamps become Unix integers. Your domain code works with rich types while the wire format stays simple.
Testing Strategies for Adapter Implementations
Adapters dramatically improve testability. Instead of mocking complex third-party clients, you mock your own interface:
// Mock adapter for testing
type MockCache struct {
data map[string][]byte
getCalls int
setCalls int
}
func NewMockCache() *MockCache {
return &MockCache{data: make(map[string][]byte)}
}
func (m *MockCache) Get(ctx context.Context, key string) ([]byte, error) {
m.getCalls++
if val, ok := m.data[key]; ok {
return val, nil
}
return nil, ErrCacheMiss
}
func (m *MockCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
m.setCalls++
m.data[key] = value
return nil
}
func (m *MockCache) Delete(ctx context.Context, key string) error {
delete(m.data, key)
return nil
}
// Test using the mock
func TestUserService_GetUser(t *testing.T) {
cache := NewMockCache()
cache.data["user:123"] = []byte(`{"id":"123","name":"Alice"}`)
service := NewUserService(cache)
user, err := service.GetUser(context.Background(), "123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
if cache.getCalls != 1 {
t.Errorf("expected 1 cache get, got %d", cache.getCalls)
}
}
Your tests run without Redis connections, network calls, or external service dependencies. The mock implements the same interface your real adapter does.
Best Practices and Common Pitfalls
Keep adapters thin. An adapter’s job is translation, nothing more. The moment you add validation, business rules, or conditional logic, you’ve created a hybrid that’s hard to test and reason about. If you find yourself writing complex logic in an adapter, that logic belongs in a service layer.
Watch for over-abstraction. Not every external dependency needs an adapter. If you’re using a well-designed library that already matches your needs, wrapping it adds indirection without benefit. Adapters make sense at true boundaries—where interfaces genuinely mismatch or where you need swappability.
Consider performance. Each adapter adds a function call and potential memory allocation. For hot paths processing millions of requests, measure the overhead. In most applications, the cost is negligible compared to network I/O, but don’t assume—profile when it matters.
Error handling belongs in adapters. Translating third-party error types to your domain’s error types is legitimate adapter work. Wrapping errors with context (fmt.Errorf("redis get failed: %w", err)) makes debugging easier without leaking implementation details.
Name adapters clearly. StripePaymentAdapter beats PaymentAdapter or StripeWrapper. The name should indicate both what it wraps and what interface it satisfies.
Adapters are a tool for managing complexity at boundaries. Use them where interfaces genuinely conflict, where testability matters, or where you need implementation flexibility. Skip them when direct usage is cleaner.