Adapter Pattern in Go: Interface Compatibility
Adapter Pattern solves the problem of interface incompatibility. You have code expecting one interface, and a library providing another. An adapter is a layer that makes them compatible.
Problem: Incompatible Interfaces
Your code works with Logger interface:
type Logger interface {
Log(level, message string)
}
But a third-party library provides a different interface:
type ZapLogger struct{}
func (z *ZapLogger) Info(msg string) {}
func (z *ZapLogger) Error(msg string) {}
func (z *ZapLogger) Debug(msg string) {}
Interfaces are incompatible. Need an adapter.
Solution: Adapter
type ZapLoggerAdapter struct {
logger *ZapLogger
}
func (a *ZapLoggerAdapter) Log(level, message string) {
switch level {
case "info":
a.logger.Info(message)
case "error":
a.logger.Error(message)
case "debug":
a.logger.Debug(message)
}
}
Now ZapLogger is compatible with Logger interface.
Real Example 1: Migrating from Logrus to Zap
You have legacy code with Logrus:
type LogrusLogger struct {
logger *logrus.Logger
}
func (l *LogrusLogger) Info(msg string) {
l.logger.Info(msg)
}
New code uses standard interface:
type AppLogger interface {
Log(ctx context.Context, level Level, msg string)
}
Adapter for Logrus:
type LogrusAdapter struct {
logger *logrus.Logger
}
func (a *LogrusAdapter) Log(ctx context.Context, level Level, msg string) {
entry := a.logger.WithContext(ctx)
switch level {
case LevelInfo:
entry.Info(msg)
case LevelError:
entry.Error(msg)
case LevelDebug:
entry.Debug(msg)
}
}
Adapter for Zap:
type ZapAdapter struct {
logger *zap.Logger
}
func (a *ZapAdapter) Log(ctx context.Context, level Level, msg string) {
switch level {
case LevelInfo:
a.logger.Info(msg)
case LevelError:
a.logger.Error(msg)
case LevelDebug:
a.logger.Debug(msg)
}
}
Now you can switch between loggers without changing code:
var logger AppLogger
if useZap {
logger = &ZapAdapter{logger: zap.NewProduction()}
} else {
logger = &LogrusAdapter{logger: logrus.New()}
}
logger.Log(ctx, LevelInfo, "Application started")
Real Example 2: Payment System Integration
Your application works with Payment interface:
type Payment interface {
Charge(amount int, currency string) (string, error)
Refund(transactionID string) error
}
Stripe API:
type StripeClient struct{}
func (s *StripeClient) CreateCharge(params ChargeParams) (*Charge, error) {
// Stripe-specific logic
}
func (s *StripeClient) CreateRefund(chargeID string) (*Refund, error) {
// Stripe-specific logic
}
Adapter for Stripe:
type StripeAdapter struct {
client *StripeClient
}
func (a *StripeAdapter) Charge(amount int, currency string) (string, error) {
charge, err := a.client.CreateCharge(ChargeParams{
Amount: amount,
Currency: currency,
})
if err != nil {
return "", err
}
return charge.ID, nil
}
func (a *StripeAdapter) Refund(transactionID string) error {
_, err := a.client.CreateRefund(transactionID)
return err
}
PayPal API is different:
type PayPalClient struct{}
func (p *PayPalClient) ProcessPayment(req PaymentRequest) (*PaymentResponse, error) {
// PayPal-specific logic
}
func (p *PayPalClient) RefundPayment(paymentID string) error {
// PayPal-specific logic
}
Adapter for PayPal:
type PayPalAdapter struct {
client *PayPalClient
}
func (a *PayPalAdapter) Charge(amount int, currency string) (string, error) {
resp, err := a.client.ProcessPayment(PaymentRequest{
Amount: amount,
Currency: currency,
})
if err != nil {
return "", err
}
return resp.TransactionID, nil
}
func (a *PayPalAdapter) Refund(transactionID string) error {
return a.client.RefundPayment(transactionID)
}
Usage:
func ProcessOrder(payment Payment, amount int) error {
transactionID, err := payment.Charge(amount, "USD")
if err != nil {
return err
}
// If something goes wrong
if needRefund {
return payment.Refund(transactionID)
}
return nil
}
// Works with any provider
stripe := &StripeAdapter{client: stripeClient}
paypal := &PayPalAdapter{client: paypalClient}
ProcessOrder(stripe, 1000)
ProcessOrder(paypal, 1000)
Real Example 3: Working with Different Databases
Your repository interface:
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
}
PostgreSQL with sqlx:
type PostgresUserRepo struct {
db *sqlx.DB
}
func (r *PostgresUserRepo) FindByID(ctx context.Context, id string) (*User, error) {
var user User
err := r.db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", id)
return &user, err
}
func (r *PostgresUserRepo) Save(ctx context.Context, user *User) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO users (id, name, email) VALUES ($1, $2, $3)",
user.ID, user.Name, user.Email)
return err
}
MongoDB with official driver:
type MongoUserRepo struct {
collection *mongo.Collection
}
func (r *MongoUserRepo) FindByID(ctx context.Context, id string) (*User, error) {
var user User
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
return &user, err
}
func (r *MongoUserRepo) Save(ctx context.Context, user *User) error {
_, err := r.collection.InsertOne(ctx, user)
return err
}
Both repositories implement the same interface — this is the adapter. You can switch between databases:
var repo UserRepository
if useMongo {
repo = &MongoUserRepo{collection: mongoCollection}
} else {
repo = &PostgresUserRepo{db: postgresDB}
}
user, err := repo.FindByID(ctx, "123")
Real Example 4: Caching with Different Backends
Cache interface:
type Cache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, ttl time.Duration) error
Delete(key string) error
}
Redis adapter:
type RedisCache struct {
client *redis.Client
}
func (c *RedisCache) Get(key string) ([]byte, error) {
return c.client.Get(context.Background(), key).Bytes()
}
func (c *RedisCache) Set(key string, value []byte, ttl time.Duration) error {
return c.client.Set(context.Background(), key, value, ttl).Err()
}
func (c *RedisCache) Delete(key string) error {
return c.client.Del(context.Background(), key).Err()
}
Memcached adapter:
type MemcachedCache struct {
client *memcache.Client
}
func (c *MemcachedCache) Get(key string) ([]byte, error) {
item, err := c.client.Get(key)
if err != nil {
return nil, err
}
return item.Value, nil
}
func (c *MemcachedCache) Set(key string, value []byte, ttl time.Duration) error {
return c.client.Set(&memcache.Item{
Key: key,
Value: value,
Expiration: int32(ttl.Seconds()),
})
}
func (c *MemcachedCache) Delete(key string) error {
return c.client.Delete(key)
}
In-memory adapter for tests:
type InMemoryCache struct {
data map[string][]byte
mu sync.RWMutex
}
func (c *InMemoryCache) Get(key string) ([]byte, error) {
c.mu.RLock()
defer c.mu.RUnlock()
value, ok := c.data[key]
if !ok {
return nil, errors.New("key not found")
}
return value, nil
}
func (c *InMemoryCache) Set(key string, value []byte, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
return nil
}
func (c *InMemoryCache) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
return nil
}
Real Example 5: HTTP Clients
Standard interface:
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
Standard http.Client already implements this interface. But what if you need a client with retry?
type RetryHTTPClient struct {
client *http.Client
retries int
}
func (c *RetryHTTPClient) Do(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
for i := 0; i <= c.retries; i++ {
resp, err = c.client.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if i < c.retries {
time.Sleep(time.Second * time.Duration(i+1))
}
}
return resp, err
}
Or client with metrics:
type MetricsHTTPClient struct {
client HTTPClient
metrics *prometheus.CounterVec
}
func (c *MetricsHTTPClient) Do(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := c.client.Do(req)
duration := time.Since(start)
status := "success"
if err != nil {
status = "error"
}
c.metrics.WithLabelValues(req.Method, status).Inc()
return resp, err
}
Composing adapters:
client := &MetricsHTTPClient{
client: &RetryHTTPClient{
client: http.DefaultClient,
retries: 3,
},
metrics: prometheusMetrics,
}
Real Example 6: Cloud Storage
Storage interface:
type Storage interface {
Upload(ctx context.Context, key string, data []byte) error
Download(ctx context.Context, key string) ([]byte, error)
Delete(ctx context.Context, key string) error
}
AWS S3 adapter:
type S3Storage struct {
client *s3.Client
bucket string
}
func (s *S3Storage) Upload(ctx context.Context, key string, data []byte) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
})
return err
}
func (s *S3Storage) Download(ctx context.Context, key string) ([]byte, error) {
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
defer result.Body.Close()
return io.ReadAll(result.Body)
}
Google Cloud Storage adapter:
type GCSStorage struct {
client *storage.Client
bucket string
}
func (g *GCSStorage) Upload(ctx context.Context, key string, data []byte) error {
wc := g.client.Bucket(g.bucket).Object(key).NewWriter(ctx)
defer wc.Close()
_, err := wc.Write(data)
return err
}
func (g *GCSStorage) Download(ctx context.Context, key string) ([]byte, error) {
rc, err := g.client.Bucket(g.bucket).Object(key).NewReader(ctx)
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
Local filesystem for development:
type LocalStorage struct {
basePath string
}
func (l *LocalStorage) Upload(ctx context.Context, key string, data []byte) error {
path := filepath.Join(l.basePath, key)
return os.WriteFile(path, data, 0644)
}
func (l *LocalStorage) Download(ctx context.Context, key string) ([]byte, error) {
path := filepath.Join(l.basePath, key)
return os.ReadFile(path)
}
When to Use Adapter
- Third-party library integration — when their interface doesn’t match yours
- Technology migration — gradual replacement of one library with another
- Testing — creating mock adapters for tests
- Multi-tenancy — supporting different providers for different clients
- Infrastructure abstraction — independence from specific implementations
Adapter vs Facade
Adapter makes one interface compatible with another. Facade simplifies a complex interface.
// Adapter: interface transformation
type LoggerAdapter struct {
logger *ThirdPartyLogger
}
func (a *LoggerAdapter) Log(msg string) {
a.logger.WriteLog(msg, time.Now())
}
// Facade: interface simplification
type DatabaseFacade struct {
conn *sql.DB
cache *redis.Client
queue *kafka.Producer
}
func (f *DatabaseFacade) SaveUser(user *User) error {
// Hides complexity of working with DB, cache, and queue
}
Performance
Adapter adds one level of indirection. Overhead is minimal:
func BenchmarkDirect(b *testing.B) {
logger := &ZapLogger{}
for i := 0; i < b.N; i++ {
logger.Info("test")
}
}
func BenchmarkAdapter(b *testing.B) {
adapter := &ZapLoggerAdapter{logger: &ZapLogger{}}
for i := 0; i < b.N; i++ {
adapter.Log("info", "test")
}
}
Difference is usually within 1-2 nanoseconds.
Conclusion
Adapter Pattern in Go:
- Use for third-party library integration
- Apply during technology migration
- Create adapters for testing
- Abstract infrastructure through adapters
- Combine adapters to extend functionality
Adapter isn’t complication. It’s flexibility and independence from specific implementations.
In modern applications, adapters are everywhere: loggers, databases, caches, payment systems, cloud services. They make code portable and testable.