How the Strategy Pattern works?

How the Strategy Pattern works?

See how to implement and switch between different strategies to reach a goal by using the Strategy pattern with go.

Overview

Have you ever entered to an e-commerce and buy a product?, If you have, then you may have been show different ways to pay (PayPal, Credit Card, etc.)

image.png

This purchase process is a way to see the Strategy Pattern. Strategy is a Behavioral design pattern that lets you define different strategies and switch between them to reach a goal. In the above image, you have a goal (buy a product) and different strategies to accomplish it (pay with PayPal, Credit Card, etc.).

Let’s see how we would implement this purchase process.

Structure of Strategy Pattern

First, let's understand the structure of the pattern. It is defined by 3 parts:

  1. Context Class: This is our goal

  2. Contract Interface: These are the rules to be followed by the Strategies

  3. Strategy Classes: Refers to the different ways to reach my goal, are the implementation of the Contract Interface

Here is a Class Diagram for the Strategy structure image.png

Will also need a Client who trigger the purchase process. Will see this in the code examples.

Code time, yay!

Now, let’s take the Class Diagram into code to see how it is done in code. I’ll be using Golang because it is the language I’m comfortable with:

  1. Define the Contract

     // PaymentMethodStrategy will let us interchange 
     // the payment method in the context
     type PaymentMethodStrategy interface {
             Pay() error
     }
    
  2. Define the Context

     // Purchase is our goal/context which maintains a reference to all the
     // possible strategies that the client can use to pay
     type Purchase struct {
         // we create a map to save all the strategies implementations
         // so in runtime, when a client execute the Process method
         // the client can choose the strategy to use
         paymentMethodStrategies map[string]PaymentMethodStrategy
     }
    
     func NewPurchase() Purchase {
         return Purchase{paymentMethodStrategies: make(map[string]PaymentMethodStrategy)}
     }
    
     // RegisterStrategy will let us register all the possible strategies that
     // the client can use
     func (p *Purchase) RegisterStrategy(name string, strategy PaymentMethodStrategy) {
         p.paymentMethodStrategies[name] = strategy
     }
    
     // Process processes the purchase by doing the necessary validations
     // and calling the Pay() method of the selected strategy by the client
     func (p Purchase) Process(paymentMethod string) error {
         // you can add logic to query and validate the order
    
         // in runtime, we chose the payment method strategy
         paymentMethodStrategy := p.paymentMethodStrategies[paymentMethod]
    
         // after we got the strategy implementation we just use it by calling the Pay() method
         if err := paymentMethodStrategy.Pay(); err != nil {
             return fmt.Errorf("purchase.Process.paymentMethodStrategy.Pay(): %w", err)
         }
    
         // here you may want to create an invoice, send a notification, etc
    
         fmt.Printf("Purchase with %s completed\n", paymentMethod)
    
         return nil
     }
    
  3. Implement the Strategies

    For this, will have 3 different classes (PayPal, CreditCard and Bank)

     // PayPal implementation of the PaymentMethodStrategy interface
     // with the necessary logic to pay with PayPal
     type PayPal struct {}
    
     func NewPaypal() PayPal {
         return PayPal{}
     }
    
     func (p PayPal) Pay() error {
         // Here you'll add the logic to pay with PayPal
    
         fmt.Println("Processing purchase with PayPal...")
    
         return nil
     }
    
     type CreditCard struct {}
    
     func NewCreditCard() CreditCard {
         return CreditCard{}
     }
    
     func (c CreditCard) Pay() error {
         // Here you'll add the logic to pay with credit card
    
         fmt.Println("Processing purchase with CreditCard...")
    
         return nil
     }
    
     type Bank struct {}
    
     func NewBank() Bank {
         return Bank{}
     }
    
     func (b Bank) Pay() error {
         // Here you'll add the logic to pay with a bank
    
         fmt.Println("Processing purchase with Bank...")
    
          return nil
      }
    
  1. Client

    In this example, I’ll be using the console to execute the purchase process.

     func main() {
         // we initialize our strategies
         purchase := NewPurchase()
         purchase.RegisterStrategy("PayPal", NewPaypal())
         purchase.RegisterStrategy("CreditCard", NewCreditCard())
         purchase.RegisterStrategy("Bank", NewBank())
    
         fmt.Println("Enter the payment method you want to use (PayPal, CreditCard or Bank: ")
         var paymentMethod string
         fmt.Scanln(&paymentMethod)
    
         if err := purchase.Process(paymentMethod); err != nil {
             log.Fatalln(err)
         }
     }
    

