Have you ever used a promotional code on an e-commerce website? How does the promotional code know what items are eligible? Is it a fixed amount off, or a percentage? Does it include shipping and handling? Was there a minimum purchase amount you had to meet to be eligible? These are all design considerations that needed to be taken into account, and the strategy pattern was a great way to represent this in my codebase.
My new requirement was to incorporate a promotional code that took 10% off the shopping cart total (excluding certain items of course – why can requirements never be simple?!) The system already had an existing promotional code system, but it was rudimentary, and only worked for fixed amounts off the total (e.g. $5 off your total).
The strategy pattern is defined as having these qualities:
- A strategy can be thought of as an algorithm that is conditionally applied at runtime based on conditions.
- The strategy encapsulates the execution of that algorithm.
- Finally a strategy is an interface for the algorithm allowing them to be interchangeable.
Implementing the Strategy Pattern
Lets look at the existing promotional code logic before I implement the logic to take a percentage discount:
# app/models/shopping_cart.rb
...
has_many :items
# Returns the most expensive item in the shopping cart
def costliest_item
items.map { |a,b| b.price <=> a.price}.first
end
def apply_promotional_code!(promotional_code)
costliest_item.apply_promotional_code! promotional_code
shopping_cart.total -= promotional_code.amount
end
For added context, a promotional code has a code we can lookup, and an amount
property that tells us how much that promotional code is valued at.
We have a ShoppingCart
which has many Item
s associated with it. When we call apply_promotional_code!
and pass in a promotional code (e.g. “HOTSUMMER”), it returns the costliest item in the shopping cart (based on price
) and associates the promotional code against it.
Why lookup the costliest item at all? Because we want to show an itemized receipt of each item total in the shopping cart. We then subtract this promotional code amount from the shopping basket total, which is the amount that will be charged to the customer’s credit card.
Adding a percentage based promotional code
If we were to change this to a percentage based discount (e.g. 10%) instead of a fixed amount (e.g. $5) we would need to change our calculation in the apply_promotional_code!
method to something more complex:
def apply_promotional_code!(promotional_code)
costliest_item.apply_promotional_code! promotional_code
if promotional_code.amount.present?
shopping_cart.total -= promotional_code.amount
else
shopping_cart.total -= shopping_cart.total * (promotional_code.percentage / 100.0)
end
end
We have also changed the number of items this can be applied against (shown in our itemized receipt). If the promotional code is 10% off of the total, then it is 10% off of each item in the shopping cart. This means we will have to revisit our costliest_item
method to make it return an array of zero or more items, instead of a single item:
# Returns an array of eligible items for the promotional code
def eligible_items
if promotional_code.amount.present?
Array(items.map { |a,b| b.price <=> a.price}.first)
else
items
end
end
def apply_promotional_code!(promotional_code)
eligible_items.each {|item| item.apply_promotional_code! promotional_code }
if promotional_code.amount.present?
shopping_cart.total -= promotional_code.amount
else
shopping_cart.total -= shopping_cart.total * (promotional_code.percentage / 100.0)
end
end
With the addition of a percentage based promotional code alongside the fixed amount promotional code, we have introduced more complexity. apply_promotional_code!
now has to be aware of how to apply both a fixed, and a percentage based amount. Further, the eligible_items
(formerly costliest_item
) now needs to be aware of the type of promotional code. Our promotional logic is spilling over into areas that need not be concerned with promotional logic.
Encapsulating via a strategy
Instead of concerning apply_promotional_code!
with the knowledge of all possible types of promotional code types, lets decompose this into two separate strategies. The first will be our original fixed amount against the costliest item in the shopping cart. The second will be our new percentage based discount off the entire shopping cart:
# app/models/promotional_codes/stategies/fixed_amount_against_costliest_item.rb
class FixedAmountAgainstCostliestItem
attr_accessible :shopping_cart, :promotional_code
def initialize(items, promotional_code)
self.shopping_cart = shopping_cart
self.promotional_code = promotional_code
end
def discount
promotional_code.amount
end
def eligible_items
shopping_cart.eligible_items
end
end
class PercentageAgainstMultipleItems
attr_accessible :shopping_cart, :promotional_code
def initialize(items, promotional_code)
self.shopping_cart = shopping_cart
self.promotional_code = promotional_code
end
def discount
shopping_cart.total * (promotional_code.percentage / 100.0)
end
def eligible_items
shopping_cart.eligible_items
end
end
A few things to note: Firstly there are now two classes, and each is responsible for a single type of promotional code application. Secondly, there is similarity in these two classes. This is a good sign that we want to refactor and make a common super class that these inherit from. Similarity means there is a good chance we can accomplish interchangeability. Lastly, we have proxied the logic of what items are eligible through our two new strategies. As a next pass we will move the eligible_items
logic to reside with the strategy. This keeps our promotional code logic in one place:
class BasePromotionalStrategy
attr_accessible :shopping_cart, :promotional_code
def initialize(items, promotional_code)
self.shopping_cart = shopping_cart
self.promotional_code = promotional_code
end
def discount
raise NotImplementedError
end
def eligible_items
raise NotImplementedError
end
end
class FixedAmountAgainstCostliestItem < BasePromotionalStrategy
def discount
promotional_code.amount
end
def eligible_items
Array(items.map { |a,b| b.price <=> a.price}.select { |item| item.price >= promotional_code.amount }.first)
end
end
class PercentageAgainstMultipleItems < BasePromotionalStrategy
def discount
promotional_code.amount
end
def eligible_items
items
end
end
Now that we have extracted the common elements into a BasePromotionalStrategy
class, both FixedAmountAgainstCostliestItem
and PercentageAgainstMultipleItems
can extend it. With the boilerplate constructor logic out of the way, we can define our interface with method placeholders. As an example of what eligible_items
might guard against, we will check that the item cost is greater than or equal to the promotional_code.amount
to ensure our shopping cart has a positive balance. (So much for early retirement by buying thousands of paperclips one at a time with a $5 off coupon!). Percentage based promotional codes have a sliding value so there is no minimum spend we need to guard against. You can see that we just return all items
as eligible_items
.
Regardless of whether we instantiate FixedAmountAgainstCostliestItem
or aPercentageAgainstMultipleItems
, we can call discount
to calculate the amount taken off the shopping cart, and we can call eligible_items
to return a list of items that the itemized list should show the promotional discount applying to. In the case of theFixedAmountAgainstCostliestItem
it will always be an empty array, or an array with one item, but we consistently return an array type regardless of the number of entities it contains. Consistent return types are an interface must!
Calling our new strategies
Now that we have implemented (and written tests – right?) for our strategy, we can revisit our caller to take advantage of this new logic:
# app/models/promotional_code.rb
class PromotionalCode
...
def strategy
if amount.present?
PromotionalCode::Strategies::FixedAmountAgainstCostliestItem
else
PromotionalCode::Strategies::PercentageAgainstMultipleItems
end
end
end
# app/models/shopping_cart.rb
...
has_many :items
def apply_promotional_code!(promotional_code)
strategy = promotional_code.strategy.new(self, promotional_code)
strategy.eligible_items.each { |item| item.apply_promotional_code!(promotional_code) }
shopping_cart.total -= strategy.discount
end
The PromotionalCode
is responsible for knowing which strategy will be executed when it is applied to a shopping cart. This strategy method returns a class reference. This reference is then instantiated passing in the necessary arguments of the shopping_basket
, and the promotional_code
. This is everything the shopping cart needs to know about executing a promotional code strategy. The ShoppingCart does not know which strategy to instantiate, but simply which arguments to pass to the return result of strategy.
Further, ShoppingBasket
now knows nothing about what items are eligible for a PromotionalCode
. That logic again resides within the strategy definition. Our promotional code domain is nicely encapsulated. This adheres to the open/closed principle of good object oriented design. Adding a new type of promotional code (e.g. buy one get one free, or a promotional code with a minimum spend) would be as easy as adding a new strategy, and telling the PromotionalCode#strategy
when to apply it.
NullStrategy
You might have noticed that we use an if...else
as opposed to an if...elsif
(for simplicity sake). This means that the percentage based promotional code strategy is returned more often than it should be. Ideally, we’d want to return an inert strategy that confirms to our interface but doesn’t actually give any discounts to our shopping cart. This is where a Null Object pattern could be used in conjunction with a strategy:
# app/models/promotional_codes/stategies/null_strategy.rb
class NullStrategy < BasePromotionalStrategy
def discount
0
end
def eligible_items
[]
end
end
# app/models/promotional_code.rb
class PromotionalCode
...
def strategy
if amount.present?
PromotionalCode::Strategies::FixedAmountAgainstCostliestItem
elsif percentage.present?
PromotionalCode::Strategies::PercentageAgainstMultipleItems
else
PromotionalCode::Strategies::NullStrategy
end
end
end
One additional benefit of this strategy pattern is the ease of testing. PromotionalCode#strategy
could easily be stubbed to return this NullStrategy
and still remain consistent with the interface we’ve established in our other strategies. Now in testing, we can simply stub the NullStrategy
and continue to chain our interface method calls:
PromotionalCode.any_instance.stubs(strategy: PromotionalCode::Strategies::NullStrategy)
$> PromotionalCode.new.strategy # => PromotionalCode::Strategies::NullStrategy
$> PromotionalCode.new.discount # => 0
$> PromotionalCode.new.eligible_items # => []
...
$> shopping_basket.total -= promotional_code.discount
Conclusion
Strategies can be a great way to split different algorithms apart. An indicator that you could benefit from a formalized strategy pattern is when you find conditionals in your methods that change the way something is calculated based on context. In our case, we had a conditional (if...else
) that checked whether amount
was set on a PromotionalCode
. Another indicator was the need to encapsulate the concern of a PromotionalCode. Its neither a concern of ShoppingBasket
nor Item
. It needed its own home. By implementing a strategy class, we give this logic an obvious place to live.
A strategy can be a great tool when you have a requirement that lives alongside an existing requirement that changes how a calculation is done at runtime.
Additional Information on strategies can be found at https://sourcemaking.com/design_patterns/strategy