Solving a problem using strategy pattern
How I used Strategy Design Pattern to simplify promo codes in Rails
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:
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).
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:
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.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 thepromo_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! 🤓"
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
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.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.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.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 thecode_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.
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.
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:
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
Clue 3
Clue 4
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! 👩💻👨💻