Strategy Pattern in Go

Published by

on

The Strategy Pattern is a behavioral design pattern that enables you to define a family of algorithms, encapsulate each one, and make them interchangeable. This pattern lets you choose an algorithm’s behavior at runtime, allowing flexibility in changing functionality without modifying the classes involved.

In this blog post, we will:

  • discuss the strategy pattern in Go.
  • cover why it’s used and where it’s commonly applied.
  • explain how to implement it and a few things to consider.

In Go, the Strategy Pattern is typically implemented using interfaces, which define functional contracts for specific behaviors. The goal is to allow different concrete implementations of the same operation, enabling flexibility and interchangeable behavior.

It is commonly used to:

  1. Reduce conditional logic: It helps avoid large switch-case or if-else blocks by encapsulating each behavior in a separate implementation.
  2. Improve code flexibility: It allows algorithms to be swapped out or extended without modifying the main code.
  3. Enhance testability and readability: Each algorithm can be independently tested, and the purpose of each behavior is explicit in its own type.

This pattern is effective when there are multiple ways to accomplish the same task and when the choice of implementation can change based on conditions or configurations.

Let’s walk through a practical example where we use the Strategy Pattern to handle payment processing for an e-commerce system.

Step 1: Define the Strategy Interface

First, define an interface that declares a common method for all payment strategies:

package main

import "fmt"

// PaymentStrategy defines the method required for a payment type.
type PaymentStrategy interface {
	ProcessPayment(amount float64) string
}

Step 2: Create Concrete Strategies

Next, create structs that implement this interface. Each struct will represent a different payment type.

// CreditCard represents a payment via credit card.
type CreditCard struct{}

func (cc CreditCard) ProcessPayment(amount float64) string {
	return fmt.Sprintf("Processing credit card payment of $%.2f", amount)
}

// PayPal represents a payment via PayPal.
type PayPal struct{}

func (pp PayPal) ProcessPayment(amount float64) string {
	return fmt.Sprintf("Processing PayPal payment of $%.2f", amount)
}

// Bitcoin represents a payment via Bitcoin.
type Bitcoin struct{}

func (btc Bitcoin) ProcessPayment(amount float64) string {
	return fmt.Sprintf("Processing Bitcoin payment of $%.2f", amount)
}

Each concrete strategy (e.g., CreditCard, PayPal, and Bitcoin) implements the ProcessPayment method according to the requirements for each type.

Step 3: Create the Context

The PaymentProcessor struct acts as the context in this pattern. It allows us to swap the PaymentStrategy dynamically.

// PaymentProcessor holds the payment strategy.
type PaymentProcessor struct {
	strategy PaymentStrategy
}

// SetStrategy allows updating the payment strategy dynamically.
func (p *PaymentProcessor) SetStrategy(strategy PaymentStrategy) {
	p.strategy = strategy
}

// ExecutePayment uses the strategy to process a payment.
func (p *PaymentProcessor) ExecutePayment(amount float64) string {
	return p.strategy.ProcessPayment(amount)
}

Step 4: Using the Strategy Pattern in Action

Now, let’s create a payment processor and execute payments with different strategies:

func main() {
	processor := PaymentProcessor{}

	// Pay with credit card
	processor.SetStrategy(CreditCard{})
	fmt.Println(processor.ExecutePayment(100.00))

	// Pay with PayPal
	processor.SetStrategy(PayPal{})
    fmt.Println(processor.ExecutePayment(250.00))

	// Pay with Bitcoin
	processor.SetStrategy(Bitcoin{})
	fmt.Println(processor.ExecutePayment(350.00))
}

When you run this code, you’ll see that the PaymentProcessor can handle payments through different methods by simply changing the strategy, without modifying the processor itself.

Processing credit card payment of $100.00
Processing PayPal payment of $250.00
Processing Bitcoin payment of $350.00

Things to Consider When Using the Strategy Pattern

  1. Avoid Overengineering: Use the Strategy Pattern only when multiple algorithms need to coexist, or when you anticipate changes in behavior. For simple cases, an inline function may be simpler and more efficient.
  2. Error Handling: Each strategy could have unique error handling requirements, so consider how best to centralize or handle errors specific to each strategy.
  3. Testability: Isolating each strategy makes testing simpler, but ensure tests cover the context (e.g., PaymentProcessor) to verify that it correctly switches between strategies as expected.
  4. Readability and Maintenance: If strategies grow complex, break them down further for maintainability. For instance, payment processing might involve multiple steps (e.g., validation, authorization, etc.), which could each become separate strategies within a larger context.

Summary

The Strategy Pattern is a valuable design pattern in Go that helps keep code flexible, modular, and easier to maintain. It’s particularly useful for scenarios where the same action may need to vary based on external conditions or configurations. By following best practices, using clear interfaces, and testing each strategy thoroughly, you can effectively apply the Strategy Pattern to improve the flexibility and organization of your Go applications.

For another example of how to implement the strategy pattern please see a go module I wrote named sanitags. sanitags is a module which leverages custom struct tags to easily mark properties within a struct as needing HTML sanitization. In order to keep the module agnostic to how the actual content santization happens, I leveraged the strategy pattern to encapsulate sanitizing logic into a config interface. As a result, callers are able to configure the module to use whatever 3rd party sanitizing library they wish such as bluemonday!

cheers!

Leave a comment