Solving a problem using strategy pattern

How I used Strategy Design Pattern to simplify promo codes in Rails

Solving a problem using strategy pattern

Introduction

"Imagine this."

You’re working on the codebase of a book e-commerce application, and everything seems to be running smoothly. The application includes various features, such as a promo code system for applying discounts. Currently, the system supports standard promo codes, like percentage-based discounts. This implementation has been reliable and effective, giving the team confidence in the feature's stability.

However, a new requirement arises: the system must now support a different type of promo code. At first, this seems straightforward—just add the logic for the new promo code, right? But let’s take a closer look…

"So, here’s the requirement."
We need to introduce a new type of promo code called the Individual Promo Code. Unlike the existing promo codes, this one has some unique characteristics:

  1. User-Specific: It is designed to be used by only one user, identified by their email address(could be a user in the system or new user).

  2. Single-Use Only: Once the promo code is used, it cannot be applied again.

This means the system must ensure that the promo code is associated with the specified email and verify that it hasn’t already been used.


Current System Overview and Limitations

"Let’s review the existing system and its current capabilities."
The system uses a database table called promo_codes to store information about promo codes. Here’s the structure:

  • id: A unique identifier for each promo code.

  • name: The name of the promo code (e.g., “SUMMER20”).

  • percentage: The discount percentage the promo code applies.

  • expiration_date: The date and time when the promo code expires.

  • created_at: The timestamp indicating when the promo code was created.

Currently, this setup works well for general promo codes that are reusable and apply to all users. However, there’s no support for the new requirements, like associating a promo code with a specific email or limiting it to a single use.

The controller

class Api::V1::PromoCodesController < ApplicationController
  ######
  # Add promo code
  # POST: api/v1/promo_codes
  ######
  def create
    promo_code = PromoCode.create(promo_code_params)
    render_success('promocode', PromoCodeSerializer.new(promo_code).to_j, :created)
  end

  private

 def promo_code_params
    params.permit(:name, :percentage, :expiration_date)
  end
end

Currently, the controller handles basic promo code creation with attributes like name, percentage, and expiration_date. However, it doesn't support the new requirements like linking a promo code to a specific email or limiting its use to a single time.

A Scalability Problem in the Making

Let’s begin with the database…

To meet the new requirements, we’ve updated the database design as follows:

  1. promo_codes Table:
    A new column, code_type, has been added to differentiate between different types of promo codes. This column will store values such as "normal" for general promo codes and "individual" for the new promo code type.

  2. individual_promo_codes Table:
    This new table is designed to store the email addresses of users and link them to a specific promo code. Each entry will include the user’s email and a foreign key reference to the promo_codes table:

Doing some changes in the controller..🤓“

class Api::V1::PromoCodesController < ApplicationController
  ######
    # Add promo code
    # POST: api/v1/promo_codes
  ######
  def create
    # Extract the code_type from params and remove it for further logic
    code_type = params.delete(:code_type)
    # Extract the email from params because it's not needed for the creation of promo_code
    email = params.delete(:email)

    # Create the promo code with the necessary parameters
    promo_code = PromoCode.create(promo_code_params)

    # If the code_type is 'individual', create the corresponding entry in individual_promo_codes
    if code_type == 'individual'
      # Create the individual promo code entry with the user's email and the promo_code_id
      IndividualPromoCode.create(email: email, promo_code_id: promo_code.id)
    end

    # Render a success response
    render_success('promocode', PromoCodeSerializer.new(promo_code).to_j, :created)
  end

  private

  def promo_code_params
    # Permit the newly added 'code_type' column and the required data for 'individual_promo_codes'
    params.permit(:name, :percentage, :code_type, :expiration_date, :email)
  end
end

"Ahhh… the work is done! 🤓"

The current implementation only handles the creation of a promo code, and even at this stage, the code is already showing signs of being less than ideal. Applying discounts will be far more complex, as each type of promo code requires its own validations and logic. We'll explore a more robust solution for this challenge in the final section of the article.

Now, just when we thought everything was in place, a new requirement comes in:

“We need to add a promo code type based on the country ID.”

This new requirement adds another layer of complexity. The current promo code system is already tightly coupled with the main logic, and every time a new promo code type is added, we’re diving back into the same tangled code. The more we add, the harder it becomes to manage and maintain. Each change risks breaking something that’s already working.

Imagine this happening repeatedly. With every new promo code type, we’re stuck in the same cycle of complexity and mess. This raises the question: Is there a cleaner, more scalable way to handle this?

