Gaurab Paul

Polyglot software developer & consultant passionate about web development, distributed systems and open source technologies

Support my blog and open-source work

Tags

Leveraging the strategy pattern in Rails - I
Posted  9 years ago

This post has not been updated in quite some time and the content here may be out of date or not reflect my current my recommedation in the matter.

To begin with, what is strategy pattern ?

Quoting from Wikipedia,

the strategy pattern (also known as the policy pattern) is a software design pattern that enables an algorithm's behavior to be selected at runtime.

So how does this help us ?

Strategy pattern just helps us escape the soup of complex nested conditionals and model behavior selection in an object oriented fashion. To understand why this is required let us explore a fictional example:

An adventure

Suppose that we are developing a social network for book lovers. To encourage users to read more we decide to show a recommendations panel which highlights books that are trending in the community.

Our (over-simplified) implementation might be something like:

class Book < ActiveRecord::Base

  has_many :recommendations
  has_many :tags

  scope :popular, -> {  where 'recommendations_count > 100' }

  # Return a random subset of recommended books
  #
  # Yes, this approach is sub-optimal for large number of popular books. Better
  # approaches are outlined here:
  # http://stackoverflow.com/questions/4329396/mysql-select-10-random-rows-from-600k-rows-fast
  #
  def self.recommended
    popular.order('RAND()').limit(5)
  end

end

class Recommendation
  belongs_to :user, counter_cache: true
  belongs_to :book, counter_cache: true
end

In home/index.html.erb

<ul id='recommended-book-list'>
  <% Book.recommended.each do %>
    <li> <%= book.title %> </li>
  <% end %>
</ul>

So far so good, however we realize that for users who have been using our service for a while, it makes more sense to show recommendations based on their intersts. So we do a shotgun surgery and modify our code to the following:

class Book < ActiveRecord::Base
  ...
  def self.recommended_for user
    if user.blank? || (user.recommendations_count < 5)
      popular
    else
      not_recommended_by(user).where(tags: user.recommended_tags)
    end.order('RAND()').limit(5)
  end

end

class User < ActiveRecord::Base
  ...
  has_many :recommendations
  has_many :recommended_books,
    through: :recommendations,
    source: :book
  has_many :recommended_tags,
    through: :recommended_books,
    source: :tags

end

class Book < ActiveRecord::Base
  ...
  scope :not_recommended_by, -> (user) do
    joins(:recommendations)
      .where('recommendations.user_id != ?', user.id)
  end

end

And our template becomes something like:

<ul id='recommended-book-list'>
  <% Book.recommended_for(current_user).each do %>
    <li> <%= book.title %> </li>
  <% end %>
</ul>

We see that at this point our recommended_for method is burdened with multiple responsibilities - the decision for the approach to be used as well as the logic for multiple approaches all reside in the same method, which is not very ideal.

Let us push this further. Say, after a couple of months our social networks gains a lot of traction and we strike a very profitable deal with a major publishing firm 'Jackass Kangaroo Publications' and as a part of the deal we need to ensure that the recommended books include only those which have been published by this publication.

No problem, we just need add a few lines of code:

class Book < ActiveRecord::Base
  ...
  def self.recommended_for user
    query = Book

    # Comment this out when deal with Jackass Kangaroo Publication is over.
    query = query.where(publisher: Publisher.where(name: 'Jackass Kangaroo Publication').first)

    if user.blank? || (user.recommendations_count < 5)
      query.popular
    else
      query.not_recommended_by(user).where(tags: user.recommended_tags)
    end.order('RAND()').limit(5)
  end
end

No words are needed to describe the ugliness of the code above. Our eyes bleed but we choose to look away and carry on with our buisness.

Of course, the journey of our social network is not all rosy. We get hit by a lawsuit making our deal with Jackass Kangaroo Publications illegal in a specific country. But why bother backing off from this insanity when all problems can be resolved by adding just another condition:

class Book < ActiveRecord::Base
  ...
  def self.recommended_for user
    query = Book

    # Comment this out when deal with Jackass Kangaroo Publication is over.
    unless user.located_in? DISPUTED_DEMOGRAPHY
      query = query.where(publisher: Publisher.where(name: 'Jackass Kangaroo Publication').first)
    end

    ...
  end
end

A downhill slope

So requirements keep stacking up and we keep adding conditions. Fast forward a few years, and a sincere programmer who is new to the project, unfamiliar with our rocky history and now is responsible for maintenance of the project is staring blankly at the entangled mess of conditional statements. Of course the crutial details of the deal and the subsequent lawsuits are now lost in sands of time, and none of the present team members have any idea what is going on.

Retrospection

The question now is, what could be done to avoid a situation like this ? As you might have guessed at this point, burdening the Book class with responsibility to determine various aspects of application that affect our recommendation policy as well as the complete implementation of all these policies is cumbersome. What we can do is that we can refactor out the specific strategies into dedicated classes that encapsulate the actual implementation details. This is exactly what the strategy pattern encourages us to embrace.

module Strategies
  class RecommendationGeneration < Struct.new(user, scoped_collection)
    def scoped_collection
      super || Book
    end

    def applicable?
      false
    end

    def execute
    end

  end
end

module Strategies
  class DefaultRecommendationGeneration < RecommendationGeneration

    def applicable?
          true
    end

        def scoped_collection
          super || popular
        end

    def execute
          scoped_collection.order('RAND()').limit(5)
    end

  end
end


module Strategies
  class UserAdaptedRecommendationGeneration < RecommendationGeneration

    def applicable?
      user.present?
    end

    def scoped_collection
      if user.present?
        not_recommended_by(user).where(tags: user.recommended_tags)
      else
            super
          end
        end

        def execute
          Strategies::DefaultRecommendation
            .new(user, scoped_collection)
                .execute
        end

  end
end


module Strategies
  class PartnershipAdaptedRecommendationGeneration < RecommendationGeneration

    # It is better to model buisness constraints in the persistance layer
        # rather than relying on implicit assumptions.
        #
        def applicable?
          ! partner_publisher.blank?
        end

        def partner_publisher
          Partnership
            .legal_in(user.demography)
        .having_recommendation_priviledge
                .active
                .first
                .publisher
        end

        def scoped_collection
          Book.where(publisher: partner_publisher)
        end

        def execute
          Strategies::UserAdaptedRecommendationGeneration
                .new(user, scoped_collection)
                .execute
        end

  end
end

Now our recommended_for method just has to decide which is the applicable strategy and execute it:


class Book < ActiveRecord::Base
  ...

  RECOMMENDATION_STRATEGIES = [
    PartnershipAdaptedRecommendationGeneration
        UserAdaptedRecommendationGeneration
        DefaultRecommendationGeneration
  ]

  def self.recommended_for user
    RECOMMENDATION_STRATEGIES.each do |strategy_class|
          strategy = strategy_class.new(user)
          if strategy.applicable?
            strategy.execute
                break
          end
        end
  end

end

This is signficantly better than our prior approach and aligns well with the tenets of SOLID principles. Apart from explaining the use of strategy pattern it also illustrates how strategies can reuse existing strategies by means of composition thus keeping our code DRY.

So we see that, the strategy pattern is especially helpful when it comes to applications where requirements are rapidly changing all the time. Since the core logic is encapsulated into interchangeable concrete implementations, strategy implementations can be introduced or switched with relative ease at a later phase.

While this post focussed on use of strategy pattern to simply complex logic in model layer, in subsequent posts we will cover how this pattern can simplify our implementations in controller and view layers as well.

This concludes the post. Please feel free to let me know about your suggestions for improvements, or mistakes that I might have made in the post above.