When we run our program, this is what happen: strategy.gif

ℹ️ Note: This pattern is a way to implement the Open/Closed Principle of SOLID.

What if we want to add another payment method?

This would be easy, you just have to create another class that implements the PaymentMethodStrategy interface:

type Bitcoin struct {}

func NewBitcoin() Bitcoin {
    return Bitcoin{}
}

func (b Bitcoin) Pay() error {
    // Here you'll add the logic to pay with a Bitcoin

    fmt.Println("Processing purchase with Bitcoin...")

    return nil
}

And after that, you just have to register it in your context class.

purchase.RegisterStrategy("Bitcoin", NewBitcoin())

The conventional way

How would it be to implement this process without the Strategy pattern?

Well, instead of having 3 classes (Context, Contract and Strategies), we’ll only have 1 class that implements all the logic to pay with PayPal, CreditCard, Bank, etc.

  1. Our Purchase class that implements all the logic

     type Purchase struct{}
    
     func NewPurchase() Purchase {
         return Purchase{}
     }
    
     func (p Purchase) Process(paymentMethod string) error {
         // you can add logic to query and validate the order
    
         switch paymentMethod {
         case "PayPal":
             if err := p.payWithPayPal(); err != nil {
                 return err
             }
         case "CreditCard":
             if err := p.payWithCreditCard(); err != nil {
                 return err
             }
         case "Bank":
             if err := p.payWithBank(); err != nil {
                 return err
             }
         }
    
         // here you may want to create an invoice, send a notification, etc
    
         fmt.Printf("Purchase with %s completed\n", paymentMethod)
    
         return nil
     }
    
     func (p Purchase) payWithPayPal() error {
         // Here you'll add the logic to pay with PayPal
    
         fmt.Println("Processing purchase with PayPal...")
    
         return nil
     }
    
     func (p Purchase) payWithCreditCard() error {
         // Here you'll add the logic to pay with CreditCard
    
         fmt.Println("Processing purchase with CreditCard...")
    
         return nil
     }
    
     func (p Purchase) payWithBank() error {
         // Here you'll add the logic to pay with Bank
    
         fmt.Println("Processing purchase with Bank...")
    
         return nil
     }
    
  2. Our client

     func main() {
         // unlike the Strategy Pattern implementation
         // here we don't have to initialize our strategies
         // because all the logic is inside the Purchase "Class"
         purchase := NewPurchase()
    
         fmt.Println("Enter the payment method you want to use (PayPal, CreditCard or Bank: ")
         var paymentMethod string
         fmt.Scanln(&paymentMethod)
    
         if err := purchase.Process(paymentMethod); err != nil {
             log.Fatalln(err)
         }
     }
    

When we run our program, this is what happen: without-strategy.gif

This could be fine when you first build our e-commerce, or when you know that you’ll rarely will add or modify existent logic. But if you know that the business logic will grow, things like this may happen in the future:

  1. It will be difficult to navigate throughout the code
  2. Highly chance you modify something you’re not supposed to
  3. Git conflicts when different people working on the same class
  4. A lot of conditionals to switch between the different payment methods

From the business perspective may be ok because it works, but from the technical perspective is not because it will not be maintainable in the future.

ℹ️ Note: Putting all the logic in one class, for this example, will go against the Single Responsibility Principle of SOLID.

Practice

Here I showed what the Strategy Pattern is, how it is structured, and why you should use this pattern instead of putting all the logic in one class.

I invite you to implement this pattern to apply a discount to an order and share the code in the comments. Hope this blog helped you understand the Strategy pattern. See you in the next Design Pattern blog of this series.

References

  1. Dive into Design Patterns: This article is made by the notes I took from this book

  2. Peek: Tool to record my terminal

  3. Figma Jam and IconDuck: To make the illustrations

  4. Article repository: You can find the examples in my GitHub

Did you find this article valuable?

Support Hernan Reyes by becoming a sponsor. Any amount is appreciated!