Strategy Pattern in Go: Interface Strategies
The Strategy pattern encapsulates interchangeable algorithms behind a common interface, letting you swap behaviors at runtime without modifying the code that uses them. It's one of the Gang of Four...
Key Insights
- Go’s implicit interface satisfaction makes the Strategy pattern feel native—you don’t need abstract base classes or explicit declarations, just matching method signatures.
- Function types offer a lightweight alternative to full interface strategies when you only need a single behavior, reducing boilerplate without sacrificing flexibility.
- Strategy registries with configuration-driven selection let you swap algorithms at runtime without code changes, making your systems genuinely pluggable.
Introduction to the Strategy Pattern
The Strategy pattern encapsulates interchangeable algorithms behind a common interface, letting you swap behaviors at runtime without modifying the code that uses them. It’s one of the Gang of Four patterns that translates beautifully to Go—arguably better than in languages that require explicit interface implementation.
Go’s interface system is implicit. If your type has the right methods, it satisfies the interface. No implements keyword, no inheritance hierarchies. This means you can define strategies in separate packages, add new ones without touching existing code, and test with mock implementations trivially.
You’ll find this pattern everywhere in production systems: payment gateways that support multiple processors, compression libraries that offer different algorithms, authentication systems that handle OAuth, API keys, and JWTs, or notification services that route through email, SMS, or push notifications.
The pattern shines when you have multiple ways to accomplish the same task and need to choose between them based on configuration, user preference, or runtime conditions.
Core Components in Go
Every Strategy implementation has three pieces: the strategy interface, concrete implementations, and a context that uses them.
package sorting
// Strategy interface - defines what all sorting algorithms must do
type Sorter interface {
Sort(data []int) []int
Name() string
}
// Concrete strategy: QuickSort
type QuickSort struct{}
func (q QuickSort) Sort(data []int) []int {
if len(data) <= 1 {
return data
}
result := make([]int, len(data))
copy(result, data)
quicksort(result, 0, len(result)-1)
return result
}
func (q QuickSort) Name() string { return "quicksort" }
// Concrete strategy: MergeSort
type MergeSort struct{}
func (m MergeSort) Sort(data []int) []int {
if len(data) <= 1 {
return data
}
mid := len(data) / 2
left := m.Sort(data[:mid])
right := m.Sort(data[mid:])
return merge(left, right)
}
func (m MergeSort) Name() string { return "mergesort" }
// Context that uses strategies
type DataProcessor struct {
sorter Sorter
}
func NewDataProcessor(s Sorter) *DataProcessor {
return &DataProcessor{sorter: s}
}
func (d *DataProcessor) Process(data []int) []int {
fmt.Printf("Processing with %s\n", d.sorter.Name())
return d.sorter.Sort(data)
}
func (d *DataProcessor) SetSorter(s Sorter) {
d.sorter = s
}
The context doesn’t know or care which sorting algorithm it’s using. It just calls Sort(). You can swap strategies at construction time or runtime via SetSorter().
Implementation Walkthrough: Payment Processing
Let’s build something realistic. Payment processing is a textbook Strategy use case because you’ll inevitably support multiple payment providers, and each has wildly different APIs.
package payment
import (
"context"
"fmt"
)
// Core types
type Amount struct {
Cents int64
Currency string
}
type PaymentResult struct {
TransactionID string
Status string
ProviderRef string
}
type PaymentDetails struct {
CustomerID string
Amount Amount
Description string
Metadata map[string]string
}
// Strategy interface
type PaymentStrategy interface {
Charge(ctx context.Context, details PaymentDetails) (*PaymentResult, error)
Refund(ctx context.Context, transactionID string, amount Amount) error
Name() string
}
// Stripe implementation
type StripePayment struct {
apiKey string
client *stripeClient // your Stripe SDK wrapper
}
func NewStripePayment(apiKey string) *StripePayment {
return &StripePayment{
apiKey: apiKey,
client: newStripeClient(apiKey),
}
}
func (s *StripePayment) Charge(ctx context.Context, details PaymentDetails) (*PaymentResult, error) {
// Real implementation would call Stripe API
charge, err := s.client.CreateCharge(ctx, details.Amount.Cents, details.Currency)
if err != nil {
return nil, fmt.Errorf("stripe charge failed: %w", err)
}
return &PaymentResult{
TransactionID: generateID(),
Status: "completed",
ProviderRef: charge.ID,
}, nil
}
func (s *StripePayment) Refund(ctx context.Context, txID string, amount Amount) error {
return s.client.CreateRefund(ctx, txID, amount.Cents)
}
func (s *StripePayment) Name() string { return "stripe" }
// PayPal implementation
type PayPalPayment struct {
clientID string
clientSecret string
sandbox bool
}
func NewPayPalPayment(clientID, secret string, sandbox bool) *PayPalPayment {
return &PayPalPayment{
clientID: clientID,
clientSecret: secret,
sandbox: sandbox,
}
}
func (p *PayPalPayment) Charge(ctx context.Context, details PaymentDetails) (*PaymentResult, error) {
// PayPal has a completely different flow - OAuth, then create order, then capture
token, err := p.authenticate(ctx)
if err != nil {
return nil, err
}
order, err := p.createOrder(ctx, token, details)
if err != nil {
return nil, err
}
return &PaymentResult{
TransactionID: generateID(),
Status: "completed",
ProviderRef: order.ID,
}, nil
}
func (p *PayPalPayment) Refund(ctx context.Context, txID string, amount Amount) error {
// PayPal refund logic
return nil
}
func (p *PayPalPayment) Name() string { return "paypal" }
// Checkout context
type CheckoutService struct {
payment PaymentStrategy
logger Logger
}
func NewCheckoutService(payment PaymentStrategy, logger Logger) *CheckoutService {
return &CheckoutService{payment: payment, logger: logger}
}
func (c *CheckoutService) ProcessOrder(ctx context.Context, order Order) (*PaymentResult, error) {
c.logger.Info("processing order", "provider", c.payment.Name(), "amount", order.Total)
details := PaymentDetails{
CustomerID: order.CustomerID,
Amount: order.Total,
Description: fmt.Sprintf("Order %s", order.ID),
Metadata: map[string]string{"order_id": order.ID},
}
result, err := c.payment.Charge(ctx, details)
if err != nil {
c.logger.Error("payment failed", "error", err)
return nil, err
}
c.logger.Info("payment successful", "transaction_id", result.TransactionID)
return result, nil
}
The CheckoutService doesn’t contain a single line of Stripe or PayPal-specific code. Adding a crypto payment provider means implementing the interface—zero changes to checkout logic.
Functional Strategy Variant
Sometimes a full interface is overkill. When your strategy is a single operation, use a function type instead.
package compression
// Function type as strategy
type CompressFunc func(data []byte) ([]byte, error)
type DecompressFunc func(data []byte) ([]byte, error)
// Gzip implementation
func GzipCompress(data []byte) ([]byte, error) {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func GzipDecompress(data []byte) ([]byte, error) {
r, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// Zstd implementation
func ZstdCompress(data []byte) ([]byte, error) {
encoder, _ := zstd.NewWriter(nil)
return encoder.EncodeAll(data, nil), nil
}
// Usage in context
type FileStore struct {
compress CompressFunc
decompress DecompressFunc
}
func NewFileStore(compress CompressFunc, decompress DecompressFunc) *FileStore {
return &FileStore{compress: compress, decompress: decompress}
}
func (f *FileStore) Save(path string, data []byte) error {
compressed, err := f.compress(data)
if err != nil {
return err
}
return os.WriteFile(path, compressed, 0644)
}
// Construction
store := NewFileStore(GzipCompress, GzipDecompress)
// or
store := NewFileStore(ZstdCompress, ZstdDecompress)
Choose function types when you have a single method and don’t need to carry state. Choose interfaces when strategies need multiple methods, configuration, or you want to group related behaviors.
Runtime Strategy Selection
Production systems rarely hardcode strategy choices. You want configuration files, environment variables, or database flags to control which strategy runs.
package strategies
// Registry pattern
type PaymentRegistry struct {
strategies map[string]PaymentStrategy
mu sync.RWMutex
}
func NewPaymentRegistry() *PaymentRegistry {
return &PaymentRegistry{
strategies: make(map[string]PaymentStrategy),
}
}
func (r *PaymentRegistry) Register(name string, strategy PaymentStrategy) {
r.mu.Lock()
defer r.mu.Unlock()
r.strategies[name] = strategy
}
func (r *PaymentRegistry) Get(name string) (PaymentStrategy, error) {
r.mu.RLock()
defer r.mu.RUnlock()
strategy, ok := r.strategies[name]
if !ok {
return nil, fmt.Errorf("unknown payment strategy: %s", name)
}
return strategy, nil
}
// Factory with configuration
type Config struct {
PaymentProvider string `yaml:"payment_provider"`
Stripe struct {
APIKey string `yaml:"api_key"`
} `yaml:"stripe"`
PayPal struct {
ClientID string `yaml:"client_id"`
Secret string `yaml:"secret"`
Sandbox bool `yaml:"sandbox"`
} `yaml:"paypal"`
}
func BuildPaymentStrategy(cfg Config) (PaymentStrategy, error) {
switch cfg.PaymentProvider {
case "stripe":
return NewStripePayment(cfg.Stripe.APIKey), nil
case "paypal":
return NewPayPalPayment(cfg.PayPal.ClientID, cfg.PayPal.Secret, cfg.PayPal.Sandbox), nil
default:
return nil, fmt.Errorf("unsupported provider: %s", cfg.PaymentProvider)
}
}
// Application startup
func main() {
cfg := loadConfig()
registry := NewPaymentRegistry()
registry.Register("stripe", NewStripePayment(cfg.Stripe.APIKey))
registry.Register("paypal", NewPayPalPayment(cfg.PayPal.ClientID, cfg.PayPal.Secret, true))
// Per-request strategy selection (e.g., user preference)
http.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
strategy, err := registry.Get(provider)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
checkout := NewCheckoutService(strategy, logger)
// ... process order
})
}
The registry pattern lets you add strategies dynamically, even from plugins loaded at runtime.
Testing Strategies
Strategies are trivial to test because they’re isolated behind interfaces.
package payment_test
// Mock strategy for testing
type MockPayment struct {
ChargeFunc func(ctx context.Context, d PaymentDetails) (*PaymentResult, error)
RefundFunc func(ctx context.Context, txID string, amount Amount) error
ChargeCalls []PaymentDetails
}
func (m *MockPayment) Charge(ctx context.Context, d PaymentDetails) (*PaymentResult, error) {
m.ChargeCalls = append(m.ChargeCalls, d)
if m.ChargeFunc != nil {
return m.ChargeFunc(ctx, d)
}
return &PaymentResult{TransactionID: "mock-tx-123", Status: "completed"}, nil
}
func (m *MockPayment) Refund(ctx context.Context, txID string, amount Amount) error {
if m.RefundFunc != nil {
return m.RefundFunc(ctx, txID, amount)
}
return nil
}
func (m *MockPayment) Name() string { return "mock" }
// Table-driven tests
func TestCheckoutService(t *testing.T) {
tests := []struct {
name string
setupMock func(*MockPayment)
order Order
wantErr bool
wantStatus string
}{
{
name: "successful charge",
setupMock: func(m *MockPayment) {
m.ChargeFunc = func(ctx context.Context, d PaymentDetails) (*PaymentResult, error) {
return &PaymentResult{TransactionID: "tx-1", Status: "completed"}, nil
}
},
order: Order{ID: "order-1", Total: Amount{Cents: 1000, Currency: "USD"}},
wantErr: false,
wantStatus: "completed",
},
{
name: "payment declined",
setupMock: func(m *MockPayment) {
m.ChargeFunc = func(ctx context.Context, d PaymentDetails) (*PaymentResult, error) {
return nil, errors.New("card declined")
}
},
order: Order{ID: "order-2", Total: Amount{Cents: 5000, Currency: "USD"}},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &MockPayment{}
tt.setupMock(mock)
svc := NewCheckoutService(mock, noopLogger{})
result, err := svc.ProcessOrder(context.Background(), tt.order)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
}
if !tt.wantErr && result.Status != tt.wantStatus {
t.Errorf("status = %s, want %s", result.Status, tt.wantStatus)
}
})
}
}
Test each strategy implementation in isolation, then test the context with mock strategies. This gives you fast, reliable unit tests.
Best Practices and Pitfalls
Keep interfaces small. A strategy interface with ten methods is a code smell. If you need that many operations, you probably have multiple strategies bundled together. Split them.
Avoid strategy proliferation. Don’t create a new strategy type for every minor variation. If strategies differ only in configuration, use a single parameterized implementation.
Know when it’s overkill. If you only have one implementation and no realistic prospect of adding more, skip the pattern. You can always refactor later when a second implementation appears.
Consider alternatives. Go’s middleware pattern (wrapping handlers) and functional options pattern solve related but different problems. Middleware chains behaviors; options configure construction. Strategies swap behaviors entirely.
The Strategy pattern works because Go’s interfaces are small, implicit, and composable. Use it when you genuinely need interchangeable algorithms, and keep your strategy interfaces focused on a single responsibility.