The problems of this implemeition

  1. Violation of the Open/Closed Principle:
    The system violates the Open/Closed Principle, which states that software entities should be open for extension but closed for modification. Every time a new promo code type is introduced, the existing code needs to be modified, rather than extending the system in a modular way.

  2. Violation of the Single Responsibility Principle (SRP):
    The controller is handling too many responsibilities, such as creating promo codes, handling different types, and associating them with specific users.

  3. Lack of Separation of Concerns:
    The current implementation doesn’t effectively separate the logic for different types of promo codes, causing the controller to become bloated and harder to read and maintain.

  4. Hard to Test:
    Due to the tightly coupled, monolithic nature of the controller, testing becomes difficult. With multiple responsibilities handled by a single class, writing unit tests is challenging, and the tests become fragile as new requirements are added.


The solution

The Strategy Design Pattern is ideal for scenarios where you have multiple variations of a behavior and want to isolate each variation into its own class, making the system easier to extend and maintain. Here’s why this pattern fits perfectly for handling promo codes.

Behavioral Differences Between Promo Codes

The key reason for choosing the Strategy Pattern is that different promo codes have distinct behaviors. For example:

  • Normal Promo Codes: Can be reused and are not tied to a specific user.

  • Individual Promo Codes: Are tied to a user’s email and can only be used once.

Instead of cluttering the controller or database logic with if-else statements to handle these differences, we delegate the behavior to separate strategy classes. This keeps the logic modular and focused.

Database design

We will retain the same database design introduced in the previous implementation.

Folder structure

app/
└── services/
    └── promo_codes/
        ├── strategies/
        │   ├── individual_strategy.rb
        │   ├── normal_strategy.rb
        ├── base_strategy.rb
        └── registry.rb
  • services/promo_codes/: The main directory where all promo code-related logic resides.

  • strategies/: A subfolder containing specific implementations of promo code strategies. Each strategy (e.g., IndividualStrategy, NormalStrategy) is defined in its own file to follow the Single Responsibility Principle.

  • base_strategy.rb: Contains the base or abstract class/interface for the strategies, outlining the common contract that all strategies must implement.

  • registry.rb: Acts as a centralized place to register and retrieve the appropriate strategy based on the code_type. This is part of the Strategy Design Pattern.

The implementation

  • services/promo_codes/registry.rb
 module PromoCodes
  class Registry
    STRATEGIES = {
      'normal' => Strategies::NormalStrategy,
      'individual' => Strategies::IndividualStrategy
    }.freeze

    def self.fetch(code_type)
      STRATEGIES[code_type] || raise(ArgumentError, "Unknown promo code type: #{code_type}")
    end
  end
end

The Registry class is a central component in the Strategy Design Pattern. It serves as a mapping between the code_type and its corresponding strategy class, making it easy to dynamically select the appropriate behavior for a promo code at runtime.

While it’s possible to define this mapping in an initializer (e.g., config/initializers/promo_codes.rb), we’ve chosen to keep it in this dedicated Registry file for simplicity and maintainability.
  • services/promo_codes/base_strategy.rb

      module PromoCodes
        class BaseStrategy
          def apply_discount(_promo_code, _book, _user = nil)
            raise NotImplementedError, "Subclasses must implement `apply_discount`"
          end
    
          def create_promo_code(_params)
            raise NotImplementedError, "Subclasses must implement `create_promo_code`"
          end
    
          def delete_promo_code(_promo_code)
            raise NotImplementedError, "Subclasses must implement `delete_promo_code`"
          end
    
          def permitted_params(_params)
            raise NotImplementedError, "Subclasses must implement `permitted_params`"
          end
    
          protected
    
          def base_params
            [:name, :percentage, :code_type,]
          end
        end
      end
    

    The BaseStrategy class is an abstract base class that defines a common interface and shared functionality for all promo code strategies. It ensures that any strategy class inheriting from it will implement the required methods, maintaining consistency across all strategy implementations.

  • services/promo_codes/NormalStrategy.rb

module PromoCodes
  module Strategies
    class NormalStrategy < BaseStrategy
      def apply_discount(promo_code, book, _user = nil)
        book.price * (100 - promo_code.percentage) / 100.0
      end

      def create_promo_code(params)
        PromoCode.create!(params)
      rescue ActiveRecord::RecordInvalid => e
        raise StandardError, e.message
      end

      def permitted_params(params)
        params.permit(base_params)
      end
    end
  end
end

The NormalStrategy class is a subclass of BaseStrategy, implementing the behavior for normal promo codes. It defines how promo codes of this type are applied, created, and what parameters are permitted.

  • services/promo_codes/IndividualStrategy.rb
