The Problem
When integrating external services or third-party libraries, you often face:
- Import pollution: Your domain logic imports concrete external types
- Tight coupling: Changes in external packages break your code
- Difficult testing: Hard to mock external dependencies
- 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, "user@example.com", "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
- Consumer defines the interface: The package that uses the service defines what it needs
- Adapter translates: Convert between your domain types and external types
- Composition root wires it up: Dependencies are connected at the application entry point
- Domain stays pure: Business logic never imports external service packages
- 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