Code.Art.Web

Code.Art.Web

Profile Picture

Lorefnon

Leveraging the strategy pattern in Rails - I

Abstract

Strategy pattern is a well known software design pattern. In this post we explore how to apply it in the context of a Rails application

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.

comments powered by Disqus
Separator line
Separator line
Lorefnon

Full stack web developer and polyglot programmer with strong interest in dynamic languages, web application development and user experience design.


Strong believer in agile methodologies, behaviour driven development and efficacy of open source technologies.


© 2013 - 2015 Gaurab Paul


Code licensed under the The MIT License. Content and Artwork licensed under CC BY-NC-SA.


The opinions expressed herein are my personal viewpoints and may not be taken as professional recommendations from any of my previous or current employers.


Site is powered by Jekyll and graciously hosted by Github