module PromoCodes
  module Strategies
    class IndividualStrategy < BaseStrategy
      def apply_discount(promo_code, book, user)
        # Individual promo_code validations
        raise StandardError, I18n.t('promo_codes.errors.missing_user') unless user
        raise StandardError, I18n.t('promo_codes.errors.invalid_user') unless user.email == promo_code.individual_promo_code&.email
        raise StandardError, I18n.t('promo_codes.errors.invalid_promocode') if promo_code.used_count > 0
        # Calculate and return the discounted price based on the promo code's percentage discount.
        book.price * (100 - promo_code.percentage) / 100.0
      end

      def create_promo_code(params)
        # Extract email from the params
        email = params.delete(:email)
        raise StandardError, I18n.t('promo_codes.errors.missing_email') unless email.present?

        ActiveRecord::Base.transaction do
          # create promo_code
          promo_code = PromoCode.create!(params)
          # create individual_promo_code
          individual_promo = IndividualPromoCode.create!( promo_code: promo_code, email: email)
          promo_code
        end
      rescue ActiveRecord::RecordInvalid => e
        raise StandardError, e.message
      end

      def permitted_params(params)
        params.permit(base_params + individual_params)
      end

      protected

      def individual_params
        [:email, :first_name, :last_name]
      end
    end
  end
end

The IndividualStrategy class is a subclass of BaseStrategy, implementing the behavior for individual promo codes. It defines how promo codes of this type are applied, created, and what parameters are permitted.

💡
As you can observe, the parameters differ, and the applying discount process also differs due to the presence of additional validations (that its the promo_code is only applied for only one user).

The magical part 🪄

Let’s take a look at how this implementation shapes the controller and how it perfectly adheres to the Open/Closed Principle.

class Api::AdminPortal::V1::PromoCodesController < Api::AdminPortal::V1::AdminPortalController
  # Before executing the `create` or `destroy` actions, call the `set_strategy` method
  # to determine the appropriate strategy for handling promo codes.
  before_action :set_strategy, only: %i[create destroy]

  ######
  # Add promo code
  # POST: api/v1/promo_codes
  ######
  # Define the action to handle POST requests for creating promo codes.
  def create
    # Use the strategy determined earlier to create a new promo code
    # using the parameters provided in the request.
    promo_code = @strategy.create_promo_code(promo_code_params)

    # Check if the promo code was successfully saved to the database.
    if promo_code.persisted?
      # If successful, respond with a success message, serialized promo code data,
      # and an HTTP 201 (Created) status.
      render_success('promocode', PromoCodeSerializer.new(promo_code).to_j, :created)
    else
      # If unsuccessful, respond with an error message and an HTTP 422 (Unprocessable Entity) status.
      render_error(promo_code.errors.full_messages.join(', '), :unprocessable_entity)
    end
  end
  # The methods below are private and cannot be accessed outside this class.
  private
  # Define a method to retrieve and filter the parameters allowed for promo code creation.
  def promo_code_params
    # Use the strategy to determine which parameters are allowed.
    @strategy.permitted_params(params)
  end
  # Define a method to set the strategy for handling promo codes based on their type.
  def set_strategy    
    # Get the type of promo code from the request parameters.
    promo_type = params[:code_type]

    # Use a registry to fetch the appropriate strategy class for the promo code type,
    # and create a new instance of that class.   
    @strategy = PromoCodes::Registry.fetch(promo_type).new
  end
end

In the updated controller, the create action is now streamlined and flexible, thanks to the Strategy Pattern. By delegating the responsibility of creating promo codes to the appropriate strategy (either NormalStrategy or IndividualStrategy), the controller remains simple and easy to extend. If a new promo code type needs to be added in the future, all that’s required is a new strategy class, and the controller will automatically support it without any modifications. This is a clear example of how the Open/Closed Principle is followed: the system is open for extension (you can easily add new promo code types) but closed for modification (you don’t have to change the existing code to introduce new types).

Challenge: Adding a New Promo Code Based on Country IDs

Manager:
"We need to introduce a new promo code that’s based on the user’s country."

You:
"No problem, I’ve got this!"

Now, where would you make the changes in the code to support this new requirement? 🤔

Take a moment and think about how we could extend the existing system to accommodate this new promo code type while following the same principles we've discussed.

Here are some clues to guide you:

Clue 1:
You need to add a countries_promo_codes table to incorporate country-specific logic or associations, as well as include a new type in the PromoCode model's enum.
Clue 2
You’ll need to create a new strategy class for country-based promo codes.
Clue 3
The logic for how the promo code will be applied based on the country should live in this new strategy.
Clue 4
The controller doesn’t need to change, as it’s already set up to fetch the correct strategy dynamically.

Outro

I hope this article has given you a clear understanding of how the Strategy Pattern works and how it can help in organizing complex logic in a clean, extendable way. By separating different behaviors into their own strategy classes, we can easily add new promo code types without modifying the existing code, which adheres to the Open/Closed Principle, as well as other SOLID principles like Single Responsibility and Dependency Inversion.

By following these principles, we ensure that the code remains maintainable, scalable, and easy to extend in the future. I hope this approach inspires you to use the Strategy Pattern in your own projects, whether you're working on a promo code system or any other domain where different behaviors need to be managed flexibly.

Thanks for reading! I hope you liked it, and I’m confident that you’ll find this pattern helpful in your future work. Happy coding! 👩‍💻👨‍💻