The Problem

When integrating external services or third-party libraries, you often face:

  1. Import pollution: Your domain logic imports concrete external types
  2. Tight coupling: Changes in external packages break your code
  3. Difficult testing: Hard to mock external dependencies
  4. Vendor lock-in: Switching providers requires widespread changes

Example scenario: Your notification service needs to send emails, but you don’t want your business logic to know about specific email providers (SendGrid, AWS SES, etc.).

The Principle

Dependency Inversion Principle (SOLID)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Key Idea: The consumer defines the interface it needs, not the provider.

graph TB
    Domain["Domain Layer<br/>(defines interface)"]
    Adapter["Adapter Layer<br/>(translates types)"]
    External["External Service<br/>(third-party code)"]
    
    Adapter -->|implements| Domain
    Adapter -->|uses| External

Example Code

Step 1: Domain Layer Defines Its Needs

// package notification - your domain layer
 
package notification
 
import "context"
 
// Message is your domain model
type Message struct {
    To      string
    Subject string
    Body    string
}
 
// EmailService is the interface YOUR domain needs
// Note: Defined in YOUR package, not the provider's package
type EmailService interface {
    Send(ctx context.Context, msg *Message) error
}
 
// NotificationService is your business logic
type NotificationService struct {
    email EmailService
}
 
func NewNotificationService(email EmailService) *NotificationService {
    return &NotificationService{email: email}
}
 
func (s *NotificationService) NotifyUser(ctx context.Context, userEmail, message string) error {
    msg := &Message{
        To:      userEmail,
        Subject: "Notification",
        Body:    message,
    }
    return s.email.Send(ctx, msg)
}

Step 2: External Service (Third-Party)

// package sendgrid - external library (you don't control this)
 
package sendgrid
 
type Email struct {
    Recipient string
    Title     string
    Content   string
    APIKey    string
}
 
type Client struct {
    apiKey string
}
 
func NewClient(apiKey string) *Client {
    return &Client{apiKey: apiKey}
}
 
func (c *Client) SendEmail(email *Email) error {
    // External API call implementation
    return nil
}

Step 3: Adapter Layer

// package sendgrid - you add this to adapt the external service
 
package sendgrid
 
import (
    "context"
    "myapp/notification"
)
 
// Adapter translates between your domain and external service
type Adapter struct {
    client *Client
}
 
func NewAdapter(client *Client) *Adapter {
    return &Adapter{client: client}
}
 
// Send implements notification.EmailService interface
func (a *Adapter) Send(ctx context.Context, msg *notification.Message) error {
    // Translate domain model to external model
    externalEmail := &Email{
        Recipient: msg.To,
        Title:     msg.Subject,
        Content:   msg.Body,
        APIKey:    a.client.apiKey,
    }
    
    return a.client.SendEmail(externalEmail)
}

Step 4: Wiring It Up (main.go)

package main
 
import (
    "myapp/notification"
    "myapp/sendgrid"
)
 
func main() {
    // Create external service client
    sendgridClient := sendgrid.NewClient("api-key-123")
    
    // Wrap it with adapter
    emailAdapter := sendgrid.NewAdapter(sendgridClient)
    
    // Inject adapter into domain service
    notificationService := notification.NewNotificationService(emailAdapter)
    
    // Use it - domain layer has no knowledge of SendGrid
    notificationService.NotifyUser(ctx, "[email protected]", "Hello!")
}

Benefits

1. Easy Provider Switching

Need to switch from SendGrid to AWS SES? Just create a new adapter:

package awsses
 
import "myapp/notification"
 
type Adapter struct {
    sesClient *SESClient
}
 
func (a *Adapter) Send(ctx context.Context, msg *notification.Message) error {
    // Translate to AWS SES format
    // ...
}

Change one line in main.go:

emailAdapter := awsses.NewAdapter(sesClient)

2. Clean Testing

Mock the interface without knowing about external services:

type MockEmailService struct {
    sent []*notification.Message
}
 
func (m *MockEmailService) Send(ctx context.Context, msg *notification.Message) error {
    m.sent = append(m.sent, msg)
    return nil
}

3. Isolation

notification package imports: 0 external packages
sendgrid package imports: notification package only

Domain stays clean and focused.

Architecture Diagram

graph TB
    Main["main.go (Composition Root)<br/>- Creates concrete implementations<br/>- Wires dependencies"]
    
    Notification["notification pkg<br/>- Defines needs<br/>- Business logic"]
    Sendgrid["sendgrid pkg<br/>- Adapter<br/>- Client"]
    AwsSes["awsses pkg<br/>- Adapter<br/>- Client"]
    
    Main --> Notification
    Main --> Sendgrid
    Main --> AwsSes
    
    Sendgrid -.->|implements<br/>notification.EmailService| Notification
    AwsSes -.->|implements<br/>notification.EmailService| Notification

Key Takeaways

  1. Consumer defines the interface: The package that uses the service defines what it needs
  2. Adapter translates: Convert between your domain types and external types
  3. Composition root wires it up: Dependencies are connected at the application entry point
  4. Domain stays pure: Business logic never imports external service packages
  5. Flexibility: Easy to swap implementations, test, and maintain

When to Use

Use the Adapter Pattern when:

  • Integrating third-party services (payment gateways, email providers, cloud services)
  • You want to protect your domain from external changes
  • You need to support multiple implementations
  • Testing requires mocking external dependencies

Avoid when:

  • The external service is simple and unlikely to change
  • You’re building a thin wrapper with no domain logic
  • The overhead of translation is not justified